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,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Schema
|
|
5
|
+
INTERNAL_PREFIXES = %w[
|
|
6
|
+
ActiveRecord:: ActiveStorage:: ActionText:: ActionMailbox::
|
|
7
|
+
SolidQueue:: SolidCache:: SolidCable:: Delayed:: PgSearch::
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
Model = Struct.new(:name, :table, :columns, :associations, :referenced_by, :abstract,
|
|
11
|
+
keyword_init: true) do
|
|
12
|
+
def column_names
|
|
13
|
+
columns.map(&:first)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def association_names
|
|
17
|
+
associations.map { |association| association[:name].to_s }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def keywords
|
|
21
|
+
[name.downcase, *name.gsub(/([a-z])([A-Z])/, '\1 \2').downcase.split, table.to_s,
|
|
22
|
+
*column_names, *association_names].uniq
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_context(columns: true)
|
|
26
|
+
lines = ["#{name} (#{table})"]
|
|
27
|
+
lines << " columns: #{column_list}" if columns
|
|
28
|
+
associations.each { |association| lines << " #{association_line(association)}" }
|
|
29
|
+
lines += inbound_lines.map { |line| " #{line}" } if inbound.any?
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inbound
|
|
34
|
+
Array(referenced_by)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inbound_lines
|
|
38
|
+
["#{missing_joins} all raise: no such association exists here.",
|
|
39
|
+
"query from the other side instead -- #{inbound_list} -- " \
|
|
40
|
+
"or filter with a subquery: #{subquery_example}"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def missing_joins
|
|
44
|
+
inbound.map { |reference| "#{name}.joins(:#{reference[:table]})" }.join(" and ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inbound_list
|
|
48
|
+
inbound.map do |reference|
|
|
49
|
+
"#{reference[:model]}.joins(:#{reference[:association]}) (#{reference[:foreign_key]})"
|
|
50
|
+
end.join(", ")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def subquery_example
|
|
54
|
+
reference = inbound.first
|
|
55
|
+
"#{name}.where(id: #{reference[:model]}.select(:#{reference[:foreign_key]}))"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def column_list
|
|
59
|
+
columns.map { |name, type, extra| extra ? "#{name}:#{type} #{extra}" : "#{name}:#{type}" }.join(", ")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def association_line(association)
|
|
63
|
+
line = "#{association[:macro]} :#{association[:name]}"
|
|
64
|
+
line += " (#{association[:class_name]})" if association[:class_name] != default_class_name(association)
|
|
65
|
+
line += " through: :#{association[:through]}" if association[:through]
|
|
66
|
+
line
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def default_class_name(association)
|
|
72
|
+
base = association[:name].to_s
|
|
73
|
+
base = base.sub(/s\z/, "") if %i[has_many has_and_belongs_to_many].include?(association[:macro])
|
|
74
|
+
base.split("_").map(&:capitalize).join
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class << self
|
|
79
|
+
def models
|
|
80
|
+
@models ||= load_models
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def model_names
|
|
84
|
+
models.map(&:name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def find(name)
|
|
88
|
+
models.find { |model| model.name == name.to_s }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reset!
|
|
92
|
+
@models = nil
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def context(prompt = nil)
|
|
97
|
+
return "No ActiveRecord models found." if models.empty?
|
|
98
|
+
|
|
99
|
+
focused, rest = split_by_relevance(prompt)
|
|
100
|
+
sections = [header]
|
|
101
|
+
sections << focused.map(&:to_context).join("\n\n")
|
|
102
|
+
sections << "Other models: #{rest.map(&:name).sort.join(", ")}" if rest.any?
|
|
103
|
+
sections << RailsAgentConsole.configuration.extra_context if RailsAgentConsole.configuration.extra_context
|
|
104
|
+
sections.compact.join("\n\n")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def tree(names)
|
|
108
|
+
Array(names).flat_map do |name|
|
|
109
|
+
model = find(name)
|
|
110
|
+
next [] unless model
|
|
111
|
+
|
|
112
|
+
lines = [model.name]
|
|
113
|
+
model.associations.each_with_index do |association, index|
|
|
114
|
+
last = index == model.associations.size - 1
|
|
115
|
+
lines << " #{last ? "\u2514\u2500\u2500" : "\u251C\u2500\u2500"} #{model.association_line(association)}"
|
|
116
|
+
end
|
|
117
|
+
lines
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inspect
|
|
122
|
+
context
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def print(prompt = nil)
|
|
126
|
+
RailsAgentConsole.configuration.out.puts(context(prompt))
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def header
|
|
133
|
+
parts = []
|
|
134
|
+
parts << "Rails #{Rails.version}" if defined?(Rails) && Rails.respond_to?(:version)
|
|
135
|
+
parts << "adapter: #{adapter_name}" if adapter_name
|
|
136
|
+
parts.empty? ? "Application schema:" : parts.join(", ").to_s
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def adapter_name
|
|
140
|
+
return unless active_record?
|
|
141
|
+
|
|
142
|
+
ActiveRecord::Base.connection_db_config.adapter
|
|
143
|
+
rescue StandardError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def split_by_relevance(prompt)
|
|
148
|
+
config = RailsAgentConsole.configuration
|
|
149
|
+
all = models.first(config.max_models)
|
|
150
|
+
return [all, models.drop(config.max_models)] if prompt.nil? || all.size <= config.max_focused_models
|
|
151
|
+
|
|
152
|
+
scored = all.sort_by { |model| [-relevance(model, prompt), model.name] }
|
|
153
|
+
focused = scored.first(config.max_focused_models)
|
|
154
|
+
focused = focused.sort_by(&:name)
|
|
155
|
+
[focused, all - focused]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def relevance(model, prompt)
|
|
159
|
+
tokens = prompt.to_s.downcase.scan(/[a-z_]{3,}/).map { |token| token.sub(/(ies|es|s)\z/, "") }
|
|
160
|
+
return 0 if tokens.empty?
|
|
161
|
+
|
|
162
|
+
model.keywords.sum do |keyword|
|
|
163
|
+
stem = keyword.sub(/(ies|es|s)\z/, "")
|
|
164
|
+
tokens.count { |token| stem.include?(token) || token.include?(stem) }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def load_models
|
|
169
|
+
return [] unless active_record?
|
|
170
|
+
|
|
171
|
+
eager_load!
|
|
172
|
+
candidates = ActiveRecord::Base.descendants.reject do |klass|
|
|
173
|
+
klass.name.nil? || klass.abstract_class? || internal?(klass.name) || excluded?(klass.name)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
described = candidates.uniq(&:name).sort_by(&:name).filter_map { |klass| describe(klass) }
|
|
177
|
+
link_inbound(described)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def link_inbound(described)
|
|
181
|
+
by_name = described.to_h { |model| [model.name, model] }
|
|
182
|
+
|
|
183
|
+
described.each do |model|
|
|
184
|
+
model.associations.select { |association| association[:macro] == :belongs_to }.each do |association|
|
|
185
|
+
target = by_name[association[:class_name]]
|
|
186
|
+
next if target.nil? || target.name == model.name
|
|
187
|
+
next if target.associations.any? { |inverse| inverse[:class_name] == model.name }
|
|
188
|
+
|
|
189
|
+
target.referenced_by << inbound_reference(model, association)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
described
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def inbound_reference(model, association)
|
|
197
|
+
{ model: model.name, association: association[:name],
|
|
198
|
+
foreign_key: association[:foreign_key], table: model.table }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def describe(klass)
|
|
202
|
+
return unless klass.table_exists?
|
|
203
|
+
|
|
204
|
+
Model.new(
|
|
205
|
+
name: klass.name,
|
|
206
|
+
table: klass.table_name,
|
|
207
|
+
columns: columns_for(klass),
|
|
208
|
+
associations: associations_for(klass),
|
|
209
|
+
referenced_by: [],
|
|
210
|
+
abstract: false
|
|
211
|
+
)
|
|
212
|
+
rescue StandardError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def columns_for(klass)
|
|
217
|
+
klass.columns.map do |column|
|
|
218
|
+
extra = []
|
|
219
|
+
extra << "pk" if column.name == klass.primary_key
|
|
220
|
+
extra << "null" if column.null && column.name != klass.primary_key
|
|
221
|
+
[column.name, column.type, extra.empty? ? nil : "(#{extra.join(",")})"]
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def associations_for(klass)
|
|
226
|
+
klass.reflect_on_all_associations.map do |reflection|
|
|
227
|
+
{
|
|
228
|
+
macro: reflection.macro,
|
|
229
|
+
name: reflection.name,
|
|
230
|
+
class_name: safe_class_name(reflection),
|
|
231
|
+
foreign_key: safe_foreign_key(reflection),
|
|
232
|
+
through: reflection.options[:through]
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
rescue StandardError
|
|
236
|
+
[]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def safe_class_name(reflection)
|
|
240
|
+
reflection.options[:class_name]&.to_s || reflection.class_name
|
|
241
|
+
rescue StandardError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def safe_foreign_key(reflection)
|
|
246
|
+
reflection.foreign_key.to_s
|
|
247
|
+
rescue StandardError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def internal?(name)
|
|
252
|
+
return false if RailsAgentConsole.configuration.include_internal_models
|
|
253
|
+
|
|
254
|
+
INTERNAL_PREFIXES.any? { |prefix| name.start_with?(prefix) }
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def excluded?(name)
|
|
258
|
+
RailsAgentConsole.configuration.excluded_models.map(&:to_s).include?(name)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def eager_load!
|
|
262
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
263
|
+
return if Rails.application.config.eager_load
|
|
264
|
+
|
|
265
|
+
Rails.application.eager_load!
|
|
266
|
+
rescue StandardError
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def active_record?
|
|
271
|
+
defined?(ActiveRecord::Base) && ActiveRecord::Base.respond_to?(:descendants)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
module Setup
|
|
5
|
+
PROVIDER_LABELS = {
|
|
6
|
+
openai: "OpenAI",
|
|
7
|
+
anthropic: "Anthropic",
|
|
8
|
+
gemini: "Gemini",
|
|
9
|
+
ollama: "Ollama"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def interactive?(config = RailsAgentConsole.configuration)
|
|
14
|
+
input = config.in
|
|
15
|
+
input.respond_to?(:tty?) && input.tty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def needed?(config = RailsAgentConsole.configuration)
|
|
19
|
+
!config.api_key?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run(config: RailsAgentConsole.configuration, ui: UI.new(config))
|
|
23
|
+
ui.blank
|
|
24
|
+
ui.heading "Rails Agent Console"
|
|
25
|
+
ui.say "No AI provider configured yet. A few short questions; Enter takes the suggestion."
|
|
26
|
+
return false unless Wizard.run(config: config, ui: ui)
|
|
27
|
+
|
|
28
|
+
ui.success "Ready. Try: ai \"how many #{example_model.downcase} records do we have?\""
|
|
29
|
+
ui.blank
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def example_model
|
|
36
|
+
Schema.model_names.first || "User"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
class UI
|
|
5
|
+
COLORS = {
|
|
6
|
+
black: 30, red: 31, green: 32, yellow: 33,
|
|
7
|
+
blue: 34, magenta: 35, cyan: 36, white: 37, gray: 90
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
ROBOT = "\u{1F916}"
|
|
11
|
+
WARNING = "\u26A0\uFE0F"
|
|
12
|
+
CHECK = "\u2713"
|
|
13
|
+
CROSS = "\u2717"
|
|
14
|
+
|
|
15
|
+
def initialize(config = RailsAgentConsole.configuration)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def say(text = "")
|
|
20
|
+
out.puts(text)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def blank
|
|
24
|
+
out.puts
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def heading(text)
|
|
28
|
+
say "#{ROBOT} #{bold(text)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def note(text)
|
|
32
|
+
say dim(text)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def success(text)
|
|
36
|
+
say "#{colorize(CHECK, :green)} #{text}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def failure(text)
|
|
40
|
+
say "#{colorize(CROSS, :red)} #{text}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def warn(text)
|
|
44
|
+
say "#{WARNING} #{colorize(text, :yellow)}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def code(source)
|
|
48
|
+
source.to_s.each_line do |line|
|
|
49
|
+
say " #{colorize(line.chomp, :cyan)}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def tree(lines)
|
|
54
|
+
lines.each { |line| say " #{dim(line)}" }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ask(question, default: nil)
|
|
58
|
+
out.print("#{question} ")
|
|
59
|
+
out.flush
|
|
60
|
+
answer = read_line
|
|
61
|
+
return default if answer.nil? || answer.strip.empty?
|
|
62
|
+
|
|
63
|
+
answer.strip
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def yes?(question, default: false)
|
|
67
|
+
suffix = default ? "[Y/n]" : "[y/N]"
|
|
68
|
+
answer = ask("#{question} #{suffix}", default: nil)
|
|
69
|
+
return default if answer.nil?
|
|
70
|
+
|
|
71
|
+
case answer.downcase
|
|
72
|
+
when "y", "yes" then true
|
|
73
|
+
when "n", "no", "" then false
|
|
74
|
+
else default
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def secret(question)
|
|
79
|
+
out.print("#{question} ")
|
|
80
|
+
out.flush
|
|
81
|
+
value = read_secret
|
|
82
|
+
out.puts
|
|
83
|
+
value.to_s.strip
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def bold(text)
|
|
87
|
+
@config.color? ? "\e[1m#{text}\e[0m" : text.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def dim(text)
|
|
91
|
+
@config.color? ? "\e[2m#{text}\e[0m" : text.to_s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def colorize(text, color)
|
|
95
|
+
return text.to_s unless @config.color?
|
|
96
|
+
|
|
97
|
+
"\e[#{COLORS.fetch(color, 37)}m#{text}\e[0m"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def out
|
|
103
|
+
@config.out
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def read_line
|
|
107
|
+
@config.in.gets
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_secret
|
|
111
|
+
input = @config.in
|
|
112
|
+
require "io/console"
|
|
113
|
+
|
|
114
|
+
return input.gets.to_s.chomp unless echo_controllable?(input)
|
|
115
|
+
|
|
116
|
+
input.noecho { input.gets.to_s.chomp }
|
|
117
|
+
rescue LoadError, Errno::ENOTTY, Errno::ENODEV
|
|
118
|
+
input.gets.to_s.chomp
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def echo_controllable?(input)
|
|
122
|
+
input.respond_to?(:noecho) && input.respond_to?(:tty?) && input.tty?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAgentConsole
|
|
4
|
+
# Spots a condition that compares a column with a value the column never holds,
|
|
5
|
+
# such as country: "Croatia" where the data says "HR". The values it lists are
|
|
6
|
+
# printed in the console only; nothing here is sent to the model.
|
|
7
|
+
module ValueCheck
|
|
8
|
+
MAX_DISTINCT = 30
|
|
9
|
+
SHOWN = 10
|
|
10
|
+
TEXT_TYPES = %i[string text citext].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def notes(relation)
|
|
15
|
+
conditions(relation.where_clause.ast).uniq.filter_map { |table, column, value| note(table, column, value) }
|
|
16
|
+
rescue StandardError
|
|
17
|
+
[]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def conditions(node)
|
|
21
|
+
case node
|
|
22
|
+
when Arel::Nodes::HomogeneousIn
|
|
23
|
+
pairs(node.attribute, node.values)
|
|
24
|
+
when Arel::Nodes::Equality, Arel::Nodes::NotEqual, Arel::Nodes::In, Arel::Nodes::NotIn
|
|
25
|
+
comparison(node)
|
|
26
|
+
when Arel::Nodes::Binary, Arel::Nodes::And, Arel::Nodes::Or, Arel::Nodes::Grouping, Arel::Nodes::Not
|
|
27
|
+
children(node).flat_map { |child| conditions(child) }
|
|
28
|
+
else
|
|
29
|
+
[]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def children(node)
|
|
34
|
+
return node.children if node.respond_to?(:children)
|
|
35
|
+
return [node.expr] if node.respond_to?(:expr)
|
|
36
|
+
|
|
37
|
+
[node.left, node.right]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def comparison(node)
|
|
41
|
+
pairs(node.left, node.right)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pairs(attribute, values)
|
|
45
|
+
return [] unless attribute.is_a?(Arel::Attributes::Attribute)
|
|
46
|
+
|
|
47
|
+
Array(values).filter_map { |value| literal(value) }
|
|
48
|
+
.map { |value| [attribute.relation.name.to_s, attribute.name.to_s, value] }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def literal(node)
|
|
52
|
+
value = node.respond_to?(:value) ? node.value : node
|
|
53
|
+
value = value.value_before_type_cast if value.respond_to?(:value_before_type_cast)
|
|
54
|
+
value if value.is_a?(String) && !value.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def note(table, column, value)
|
|
58
|
+
klass = model_for(table, column)
|
|
59
|
+
return if klass.nil?
|
|
60
|
+
|
|
61
|
+
counts = klass.unscoped.where.not(column => nil).group(column).order(Arel.sql("COUNT(*) DESC"))
|
|
62
|
+
.limit(MAX_DISTINCT + 1).count
|
|
63
|
+
return if counts.size > MAX_DISTINCT || counts.key?(value)
|
|
64
|
+
|
|
65
|
+
"#{table}.#{column} never holds #{value.inspect}. #{listing(counts.keys, value)}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def listing(values, wanted)
|
|
69
|
+
close = values.find { |candidate| candidate.to_s.casecmp?(wanted) }
|
|
70
|
+
return "It holds #{close.inspect}, with different capitals." if close
|
|
71
|
+
|
|
72
|
+
shown = values.first(SHOWN).join(", ")
|
|
73
|
+
more = values.size > SHOWN ? " (+#{values.size - SHOWN} more)" : ""
|
|
74
|
+
"The values it holds are: #{shown}#{more}."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def model_for(table, column)
|
|
78
|
+
model = Schema.models.find { |candidate| candidate.table == table }
|
|
79
|
+
return if model.nil?
|
|
80
|
+
|
|
81
|
+
type = model.columns.find { |name, _| name == column }&.dig(1)
|
|
82
|
+
Object.const_get(model.name) if TEXT_TYPES.include?(type)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|