rails-ai-context 5.35.2 → 5.36.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -1
  3. data/CONTRIBUTING.md +1 -0
  4. data/docs/COMPATIBILITY.md +14 -1
  5. data/docs/SECURITY.md +12 -10
  6. data/docs/TOOLS.md +12 -11
  7. data/docs/TROUBLESHOOTING.md +1 -1
  8. data/lib/rails_ai_context/database_family/mysql_runtime.rb +17 -2
  9. data/lib/rails_ai_context/database_family/postgresql_plan.rb +6 -2
  10. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +41 -6
  11. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +9 -3
  12. data/lib/rails_ai_context/database_family.rb +27 -5
  13. data/lib/rails_ai_context/introspector.rb +12 -0
  14. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -2
  15. data/lib/rails_ai_context/introspectors/job_introspector.rb +57 -14
  16. data/lib/rails_ai_context/introspectors/model_introspector.rb +13 -11
  17. data/lib/rails_ai_context/introspectors/schema_introspector.rb +12 -1
  18. data/lib/rails_ai_context/payload.rb +2 -3
  19. data/lib/rails_ai_context/secret_columns.rb +59 -37
  20. data/lib/rails_ai_context/sql_gate.rb +118 -25
  21. data/lib/rails_ai_context/tools/get_conventions.rb +10 -5
  22. data/lib/rails_ai_context/tools/get_schema.rb +35 -11
  23. data/lib/rails_ai_context/tools/query.rb +12 -2
  24. data/lib/rails_ai_context/tools/runtime_info.rb +15 -10
  25. data/lib/rails_ai_context/version.rb +1 -1
  26. metadata +1 -23
  27. data/.rspec +0 -3
  28. data/.rubocop.yml +0 -16
  29. data/.ruby-version +0 -1
  30. data/AGENTS.md +0 -15
  31. data/CLAUDE.md +0 -3
  32. data/CODE_OF_CONDUCT.md +0 -44
  33. data/CONTEXT.md +0 -242
  34. data/Rakefile +0 -80
  35. data/docs/RELEASING.md +0 -72
  36. data/docs/_config.yml +0 -15
  37. data/docs/_includes/head-custom.html +0 -16
  38. data/docs/adr/0001-own-listener-registration.md +0 -9
  39. data/docs/adr/0002-static-tier-declared-not-detected.md +0 -5
  40. data/docs/adr/0003-shared-tool-cache-semantics.md +0 -31
  41. data/docs/adr/0004-install-e2e-stays-example-rich.md +0 -28
  42. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +0 -38
  43. data/docs/adr/0009-dbhub-shapes-in-ruby.md +0 -26
  44. data/docs/adr/0010-one-config-tool-with-sections.md +0 -32
  45. data/docs/agents/domain.md +0 -38
  46. data/docs/agents/issue-tracker.md +0 -45
  47. data/docs/agents/triage-labels.md +0 -15
  48. data/server.json +0 -21
@@ -32,8 +32,9 @@ module RailsAiContext
32
32
 
33
33
  def read_only(conn, sql, timeout)
34
34
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
35
- # A child opens the file itself and would answer without the rows of a transaction open here.
36
- if !conn.transaction_open? && (path = fork_path(conn)) && !DatabaseFamily.bounding { held_here?(conn, sql) }
35
+ # A child opens the file itself and would answer without the rows of a transaction open here, one the app
36
+ # opened with a raw BEGIN too, which only the connection itself knows of.
37
+ if !conn.raw_connection.transaction_active? && (path = fork_path(conn)) && !DatabaseFamily.bounding { held_here?(conn, sql) }
37
38
  result = in_child(conn, path, sql, deadline)
38
39
  return Rows.new(result.columns, result.rows, false) if result
39
40
  end
@@ -85,6 +86,11 @@ module RailsAiContext
85
86
  DatabaseFamily.bounding(&reads)
86
87
  end
87
88
 
89
+ # PRAGMA query_only, the one setting it sets, is refused by name.
90
+ def bound_settings(_conn)
91
+ {}
92
+ end
93
+
88
94
  def explain(conn, sql, timeout)
89
95
  result = read_only(conn, "EXPLAIN QUERY PLAN #{sql}", timeout)
90
96
  scans = []
@@ -113,7 +119,7 @@ module RailsAiContext
113
119
  []
114
120
  end
115
121
 
116
- def plan(_conn, _sql, _timeout)
122
+ def plan(_conn, _sql, _timeout, **)
117
123
  nil
118
124
  end
119
125
 
@@ -95,8 +95,8 @@ module RailsAiContext
95
95
 
96
96
  # @raise [TimedOut, ActiveRecord::StatementInvalid] when PostgreSQL cannot plan the statement
97
97
  # @return [Hash, nil] the PostgreSQL JSON plan's top node; nil on other families or when the answer is no plan
98
- def plan(connection, sql, timeout:)
99
- runtime.plan(connection, sql, timeout)
98
+ def plan(connection, sql, timeout:, generic: false)
99
+ runtime.plan(connection, sql, timeout, generic: generic)
100
100
  end
101
101
 
102
102
  # @return [Array<Array(Integer, String, String)>] [result column index, table column, relation] from the
@@ -148,6 +148,17 @@ module RailsAiContext
148
148
  runtime.index_tables(connection, schemas)
149
149
  end
150
150
 
151
+ # The settings the tool sets for a statement among those it reads (SqlGate.settings_read), each with the session's
152
+ # own value. Read with no bound: reading a setting waits on no lock.
153
+ # @return [Hash{String => String}]
154
+ def own_settings(connection, read)
155
+ return {} if read.empty?
156
+
157
+ runtime.bound_settings(connection).filter_map do |name, query|
158
+ [ name, connection.select_value(query).to_s ] if read.include?(name)
159
+ end.to_h
160
+ end
161
+
151
162
  # @return [Array<Hash>, nil] { name:, bytes: } largest first; nil when the family has none
152
163
  def table_sizes(connection)
153
164
  runtime.table_sizes(connection)
@@ -268,15 +279,26 @@ module RailsAiContext
268
279
  def timed_out?(error)
269
280
  # A connection the server dropped is a QueryAborted on Rails 7.1 and later, and it is no timeout.
270
281
  return false if defined?(ActiveRecord::ConnectionFailed) && error.is_a?(ActiveRecord::ConnectionFailed)
271
- return true if defined?(ActiveRecord::QueryAborted) && error.is_a?(ActiveRecord::QueryAborted)
282
+ return !canceled?(error.cause) if defined?(ActiveRecord::QueryAborted) && error.is_a?(ActiveRecord::QueryAborted)
272
283
  # A wait on a lock that the database's own lock timeout ended.
273
284
  return true if error.is_a?(ActiveRecord::LockWaitTimeout)
274
285
 
286
+ TIMEOUT_CODES.include?(native_code(error))
287
+ end
288
+
289
+ # PostgreSQL gives its timeout and a cancel another session sent one error code, and only the server's
290
+ # message tells them apart. A server that writes its messages in another language reads as the timeout.
291
+ def canceled?(driver_error)
292
+ result = driver_error.result if driver_error.respond_to?(:result)
293
+ result.respond_to?(:error_field) && result.error_field(PG::PG_DIAG_MESSAGE_PRIMARY).to_s.end_with?("due to user request")
294
+ end
295
+
296
+ # @return [Integer, nil] the MySQL or MariaDB error number the driver's error carries
297
+ def native_code(error)
275
298
  cause = error.cause
276
- code = if cause.respond_to?(:error_number) then cause.error_number
299
+ if cause.respond_to?(:error_number) then cause.error_number
277
300
  elsif cause.respond_to?(:error_code) then cause.error_code
278
301
  end
279
- TIMEOUT_CODES.include?(code)
280
302
  end
281
303
  end
282
304
  end
@@ -20,6 +20,17 @@ module RailsAiContext
20
20
  RunCache.around { introspect_all }
21
21
  end
22
22
 
23
+ # Every reader names a database's adapter from this section, so a preset without it, or one where it failed,
24
+ # still carries the list. A list that cannot be read is a warning, as a failed section is.
25
+ private def fill_multi_database(context)
26
+ context[:multi_database] = Introspectors::MultiDatabaseIntrospector.new(app).listing
27
+ rescue StandardError, ScriptError => e
28
+ RailsAiContext.log_warn "[rails-ai-context] app databases listing failed: #{e.message}"
29
+ failed = { error: e.message }.tap { |data| portable_errors!(data) }
30
+ warnings = (context[:_warnings] ||= [])
31
+ warnings << { introspector: "multi_database", error: failed[:error] } if warnings.none? { |w| w[:introspector] == "multi_database" }
32
+ end
33
+
23
34
  private def introspect_all
24
35
  context = {
25
36
  app_name: app_name,
@@ -60,6 +71,7 @@ module RailsAiContext
60
71
  end
61
72
  context[:_warnings] = warnings if warnings.any?
62
73
 
74
+ fill_multi_database(context) unless Payload.section(context, :multi_database)
63
75
  resolve_schema_adapter(context)
64
76
 
65
77
  context
@@ -121,11 +121,21 @@ module RailsAiContext
121
121
  loader = Rails.autoloaders.main
122
122
  files.each do |file|
123
123
  cpath_for(loader, path, file).constantize
124
- rescue StandardError, ScriptError
125
- next
124
+ rescue StandardError, ScriptError => e
125
+ raised_files[real(file)] = e.message.to_s.lines.first.to_s.strip
126
126
  end
127
127
  end
128
128
 
129
+ # What loading a file raised this run, by its real path, or nil: Ruby registers a class before its body
130
+ # runs, so one whose body raised looks loaded with only what ran before the raise.
131
+ def raised(file)
132
+ raised_files[file]
133
+ end
134
+
135
+ def raised_files
136
+ RunCache.fetch([ :eager_load_raised ]) { {} }
137
+ end
138
+
129
139
  # SourceScan records, each loaded by the constant its file holds. Without
130
140
  # the loader's answer (Zeitwerk < 2.6.9) the source's own declaration names
131
141
  # it: path_name reads app/services/x.rb as Services::X.
@@ -812,19 +812,33 @@ module RailsAiContext
812
812
  # Where a mailer with no actions keeps its interface, such as `self.` methods that
813
813
  # fan a notification out to many recipients.
814
814
  def no_action_detail(mailer)
815
+ walked = []
816
+ outside = mailer.superclass
817
+ while outside && app_defined?(outside)
818
+ walked << outside.name
819
+ outside = outside.superclass
820
+ end
815
821
  { class_actions: callable_class_methods(mailer.singleton_methods(false).map(&:to_s)).sort,
816
- parent_class: mailer.superclass&.name }.reject { |_k, v| v.nil? || v == [] }
822
+ parent_class: inherited_from(walked, outside&.name) }.reject { |_k, v| v.nil? || v == [] }
823
+ end
824
+
825
+ # The parent past the app's own mailers that a mailer with no action of its own takes its actions from. None past
826
+ # the app base, whose methods and everything above it are no actions, and none from Rails' own base.
827
+ def inherited_from(walked, parent_name)
828
+ return nil if walked.any? { |name| app_mailer_base?(name) }
829
+
830
+ name = parent_name.to_s.delete_prefix("::")
831
+ name unless name.empty? || name.start_with?("ActionMailer::", "AbstractController::") || app_mailer_base?(name)
832
+ end
833
+
834
+ def app_mailer_base?(name)
835
+ ActionResolver.app_base_name?(name, kind: :mailer)
817
836
  end
818
837
 
819
838
  def callable_class_methods(names)
820
839
  names.reject { |n| n.start_with?("_") || MAILER_CLASS_CONFIG.include?(n) }
821
840
  end
822
841
 
823
- # A mailer's actions are its public instance methods, and the AST sees
824
- # one file at a time. `action_methods` counts the public methods a mailer
825
- # inherits too, so a public helper on a base class is an action the
826
- # booted tier reports and this one cannot - the entries are tagged STATIC
827
- # for that reason.
828
842
  # ActionMailer's interceptor and observer hooks. A class or module under
829
843
  # app/mailers that implements one is registered with the framework, not
830
844
  # delivered by it: reporting OpenProject's Interceptors::DefaultHeaders
@@ -853,7 +867,7 @@ module RailsAiContext
853
867
 
854
868
  def source_mailers
855
869
  @source_mailers ||= begin
856
- scanned = source_classes("app/mailers", macros: ACTION_CALLBACKS + MAILER_DECLARATIONS)
870
+ scanned = source_classes("app/mailers", macros: ACTION_CALLBACKS + MAILER_DECLARATIONS + VISIBILITY_CALLS)
857
871
  candidates = scanned.reject { |klass| framework_hook?(klass) } +
858
872
  mailers_outside_mailer_dirs(scanned.map(&:file))
859
873
  # A mixin under app/mailers holds a mailer's actions (an Emails::* module); a class
@@ -863,7 +877,8 @@ module RailsAiContext
863
877
  bases = abstract_mailer_bases(klasses.reject(&:mixin))
864
878
  mailers = klasses.reject { |klass| bases.include?(klass.name) }
865
879
 
866
- { mailers: mailers.map { |klass| mailer_entry(klass) }.sort_by { |m| m[:name] },
880
+ by_name = klasses.reject(&:mixin).to_h { |klass| [ klass.name, klass ] }
881
+ { mailers: mailers.map { |klass| mailer_entry(klass, app_ancestors(klass, by_name, parent_of)) }.sort_by { |m| m[:name] },
867
882
  bases: bases.map do |base|
868
883
  klass = klasses.find { |k| k.name == base }
869
884
  heirs = mailers.reject(&:mixin).map(&:name).select { |name| inherits_from?(name, base, parent_of) }
@@ -1071,6 +1086,9 @@ module RailsAiContext
1071
1086
  # app wrote it, the methods it defines, and who inherits it.
1072
1087
  MAILER_DECLARATIONS = %i[layout helper helper_method include default default_url_options=].freeze
1073
1088
 
1089
+ # `private :name` in a mailer hides a method it inherits, and `public :name` shows one.
1090
+ VISIBILITY_CALLS = %i[private protected public].freeze
1091
+
1074
1092
  def mailer_base_record(name, file, source, macros, methods, heirs)
1075
1093
  declares = SourceIntrospector.outside_defs(macros, methods).select { |m| (MAILER_DECLARATIONS + ACTION_CALLBACKS).include?(m[:macro]) }
1076
1094
  .sort_by { |m| m[:offset] }.map { |m| written(source, m) }
@@ -1100,6 +1118,20 @@ module RailsAiContext
1100
1118
  @candidate_bodies[name] ||= SourceIntrospector.outside_defs(job_candidates[name].ast[:macros], job_candidates[name].ast[:methods])
1101
1119
  end
1102
1120
 
1121
+ # The app's own classes a class inherits from, nearest first.
1122
+ def app_ancestors(klass, by_name, parent_of)
1123
+ chain = []
1124
+ name = klass.name
1125
+ while chain.size < SuperclassChain::MAX_DEPTH
1126
+ superclass, nesting = parent_of[name]
1127
+ name = SuperclassChain.resolve_in_scope(name, superclass, nesting: nesting) { |candidate| candidate if by_name.key?(candidate) }
1128
+ break if name.nil? || name == klass.name || chain.any? { |ancestor| ancestor.name == name }
1129
+
1130
+ chain << by_name[name]
1131
+ end
1132
+ chain
1133
+ end
1134
+
1103
1135
  def inherits_from?(name, base, parent_of, seen = [])
1104
1136
  return false if seen.include?(name) || seen.size >= SuperclassChain::MAX_DEPTH
1105
1137
 
@@ -1158,12 +1190,22 @@ module RailsAiContext
1158
1190
  false
1159
1191
  end
1160
1192
 
1161
- # What the static tier can say a mailer offers. A class with no action of its own is
1162
- # still a mailer: its actions come from a parent or mixin, or it offers class methods.
1163
- def mailer_entry(klass)
1193
+ # The public methods of a mailer and of its app parents below the app base, each with the visibility the nearest
1194
+ # gives it; tagged STATIC, since a gem's base or a mixin can add actions only the booted tier sees.
1195
+ def mailer_entry(klass, ancestors)
1196
+ # The app base's public methods are no actions, as the booted tier subtracts them; its callbacks still filter.
1197
+ action_chain = [ klass, *ancestors.take_while { |declaring| !app_mailer_base?(declaring.name) } ]
1198
+ visibility = {}
1199
+ action_chain.each do |declaring|
1200
+ own = ActionResolver.own_methods(declaring.methods, declaring.name).select { |m| m[:scope] == :instance }
1201
+ own.each { |m| visibility[m[:name]] ||= m[:visibility] }
1202
+ declaring.macros.each do |m|
1203
+ m[:args].each { |name| visibility[name.to_s] ||= m[:macro] } if VISIBILITY_CALLS.include?(m[:macro])
1204
+ end
1205
+ end
1164
1206
  actions = ActionResolver.deliverable_actions(
1165
- ActionResolver.own_actions(klass.methods, class_name: klass.name),
1166
- klass.macros.select { |m| ACTION_CALLBACKS.include?(m[:macro]) }.flat_map { |m| m[:args] }
1207
+ visibility.select { |name, seen| seen == :public && !name.start_with?("_") }.keys.sort,
1208
+ [ klass, *ancestors ].flat_map { |declaring| declaring.macros.select { |m| ACTION_CALLBACKS.include?(m[:macro]) }.flat_map { |m| m[:args] } }
1167
1209
  )
1168
1210
 
1169
1211
  entry = { name: klass.name, file: klass.file, actions: actions,
@@ -1174,7 +1216,8 @@ module RailsAiContext
1174
1216
  .select { |m| m[:scope] == :class && m[:visibility] == :public }
1175
1217
  .map { |m| m[:name] }
1176
1218
  entry[:class_actions] = class_actions.sort if (class_actions = callable_class_methods(class_actions)).any?
1177
- entry[:parent_class] = klass.parent_class if klass.parent_class
1219
+ source = inherited_from(ancestors.map(&:name), (ancestors.last || klass).parent_class)
1220
+ entry[:parent_class] = source if source
1178
1221
  entry
1179
1222
  end
1180
1223
 
@@ -601,6 +601,12 @@ module RailsAiContext
601
601
  source = model_source(record.path).to_s
602
602
  path_name = SourceScan.concerns_name(path_name, source)
603
603
  end
604
+ # A class whose body raised is registered holding what ran before the raise.
605
+ if EagerLoad.raised(record.path)
606
+ half_built = DeclaredConstant.named(model_source(record.path).to_s, path_name)
607
+ models.reject! { |model| model.name == half_built }
608
+ known.delete(half_built)
609
+ end
604
610
  next if known.include?(path_name)
605
611
  next if config.excluded_models.include?(path_name)
606
612
  next if path_name != record.path_name && mixin_path?(record.path_name.underscore, source)
@@ -1128,21 +1134,17 @@ module RailsAiContext
1128
1134
 
1129
1135
  def extract_sti_info(model)
1130
1136
  type_column = model.inheritance_column.to_s
1131
- has_type_column = if model.connected? && model.table_exists?
1132
- model.columns_hash.key?(type_column)
1133
- else
1134
- SchemaReader.for(app.root).column?(model.table_name, type_column)
1135
- end
1136
-
1137
- return nil unless has_type_column
1137
+ connected = model.connected? && model.table_exists?
1138
+ return nil if type_column.empty?
1139
+ return nil if connected ? !model.columns_hash.key?(type_column) : lacks_column?(model.table_name, type_column)
1138
1140
 
1139
1141
  children = DeclaredConstant.carried(model.descendants).map(&:name).sort
1140
1142
 
1141
1143
  parent = model.superclass
1142
- sti_parent = if parent && parent != ActiveRecord::Base &&
1143
- (!defined?(ApplicationRecord) || parent != ApplicationRecord)
1144
- parent.name
1145
- end
1144
+ # An abstract base (ApplicationRecord, or the one a second database connects through) holds no rows of its own.
1145
+ sti_parent = parent.name if parent && parent != ActiveRecord::Base && !parent.abstract_class?
1146
+ # A table db/schema.rb does not hold is in another database, so only a class family says it is STI.
1147
+ return nil if !connected && !SchemaReader.for(app.root).table?(model.table_name) && sti_parent.nil? && children.empty?
1146
1148
 
1147
1149
  {
1148
1150
  sti_base: sti_parent.nil? && children.any?,
@@ -544,7 +544,18 @@ module RailsAiContext
544
544
 
545
545
  def declaration_keys
546
546
  declared = declared_table_names
547
- { declared_tables: declared, declared_in: (relative_dump_path(schema_file_path) if declared) }
547
+ keys = { declared_tables: declared, declared_in: (relative_dump_path(schema_file_path) if declared) }
548
+ shared = declared && shared_dump
549
+ shared ? keys.merge(shared_dump: shared) : keys
550
+ end
551
+
552
+ # Rails dumps both the first database and the one named primary to schema.rb, so the
553
+ # file holds whichever was dumped last.
554
+ def shared_dump
555
+ dump = File.expand_path(schema_file_path)
556
+ primary = app_databases.primary
557
+ other = app_databases.find { |db| db.here && db != primary && db.dump && File.expand_path(db.dump.path) == dump }
558
+ { primary: primary.name, other: other.name } if primary && other
548
559
  end
549
560
 
550
561
  # The configured dump first (database.yml's schema_dump, schema_format), then the default files.
@@ -135,10 +135,9 @@ module RailsAiContext
135
135
  words ? " (#{words})" : ""
136
136
  end
137
137
 
138
- # The multi_database section. A payload that holds none (a preset without it) gets the same list
139
- # from the app databases, so no mark or name depends on the preset.
138
+ # The multi_database section, which the introspector seam fills on every preset.
140
139
  def multi_database(context)
141
- section(context, :multi_database) || Introspectors::MultiDatabaseIntrospector.new(RailsAiContext.default_app).listing
140
+ section(context, :multi_database) || {}
142
141
  end
143
142
 
144
143
  def databases(context)
@@ -56,20 +56,21 @@ module RailsAiContext
56
56
  raise unreadable(connection) unless relations
57
57
 
58
58
  relations = relations.merge(CATALOG_CREDENTIALS) if family.plan_checks?
59
- new(relations: relations, encrypted: encrypted_from(context), connection: connection, **definitions_from(connection, schemas))
59
+ new(relations: relations, encrypted: encrypted_from(context), connection: connection, family: family,
60
+ **definitions_from(connection, schemas))
60
61
  end
61
62
  statement.to_s.strip.empty? ? secrets : secrets.cover(statement)
62
63
  end
63
64
 
64
- # A name or a statement as the databases compare names: case folded, a dotted capital I read as i (MySQL does,
65
- # Ruby's downcase keeps the dot), and runs of spaces squeezed, so a name is found in text squeezed or not.
66
- def self.fold(text)
67
- text.to_s.downcase.delete("\u0307").squeeze(" ")
65
+ # A name or a statement as the databases compare names: case folded (ASCII alone with `ascii:`, as SQLite folds),
66
+ # a dotted capital I read as i as MySQL reads it, and runs of spaces squeezed, so text squeezed or not matches.
67
+ def self.fold(text, ascii: false)
68
+ ascii ? text.to_s.downcase(:ascii).squeeze(" ") : text.to_s.downcase.delete("\u0307").squeeze(" ")
68
69
  end
69
70
 
70
71
  # Matches the name as a whole identifier in folded text, whatever characters it holds and however it is quoted.
71
- def self.identifier(name)
72
- name = fold(name)
72
+ def self.identifier(name, ascii: false)
73
+ name = fold(name, ascii: ascii)
73
74
  spellings = [ name, name.gsub('"', '""'), name.gsub("`", "``"), name.gsub("'", "''") ].uniq.map { |spelling| Regexp.escape(spelling) }
74
75
  /(?<!\p{Word})(?:#{spellings.join("|")})(?!\p{Word})/
75
76
  end
@@ -158,9 +159,11 @@ module RailsAiContext
158
159
  # Each argument is what the DatabaseFamily::Family read of the same name answers. views is nil when the
159
160
  # database's views are not known. connection: where more schemas are read from; with none the rule keeps what it was given.
160
161
  def initialize(relations: {}, encrypted: [], views: nil, traced_views: [], generated: {}, index_tables: {}, connection: nil,
161
- config: RailsAiContext.configuration)
162
- fold = self.class.method(:fold)
163
- @redacted = (Array(config.query_redacted_columns) + BUILT_IN_NAMES).to_set(&fold)
162
+ family: nil, config: RailsAiContext.configuration)
163
+ @ascii = family&.sqlite? || false
164
+ fold = method(:fold)
165
+ @configured = Array(config.query_redacted_columns)
166
+ @redacted = (@configured + BUILT_IN_NAMES).to_set(&fold)
164
167
  @allowed = Array(config.query_allowed_columns).to_set(&fold)
165
168
  @encrypted = Array(encrypted).to_set(&fold)
166
169
  @connection = connection
@@ -222,22 +225,25 @@ module RailsAiContext
222
225
 
223
226
  # Whether the relation's columns were read. A PostgreSQL relation is asked for as `schema.relation`.
224
227
  def knows?(relation)
225
- @columns.key?(self.class.fold(relation))
228
+ @columns.key?(fold(relation))
226
229
  end
227
230
 
228
231
  def secret?(name)
229
- down = self.class.fold(name)
232
+ down = fold(name)
230
233
  return false if down.empty?
231
234
  return true if encrypted?(down)
232
235
  return false if @allowed.include?(down)
233
236
 
234
- @generated.key?(down) || @redacted.include?(down) || heuristic?(down) || RailsAiContext::Redaction.secret_name?(down)
237
+ # What a name means is read with every letter folded, whatever the database folds.
238
+ meaning = self.class.fold(name)
239
+ @generated.key?(down) || @redacted.include?(down) || @secret_meanings.include?(meaning) || heuristic?(meaning) ||
240
+ RailsAiContext::Redaction.secret_name?(meaning)
235
241
  end
236
242
 
237
243
  # True for an `encrypts` column and for a generated column that reads one: neither is released by name.
238
244
  # Generated columns of one name on two tables are one name here, encrypted when either reads such a column.
239
245
  def encrypted?(name, through = [])
240
- down = self.class.fold(name)
246
+ down = fold(name)
241
247
  return true if @encrypted.include?(down)
242
248
 
243
249
  !through.include?(down) && @generated.fetch(down, []).any? { |source| encrypted?(source, through + [ down ]) }
@@ -245,14 +251,14 @@ module RailsAiContext
245
251
 
246
252
  # @return [String, nil] a secret column a generated column's expression reads, an `encrypts` one first
247
253
  def generated_from(name)
248
- sources = @generated.fetch(self.class.fold(name), [])
254
+ sources = @generated.fetch(fold(name), [])
249
255
  sources.find { |source| encrypted?(source) } || sources.first
250
256
  end
251
257
 
252
258
  # [view, why] for the first refused view the text names, or nil. A view's definition renames a
253
259
  # column outside the statement, where no check on the statement reads it.
254
260
  def refused_view_in(text)
255
- down = self.class.fold(text)
261
+ down = fold(text)
256
262
  view, why = @refused_views.find { |name, _why| names?(down, name) }
257
263
  [ spelled(view), why ] if view
258
264
  end
@@ -260,7 +266,7 @@ module RailsAiContext
260
266
  # Why the view at exactly this place is refused, or nil. The plan names a relation as `schema.relation`, so a table
261
267
  # is not taken for a view of its name in another schema; a view with no definition read is told by its bare name.
262
268
  def placed_view_refusal(relation, name)
263
- placed, bare = self.class.fold(relation), self.class.fold(name)
269
+ placed, bare = fold(relation), fold(name)
264
270
  return @refused_views[placed] if @views&.key?(placed)
265
271
 
266
272
  @refused_views[bare] if @views&.key?(bare) && @views[bare].nil?
@@ -268,21 +274,21 @@ module RailsAiContext
268
274
 
269
275
  # Whether the relation is a view whose rows the plan does not trace to their table columns.
270
276
  def stored_view?(relation)
271
- placed = self.class.fold(relation)
277
+ placed = fold(relation)
272
278
  !@views.nil? && @views.key?(placed) && !@traced.include?(placed)
273
279
  end
274
280
 
275
281
  # [table, why] for the first table the text names that is never read, or nil: the file's pages, a shadow
276
282
  # table, and a virtual table or its vocabulary when the virtual table holds a secret column.
277
283
  def unread_table_in(text)
278
- down = self.class.fold(text)
284
+ down = fold(text)
279
285
  table, why = @unread_tables.find { |name, _why| names?(down, name) }
280
286
  [ spelled(table), why ] if table
281
287
  end
282
288
 
283
289
  # The first relation holding a secret column that the statement's text names, or nil.
284
290
  def relation_named_in(text)
285
- down = self.class.fold(text)
291
+ down = fold(text)
286
292
  relation = @by_relation.each_key.find { |name| names?(down, name) }
287
293
  relation && spelled(relation)
288
294
  end
@@ -290,20 +296,25 @@ module RailsAiContext
290
296
  # The first secret name the statement's text names, or nil. Case-insensitive
291
297
  # and word-bounded, so a table or alias that merely holds a secret word passes.
292
298
  def named_in(text)
293
- down = self.class.fold(text)
299
+ down = fold(text)
294
300
  @named.each { |name, pattern| return name if down.match?(pattern) }
295
301
  nil
296
302
  end
297
303
 
304
+ # view => its definition, for each view the text names and each one their definitions name.
305
+ def view_definitions_in(text)
306
+ views_reached(text).keys.filter_map { |view| [ spelled(view), @views[view] ] if @views[view] }.to_h
307
+ end
308
+
298
309
  # The schema's real secret columns, read by the PostgreSQL plan checks.
299
310
  attr_reader :in_schema
300
311
 
301
312
  def of_relation(relation)
302
- @by_relation.fetch(self.class.fold(relation), [])
313
+ @by_relation.fetch(fold(relation), [])
303
314
  end
304
315
 
305
316
  def relation_secret?(relation)
306
- @by_relation.key?(self.class.fold(relation))
317
+ @by_relation.key?(fold(relation))
307
318
  end
308
319
 
309
320
  private
@@ -325,11 +336,11 @@ module RailsAiContext
325
336
  # view => the view the text names that leads to it, for each view the text names and each one their
326
337
  # definitions name. A view the plan reads through is no start: its relations are in the plan.
327
338
  def views_reached(text)
328
- down = self.class.fold(text)
339
+ down = fold(text)
329
340
  reached = @views.to_h.keys.select { |view| !@traced.include?(view) && names?(down, view) }.to_h { |view| [ view, view ] }
330
341
  waiting = reached.keys
331
342
  while (view = waiting.shift)
332
- definition = self.class.fold(@views[view])
343
+ definition = fold(@views[view])
333
344
  (@views.keys - reached.keys).each do |other|
334
345
  next unless names?(definition, other)
335
346
 
@@ -341,8 +352,8 @@ module RailsAiContext
341
352
  end
342
353
 
343
354
  def too_deep(found, followed, family)
344
- found = found.map { |schema| self.class.fold(schema) }
345
- view = followed.find { |name, _start| (dotted_names(self.class.fold(@views[name])) & found).any? }&.last
355
+ found = found.map { |schema| fold(schema) }
356
+ view = followed.find { |name, _start| (dotted_names(fold(@views[name])) & found).any? }&.last
346
357
  Unreadable.new("The view `#{spelled(view)}` leads through more #{family.postgresql? ? "schemas" : "databases"} than rails_query follows, " \
347
358
  "so the secret columns it could read cannot be told and no statement that names it runs.")
348
359
  end
@@ -353,7 +364,7 @@ module RailsAiContext
353
364
  end
354
365
 
355
366
  def learn(relations:, views: nil, traced_views: [], generated: {}, index_tables: {})
356
- fold = self.class.method(:fold)
367
+ fold = method(:fold)
357
368
  [ relations, views, index_tables ].each { |named| named&.each_key { |name| @spelled[fold.call(name)] ||= name.to_s } }
358
369
  relations.each { |relation, names| @columns[fold.call(relation)].concat(Array(names).map(&fold)) }
359
370
  generated.each do |relation, expressions|
@@ -383,12 +394,15 @@ module RailsAiContext
383
394
  end
384
395
 
385
396
  def flag_secret_columns(columns)
397
+ # A configured name that no column spells as the database folds it still holds the columns it folds to.
398
+ known = columns.values.flatten.to_set
399
+ @secret_meanings = (BUILT_IN_NAMES + @configured.reject { |name| known.include?(fold(name)) }).to_set { |name| self.class.fold(name) }
386
400
  @by_relation = columns.each_with_object({}) do |(relation, names), acc|
387
401
  flagged = names.uniq.select { |c| secret?(c) }
388
402
  acc[relation] = flagged unless flagged.empty?
389
403
  end
390
404
  @in_schema = @by_relation.values.flatten.uniq.freeze
391
- @named = ((@redacted.to_a - @allowed.to_a) | @encrypted.to_a | @in_schema).map { |c| [ c, self.class.identifier(c) ] }
405
+ @named = ((@redacted.to_a - @allowed.to_a) | @encrypted.to_a | @in_schema).map { |c| [ c, identifier(c) ] }
392
406
  end
393
407
 
394
408
  # Whether another secret column was found that a generated column reads. Every source of a name is kept.
@@ -396,8 +410,8 @@ module RailsAiContext
396
410
  found = false
397
411
  generated.each_value do |expressions|
398
412
  expressions.each do |column, expression|
399
- column = self.class.fold(column)
400
- down = self.class.fold(expression)
413
+ column = fold(column)
414
+ down = fold(expression)
401
415
  sources = @named.filter_map { |name, pattern| name if name != column && down.match?(pattern) } - @generated.fetch(column, [])
402
416
  next if sources.empty?
403
417
 
@@ -410,7 +424,7 @@ module RailsAiContext
410
424
 
411
425
  # view name => why it is refused, followed through views built on refused views.
412
426
  def refused_views(views)
413
- views = views.to_h { |view, definition| [ self.class.fold(view), self.class.fold(definition) ] }
427
+ views = views.to_h { |view, definition| [ fold(view), fold(definition) ] }
414
428
  refused = views.transform_values { |definition| refusal_of(definition) }.compact
415
429
  loop do
416
430
  nested = views.filter_map do |view, definition|
@@ -439,7 +453,7 @@ module RailsAiContext
439
453
  "table reads a column by its position, with no name written"
440
454
  end
441
455
  end
442
- unread[self.class.fold(table)] = why if why
456
+ unread[fold(table)] = why if why
443
457
  end
444
458
  end
445
459
 
@@ -453,10 +467,10 @@ module RailsAiContext
453
467
  # its rows, or its words, are theirs.
454
468
  def built_on_unread(index_tables)
455
469
  index_tables.each_with_object({}) do |(table, (kind, owner, definition)), built|
456
- table = self.class.fold(table)
470
+ table = fold(table)
457
471
  next if definition.nil? || @unread_tables.key?(table)
458
472
 
459
- down = self.class.fold("#{owner if kind == :vocabulary} #{relation_words(table, definition)}")
473
+ down = fold("#{owner if kind == :vocabulary} #{relation_words(table, definition)}")
460
474
  view, why = @refused_views.find { |name, _why| name != table && names?(down, name) }
461
475
  other = @unread_tables.keys.find { |name| name != table && names?(down, name) } unless view
462
476
  built[table] = "its definition names the view `#{spelled(view)}`, which is not queried: #{why}" if view
@@ -476,7 +490,7 @@ module RailsAiContext
476
490
  mod_name = mod.delete(%q("'`[]))
477
491
  columns = mod_name.match?(/\Afts[345]\z/i) ? @columns[table] : []
478
492
  named = arguments.to_s.scan(MODULE_ARGUMENT).reject do |argument|
479
- !argument.include?("=") && columns.include?(self.class.fold(argument[LEADING_NAME].to_s.delete(%q("'`[])).strip))
493
+ !argument.include?("=") && columns.include?(fold(argument[LEADING_NAME].to_s.delete(%q("'`[])).strip))
480
494
  end
481
495
  # A vocabulary's last argument is its type (row, col or instance), which SQLite reads as no relation.
482
496
  named.pop if mod_name.casecmp?("fts5vocab") && named.size > 1
@@ -496,7 +510,15 @@ module RailsAiContext
496
510
  end
497
511
 
498
512
  def names?(down, name)
499
- down.match?(self.class.identifier(name))
513
+ down.match?(identifier(name))
514
+ end
515
+
516
+ def fold(text)
517
+ self.class.fold(text, ascii: @ascii)
518
+ end
519
+
520
+ def identifier(name)
521
+ self.class.identifier(name, ascii: @ascii)
500
522
  end
501
523
 
502
524
  def heuristic?(down)