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,305 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
require_relative "query_validator/model_suggestion"
|
|
7
|
+
require_relative "query_validator/sensitive_columns"
|
|
8
|
+
require_relative "query_validator/rules"
|
|
9
|
+
|
|
10
|
+
module RailsAgentConsole
|
|
11
|
+
class QueryValidator
|
|
12
|
+
include Rules
|
|
13
|
+
|
|
14
|
+
def self.validate(code, write_mode: RailsAgentConsole.configuration.write_mode?)
|
|
15
|
+
new(code, write_mode: write_mode).validate
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(code, write_mode: false)
|
|
19
|
+
@code = code.to_s
|
|
20
|
+
@write_mode = write_mode
|
|
21
|
+
@violations = []
|
|
22
|
+
@methods = []
|
|
23
|
+
@constants = []
|
|
24
|
+
@writes = []
|
|
25
|
+
@unknown = []
|
|
26
|
+
@unknown_constants = []
|
|
27
|
+
@bare = []
|
|
28
|
+
@blocked = false
|
|
29
|
+
@syntax_error = false
|
|
30
|
+
@locals = Set.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate
|
|
34
|
+
if @code.strip.empty?
|
|
35
|
+
@violations << "empty code"
|
|
36
|
+
return result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sexp = Ripper.sexp(@code)
|
|
40
|
+
if sexp.nil?
|
|
41
|
+
@syntax_error = true
|
|
42
|
+
@violations << syntax_violation
|
|
43
|
+
return result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
collect_locals(sexp)
|
|
47
|
+
inspect_node(sexp)
|
|
48
|
+
classify_methods
|
|
49
|
+
check_sensitive_columns
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def syntax_violation
|
|
56
|
+
message = ParseError.message_for(@code)
|
|
57
|
+
message ? "the code is not valid Ruby: #{message}" : "the code is not valid Ruby"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def result
|
|
61
|
+
Result.new(code: @code, violations: @violations, writes: @writes,
|
|
62
|
+
methods: @methods, constants: @constants, unknown: @unknown,
|
|
63
|
+
unknown_constants: @unknown_constants, blocked: @blocked,
|
|
64
|
+
syntax_error: @syntax_error)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def collect_locals(node, inside_params: false)
|
|
68
|
+
return unless node.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
case node.first
|
|
71
|
+
when :var_field, :params, :block_var, :rest_param, :kwrest_param, :blockarg
|
|
72
|
+
collect_identifiers(node)
|
|
73
|
+
inside_params = true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
node.each { |child| collect_locals(child, inside_params: inside_params) if child.is_a?(Array) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def collect_identifiers(node)
|
|
80
|
+
return unless node.is_a?(Array)
|
|
81
|
+
|
|
82
|
+
@locals << node[1] if node.first == :@ident && node[1].is_a?(String)
|
|
83
|
+
node.each { |child| collect_identifiers(child) if child.is_a?(Array) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inspect_node(node)
|
|
87
|
+
return unless node.is_a?(Array)
|
|
88
|
+
return if handled_as_data?(node)
|
|
89
|
+
return if handled_as_declaration?(node)
|
|
90
|
+
return if handled_as_call?(node)
|
|
91
|
+
|
|
92
|
+
inspect_children(node)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handled_as_data?(node)
|
|
96
|
+
case node.first
|
|
97
|
+
when :symbol_literal, :@label
|
|
98
|
+
true
|
|
99
|
+
when :@tstring_content
|
|
100
|
+
check_string(node[1])
|
|
101
|
+
true
|
|
102
|
+
when :@const
|
|
103
|
+
@constants << node[1]
|
|
104
|
+
true
|
|
105
|
+
else
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handled_as_declaration?(node)
|
|
111
|
+
case node.first
|
|
112
|
+
when :xstring_literal
|
|
113
|
+
block("shell command execution via backticks")
|
|
114
|
+
when :def, :defs, :class, :module, :sclass
|
|
115
|
+
block("defining methods or classes is not allowed in a generated query")
|
|
116
|
+
when :@ivar, :@gvar, :@cvar
|
|
117
|
+
block("access to variable #{node[1]}")
|
|
118
|
+
when :field
|
|
119
|
+
block("attribute assignment")
|
|
120
|
+
inspect_children(node)
|
|
121
|
+
when :super, :zsuper
|
|
122
|
+
block("super")
|
|
123
|
+
else
|
|
124
|
+
return false
|
|
125
|
+
end
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handled_as_call?(node)
|
|
130
|
+
case node.first
|
|
131
|
+
when :call
|
|
132
|
+
symbol_receiver(node)
|
|
133
|
+
record_message(node[3], receiver: node[1])
|
|
134
|
+
inspect_node(node[1])
|
|
135
|
+
when :command_call
|
|
136
|
+
record_message(node[3], receiver: node[1])
|
|
137
|
+
inspect_node(node[1])
|
|
138
|
+
inspect_node(node[4])
|
|
139
|
+
when :fcall
|
|
140
|
+
record_message(node[1])
|
|
141
|
+
when :command
|
|
142
|
+
record_message(node[1])
|
|
143
|
+
inspect_node(node[2])
|
|
144
|
+
when :vcall
|
|
145
|
+
record_bare(node[1])
|
|
146
|
+
else
|
|
147
|
+
return false
|
|
148
|
+
end
|
|
149
|
+
true
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def symbol_receiver(node)
|
|
153
|
+
return unless node[1].is_a?(Array) && node[1].first == :symbol_literal
|
|
154
|
+
return unless node[3].is_a?(Array) && node[3].first == :@ident
|
|
155
|
+
return if %w[to_s to_sym to_proc].include?(node[3][1])
|
|
156
|
+
|
|
157
|
+
name = node[1].flatten.find { |part| part.is_a?(String) }
|
|
158
|
+
violate("`:#{name}.#{node[3][1]}` calls #{node[3][1]} on a Symbol, not on a table. " \
|
|
159
|
+
"A column of a joined table is written in SQL: where(\"#{name.pluralize}.#{node[3][1]} = ...\")")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def inspect_children(node)
|
|
163
|
+
node.each { |child| inspect_node(child) if child.is_a?(Array) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def record_message(message, receiver: nil)
|
|
167
|
+
return @methods << "call" if message == :call
|
|
168
|
+
return unless message.is_a?(Array)
|
|
169
|
+
|
|
170
|
+
case message.first
|
|
171
|
+
when :@ident, :@op, :@kw
|
|
172
|
+
@methods << message[1] unless read_on_receiver?(message[1], receiver)
|
|
173
|
+
when :@const then @constants << message[1]
|
|
174
|
+
else inspect_node(message)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def read_on_receiver?(name, receiver)
|
|
179
|
+
value_construction?(name, receiver) || relation_load?(name, receiver)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def relation_load?(name, receiver)
|
|
183
|
+
return false unless name == "load" && !receiver.nil?
|
|
184
|
+
|
|
185
|
+
constant = constant_name(receiver)
|
|
186
|
+
constant.nil? || !UNSAFE_LOAD_RECEIVERS.include?(constant)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def value_construction?(name, receiver)
|
|
190
|
+
return false unless CONSTRUCTORS.include?(name)
|
|
191
|
+
|
|
192
|
+
constant = constant_name(receiver)
|
|
193
|
+
!constant.nil? && VALUE_CONSTANTS.include?(constant) && !model_constants.include?(constant)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def constant_name(node)
|
|
197
|
+
return unless node.is_a?(Array)
|
|
198
|
+
|
|
199
|
+
case node.first
|
|
200
|
+
when :var_ref, :top_const_ref then constant_name(node[1])
|
|
201
|
+
when :const_path_ref then constant_name(node[2])
|
|
202
|
+
when :@const then node[1]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def record_bare(message)
|
|
207
|
+
return unless message.is_a?(Array) && message.first == :@ident
|
|
208
|
+
return if @locals.include?(message[1])
|
|
209
|
+
|
|
210
|
+
@methods << message[1]
|
|
211
|
+
@bare << message[1]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def check_string(value)
|
|
215
|
+
block("raw SQL that modifies data: #{value.strip.inspect}") if value.to_s.match?(DANGEROUS_SQL)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def classify_methods
|
|
219
|
+
@methods.uniq.each do |name|
|
|
220
|
+
if FORBIDDEN.include?(name)
|
|
221
|
+
block("`#{name}` is never allowed from the agent console")
|
|
222
|
+
elsif WRITE.include?(name)
|
|
223
|
+
@writes << name
|
|
224
|
+
violate("`#{name}` writes to the database (read-only mode)") unless @write_mode
|
|
225
|
+
elsif allowed?(name)
|
|
226
|
+
next
|
|
227
|
+
else
|
|
228
|
+
@unknown << name
|
|
229
|
+
violate("`#{name}` is not on the read-only allowlist")
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
@constants.uniq.each do |name|
|
|
234
|
+
next if allowed_constant?(name)
|
|
235
|
+
|
|
236
|
+
if BLOCKED_CONSTANTS.include?(name)
|
|
237
|
+
block("constant `#{name}` is never allowed from the agent console")
|
|
238
|
+
else
|
|
239
|
+
@unknown_constants << name
|
|
240
|
+
violate("constant `#{name}` is not allowed#{ModelSuggestion.for(name)}")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def allowed?(name)
|
|
246
|
+
return allowed_bare?(name) if @bare.include?(name)
|
|
247
|
+
|
|
248
|
+
ALLOWED.include?(name) || schema_readers.include?(name) ||
|
|
249
|
+
extra_allowed_methods.include?(name) ||
|
|
250
|
+
(name.end_with?("?") && ALLOWED.include?(name.chomp("?")))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def allowed_bare?(name)
|
|
254
|
+
ALLOWED.include?(name) || extra_allowed_methods.include?(name) ||
|
|
255
|
+
(name.end_with?("?") && ALLOWED.include?(name.chomp("?")))
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def schema_readers
|
|
259
|
+
@schema_readers ||= Set.new(
|
|
260
|
+
Schema.models.flat_map do |model|
|
|
261
|
+
model.column_names + model.column_names.map { |column| "#{column}?" } + model.association_names
|
|
262
|
+
end
|
|
263
|
+
)
|
|
264
|
+
rescue StandardError
|
|
265
|
+
Set.new
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def allowed_constant?(name)
|
|
269
|
+
SAFE_CONSTANTS.include?(name) || model_constants.include?(name) ||
|
|
270
|
+
extra_allowed_constants.include?(name)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def model_constants
|
|
274
|
+
@model_constants ||= Set.new(Schema.model_names.flat_map { |model| model.split("::") })
|
|
275
|
+
rescue StandardError
|
|
276
|
+
Set.new
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def extra_allowed_methods
|
|
280
|
+
Set.new(RailsAgentConsole.configuration.extra_allowed_methods.map(&:to_s))
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def extra_allowed_constants
|
|
284
|
+
Set.new(RailsAgentConsole.configuration.extra_allowed_constants.map(&:to_s))
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def check_sensitive_columns
|
|
288
|
+
SensitiveColumns.named_in(@code).each do |name|
|
|
289
|
+
block("`#{name}` holds a credential and is never read by the agent")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def violate(message)
|
|
294
|
+
@violations << message
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def block(message)
|
|
298
|
+
@blocked = true
|
|
299
|
+
violate(message)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
require_relative "query_validator/parse_error"
|
|
305
|
+
require_relative "query_validator/result"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module RailsAgentConsole
|
|
6
|
+
class Railtie < ::Rails::Railtie
|
|
7
|
+
config.rails_agent_console = ActiveSupport::OrderedOptions.new
|
|
8
|
+
|
|
9
|
+
initializer "rails_agent_console.configure" do |app|
|
|
10
|
+
app.config.rails_agent_console.each do |key, value|
|
|
11
|
+
setter = "#{key}="
|
|
12
|
+
configuration = RailsAgentConsole.configuration
|
|
13
|
+
configuration.public_send(setter, value) if configuration.respond_to?(setter)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
console do
|
|
18
|
+
require "irb"
|
|
19
|
+
require "rails_agent_console/console_installer"
|
|
20
|
+
require "rails_agent_console/console_session"
|
|
21
|
+
|
|
22
|
+
RailsAgentConsole::ConsoleInstaller.install!
|
|
23
|
+
RailsAgentConsole::ConsoleSession.start
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Rewriter
|
|
5
|
+
class Arguments
|
|
6
|
+
ARGUMENTS = /\A\.\s*\w+[?!]?\s*\((.*)\)\s*\z/m
|
|
7
|
+
STRING_ARGUMENT = /\A\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\z/
|
|
8
|
+
COLUMN_PAIR = /\A\s*(\w+):\s*([a-z_]\w*)\s*\z/
|
|
9
|
+
SYMBOL_PAIR = /\A\s*(\w+):\s*:(\w+)\s*\z/
|
|
10
|
+
SINGLE_PAIR = /\A\s*(\w+):\s*(.+?)\s*\z/m
|
|
11
|
+
HASH_KEY = /(?<![\w.:])(\w+):\s*\{/
|
|
12
|
+
ORDER_COLUMN = /\A\s*:?(\w+)(?::\s*:(asc|desc))?\s*\z/i
|
|
13
|
+
WEEKDAY = /\A\s*(\w+):\s*(.*\bwday\b.*?)\s*\z/m
|
|
14
|
+
WEEKDAY_TEST = /(?:Time\.(?:current|now)|Date\.(?:current|today))\.wday\s*==\s*([0-6])/
|
|
15
|
+
RAW_SQL_CALLS = %w[pluck pick order reorder].to_set.freeze
|
|
16
|
+
COMPARE_CALLS = %w[where not rewhere].to_set.freeze
|
|
17
|
+
DIRECTIONS = %w[asc desc].to_set.freeze
|
|
18
|
+
VALUES = %w[true false nil].to_set.freeze
|
|
19
|
+
|
|
20
|
+
NOTES = {
|
|
21
|
+
compare: "a hash value is a Ruby value, so comparing two columns is written in SQL",
|
|
22
|
+
weekday: "Time.current.wday is today's weekday, worked out once; the day of each row is EXTRACT(DOW ...)",
|
|
23
|
+
order: "ordering by a joined table's column is written as table.column",
|
|
24
|
+
joined_key: "the column belongs to a joined table, so the condition names that table",
|
|
25
|
+
table_key: "a condition names the joined table rather than the association, " \
|
|
26
|
+
"so every reference to it agrees",
|
|
27
|
+
order_in_group: "a grouped query can only order by what it groups or aggregates, so it orders by MIN(...)",
|
|
28
|
+
raw_sql: "Rails only accepts an SQL expression in pluck and order through Arel.sql",
|
|
29
|
+
count: "counting a joined table counts its id",
|
|
30
|
+
nest: "an association of a joined model is joined through it",
|
|
31
|
+
bare: "an association is named with a symbol, not a bare word"
|
|
32
|
+
}.freeze
|
|
33
|
+
ASSOCIATION_CALLS = (JOINS.keys + %w[includes preload eager_load]).to_set.freeze
|
|
34
|
+
BARE_WORDS = /\A\s*[a-z_]\w*(?:\s*,\s*[a-z_]\w*)*\s*\z/
|
|
35
|
+
|
|
36
|
+
def initialize(chain, model)
|
|
37
|
+
@chain = chain
|
|
38
|
+
@model = model
|
|
39
|
+
@fixed = []
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def rewrite
|
|
43
|
+
calls = nest_joins(@chain.calls)
|
|
44
|
+
tables = Joined.tables(calls, @model)
|
|
45
|
+
@grouped = calls.select { |call| call.name == "group" }.map(&:symbol_arguments)
|
|
46
|
+
calls = calls.map { |call| fix(call, tables) }
|
|
47
|
+
return if @fixed.empty?
|
|
48
|
+
|
|
49
|
+
[@chain.with_calls(calls), "#{@fixed.uniq.map { |key| NOTES[key] }.join("; ").sub(/\A./, &:upcase)}."]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def bare_association(call, inner)
|
|
55
|
+
return unless ASSOCIATION_CALLS.include?(call.name) && inner.match?(BARE_WORDS)
|
|
56
|
+
|
|
57
|
+
names = inner.split(",").map(&:strip)
|
|
58
|
+
return unless (names - @model.association_names).empty?
|
|
59
|
+
|
|
60
|
+
@fixed << :bare
|
|
61
|
+
names.map { |name| ":#{name}" }.join(", ")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def fix(call, tables)
|
|
65
|
+
original = call.text[ARGUMENTS, 1]
|
|
66
|
+
return call if original.nil?
|
|
67
|
+
|
|
68
|
+
inner = table_keys(call, original, tables)
|
|
69
|
+
replacement = bare_association(call, inner) || compare_columns(call, inner) || weekday(call, inner) ||
|
|
70
|
+
joined_key(call, inner, tables) ||
|
|
71
|
+
order_through(call, inner, tables) || order_in_group(call, inner) || wrap_sql(call, inner) ||
|
|
72
|
+
count_joined(call, inner, tables) || (inner unless inner == original)
|
|
73
|
+
replacement ? Chain::Call.new(call.name, ".#{call.name}(#{replacement})") : call
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def table_keys(call, inner, tables)
|
|
77
|
+
return inner unless COMPARE_CALLS.include?(call.name)
|
|
78
|
+
|
|
79
|
+
inner.gsub(HASH_KEY) do |key|
|
|
80
|
+
table = tables[Regexp.last_match(1)]
|
|
81
|
+
next key if table.nil? || table == Regexp.last_match(1)
|
|
82
|
+
|
|
83
|
+
@fixed << :table_key
|
|
84
|
+
"#{table}: {"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def compare_columns(call, inner)
|
|
89
|
+
left, right = inner.match(COLUMN_PAIR)&.captures
|
|
90
|
+
return unless COMPARE_CALLS.include?(call.name) && left && !VALUES.include?(right)
|
|
91
|
+
return unless ([left, right] - @model.column_names).empty?
|
|
92
|
+
|
|
93
|
+
fixed(:compare, %("#{@model.table}.#{left} = #{@model.table}.#{right}"))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def weekday(call, inner)
|
|
97
|
+
column, condition = inner.match(WEEKDAY)&.captures
|
|
98
|
+
return unless COMPARE_CALLS.include?(call.name) && column && @model.column_names.include?(column)
|
|
99
|
+
return unless condition.gsub(WEEKDAY_TEST, "").match?(/\A[\s()|]*\z/)
|
|
100
|
+
|
|
101
|
+
days = condition.scan(WEEKDAY_TEST).flatten.uniq.join(", ")
|
|
102
|
+
fixed(:weekday, %("EXTRACT(DOW FROM #{@model.table}.#{column}) IN (#{days})"))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def joined_key(call, inner, tables)
|
|
106
|
+
key, value = inner.match(SINGLE_PAIR)&.captures
|
|
107
|
+
return unless COMPARE_CALLS.include?(call.name) && key && !@model.column_names.include?(key)
|
|
108
|
+
return unless Chain::Scanner.single_argument?(value)
|
|
109
|
+
|
|
110
|
+
owners = tables.values.uniq.select { |table| Joined.model_for(table)&.column_names&.include?(key) }
|
|
111
|
+
fixed(:joined_key, "#{owners.first}: { #{key}: #{value} }") if owners.size == 1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def order_through(call, inner, tables)
|
|
115
|
+
name, column = inner.match(SYMBOL_PAIR)&.captures
|
|
116
|
+
return unless RAW_SQL_CALLS.include?(call.name) && name && !DIRECTIONS.include?(column.downcase)
|
|
117
|
+
|
|
118
|
+
table = tables[name] || (name if tables.value?(name))
|
|
119
|
+
return unless table && Joined.model_for(table)&.column_names&.include?(column)
|
|
120
|
+
|
|
121
|
+
fixed(:order, %("#{table}.#{column}"))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def order_in_group(call, inner)
|
|
125
|
+
column, direction = inner.match(ORDER_COLUMN)&.captures
|
|
126
|
+
return unless %w[order reorder].include?(call.name) && column && grouped_elsewhere?(column)
|
|
127
|
+
|
|
128
|
+
suffix = direction ? " #{direction.upcase}" : ""
|
|
129
|
+
fixed(:order_in_group, %(Arel.sql("MIN(#{@model.table}.#{column})#{suffix}")))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def grouped_elsewhere?(column)
|
|
133
|
+
return false if @grouped.empty? || @grouped.any?(&:nil?)
|
|
134
|
+
|
|
135
|
+
!@grouped.flatten.include?(column) && @model.column_names.include?(column)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def wrap_sql(call, inner)
|
|
139
|
+
literal = inner[STRING_ARGUMENT, 1]
|
|
140
|
+
return unless RAW_SQL_CALLS.include?(call.name) && literal&.include?("(")
|
|
141
|
+
|
|
142
|
+
fixed(:raw_sql, "Arel.sql(#{literal})")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def count_joined(call, inner, tables)
|
|
146
|
+
name = inner[/\A\s*:(\w+)\s*\z/, 1]
|
|
147
|
+
return unless call.name == "count" && name && !@model.column_names.include?(name)
|
|
148
|
+
|
|
149
|
+
table = joined_table(name.delete_suffix("_id"), tables)
|
|
150
|
+
table && fixed(:count, %("#{table}.id"))
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def joined_table(name, tables)
|
|
154
|
+
tables[name] || tables.values.find { |table| [name, name.pluralize].include?(table) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def nest_joins(calls)
|
|
158
|
+
joined = []
|
|
159
|
+
calls.map do |call|
|
|
160
|
+
names = JOINS.key?(call.name) && call.symbol_arguments
|
|
161
|
+
next call unless names
|
|
162
|
+
|
|
163
|
+
nested = names.map { |name| nest(name, joined) }
|
|
164
|
+
joined.concat(names)
|
|
165
|
+
nested == names ? call : nested_call(call, names, nested)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def nest(name, joined)
|
|
170
|
+
return name if Joined.target(@model, name) || @model.inbound.any? { |ref| ref[:table].to_s == name }
|
|
171
|
+
|
|
172
|
+
parent = joined.find do |candidate|
|
|
173
|
+
through = Joined.target(@model, candidate)
|
|
174
|
+
through && Joined.target(through, name)
|
|
175
|
+
end
|
|
176
|
+
parent ? "#{parent}: :#{name}" : name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def nested_call(call, names, nested)
|
|
180
|
+
@fixed << :nest
|
|
181
|
+
plain = nested.select.with_index { |item, index| item == names[index] }.map { |name| ":#{name}" }
|
|
182
|
+
pairs = nested.reject.with_index { |item, index| item == names[index] }
|
|
183
|
+
Chain::Call.new(call.name, ".#{call.name}(#{(plain + pairs).join(", ")})")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def fixed(key, replacement)
|
|
187
|
+
@fixed << key
|
|
188
|
+
replacement
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Rewriter
|
|
5
|
+
class Chain
|
|
6
|
+
RECEIVER = /\A[A-Za-z_]\w*(?:::[A-Z]\w*)*\z/
|
|
7
|
+
CALL_NAME = /\A\.\s*(\w+[?!]?)/
|
|
8
|
+
|
|
9
|
+
Call = Struct.new(:name, :text) do
|
|
10
|
+
def symbol_arguments
|
|
11
|
+
inner = text[/\A\.\s*\w+[?!]?\s*\((.*)\)\s*\z/m, 1]
|
|
12
|
+
return unless inner&.match?(/\A\s*:\w+(?:\s*,\s*:\w+)*\s*\z/)
|
|
13
|
+
|
|
14
|
+
inner.scan(/:(\w+)/).flatten
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :receiver, :calls
|
|
19
|
+
|
|
20
|
+
def self.parse(code)
|
|
21
|
+
text = code.to_s.strip
|
|
22
|
+
return if text.empty? || text.match?(/\bdo\b|;/)
|
|
23
|
+
|
|
24
|
+
pieces = Scanner.new(text).pieces
|
|
25
|
+
return if pieces.nil? || pieces.size < 2 || !pieces.first.match?(RECEIVER)
|
|
26
|
+
|
|
27
|
+
calls = pieces.drop(1).map { |piece| Call.new(piece[CALL_NAME, 1], piece) }
|
|
28
|
+
return unless calls.all? { |call| call.name && Scanner.call_shape?(call.text.sub(CALL_NAME, "")) }
|
|
29
|
+
|
|
30
|
+
new(pieces.first, calls)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(receiver, calls)
|
|
34
|
+
@receiver = receiver
|
|
35
|
+
@calls = calls
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def with_calls(calls)
|
|
39
|
+
self.class.new(receiver, calls)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
receiver + calls.map(&:text).join
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Scanner
|
|
47
|
+
def self.call_shape?(rest)
|
|
48
|
+
new(rest).call_shape?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.single_argument?(text)
|
|
52
|
+
new(text).single_argument?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(text)
|
|
56
|
+
@text = text
|
|
57
|
+
@depth = 0
|
|
58
|
+
@quote = nil
|
|
59
|
+
@escaped = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def pieces
|
|
63
|
+
cuts = []
|
|
64
|
+
@text.each_char.with_index do |char, index|
|
|
65
|
+
next if in_string?(char)
|
|
66
|
+
return nil unless balanced_after?(char)
|
|
67
|
+
|
|
68
|
+
cuts << index if char == "." && @depth.zero? && call_dot?(index)
|
|
69
|
+
end
|
|
70
|
+
return unless @depth.zero? && @quote.nil?
|
|
71
|
+
|
|
72
|
+
([0] + cuts + [@text.length]).each_cons(2).map { |from, to| @text[from...to].strip }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def call_shape?
|
|
76
|
+
@text.each_char do |char|
|
|
77
|
+
return false if @quote.nil? && @depth.zero? && !char.match?(/[\s(){}]/)
|
|
78
|
+
next if in_string?(char)
|
|
79
|
+
return false unless balanced_after?(char)
|
|
80
|
+
end
|
|
81
|
+
@depth.zero? && @quote.nil?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def single_argument?
|
|
85
|
+
@text.each_char do |char|
|
|
86
|
+
next if in_string?(char)
|
|
87
|
+
return false unless balanced_after?(char)
|
|
88
|
+
return false if char == "," && @depth.zero?
|
|
89
|
+
end
|
|
90
|
+
@depth.zero? && @quote.nil?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def in_string?(char)
|
|
96
|
+
return string_opened?(char) if @quote.nil?
|
|
97
|
+
|
|
98
|
+
if @escaped then @escaped = false
|
|
99
|
+
elsif char == "\\" then @escaped = true
|
|
100
|
+
elsif char == @quote then @quote = nil
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def string_opened?(char)
|
|
106
|
+
return false unless ["'", '"'].include?(char)
|
|
107
|
+
|
|
108
|
+
@quote = char
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def balanced_after?(char)
|
|
113
|
+
@depth += 1 if "([{".include?(char)
|
|
114
|
+
@depth -= 1 if ")]}".include?(char)
|
|
115
|
+
@depth >= 0
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def call_dot?(index)
|
|
119
|
+
before = index.positive? ? @text[index - 1] : ""
|
|
120
|
+
after = @text[index + 1].to_s
|
|
121
|
+
before != "." && after != "." && !before.match?(/\d/) && after.match?(/[\s\w]/)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Rewriter
|
|
5
|
+
module DateRanges
|
|
6
|
+
INCLUSIVE_END = /
|
|
7
|
+
(?<![.])\.\.\s*
|
|
8
|
+
(?<date>Time\.zone\.parse\((?<quote>["'])\d{4}-\d{2}-\d{2}\k<quote>\))
|
|
9
|
+
(?!\s*\.\s*end_of_day)
|
|
10
|
+
/x
|
|
11
|
+
NOTE = "A range that ended on a date stopped at midnight and missed that day, so it now runs to end_of_day."
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def fix(code)
|
|
16
|
+
fixed = code.gsub(INCLUSIVE_END) { "..#{Regexp.last_match[:date]}.end_of_day" }
|
|
17
|
+
fixed == code ? [code, nil] : [fixed, NOTE]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|