rails_agent_console 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +414 -0
  5. data/exe/rails-agent +7 -0
  6. data/lib/generators/rails_agent_console/install_generator.rb +17 -0
  7. data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
  8. data/lib/rails_agent_console/agent.rb +277 -0
  9. data/lib/rails_agent_console/cli.rb +114 -0
  10. data/lib/rails_agent_console/configuration.rb +170 -0
  11. data/lib/rails_agent_console/console_installer.rb +49 -0
  12. data/lib/rails_agent_console/console_methods.rb +54 -0
  13. data/lib/rails_agent_console/console_session.rb +41 -0
  14. data/lib/rails_agent_console/credentials.rb +66 -0
  15. data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
  16. data/lib/rails_agent_console/diagnosis.rb +149 -0
  17. data/lib/rails_agent_console/errors.rb +20 -0
  18. data/lib/rails_agent_console/executor.rb +190 -0
  19. data/lib/rails_agent_console/history.rb +65 -0
  20. data/lib/rails_agent_console/model_picker.rb +166 -0
  21. data/lib/rails_agent_console/prompt/dates.rb +34 -0
  22. data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
  23. data/lib/rails_agent_console/prompt.rb +122 -0
  24. data/lib/rails_agent_console/proposal.rb +208 -0
  25. data/lib/rails_agent_console/providers/anthropic.rb +48 -0
  26. data/lib/rails_agent_console/providers/base.rb +114 -0
  27. data/lib/rails_agent_console/providers/callable.rb +28 -0
  28. data/lib/rails_agent_console/providers/gemini.rb +45 -0
  29. data/lib/rails_agent_console/providers/ollama.rb +84 -0
  30. data/lib/rails_agent_console/providers/openai.rb +51 -0
  31. data/lib/rails_agent_console/providers.rb +28 -0
  32. data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
  33. data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
  34. data/lib/rails_agent_console/query_validator/result.rb +55 -0
  35. data/lib/rails_agent_console/query_validator/rules.rb +108 -0
  36. data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
  37. data/lib/rails_agent_console/query_validator.rb +305 -0
  38. data/lib/rails_agent_console/railtie.rb +26 -0
  39. data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
  40. data/lib/rails_agent_console/rewriter/chain.rb +126 -0
  41. data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
  42. data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
  43. data/lib/rails_agent_console/rewriter/joined.rb +42 -0
  44. data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
  45. data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
  46. data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
  47. data/lib/rails_agent_console/rewriter.rb +183 -0
  48. data/lib/rails_agent_console/schema.rb +275 -0
  49. data/lib/rails_agent_console/setup.rb +40 -0
  50. data/lib/rails_agent_console/ui.rb +125 -0
  51. data/lib/rails_agent_console/value_check.rb +85 -0
  52. data/lib/rails_agent_console/version.rb +5 -0
  53. data/lib/rails_agent_console/wizard.rb +235 -0
  54. data/lib/rails_agent_console.rb +58 -0
  55. metadata +145 -0
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Rewriter
5
+ module Distinct
6
+ SINGLE_ROW = %i[belongs_to has_one].freeze
7
+ NOTE = "`distinct` changes nothing when every join is a belongs_to, and PostgreSQL refuses it " \
8
+ "next to an order by a joined column, so it is left out."
9
+
10
+ module_function
11
+
12
+ def drop(chain)
13
+ model = Schema.find(chain.receiver)
14
+ index = chain.calls.index { |call| call.text.match?(/\A\.\s*distinct\s*\z/) }
15
+ return if model.nil? || index.nil?
16
+ return unless foreign_order?(chain, model) && single_row_joins?(chain, model)
17
+
18
+ [chain.with_calls(chain.calls.reject.with_index { |_, position| position == index }), NOTE]
19
+ end
20
+
21
+ def foreign_order?(chain, model)
22
+ chain.calls.any? do |call|
23
+ %w[order reorder].include?(call.name) &&
24
+ call.text.scan(/(\w+)\./).flatten.any? { |table| ![model.table, "Arel"].include?(table) }
25
+ end
26
+ end
27
+
28
+ def single_row_joins?(chain, model)
29
+ joins = chain.calls.select { |call| JOINS.key?(call.name) }
30
+ joins.any? && joins.all? do |call|
31
+ names = call.symbol_arguments
32
+ names&.all? { |name| SINGLE_ROW.include?(macro(model, name)) }
33
+ end
34
+ end
35
+
36
+ def macro(model, name)
37
+ model.associations.find { |association| association[:name].to_s == name }&.dig(:macro)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Rewriter
5
+ module Joined
6
+ NESTED = /(\w+):\s*:(\w+)/
7
+
8
+ module_function
9
+
10
+ def tables(calls, model)
11
+ calls.select { |call| JOINS.key?(call.name) }.each_with_object({}) do |call, tables|
12
+ arguments = call.text[/\((.*)\)\s*\z/m, 1].to_s
13
+ arguments.scan(NESTED).each { |parent, child| add_nested(tables, model, parent, child) }
14
+ arguments.gsub(NESTED, "").gsub(/"[^"]*"|'[^']*'/, "").scan(/:(\w+)/).flatten.each do |name|
15
+ add(tables, model, name)
16
+ end
17
+ call.text.scan(/JOIN\s+"?(\w+)"?/i).flatten.each { |table| tables[table] ||= table }
18
+ end
19
+ end
20
+
21
+ def add_nested(tables, model, parent, child)
22
+ through = add(tables, model, parent)
23
+ add(tables, through, child) if through
24
+ end
25
+
26
+ def add(tables, model, name)
27
+ target = target(model, name)
28
+ tables[name] = target.table if target
29
+ target
30
+ end
31
+
32
+ def target(model, name)
33
+ association = model.associations.find { |candidate| candidate[:name].to_s == name.to_s }
34
+ association && Schema.find(association[:class_name])
35
+ end
36
+
37
+ def model_for(table)
38
+ Schema.models.find { |model| model.table == table }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Rewriter
5
+ class Qualifier
6
+ SQL_CALLS = (RELATION + AGGREGATES + %w[pluck pick]).freeze
7
+ SUBQUERY = /\(\s*SELECT\b/i
8
+ STRING_LITERAL = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/
9
+
10
+ def initialize(chain, model)
11
+ @chain = chain
12
+ @model = model
13
+ @renamed = {}
14
+ @qualified = []
15
+ end
16
+
17
+ def rewrite
18
+ calls = sql_calls
19
+ tables = joined_tables(calls)
20
+ return if tables.empty?
21
+
22
+ @ambiguous = ambiguous_columns(tables)
23
+ rewritten = calls.map { |call| JOINS.key?(call.name) ? call : qualify(call) }
24
+ return if @renamed.empty? && @qualified.empty?
25
+
26
+ [@chain.with_calls(rewritten + @chain.calls.drop(calls.size)), note]
27
+ end
28
+
29
+ private
30
+
31
+ def sql_calls
32
+ count = @chain.calls.index { |call| !SQL_CALLS.include?(call.name) } || @chain.calls.size
33
+ @chain.calls.first(count)
34
+ end
35
+
36
+ def joined_tables(calls)
37
+ Joined.tables(calls, @model)
38
+ end
39
+
40
+ def ambiguous_columns(tables)
41
+ joined = tables.values.uniq.flat_map { |table| Joined.model_for(table)&.column_names || [] }
42
+ (@model.column_names & joined).to_set
43
+ end
44
+
45
+ def qualify(call)
46
+ text = call.text.gsub(STRING_LITERAL) do |literal|
47
+ quote = literal[0]
48
+ "#{quote}#{rewrite_sql(literal[1...-1])}#{quote}"
49
+ end
50
+ text == call.text ? call : Chain::Call.new(call.name, text)
51
+ end
52
+
53
+ def rewrite_sql(sql)
54
+ outside_subqueries(sql) do |part|
55
+ part = rename_associations(part)
56
+ @ambiguous.reduce(part) { |current, column| qualify_column(current, column) }
57
+ end
58
+ end
59
+
60
+ def outside_subqueries(sql)
61
+ output = +""
62
+ rest = sql
63
+ while (start = rest =~ SUBQUERY)
64
+ finish = closing_paren(rest, start)
65
+ output << yield(rest[0...start]) << rest[start..finish]
66
+ rest = rest[(finish + 1)..]
67
+ end
68
+ output << yield(rest)
69
+ end
70
+
71
+ def closing_paren(sql, start)
72
+ depth = 0
73
+ (start...sql.length).each do |index|
74
+ depth += 1 if sql[index] == "("
75
+ depth -= 1 if sql[index] == ")"
76
+ return index if depth.zero?
77
+ end
78
+ sql.length - 1
79
+ end
80
+
81
+ def rename_associations(sql)
82
+ joined_names.reduce(sql) do |current, (name, table)|
83
+ current.gsub(/(?<![\w."])#{Regexp.escape(name)}\.(?=\w)/) do
84
+ @renamed[name] = table
85
+ "#{table}."
86
+ end
87
+ end
88
+ end
89
+
90
+ def joined_names
91
+ @joined_names ||= joined_tables(sql_calls).reject { |name, table| name == table }
92
+ end
93
+
94
+ def qualify_column(sql, column)
95
+ sql.gsub(/(?<![\w."'])(?<!AS\s)(?<!as\s)#{Regexp.escape(column)}(?![\w"'(])/) do
96
+ @qualified << column unless @qualified.include?(column)
97
+ "#{@model.table}.#{column}"
98
+ end
99
+ end
100
+
101
+ def note
102
+ parts = []
103
+ if @qualified.any?
104
+ parts << "#{list(@qualified)} is in more than one of the joined tables, so it now " \
105
+ "reads #{@model.table}.#{@qualified.first}, the table the query starts from"
106
+ end
107
+ @renamed.each { |name, table| parts << "`#{name}.` is the association; SQL needs the table, #{table}" }
108
+ "#{parts.join("; ")}."
109
+ end
110
+
111
+ def list(columns)
112
+ columns.map { |column| "`#{column}`" }.join(", ")
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ripper"
4
+
5
+ module RailsAgentConsole
6
+ module Rewriter
7
+ module Quotes
8
+ SINGLE_QUOTED_SQL = /(\w\()'(.*?)'(\)(?=[.)\s,]|\z))/m
9
+ ARGUMENT_BREAK = /'\s*,/
10
+ NOTE = "SQL with its own single quotes was inside a single-quoted Ruby string, " \
11
+ "so the Ruby string now uses double quotes."
12
+
13
+ module_function
14
+
15
+ def fix(code)
16
+ broken = Ripper.sexp(code).nil?
17
+ fixed = code.gsub(SINGLE_QUOTED_SQL) do
18
+ prefix, sql, suffix = Regexp.last_match.captures
19
+ nested?(sql, broken) ? %(#{prefix}"#{sql}"#{suffix}) : Regexp.last_match(0)
20
+ end
21
+ fixed != code && Ripper.sexp(fixed) ? [fixed, NOTE] : [code, nil]
22
+ end
23
+
24
+ def nested?(sql, broken)
25
+ sql.include?("'") && !sql.include?('"') && (broken || !sql.match?(ARGUMENT_BREAK))
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAgentConsole
4
+ module Rewriter
5
+ class SqlRepair
6
+ STRING_LITERAL = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/
7
+ AGGREGATE_WITHOUT_PARENS = /\b(AVG|SUM|MIN|MAX|COUNT)\s+(\w+\((?:[^()]|\([^()]*\))*\))/i
8
+ POSTFIX_LENGTH = /\b((?:\w+\.)?\w+(?:::\w+)?)\s+length\b(?!\s*\()/i
9
+ AVERAGE_OF = /\bAVG\(\s*((?:\w+\.)?\w+)\s*\)/i
10
+ PAD_NUMBER = /\b([LR]PAD)\(\s*(EXTRACT\([^()]*\))\s*,/i
11
+ INTEGER_DIVISION = %r{
12
+ \b((?:SUM|COUNT)\((?:[^()]|\((?:[^()]|\([^()]*\))*\))*\))
13
+ (\s*/\s*)
14
+ (?=(?:COUNT|SUM|NULLIF)\s*\()
15
+ }xi
16
+ ALREADY_DECIMAL = /(?:\d\.\d+\s*\*\s*|::(?:float|numeric|decimal|real)\w*\s*\(?\s*)\z/i
17
+ SQL_WORDS = %w[select where and or not by as on from having order group].to_set.freeze
18
+ TIME_TYPES = %w[datetime timestamp timestamptz date time].to_set.freeze
19
+ NO_ALIAS = %w[group order reorder].to_set.freeze
20
+
21
+ NOTES = {
22
+ parens: "an aggregate takes its argument in parentheses, AVG(LENGTH(x))",
23
+ length: "LENGTH is a function, LENGTH(x), not a postfix",
24
+ alias: "AS names a selected column; GROUP BY and ORDER BY take the expression alone",
25
+ pad: "LPAD pads text, so the number from EXTRACT is cast to text first",
26
+ division: "a count divided by a count is whole-number division, so it is multiplied by 1.0 first",
27
+ epoch: "SQL has no average of a timestamp, so it averages EXTRACT(EPOCH FROM ...), " \
28
+ "which orders the same way"
29
+ }.freeze
30
+
31
+ def initialize(chain, model)
32
+ @chain = chain
33
+ @model = model
34
+ @fixed = []
35
+ end
36
+
37
+ def rewrite
38
+ calls = @chain.calls.map { |call| JOINS.key?(call.name) ? call : repair(call) }
39
+ return if @fixed.empty?
40
+
41
+ [@chain.with_calls(calls), "In the SQL: #{@fixed.uniq.map { |key| NOTES[key] }.join("; ")}."]
42
+ end
43
+
44
+ private
45
+
46
+ def repair(call)
47
+ text = call.text.gsub(STRING_LITERAL) do |literal|
48
+ quote = literal[0]
49
+ "#{quote}#{fix(literal[1...-1], call.name)}#{quote}"
50
+ end
51
+ text == call.text ? call : Chain::Call.new(call.name, text)
52
+ end
53
+
54
+ def fix(sql, name)
55
+ sql = replace(sql, AGGREGATE_WITHOUT_PARENS, :parens) { |match| "#{match[1]}(#{match[2]})" }
56
+ sql = replace(sql, POSTFIX_LENGTH, :length) { |match| postfix_length(match) }
57
+ sql = replace(sql, AVERAGE_OF, :epoch) { |match| average_of(match) }
58
+ sql = replace(sql, PAD_NUMBER, :pad) { |match| "#{match[1]}(#{match[2]}::text," }
59
+ sql = replace(sql, INTEGER_DIVISION, :division) { |match| decimal_division(match) }
60
+ NO_ALIAS.include?(name) ? drop_aliases(sql) : sql
61
+ end
62
+
63
+ def replace(sql, pattern, key)
64
+ sql.gsub(pattern) do
65
+ match = Regexp.last_match
66
+ replacement = yield(match)
67
+ @fixed << key unless replacement == match[0]
68
+ replacement
69
+ end
70
+ end
71
+
72
+ def decimal_division(match)
73
+ return match[0] if match.pre_match.match?(ALREADY_DECIMAL)
74
+
75
+ "#{match[1]} * 1.0#{match[2]}"
76
+ end
77
+
78
+ def postfix_length(match)
79
+ SQL_WORDS.include?(match[1].downcase) ? match[0] : "LENGTH(#{match[1]})"
80
+ end
81
+
82
+ def average_of(match)
83
+ time_column?(match[1]) ? "AVG(EXTRACT(EPOCH FROM #{match[1]}))" : match[0]
84
+ end
85
+
86
+ def time_column?(reference)
87
+ table, column = reference.include?(".") ? reference.split(".", 2) : [nil, reference]
88
+ model = table ? Schema.models.find { |candidate| candidate.table == table } : @model
89
+ type = model&.columns&.find { |name, _| name == column }&.[](1)
90
+ TIME_TYPES.include?(type.to_s)
91
+ end
92
+
93
+ def drop_aliases(sql)
94
+ depth = 0
95
+ quoted = false
96
+ out = +""
97
+ index = 0
98
+ while index < sql.length
99
+ char = sql[index]
100
+ quoted = !quoted if char == "'"
101
+ depth += { "(" => 1, ")" => -1 }.fetch(char, 0) unless quoted
102
+ if !quoted && depth.zero? && (alias_match = sql[index..].match(/\A\s+AS\s+"?\w+"?/i))
103
+ @fixed << :alias
104
+ index += alias_match[0].length
105
+ next
106
+ end
107
+ out << char
108
+ index += 1
109
+ end
110
+ out
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "rewriter/chain"
6
+ require_relative "rewriter/date_ranges"
7
+
8
+ module RailsAgentConsole
9
+ module Rewriter
10
+ Result = Struct.new(:code, :notes)
11
+
12
+ RELATION = %w[
13
+ all where not or and rewhere unscope joins left_joins left_outer_joins includes
14
+ preload eager_load references group having order reorder limit offset distinct
15
+ select readonly none
16
+ ].to_set.freeze
17
+
18
+ JOINS = { "joins" => "INNER", "left_joins" => "LEFT OUTER", "left_outer_joins" => "LEFT OUTER" }.freeze
19
+ AGGREGATES = %w[count sum average minimum maximum calculate].to_set.freeze
20
+ MOVABLE = %w[where having order reorder limit offset].to_set.freeze
21
+ REWRITES = %i[
22
+ spell_out_joins repair_arguments repair_sql qualify_columns aggregate_last first_group drop_distinct
23
+ ].freeze
24
+
25
+ class << self
26
+ def apply(code)
27
+ code, quote_note = Quotes.fix(code.to_s)
28
+ code, range_note = DateRanges.fix(code)
29
+ notes = [quote_note, range_note].compact
30
+ chain = Chain.parse(code)
31
+ return Result.new(code, notes) if chain.nil?
32
+
33
+ rewritten_notes = REWRITES.filter_map do |name|
34
+ rewritten, note = method(name).call(chain)
35
+ next if rewritten.nil?
36
+
37
+ chain = rewritten
38
+ note
39
+ end
40
+ Result.new(rewritten_notes.empty? ? code : chain.to_s, notes + rewritten_notes)
41
+ end
42
+
43
+ private
44
+
45
+ def spell_out_joins(chain)
46
+ model = Schema.find(chain.receiver)
47
+ return if model.nil? || model.inbound.empty?
48
+
49
+ spelled = []
50
+ calls = relation_calls(chain).flat_map do |call|
51
+ replacement = spell_out(call, model)
52
+ next [call] if replacement.nil?
53
+
54
+ spelled.concat(replacement[:tables])
55
+ replacement[:calls]
56
+ end
57
+ return if spelled.empty?
58
+
59
+ [chain.with_calls(calls + chain.calls.drop(calls_scanned(chain))), joins_note(model, spelled)]
60
+ end
61
+
62
+ def spell_out(call, model)
63
+ kind = JOINS[call.name]
64
+ names = kind && call.symbol_arguments
65
+ return if names.nil?
66
+
67
+ references = names.to_h { |name| [name, missing_association(model, name)] }
68
+ return if references.values.none?
69
+
70
+ missing = references.values.compact
71
+ kept = references.select { |_, reference| reference.nil? }.keys
72
+ calls = kept_joins(call, kept) + missing.map { |reference| sql_join(kind, model, reference) }
73
+ { calls: calls, tables: missing.map { |reference| reference[:table] } }
74
+ end
75
+
76
+ def kept_joins(call, names)
77
+ return [] if names.empty?
78
+
79
+ [Chain::Call.new(call.name, ".#{call.name}(#{names.map { |name| ":#{name}" }.join(", ")})")]
80
+ end
81
+
82
+ def missing_association(model, name)
83
+ return if model.association_names.include?(name)
84
+
85
+ model.inbound.find { |reference| reference[:table].to_s == name }
86
+ end
87
+
88
+ def sql_join(kind, model, reference)
89
+ on = "#{reference[:table]}.#{reference[:foreign_key]} = #{model.table}.#{primary_key(model)}"
90
+ Chain::Call.new("joins", %(.joins("#{kind} JOIN #{reference[:table]} ON #{on}")))
91
+ end
92
+
93
+ def primary_key(model)
94
+ column = model.columns.find { |_, _, extra| extra.to_s.include?("pk") }
95
+ column ? column.first : "id"
96
+ end
97
+
98
+ def joins_note(model, tables)
99
+ "#{model.name} has no has_many for #{tables.join(" or ")}, so the join is written out " \
100
+ "in SQL, the same one the association would have built."
101
+ end
102
+
103
+ def drop_distinct(chain)
104
+ Distinct.drop(chain)
105
+ end
106
+
107
+ def qualify_columns(chain)
108
+ model = Schema.find(chain.receiver)
109
+ model && Qualifier.new(chain, model).rewrite
110
+ end
111
+
112
+ def repair_arguments(chain)
113
+ model = Schema.find(chain.receiver)
114
+ model && Arguments.new(chain, model).rewrite
115
+ end
116
+
117
+ def repair_sql(chain)
118
+ SqlRepair.new(chain, Schema.find(chain.receiver)).rewrite
119
+ end
120
+
121
+ def aggregate_last(chain)
122
+ index = chain.calls.index { |call| AGGREGATES.include?(call.name) }
123
+ return if index.nil?
124
+
125
+ before = chain.calls.first(index)
126
+ after = chain.calls.drop(index + 1)
127
+ return unless misplaced?(before, after)
128
+
129
+ aggregate = chain.calls[index]
130
+ [chain.with_calls(before + after + [aggregate]), aggregate_note(aggregate, after)]
131
+ end
132
+
133
+ def first_group(chain)
134
+ index = calls_scanned(chain)
135
+ return unless chain.calls[index]&.text&.match?(/\A\.\s*first\s*\z/)
136
+
137
+ column = grouped_column(chain.calls.first(index))
138
+ return if column.nil?
139
+
140
+ rest = chain.calls.drop(index + 1)
141
+ rest = rest.drop(1) if rest.first&.text&.match?(/\A\.\s*#{column}\s*\z/)
142
+ pluck = Chain::Call.new("pluck", ".limit(1).pluck(:#{column}).first")
143
+ [chain.with_calls(chain.calls.first(index) + [pluck] + rest), first_group_note(column)]
144
+ end
145
+
146
+ def grouped_column(calls)
147
+ columns = calls.select { |call| call.name == "group" }.flat_map { |call| call.symbol_arguments || [] }
148
+ columns.first if columns.size == 1
149
+ end
150
+
151
+ def first_group_note(column)
152
+ "`first` on a grouped relation orders by id, which the GROUP BY does not contain, " \
153
+ "so the top group's #{column} is plucked instead."
154
+ end
155
+
156
+ def misplaced?(before, after)
157
+ after.any? && after.all? { |call| MOVABLE.include?(call.name) } &&
158
+ before.all? { |call| RELATION.include?(call.name) }
159
+ end
160
+
161
+ def aggregate_note(aggregate, moved)
162
+ names = moved.map { |call| "`#{call.name}`" }.uniq.join(" and ")
163
+ "#{names} came after `#{aggregate.name}`, which returns a result rather than a " \
164
+ "relation, so they now run before it."
165
+ end
166
+
167
+ def relation_calls(chain)
168
+ chain.calls.first(calls_scanned(chain))
169
+ end
170
+
171
+ def calls_scanned(chain)
172
+ chain.calls.index { |call| !RELATION.include?(call.name) } || chain.calls.size
173
+ end
174
+ end
175
+ end
176
+ end
177
+
178
+ require_relative "rewriter/distinct"
179
+ require_relative "rewriter/joined"
180
+ require_relative "rewriter/quotes"
181
+ require_relative "rewriter/arguments"
182
+ require_relative "rewriter/qualifier"
183
+ require_relative "rewriter/sql_repair"