rails-ai-context 5.35.2 → 5.36.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 +4 -4
- data/CHANGELOG.md +177 -1
- data/CONTRIBUTING.md +1 -0
- data/docs/COMPATIBILITY.md +10 -1
- data/docs/SECURITY.md +11 -9
- data/docs/TOOLS.md +11 -10
- data/docs/TROUBLESHOOTING.md +1 -1
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +17 -2
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +6 -2
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +41 -6
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +9 -3
- data/lib/rails_ai_context/database_family.rb +27 -5
- data/lib/rails_ai_context/introspector.rb +12 -0
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -2
- data/lib/rails_ai_context/introspectors/job_introspector.rb +54 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +13 -11
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +12 -1
- data/lib/rails_ai_context/payload.rb +2 -3
- data/lib/rails_ai_context/secret_columns.rb +59 -37
- data/lib/rails_ai_context/sql_gate.rb +115 -24
- data/lib/rails_ai_context/tools/get_conventions.rb +10 -5
- data/lib/rails_ai_context/tools/get_schema.rb +35 -11
- data/lib/rails_ai_context/tools/query.rb +12 -2
- data/lib/rails_ai_context/tools/runtime_info.rb +15 -10
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +1 -23
- data/.rspec +0 -3
- data/.rubocop.yml +0 -16
- data/.ruby-version +0 -1
- data/AGENTS.md +0 -15
- data/CLAUDE.md +0 -3
- data/CODE_OF_CONDUCT.md +0 -44
- data/CONTEXT.md +0 -242
- data/Rakefile +0 -80
- data/docs/RELEASING.md +0 -72
- data/docs/_config.yml +0 -15
- data/docs/_includes/head-custom.html +0 -16
- data/docs/adr/0001-own-listener-registration.md +0 -9
- data/docs/adr/0002-static-tier-declared-not-detected.md +0 -5
- data/docs/adr/0003-shared-tool-cache-semantics.md +0 -31
- data/docs/adr/0004-install-e2e-stays-example-rich.md +0 -28
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +0 -38
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +0 -26
- data/docs/adr/0010-one-config-tool-with-sections.md +0 -32
- data/docs/agents/domain.md +0 -38
- data/docs/agents/issue-tracker.md +0 -45
- data/docs/agents/triage-labels.md +0 -15
- data/server.json +0 -21
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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,30 @@ 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:
|
|
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 them from. None past the app
|
|
826
|
+
# base, whose methods and everything above it are no actions, and none from Rails itself.
|
|
827
|
+
def inherited_from(walked, outside)
|
|
828
|
+
return nil if walked.any? { |name| ActionResolver.app_base_name?(name, kind: :mailer) }
|
|
829
|
+
|
|
830
|
+
name = outside.to_s.delete_prefix("::")
|
|
831
|
+
name unless name.empty? || name.start_with?("ActionMailer::", "AbstractController::") ||
|
|
832
|
+
ActionResolver.app_base_name?(name, kind: :mailer)
|
|
817
833
|
end
|
|
818
834
|
|
|
819
835
|
def callable_class_methods(names)
|
|
820
836
|
names.reject { |n| n.start_with?("_") || MAILER_CLASS_CONFIG.include?(n) }
|
|
821
837
|
end
|
|
822
838
|
|
|
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
839
|
# ActionMailer's interceptor and observer hooks. A class or module under
|
|
829
840
|
# app/mailers that implements one is registered with the framework, not
|
|
830
841
|
# delivered by it: reporting OpenProject's Interceptors::DefaultHeaders
|
|
@@ -853,7 +864,7 @@ module RailsAiContext
|
|
|
853
864
|
|
|
854
865
|
def source_mailers
|
|
855
866
|
@source_mailers ||= begin
|
|
856
|
-
scanned = source_classes("app/mailers", macros: ACTION_CALLBACKS + MAILER_DECLARATIONS)
|
|
867
|
+
scanned = source_classes("app/mailers", macros: ACTION_CALLBACKS + MAILER_DECLARATIONS + VISIBILITY_CALLS)
|
|
857
868
|
candidates = scanned.reject { |klass| framework_hook?(klass) } +
|
|
858
869
|
mailers_outside_mailer_dirs(scanned.map(&:file))
|
|
859
870
|
# A mixin under app/mailers holds a mailer's actions (an Emails::* module); a class
|
|
@@ -863,7 +874,8 @@ module RailsAiContext
|
|
|
863
874
|
bases = abstract_mailer_bases(klasses.reject(&:mixin))
|
|
864
875
|
mailers = klasses.reject { |klass| bases.include?(klass.name) }
|
|
865
876
|
|
|
866
|
-
|
|
877
|
+
by_name = klasses.reject(&:mixin).to_h { |klass| [ klass.name, klass ] }
|
|
878
|
+
{ mailers: mailers.map { |klass| mailer_entry(klass, app_ancestors(klass, by_name, parent_of)) }.sort_by { |m| m[:name] },
|
|
867
879
|
bases: bases.map do |base|
|
|
868
880
|
klass = klasses.find { |k| k.name == base }
|
|
869
881
|
heirs = mailers.reject(&:mixin).map(&:name).select { |name| inherits_from?(name, base, parent_of) }
|
|
@@ -1071,6 +1083,9 @@ module RailsAiContext
|
|
|
1071
1083
|
# app wrote it, the methods it defines, and who inherits it.
|
|
1072
1084
|
MAILER_DECLARATIONS = %i[layout helper helper_method include default default_url_options=].freeze
|
|
1073
1085
|
|
|
1086
|
+
# `private :name` in a mailer hides a method it inherits, and `public :name` shows one.
|
|
1087
|
+
VISIBILITY_CALLS = %i[private protected public].freeze
|
|
1088
|
+
|
|
1074
1089
|
def mailer_base_record(name, file, source, macros, methods, heirs)
|
|
1075
1090
|
declares = SourceIntrospector.outside_defs(macros, methods).select { |m| (MAILER_DECLARATIONS + ACTION_CALLBACKS).include?(m[:macro]) }
|
|
1076
1091
|
.sort_by { |m| m[:offset] }.map { |m| written(source, m) }
|
|
@@ -1100,6 +1115,20 @@ module RailsAiContext
|
|
|
1100
1115
|
@candidate_bodies[name] ||= SourceIntrospector.outside_defs(job_candidates[name].ast[:macros], job_candidates[name].ast[:methods])
|
|
1101
1116
|
end
|
|
1102
1117
|
|
|
1118
|
+
# The app's own classes a class inherits from, nearest first.
|
|
1119
|
+
def app_ancestors(klass, by_name, parent_of)
|
|
1120
|
+
chain = []
|
|
1121
|
+
name = klass.name
|
|
1122
|
+
while chain.size < SuperclassChain::MAX_DEPTH
|
|
1123
|
+
superclass, nesting = parent_of[name]
|
|
1124
|
+
name = SuperclassChain.resolve_in_scope(name, superclass, nesting: nesting) { |candidate| candidate if by_name.key?(candidate) }
|
|
1125
|
+
break if name.nil? || name == klass.name || chain.any? { |ancestor| ancestor.name == name }
|
|
1126
|
+
|
|
1127
|
+
chain << by_name[name]
|
|
1128
|
+
end
|
|
1129
|
+
chain
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1103
1132
|
def inherits_from?(name, base, parent_of, seen = [])
|
|
1104
1133
|
return false if seen.include?(name) || seen.size >= SuperclassChain::MAX_DEPTH
|
|
1105
1134
|
|
|
@@ -1158,12 +1187,22 @@ module RailsAiContext
|
|
|
1158
1187
|
false
|
|
1159
1188
|
end
|
|
1160
1189
|
|
|
1161
|
-
#
|
|
1162
|
-
#
|
|
1163
|
-
def mailer_entry(klass)
|
|
1190
|
+
# The public methods of a mailer and of its app parents below the app base, each with the visibility the nearest
|
|
1191
|
+
# gives it; tagged STATIC, since a gem's base or a mixin can add actions only the booted tier sees.
|
|
1192
|
+
def mailer_entry(klass, ancestors)
|
|
1193
|
+
# The app base's public methods are no actions, as the booted tier subtracts them; its callbacks still filter.
|
|
1194
|
+
action_chain = [ klass, *ancestors.take_while { |declaring| !ActionResolver.app_base_name?(declaring.name, kind: :mailer) } ]
|
|
1195
|
+
visibility = {}
|
|
1196
|
+
action_chain.each do |declaring|
|
|
1197
|
+
own = ActionResolver.own_methods(declaring.methods, declaring.name).select { |m| m[:scope] == :instance }
|
|
1198
|
+
own.each { |m| visibility[m[:name]] ||= m[:visibility] }
|
|
1199
|
+
declaring.macros.each do |m|
|
|
1200
|
+
m[:args].each { |name| visibility[name.to_s] ||= m[:macro] } if VISIBILITY_CALLS.include?(m[:macro])
|
|
1201
|
+
end
|
|
1202
|
+
end
|
|
1164
1203
|
actions = ActionResolver.deliverable_actions(
|
|
1165
|
-
|
|
1166
|
-
klass.macros.select { |m| ACTION_CALLBACKS.include?(m[:macro]) }.flat_map { |m| m[:args] }
|
|
1204
|
+
visibility.select { |name, seen| seen == :public && !name.start_with?("_") }.keys.sort,
|
|
1205
|
+
[ klass, *ancestors ].flat_map { |declaring| declaring.macros.select { |m| ACTION_CALLBACKS.include?(m[:macro]) }.flat_map { |m| m[:args] } }
|
|
1167
1206
|
)
|
|
1168
1207
|
|
|
1169
1208
|
entry = { name: klass.name, file: klass.file, actions: actions,
|
|
@@ -1174,7 +1213,8 @@ module RailsAiContext
|
|
|
1174
1213
|
.select { |m| m[:scope] == :class && m[:visibility] == :public }
|
|
1175
1214
|
.map { |m| m[:name] }
|
|
1176
1215
|
entry[:class_actions] = class_actions.sort if (class_actions = callable_class_methods(class_actions)).any?
|
|
1177
|
-
|
|
1216
|
+
source = inherited_from(ancestors.map(&:name), (ancestors.last || klass).parent_class)
|
|
1217
|
+
entry[:parent_class] = source if source
|
|
1178
1218
|
entry
|
|
1179
1219
|
end
|
|
1180
1220
|
|
|
@@ -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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
|
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) ||
|
|
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,
|
|
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
|
|
65
|
-
#
|
|
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
|
-
|
|
163
|
-
|
|
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?(
|
|
228
|
+
@columns.key?(fold(relation))
|
|
226
229
|
end
|
|
227
230
|
|
|
228
231
|
def secret?(name)
|
|
229
|
-
down =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
313
|
+
@by_relation.fetch(fold(relation), [])
|
|
303
314
|
end
|
|
304
315
|
|
|
305
316
|
def relation_secret?(relation)
|
|
306
|
-
@by_relation.key?(
|
|
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 =
|
|
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 =
|
|
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|
|
|
345
|
-
view = followed.find { |name, _start| (dotted_names(
|
|
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 =
|
|
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,
|
|
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 =
|
|
400
|
-
down =
|
|
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| [
|
|
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[
|
|
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 =
|
|
470
|
+
table = fold(table)
|
|
457
471
|
next if definition.nil? || @unread_tables.key?(table)
|
|
458
472
|
|
|
459
|
-
down =
|
|
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?(
|
|
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?(
|
|
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)
|