rails-ai-context 5.35.0 → 5.35.2

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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1024 -0
  3. data/CONTEXT.md +37 -7
  4. data/CONTRIBUTING.md +30 -1
  5. data/README.md +9 -9
  6. data/SECURITY.md +2 -2
  7. data/docs/ARCHITECTURE.md +12 -4
  8. data/docs/CLI.md +44 -15
  9. data/docs/COMPATIBILITY.md +34 -8
  10. data/docs/CONFIGURATION.md +28 -5
  11. data/docs/CUSTOM_TOOLS.md +8 -2
  12. data/docs/FAQ.md +5 -5
  13. data/docs/GUIDE.md +33 -65
  14. data/docs/INTROSPECTORS.md +4 -4
  15. data/docs/QUICKSTART.md +1 -1
  16. data/docs/RECIPES.md +5 -5
  17. data/docs/RELEASING.md +3 -1
  18. data/docs/SECURITY.md +255 -74
  19. data/docs/SETUP.md +1 -1
  20. data/docs/STANDALONE.md +2 -2
  21. data/docs/TOOLS.md +151 -90
  22. data/docs/TROUBLESHOOTING.md +123 -8
  23. data/docs/_config.yml +1 -1
  24. data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
  25. data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
  26. data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
  27. data/docs/index.md +2 -2
  28. data/exe/rails-ai-context +24 -9
  29. data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
  30. data/lib/rails_ai_context/app_databases.rb +377 -0
  31. data/lib/rails_ai_context/app_kind.rb +2 -0
  32. data/lib/rails_ai_context/cli/app_root.rb +1 -0
  33. data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
  34. data/lib/rails_ai_context/configuration.rb +15 -8
  35. data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
  36. data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
  37. data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
  38. data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
  39. data/lib/rails_ai_context/database_family.rb +282 -0
  40. data/lib/rails_ai_context/database_file.rb +61 -7
  41. data/lib/rails_ai_context/database_yml.rb +7 -29
  42. data/lib/rails_ai_context/doctor.rb +15 -16
  43. data/lib/rails_ai_context/instrumentation.rb +1 -1
  44. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
  45. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
  46. data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
  47. data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
  48. data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
  49. data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
  50. data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
  51. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
  52. data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
  53. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
  54. data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
  55. data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
  56. data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
  57. data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
  58. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
  59. data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
  60. data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
  61. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
  62. data/lib/rails_ai_context/migration_status.rb +18 -46
  63. data/lib/rails_ai_context/payload.rb +48 -1
  64. data/lib/rails_ai_context/pending_migrations.rb +3 -4
  65. data/lib/rails_ai_context/run_cache.rb +5 -0
  66. data/lib/rails_ai_context/schema_adapter.rb +13 -18
  67. data/lib/rails_ai_context/secret_columns.rb +506 -0
  68. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
  69. data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
  70. data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
  71. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
  72. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
  73. data/lib/rails_ai_context/server.rb +143 -29
  74. data/lib/rails_ai_context/sql_gate.rb +1007 -0
  75. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
  76. data/lib/rails_ai_context/test_helper.rb +3 -1
  77. data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
  78. data/lib/rails_ai_context/tools/base_tool.rb +71 -48
  79. data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
  80. data/lib/rails_ai_context/tools/diagnose.rb +105 -42
  81. data/lib/rails_ai_context/tools/generate_test.rb +4 -6
  82. data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
  83. data/lib/rails_ai_context/tools/get_api.rb +1 -6
  84. data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
  85. data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
  86. data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
  87. data/lib/rails_ai_context/tools/get_concern.rb +6 -6
  88. data/lib/rails_ai_context/tools/get_config.rb +189 -12
  89. data/lib/rails_ai_context/tools/get_context.rb +111 -105
  90. data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
  91. data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
  92. data/lib/rails_ai_context/tools/get_engines.rb +1 -6
  93. data/lib/rails_ai_context/tools/get_env.rb +1 -6
  94. data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
  95. data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
  96. data/lib/rails_ai_context/tools/get_gems.rb +1 -6
  97. data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
  98. data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
  99. data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
  100. data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
  101. data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
  102. data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
  103. data/lib/rails_ai_context/tools/get_schema.rb +10 -8
  104. data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
  105. data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
  106. data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
  107. data/lib/rails_ai_context/tools/onboard.rb +24 -24
  108. data/lib/rails_ai_context/tools/performance_check.rb +15 -2
  109. data/lib/rails_ai_context/tools/query.rb +142 -1356
  110. data/lib/rails_ai_context/tools/read_logs.rb +1 -1
  111. data/lib/rails_ai_context/tools/review_changes.rb +6 -7
  112. data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
  113. data/lib/rails_ai_context/tools/safe_call.rb +70 -28
  114. data/lib/rails_ai_context/tools/search_code.rb +0 -4
  115. data/lib/rails_ai_context/tools/search_docs.rb +3 -1
  116. data/lib/rails_ai_context/tools/security_scan.rb +9 -5
  117. data/lib/rails_ai_context/tools/session_context.rb +18 -12
  118. data/lib/rails_ai_context/tools/validate.rb +1 -0
  119. data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
  120. data/lib/rails_ai_context/version.rb +1 -1
  121. data/lib/rails_ai_context.rb +4 -7
  122. data/server.json +2 -2
  123. metadata +14 -3
@@ -0,0 +1,391 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module RailsAiContext
6
+ module DatabaseFamily
7
+ # sqlite3-ruby holds the interpreter lock while a statement steps, so only a killable child ends
8
+ # one at the deadline. A statement the child cannot run goes to the app's own connection, unbounded.
9
+ module SqliteRuntime
10
+ module_function
11
+
12
+ TIMED_OUT = "SQLite query exceeded the statement timeout"
13
+
14
+ # A row-capped result is far below this; a larger prefix is a corrupt one.
15
+ MAX_RESULT_BYTES = 1 << 30
16
+
17
+ # What the child writes where a result's length would stand, the moment it meets a lock.
18
+ MET_LOCK = [ (1 << 64) - 1 ].pack("Q>").freeze
19
+ # What it writes once it holds its read lock: no lock can stop the statement from there on.
20
+ PAST_LOCK = [ (1 << 64) - 2 ].pack("Q>").freeze
21
+ WAITS_ON_LOCK = { MET_LOCK => true, PAST_LOCK => false }.freeze
22
+
23
+ # A plan row that reads a table: the step, then the table's name or, for a table the statement
24
+ # renames, the alias alone ("SEARCH u USING INDEX i"). SQLite before 3.36 wrote "SCAN TABLE users AS u".
25
+ PLAN_STEP = /\A(SCAN|SEARCH)\s+(?:TABLE\s+)?(.+?)(?:\s+AS\s+\S+)?(?=\s+USING\b|\s+VIRTUAL TABLE\b|\z)/
26
+
27
+ # pragma_table_xinfo's hidden flag: 2 is a virtual generated column, 3 a stored one.
28
+ GENERATED = [ 2, 3 ].freeze
29
+
30
+ # COUNT(*) walks the whole table; a count that stops here reads at most this many rows.
31
+ COUNT_CAP = 100_000
32
+
33
+ def read_only(conn, sql, timeout)
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) }
37
+ result = in_child(conn, path, sql, deadline)
38
+ return Rows.new(result.columns, result.rows, false) if result
39
+ end
40
+
41
+ result = DatabaseFamily.bounding { on_app_connection(conn, sql, deadline) }
42
+ Rows.new(result.columns, result.rows, true)
43
+ end
44
+
45
+ # The temp catalog, the temp schema, and a pragma function or dbstat: each reads any schema, by a name that can be put
46
+ # together as the statement runs.
47
+ CONNECTION_CATALOG = /(?<![\w$])(?:sqlite_temp_(?:master|schema)|temp["`'\]]?\s*\.|pragma_\w|dbstat(?![\w$]))/i
48
+
49
+ # Whether the statement names what the connection holds and a child would answer wrongly: a temp object, the temp schema or
50
+ # an attached database, in a string too. A table only an attached database holds needs no match: no child can prepare it.
51
+ def held_here?(conn, sql)
52
+ # Neither read waits on another connection's lock, which the pragma function for the same list does.
53
+ names = conn.select_rows("PRAGMA database_list").filter_map { |_seq, name, _file| name unless OWN_SCHEMAS.include?(name) } +
54
+ conn.select_values("SELECT name FROM sqlite_temp_master")
55
+ return false if names.empty?
56
+
57
+ sql.match?(CONNECTION_CATALOG) || SecretColumns.fold(sql).match?(Regexp.union(names.map { |name| SecretColumns.identifier(name) }))
58
+ end
59
+
60
+ # The watchdog's interrupt lands when the driver is back in Ruby between two rows, so it ends a
61
+ # statement that returns rows slowly and cannot end one long step: the run counts as unbounded.
62
+ def on_app_connection(conn, sql, deadline)
63
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ raise TimedOut, TIMED_OUT unless left.positive?
65
+
66
+ raw = conn.raw_connection
67
+ found = conn.select_value("PRAGMA query_only").to_i
68
+ watchdog = Thread.new { sleep(left) and raw.interrupt }
69
+ begin
70
+ conn.execute("PRAGMA query_only = ON")
71
+ conn.select_all(sql)
72
+ rescue ActiveRecord::StatementInvalid => e
73
+ raise TimedOut, TIMED_OUT if e.cause.is_a?(SQLite3::InterruptException)
74
+
75
+ raise
76
+ ensure
77
+ watchdog.kill.join
78
+ DatabaseFamily.restore(conn, "PRAGMA query_only = #{found}")
79
+ end
80
+ end
81
+
82
+ # These reads run on the app's own connection, where a wait on another writer ends at the
83
+ # connection's busy timeout (database.yml `timeout`; with none, a busy database fails at once).
84
+ def bounded(_conn, _timeout, &reads)
85
+ DatabaseFamily.bounding(&reads)
86
+ end
87
+
88
+ def explain(conn, sql, timeout)
89
+ result = read_only(conn, "EXPLAIN QUERY PLAN #{sql}", timeout)
90
+ scans = []
91
+ warnings = []
92
+ tables = conn.data_sources.map(&:downcase)
93
+ raw_lines = result.rows.map do |row|
94
+ # Columns: id, parent, notused, detail.
95
+ detail = row.last.to_s
96
+ step, name = detail.match(PLAN_STEP)&.captures
97
+ # "SCAN CONSTANT ROW" reads no table.
98
+ if step && name != "CONSTANT ROW"
99
+ table = tables.include?(name.downcase) ? name : "#{name} (a name the statement gives)"
100
+ type = if step == "SEARCH" then "index search"
101
+ elsif detail.match?(/USING.*INDEX/) then "index scan"
102
+ else "full table scan"
103
+ end
104
+ scans << { table: table, type: type, index: detail[/USING.*INDEX\s+(\w+)/, 1] }
105
+ warnings << "Full table scan on #{table}" if type == "full table scan"
106
+ end
107
+ detail
108
+ end
109
+ { scan_types: scans, warnings: warnings, raw: raw_lines.join("\n"), unbounded: result.unbounded }
110
+ end
111
+
112
+ def column_sources(_conn, _sql, _plan)
113
+ []
114
+ end
115
+
116
+ def plan(_conn, _sql, _timeout)
117
+ nil
118
+ end
119
+
120
+ # The attached databases. A statement reaches their tables with no schema named, as it does main's and temp's.
121
+ def unqualified_schemas(conn)
122
+ conn.select_rows("PRAGMA database_list").map { |_seq, name, _file| name } | OWN_SCHEMAS
123
+ end
124
+
125
+ def schemas_named(_conn, _names)
126
+ []
127
+ end
128
+
129
+ OWN_SCHEMAS = %w[main temp].freeze
130
+
131
+ # One SELECT for each schema, as one statement. Yields the schema as an identifier and as a string,
132
+ # which is how a pragma function takes it.
133
+ def across(schemas)
134
+ (schemas.empty? ? OWN_SCHEMAS : schemas).map { |schema| yield %("#{schema.gsub('"', '""')}"), "'#{schema.gsub("'", "''")}'" }.join(" UNION ALL ")
135
+ end
136
+
137
+ # A virtual table's hidden columns are left out (hidden = 1): a full-text table has one of its own name.
138
+ def relation_columns(conn, schemas = [])
139
+ DatabaseFamily.by_table(conn.select_rows(across(schemas) do |schema, name|
140
+ "SELECT m.name, p.name FROM #{schema}.sqlite_master m JOIN pragma_table_xinfo(m.name, #{name}) p " \
141
+ "WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND p.hidden <> 1"
142
+ end)).transform_values(&:flatten)
143
+ end
144
+
145
+ # The body only: the header names the view itself.
146
+ def view_definitions(conn, schemas = [])
147
+ rows = conn.select_rows(across(schemas) { |schema, _| "SELECT name, sql FROM #{schema}.sqlite_master WHERE type = 'view'" })
148
+ DatabaseFamily.by_view(rows.map { |name, sql| [ name, sql.to_s.sub(/\A.*?\bAS\b/mi, "") ] })
149
+ end
150
+
151
+ def traced_views(_conn, _schemas = [])
152
+ []
153
+ end
154
+
155
+ # SQLite keeps no expression per column, so each one is cut from its table's definition. A column whose
156
+ # part of the definition is not found answers the whole definition.
157
+ def generated_columns(conn, schemas = [])
158
+ rows = conn.select_rows(across(schemas) do |schema, name|
159
+ "SELECT m.name, p.name, p.hidden, m.sql FROM #{schema}.sqlite_master m JOIN pragma_table_xinfo(m.name, #{name}) p " \
160
+ "WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' AND m.sql LIKE '%AS%(%'"
161
+ end)
162
+ # Tables of one name in two schemas each have a definition of their own.
163
+ rows.group_by { |table, _name, _hidden, definition| [ table, definition.to_s ] }.each_with_object({}) do |((table, definition), columns), found|
164
+ generated = columns.filter_map { |_table, name, hidden, _| name if GENERATED.include?(hidden.to_i) }
165
+ next if generated.empty?
166
+
167
+ expressions = column_expressions(definition)
168
+ DatabaseFamily.keep_expressions(found, generated.map { |name| [ table, name, expressions[name.downcase(:ascii)] || definition ] })
169
+ end
170
+ end
171
+
172
+ COLUMN_NAME = /\A(?:"((?:""|[^"])*)"|`((?:``|[^`])*)`|\[([^\]]*)\]|'((?:''|[^'])*)'|([^\s(]+))(.*)\z/m
173
+ # A bare one of these opens a table constraint. In quotes it is a column's name.
174
+ TABLE_CONSTRAINT = /\A(?:CONSTRAINT|PRIMARY|UNIQUE|CHECK|FOREIGN)\z/i
175
+
176
+ # @return [Hash{String => String, nil}] column name, folded as SQLite compares names (ASCII only) => what its
177
+ # part of a CREATE TABLE statement says after AS; nil where that cannot be told
178
+ def column_expressions(definition)
179
+ scanner = SqlGate::Scanner.new(SqlGate.comment_free(definition, SQLITE))
180
+ depth = 0
181
+ parts = [ +"" ]
182
+ until scanner.eos?
183
+ read = SqlGate.quoted(scanner, SQLITE)&.last || scanner.getch
184
+ depth -= 1 if read == ")"
185
+ if depth == 1 && read == "," then parts << +""
186
+ elsif depth.positive? then parts.last << read
187
+ end
188
+ depth += 1 if read == "("
189
+ end
190
+ parts.each_with_object({}) do |part, found|
191
+ double, tick, bracket, single, bare, rest = part.strip.match(COLUMN_NAME)&.captures
192
+ next if rest.nil? || bare&.match?(TABLE_CONSTRAINT)
193
+
194
+ # A quote is doubled only inside a name its own kind encloses, and a bracketed name doubles nothing.
195
+ name = (bare || bracket || double&.gsub('""', '"') || tick&.gsub("``", "`") || single.gsub("''", "'")).downcase(:ascii)
196
+ found[name] = found.key?(name) ? nil : rest.partition(/\bAS\b/i).last.presence
197
+ end
198
+ end
199
+
200
+ # pragma_table_list, which types a shadow table, came with this version.
201
+ TYPED_TABLES = "3.37"
202
+ VOCABULARY = /\A\s*USING\s+["'`\[]?(fts5vocab|fts4aux)\b["'`\]]?\s*\((.*)\)/mi
203
+
204
+ # Modules that return the database file's pages or cells, whatever table they belong to.
205
+ PAGE_MODULES = %w[sqlite_dbpage sqlite_dbdata sqlite_dbptr].freeze
206
+
207
+ # Before pragma_table_list a shadow table is told by its name: any table that starts with a virtual table's.
208
+ def index_tables(conn, schemas = [])
209
+ tables = ->(kept) { across(schemas) { |schema, _| "SELECT name, sql FROM #{schema}.sqlite_master WHERE type = 'table' AND rootpage #{kept}" } }
210
+ virtual = conn.select_rows(tables.call("= 0")).to_h
211
+ stored = if conn.database_version >= TYPED_TABLES
212
+ conn.select_values(across(schemas) { |_, name| "SELECT name FROM pragma_table_list WHERE type = 'shadow' AND schema = #{name} COLLATE NOCASE" })
213
+ else conn.select_rows(tables.call("> 0")).map(&:first)
214
+ end
215
+ found = stored.each_with_object(PAGE_MODULES.to_h { |name| [ name, [ :pages, nil ] ] }) do |name, tables|
216
+ owner = virtual.keys.select { |table| name.downcase.start_with?("#{table.downcase}_") }.max_by(&:size)
217
+ tables[name] = [ :shadow, owner ] if owner
218
+ end
219
+ virtual.each do |name, sql|
220
+ clause = module_clause(SqlGate.comment_free(sql.to_s, SQLITE))
221
+ mod, args = clause.match(VOCABULARY)&.captures
222
+ # fts5vocab([schema,] table, type) and fts4aux([schema,] table).
223
+ names = args.to_s.scan(SecretColumns::MODULE_ARGUMENT).map { |arg| arg.strip.delete(%q('"`[])) }
224
+ found[name] = mod ? [ :vocabulary, mod.casecmp?("fts4aux") ? names.last : names[-2], clause ] : [ :virtual, name, clause ]
225
+ end
226
+ found
227
+ end
228
+
229
+ # What a virtual table's definition says after the table's own name: `USING module(arguments)`.
230
+ # SQLite stores it from the name on, and a quoted name can hold the word USING.
231
+ def module_clause(sql)
232
+ scanner = SqlGate::Scanner.new(sql)
233
+ scanner.skip(/\s*CREATE\s+VIRTUAL\s+TABLE\s+/i)
234
+ SqlGate.quoted(scanner, SQLITE) || scanner.skip(/[^\s"'`\[]+/)
235
+ scanner.rest
236
+ end
237
+
238
+ def table_sizes(conn)
239
+ # dbstat lists every b-tree, indexes and SQLite internals too.
240
+ app_tables = conn.select_values("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").to_set
241
+
242
+ begin
243
+ rows = conn.select_all("SELECT name, SUM(pgsize) AS bytes FROM dbstat GROUP BY name ORDER BY bytes DESC")
244
+ .filter_map { |r| { name: r["name"], bytes: r["bytes"].to_i } if app_tables.include?(r["name"]) }
245
+ return rows if rows.any?
246
+ rescue => e
247
+ RailsAiContext.debug_fail(e, label: "SqliteRuntime.table_sizes (dbstat)")
248
+ end
249
+
250
+ # Without dbstat, the whole file split across the tables.
251
+ total = conn.select_value("PRAGMA page_count").to_i * conn.select_value("PRAGMA page_size").to_i
252
+ tables = app_tables.to_a.sort
253
+ tables.map { |t| { name: t, bytes: total / [ tables.size, 1 ].max } }
254
+ end
255
+
256
+ # SQLite keeps no row estimate a query can read without ANALYZE; a table past the cap is a floor.
257
+ def row_estimates(conn)
258
+ names = (conn.tables - Introspectors::SqliteVirtualTables.hidden(conn)).reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
259
+ names.map do |table|
260
+ count = conn.select_value("SELECT COUNT(*) FROM (SELECT 1 FROM #{conn.quote_table_name(table)} LIMIT #{COUNT_CAP + 1})").to_i
261
+ entry = { table: table, approximate_rows: [ count, COUNT_CAP ].min }
262
+ entry[:at_least] = true if count > COUNT_CAP
263
+ entry
264
+ end.sort_by { |t| [ -t[:approximate_rows], t[:table] ] }
265
+ end
266
+
267
+ def index_usage(_conn)
268
+ nil
269
+ end
270
+
271
+ # nil for an in-memory or temporary database, or where there is no fork.
272
+ def fork_path(conn)
273
+ return nil unless Process.respond_to?(:fork)
274
+
275
+ path = conn.raw_connection.filename("main").to_s
276
+ path unless path.empty?
277
+ end
278
+
279
+ # @return [ActiveRecord::Result, nil] nil when the child could not prepare the statement, as when it
280
+ # names a function, module, collation, key or temporary table only the app's connection has
281
+ def in_child(conn, path, sql, deadline)
282
+ parent = Process.pid
283
+ extensions = Array(conn.pool.db_config.configuration_hash[:extensions]).map { |ext| (ext.is_a?(String) && ext.safe_constantize) || ext }
284
+ # sqlite3 2.x warns in every child that inherits a writable handle; Rails 8 silences it the same way.
285
+ SQLite3::ForkSafety.suppress_warnings! if defined?(SQLite3::ForkSafety)
286
+
287
+ timeout = [ deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0 ].max
288
+ reader, writer = IO.pipe.each(&:binmode)
289
+ pid = fork { answer_in_child(reader, writer, path, sql, timeout, extensions) }
290
+ writer.close
291
+ read_child_result(reader, deadline)
292
+ ensure
293
+ # Also reached in a child whose fork hooks raised: it must not carry on as a second server.
294
+ exit!(1) if parent && Process.pid != parent
295
+ reader&.close
296
+ writer&.close
297
+ reap_child(pid) if pid
298
+ end
299
+
300
+ # Writes the length-prefixed result and exits without the parent's at_exit hooks.
301
+ def answer_in_child(reader, writer, path, sql, timeout, extensions)
302
+ reader.close
303
+ payload = begin
304
+ db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
305
+ wait_on_lock(db, writer, timeout * 0.8)
306
+ # A read inside a transaction keeps its lock, so the statement itself never waits on one.
307
+ db.execute("BEGIN")
308
+ db.execute("SELECT 1 FROM sqlite_master LIMIT 1")
309
+ writer.write(PAST_LOCK)
310
+ stmt = db.prepare(sql)
311
+ [ :ok, stmt.columns, stmt.to_a ]
312
+ rescue => e
313
+ # Which step failed is the child's own fact; the message can hold text from the statement.
314
+ status = if e.is_a?(SQLite3::BusyException) then :busy
315
+ else db && !stmt ? :unprepared : :error
316
+ end
317
+ [ status, "#{e.class}: #{e.message}" ]
318
+ end
319
+ bytes = Marshal.dump(payload)
320
+ writer.write([ bytes.bytesize ].pack("Q>"), bytes)
321
+ ensure
322
+ exit!(0)
323
+ end
324
+
325
+ # Gives up under the deadline, so the child answers a database that stays locked. The parent is told at
326
+ # the first wait, for a child that started late and is still waiting at the deadline.
327
+ def wait_on_lock(db, writer, seconds)
328
+ met = nil
329
+ db.busy_handler do |count|
330
+ if count.zero?
331
+ met = Process.clock_gettime(Process::CLOCK_MONOTONIC)
332
+ writer.write(MET_LOCK)
333
+ end
334
+ sleep 0.01
335
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - met < seconds
336
+ end
337
+ end
338
+
339
+ # Read by length: any other process forked meanwhile holds a copy of the writer, so EOF may never come.
340
+ # A deadline that passes while the child waits on a lock is answered as locked; past the lock it is the timeout.
341
+ def read_child_result(reader, deadline)
342
+ on_lock = false
343
+ size = begin
344
+ loop do
345
+ prefix = read_before(reader, 8, deadline)
346
+ break prefix.unpack1("Q>") unless WAITS_ON_LOCK.key?(prefix)
347
+
348
+ on_lock = WAITS_ON_LOCK.fetch(prefix)
349
+ end
350
+ rescue TimedOut
351
+ raise unless on_lock
352
+
353
+ raise Busy, BUSY
354
+ end
355
+ status, *rest = begin
356
+ raise ArgumentError, "result length out of range" if size > MAX_RESULT_BYTES
357
+
358
+ Marshal.load(read_before(reader, size, deadline))
359
+ rescue ArgumentError, TypeError
360
+ raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result"
361
+ end
362
+ raise Busy, BUSY if status == :busy
363
+ raise ActiveRecord::StatementInvalid, rest.first if status == :error
364
+ return nil if status == :unprepared
365
+
366
+ ActiveRecord::Result.new(*rest)
367
+ end
368
+
369
+ def read_before(reader, size, deadline)
370
+ data = "".b
371
+ while data.bytesize < size
372
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
373
+ raise TimedOut, TIMED_OUT unless left.positive? && IO.select([ reader ], nil, nil, left)
374
+
375
+ chunk = reader.read_nonblock([ size - data.bytesize, 1 << 16 ].min, exception: false)
376
+ raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result" if chunk.nil?
377
+
378
+ data << chunk unless chunk == :wait_readable
379
+ end
380
+ data
381
+ end
382
+
383
+ # KILL: TERM cannot land while `step` holds the GVL, and would run inherited at_exit hooks.
384
+ # Runs in an ensure, so it rescues everything: nothing here may replace the query's own error.
385
+ def reap_child(pid)
386
+ Process.kill(:KILL, pid) rescue nil
387
+ Process.wait(pid) rescue nil
388
+ end
389
+ end
390
+ end
391
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ # What a database is by its adapter's name: PostgreSQL, MySQL or SQLite, else no family (ADR-0008).
5
+ # A family also owns how its database runs a guarded read and the statistics other tools show.
6
+ module DatabaseFamily
7
+ # A statement ran past the timeout it was given.
8
+ class TimedOut < RailsAiContext::Error; end
9
+
10
+ # Another connection holds a lock on the database, so the statement did not run.
11
+ class Busy < RailsAiContext::Error; end
12
+
13
+ # The read cannot be guarded (an adapter with no family, a MySQL transaction already open), so nothing was sent.
14
+ class Unsupported < RailsAiContext::Error; end
15
+
16
+ # Rows from a guarded read; unbounded when no timeout could hold it.
17
+ Rows = Data.define(:columns, :rows, :unbounded)
18
+
19
+ Family = Data.define(:key, :name) do
20
+ def known?
21
+ key != :unknown
22
+ end
23
+
24
+ def postgresql?
25
+ key == :postgresql
26
+ end
27
+
28
+ def mysql?
29
+ key == :mysql
30
+ end
31
+
32
+ def sqlite?
33
+ key == :sqlite
34
+ end
35
+
36
+ def implicit_pk_type
37
+ sqlite? ? "integer" : "bigint"
38
+ end
39
+
40
+ # A statement is checked against its plan before it runs, and a result column is traced to its table column.
41
+ def plan_checks?
42
+ postgresql?
43
+ end
44
+
45
+ # The read-only transaction refuses FOR UPDATE and FOR SHARE itself.
46
+ def refuses_locking_reads?
47
+ postgresql?
48
+ end
49
+
50
+ # Every string literal in a statement can be told from its identifiers.
51
+ def masks_literals?
52
+ postgresql?
53
+ end
54
+
55
+ def dollar_quoting?
56
+ postgresql?
57
+ end
58
+
59
+ # A block comment inside a block comment needs a closer of its own.
60
+ def nested_comments?
61
+ postgresql?
62
+ end
63
+
64
+ # `/*! ... */` holds text the database runs.
65
+ def executable_comments?
66
+ mysql?
67
+ end
68
+
69
+ # A backslash escapes the next character in every string.
70
+ def backslash_escapes?
71
+ mysql?
72
+ end
73
+
74
+ # @return [Regexp] a comment that runs to the end of its line: `#` too, and `--` only before a space, on MySQL
75
+ def line_comment
76
+ mysql? ? /--(?=\s|\z)[^\n]*|#[^\n]*/ : /--[^\n]*/
77
+ end
78
+
79
+ # @return [Rows] run in a read-only transaction, bounded by timeout seconds
80
+ # @raise [TimedOut, Unsupported]
81
+ def read_only(connection, sql, timeout:)
82
+ runtime.read_only(connection, sql, timeout)
83
+ end
84
+
85
+ # Runs reads beside the statement so that each one ends at timeout seconds, a wait on a lock included.
86
+ # @raise [TimedOut, Unsupported]
87
+ def bounded(connection, timeout:, &reads)
88
+ runtime.bounded(connection, timeout, &reads)
89
+ end
90
+
91
+ # @return [Hash] { scan_types:, warnings:, raw:, unbounded: }
92
+ def explain(connection, sql, timeout:)
93
+ runtime.explain(connection, sql, timeout)
94
+ end
95
+
96
+ # @raise [TimedOut, ActiveRecord::StatementInvalid] when PostgreSQL cannot plan the statement
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)
100
+ end
101
+
102
+ # @return [Array<Array(Integer, String, String)>] [result column index, table column, relation] from the
103
+ # plan; the column is nil where the relation's columns could not be read; [] where the family cannot say
104
+ def column_sources(connection, sql, plan:)
105
+ runtime.column_sources(connection, sql, plan)
106
+ end
107
+
108
+ # A schema read that is given no schemas reads at least what a statement reaches with no schema named:
109
+ # the PostgreSQL search path, the MySQL connection's database, SQLite's main and temp.
110
+
111
+ # @return [Array<String>] the schemas a statement reaches with no schema named, where the reads' own
112
+ # default does not cover them: SQLite's attached databases
113
+ def unqualified_schemas(connection)
114
+ runtime.unqualified_schemas(connection)
115
+ end
116
+
117
+ # @return [Array<String>] the names among these that are schemas a statement can read and the reads' own
118
+ # default does not cover. System schemas are left out.
119
+ def schemas_named(connection, names)
120
+ names.empty? ? [] : runtime.schemas_named(connection, names)
121
+ end
122
+
123
+ # @return [Hash{String => Array<String>}] relation name => its column names, from one catalogue read. On
124
+ # PostgreSQL also under `schema.relation`, where the bare name holds the columns of every schema read.
125
+ def relation_columns(connection, schemas = [])
126
+ runtime.relation_columns(connection, schemas)
127
+ end
128
+
129
+ # @return [Hash{String => String, nil}] view name => the definition the database stores, nil for one it
130
+ # will not show. On PostgreSQL also under `schema.view`, as traced_views names it too.
131
+ def view_definitions(connection, schemas = [])
132
+ runtime.view_definitions(connection, schemas)
133
+ end
134
+
135
+ # @return [Array<String>] the views whose result columns column_sources traces to their table columns
136
+ def traced_views(connection, schemas = [])
137
+ runtime.traced_views(connection, schemas)
138
+ end
139
+
140
+ # @return [Hash{String => Hash{String => String}}] table name => { generated column => its expression }
141
+ def generated_columns(connection, schemas = [])
142
+ runtime.generated_columns(connection, schemas)
143
+ end
144
+
145
+ # @return [Hash{String => Array}] what holds another table's rows or words under other names: table =>
146
+ # [:pages, :shadow, :vocabulary or :virtual, its virtual table, and for the last two kinds their definition]
147
+ def index_tables(connection, schemas = [])
148
+ runtime.index_tables(connection, schemas)
149
+ end
150
+
151
+ # @return [Array<Hash>, nil] { name:, bytes: } largest first; nil when the family has none
152
+ def table_sizes(connection)
153
+ runtime.table_sizes(connection)
154
+ end
155
+
156
+ # @return [Array<Hash>, nil] { table:, approximate_rows: } most rows first
157
+ def row_estimates(connection)
158
+ runtime.row_estimates(connection)
159
+ end
160
+
161
+ # @return [Array<Hash>, nil] { table:, index:, scans: } least used first
162
+ def index_usage(connection)
163
+ runtime.index_usage(connection)
164
+ end
165
+
166
+ private
167
+
168
+ def runtime
169
+ case key
170
+ when :postgresql then PostgresqlRuntime
171
+ when :mysql then MysqlRuntime
172
+ when :sqlite then SqliteRuntime
173
+ else
174
+ raise Unsupported, "The #{name} adapter has no database family, so its read-only transaction " \
175
+ "and statement timeout are not known. Supported: PostgreSQL, MySQL (with MariaDB) and SQLite."
176
+ end
177
+ end
178
+ end
179
+
180
+ POSTGRESQL = Family.new(:postgresql, "PostgreSQL")
181
+ MYSQL = Family.new(:mysql, "MySQL")
182
+ SQLITE = Family.new(:sqlite, "SQLite")
183
+
184
+ # Live adapter_name values, database.yml adapters, URL schemes, display labels and dump dialects, downcased.
185
+ NAMES = {
186
+ "postgresql" => POSTGRESQL, "postgis" => POSTGRESQL, "postgres" => POSTGRESQL,
187
+ "mysql2" => MYSQL, "trilogy" => MYSQL, "mysql" => MYSQL, "mariadb" => MYSQL,
188
+ "sqlite3" => SQLITE, "sqlite" => SQLITE
189
+ }.freeze
190
+
191
+ DEFAULT_SUFFIX = " by database.yml default"
192
+
193
+ # 3024 MySQL max_execution_time, 1317 query interrupted, 1969 MariaDB max_statement_time, 1205 lock_wait_timeout.
194
+ TIMEOUT_CODES = [ 3024, 1317, 1969, 1205 ].freeze
195
+
196
+ module_function
197
+
198
+ # @param source [#adapter_name, #adapter, String, Symbol, nil]
199
+ # @return [Family] never raises; no family carries the raw name
200
+ def of(source)
201
+ raw = raw_name(source)
202
+ NAMES.fetch(raw.downcase.delete_suffix(DEFAULT_SUFFIX)) { Family.new(:unknown, raw.empty? ? "unknown" : raw) }
203
+ end
204
+
205
+ def raw_name(source)
206
+ source = source.adapter_name if source.respond_to?(:adapter_name)
207
+ source = source.adapter if source.respond_to?(:adapter)
208
+ source.is_a?(String) || source.is_a?(Symbol) ? source.to_s : ""
209
+ rescue StandardError
210
+ ""
211
+ end
212
+
213
+ # @return [Hash{String => Array<Array>}] catalogue rows grouped by their first field, the table, without it
214
+ def by_table(rows)
215
+ rows.group_by(&:first).transform_values { |found| found.map { |row| row.drop(1) } }
216
+ end
217
+
218
+ # @return [Hash{String => String, nil}] [view, definition] rows by view. Views of one name in two schemas
219
+ # are read as one text, and as unreadable when either is.
220
+ def by_view(rows)
221
+ rows.group_by(&:first).transform_values { |found| found.map(&:last).then { |texts| texts.all? ? texts.join("\n") : nil } }
222
+ end
223
+
224
+ # Adds [table, generated column, expression] rows to found, as table => { column => expression }. Tables of one
225
+ # name in two schemas keep every expression their columns of one name have.
226
+ def keep_expressions(found, rows)
227
+ rows.each_with_object(found) do |(table, column, expression), kept|
228
+ (kept[table] ||= {}).merge!(column => expression.to_s) { |_column, known, read| known.include?(read) ? known : "#{known} #{read}" }
229
+ end
230
+ end
231
+
232
+ BUSY = "The database is locked by another connection, so the query did not run. Run it again when the other connection has finished."
233
+
234
+ # Runs the block and raises TimedOut for a database's timeout, Busy for a locked SQLite database. Message text is never read.
235
+ def bounding
236
+ yield
237
+ rescue ActiveRecord::StatementInvalid => e
238
+ raise Busy, BUSY if busy?(e)
239
+ raise TimedOut, e.message if timed_out?(e)
240
+
241
+ raise
242
+ end
243
+
244
+ # Puts a session setting back. A failed restore never replaces the caller's own error, and the
245
+ # connection it left changed leaves the pool so no later statement inherits the setting.
246
+ def restore(connection, statement)
247
+ connection.execute(statement)
248
+ rescue StandardError
249
+ begin
250
+ connection.respond_to?(:throw_away!) ? connection.throw_away! : connection.disconnect!
251
+ rescue StandardError
252
+ nil
253
+ end
254
+ end
255
+
256
+ # A connection the server dropped: the caller's to answer, and no reason to fall back to another read.
257
+ def lost_connection?(error)
258
+ error.is_a?(ActiveRecord::ConnectionNotEstablished) ||
259
+ (defined?(ActiveRecord::ConnectionFailed) && error.is_a?(ActiveRecord::ConnectionFailed))
260
+ end
261
+
262
+ # A SQLite read that found the database locked, at once or after the connection's busy timeout.
263
+ # Rails 7.0 gives it no class of its own.
264
+ def busy?(error)
265
+ defined?(SQLite3::BusyException) && error.cause.is_a?(SQLite3::BusyException)
266
+ end
267
+
268
+ def timed_out?(error)
269
+ # A connection the server dropped is a QueryAborted on Rails 7.1 and later, and it is no timeout.
270
+ return false if defined?(ActiveRecord::ConnectionFailed) && error.is_a?(ActiveRecord::ConnectionFailed)
271
+ return true if defined?(ActiveRecord::QueryAborted) && error.is_a?(ActiveRecord::QueryAborted)
272
+ # A wait on a lock that the database's own lock timeout ended.
273
+ return true if error.is_a?(ActiveRecord::LockWaitTimeout)
274
+
275
+ cause = error.cause
276
+ code = if cause.respond_to?(:error_number) then cause.error_number
277
+ elsif cause.respond_to?(:error_code) then cause.error_code
278
+ end
279
+ TIMEOUT_CODES.include?(code)
280
+ end
281
+ end
282
+ end