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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +414 -0
- data/exe/rails-agent +7 -0
- data/lib/generators/rails_agent_console/install_generator.rb +17 -0
- data/lib/generators/rails_agent_console/templates/rails_agent_console.rb +21 -0
- data/lib/rails_agent_console/agent.rb +277 -0
- data/lib/rails_agent_console/cli.rb +114 -0
- data/lib/rails_agent_console/configuration.rb +170 -0
- data/lib/rails_agent_console/console_installer.rb +49 -0
- data/lib/rails_agent_console/console_methods.rb +54 -0
- data/lib/rails_agent_console/console_session.rb +41 -0
- data/lib/rails_agent_console/credentials.rb +66 -0
- data/lib/rails_agent_console/diagnosis/extra.rb +72 -0
- data/lib/rails_agent_console/diagnosis.rb +149 -0
- data/lib/rails_agent_console/errors.rb +20 -0
- data/lib/rails_agent_console/executor.rb +190 -0
- data/lib/rails_agent_console/history.rb +65 -0
- data/lib/rails_agent_console/model_picker.rb +166 -0
- data/lib/rails_agent_console/prompt/dates.rb +34 -0
- data/lib/rails_agent_console/prompt/follow_up.rb +38 -0
- data/lib/rails_agent_console/prompt.rb +122 -0
- data/lib/rails_agent_console/proposal.rb +208 -0
- data/lib/rails_agent_console/providers/anthropic.rb +48 -0
- data/lib/rails_agent_console/providers/base.rb +114 -0
- data/lib/rails_agent_console/providers/callable.rb +28 -0
- data/lib/rails_agent_console/providers/gemini.rb +45 -0
- data/lib/rails_agent_console/providers/ollama.rb +84 -0
- data/lib/rails_agent_console/providers/openai.rb +51 -0
- data/lib/rails_agent_console/providers.rb +28 -0
- data/lib/rails_agent_console/query_validator/model_suggestion.rb +35 -0
- data/lib/rails_agent_console/query_validator/parse_error.rb +23 -0
- data/lib/rails_agent_console/query_validator/result.rb +55 -0
- data/lib/rails_agent_console/query_validator/rules.rb +108 -0
- data/lib/rails_agent_console/query_validator/sensitive_columns.rb +22 -0
- data/lib/rails_agent_console/query_validator.rb +305 -0
- data/lib/rails_agent_console/railtie.rb +26 -0
- data/lib/rails_agent_console/rewriter/arguments.rb +192 -0
- data/lib/rails_agent_console/rewriter/chain.rb +126 -0
- data/lib/rails_agent_console/rewriter/date_ranges.rb +21 -0
- data/lib/rails_agent_console/rewriter/distinct.rb +41 -0
- data/lib/rails_agent_console/rewriter/joined.rb +42 -0
- data/lib/rails_agent_console/rewriter/qualifier.rb +116 -0
- data/lib/rails_agent_console/rewriter/quotes.rb +29 -0
- data/lib/rails_agent_console/rewriter/sql_repair.rb +114 -0
- data/lib/rails_agent_console/rewriter.rb +183 -0
- data/lib/rails_agent_console/schema.rb +275 -0
- data/lib/rails_agent_console/setup.rb +40 -0
- data/lib/rails_agent_console/ui.rb +125 -0
- data/lib/rails_agent_console/value_check.rb +85 -0
- data/lib/rails_agent_console/version.rb +5 -0
- data/lib/rails_agent_console/wizard.rb +235 -0
- data/lib/rails_agent_console.rb +58 -0
- 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"
|