rails-ai-context 5.30.2 → 5.32.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.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. metadata +34 -3
@@ -19,21 +19,23 @@ module RailsAiContext
19
19
  return attach_secondary_databases(static_schema_parse)
20
20
  end
21
21
 
22
+ tables = extract_tables
22
23
  attach_secondary_databases({
23
24
  adapter: adapter_name,
24
- tables: extract_tables,
25
- total_tables: table_names.size,
25
+ tables: tables,
26
+ total_tables: SchemaConventions.table_count(tables),
26
27
  schema_version: current_schema_version,
27
28
  # The version stamp is read off db/schema.rb and the tables off the
28
29
  # connection, so the two can be one migration apart. The tables the
29
30
  # dump declares are what lets a consumer say so rather than call a
30
31
  # declared table a typo.
31
- declared_tables: declared_table_names,
32
- check_constraints: check_constraints,
32
+ **declaration_keys,
33
+ check_constraints: SchemaConventions.check_constraints_of(tables),
33
34
  enum_types: enum_types,
34
- generated_columns: generated_columns(schema_reader),
35
+ generated_columns: SchemaConventions.generated_columns_of(tables),
36
+ extensions: extensions,
35
37
  # What names the migration behind a declared table the connection lacks.
36
- pending_migrations: RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.migrate_dir_for(app.root))
38
+ pending_migrations: RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.migrate_dirs_for(app.root))
37
39
  }.compact)
38
40
  end
39
41
 
@@ -75,24 +77,150 @@ module RailsAiContext
75
77
  RailsAiContext.debug_fail(e, "unknown", label: "adapter_name")
76
78
  end
77
79
 
80
+ def sqlite?
81
+ adapter_name.to_s.match?(/sqlite/i)
82
+ end
83
+
84
+ def postgres?
85
+ adapter_name.to_s.match?(/postg/i)
86
+ end
87
+
88
+ # Every MySQL adapter inherits mariadb? from AbstractMysqlAdapter.
89
+ def mysql?
90
+ connection.respond_to?(:mariadb?)
91
+ end
92
+
78
93
  def connection
79
94
  ActiveRecord::Base.connection
80
95
  end
81
96
 
82
97
  def table_names
83
- @table_names ||= (connection.tables - @partitions.to_a)
98
+ @table_names ||= (connection.tables - @partitions.to_a - sqlite_hidden_tables)
84
99
  .reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
85
100
  end
86
101
 
87
102
  def extract_tables
88
- table_names.each_with_object({}) do |table, hash|
103
+ tables = table_names.each_with_object({}) do |table, hash|
89
104
  hash[table] = {
90
105
  columns: extract_columns(table),
91
106
  indexes: extract_indexes(table),
92
107
  foreign_keys: extract_foreign_keys(table),
93
- primary_key: SchemaConventions.primary_key_value(connection.primary_key(table))
94
- }
108
+ primary_key: SchemaConventions.primary_key_value(connection.primary_key(table)),
109
+ comment: table_comment(table),
110
+ unique_constraints: unique_constraints(table),
111
+ check_constraints: table_check_constraints(table)
112
+ }.compact
113
+ SchemaConventions.mark_primary_key(hash[table])
114
+ end
115
+ add_live_relations(tables)
116
+ end
117
+
118
+ # connection.tables leaves out views and SQLite's virtual tables; a view's SQL is the dump's.
119
+ def add_live_relations(tables)
120
+ materialized, extension_owned = pg_view_names
121
+ (connection.views - tables.keys - extension_owned).each do |view|
122
+ # Only a materialized view holds rows, so only it can carry an index.
123
+ indexes = materialized.include?(view) ? extract_indexes(view) : []
124
+ tables[view] = SchemaConventions.view_entry(declared_dump.views.dig(view, :sql), materialized: materialized.include?(view),
125
+ columns: extract_columns(view), indexes: indexes)
126
+ end
127
+ sqlite_virtual_tables.each do |name, (mod, arguments)|
128
+ tables[name] ||= SchemaConventions.virtual_table_entry(mod, arguments.to_s.split(", "))
129
+ end
130
+ tables
131
+ end
132
+
133
+ def sqlite_virtual_tables
134
+ sqlite? ? SqliteVirtualTables.of(connection) : {}
135
+ end
136
+
137
+ def sqlite_hidden_tables
138
+ sqlite? ? SqliteVirtualTables.hidden(connection) : []
139
+ end
140
+
141
+ PG_VIEWS = <<~SQL
142
+ SELECT c.relname, c.relkind, EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype = 'e')
143
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
144
+ WHERE c.relkind IN ('v', 'm') AND n.nspname = ANY (current_schemas(false))
145
+ SQL
146
+
147
+ # PostgreSQL lists materialized views among the views, and an extension's own
148
+ # (PostGIS geometry_columns, pg_stat_statements) beside the app's.
149
+ def pg_view_names
150
+ return [ [], [] ] unless postgres?
151
+
152
+ rows = connection.select_rows(PG_VIEWS)
153
+ [ rows.select { |_, kind, _| kind == "m" }.map(&:first), rows.select { |*, owned| owned == true || owned == "t" }.map(&:first) ]
154
+ rescue => e
155
+ RailsAiContext.debug_fail(e, [ [], [] ], label: "pg_view_names")
156
+ end
157
+
158
+ def table_comment(table)
159
+ comment = connection.table_comment(table) if connection.supports_comments?
160
+ comment unless comment.to_s.empty?
161
+ rescue => e
162
+ RailsAiContext.debug_fail(e, nil, label: "table_comment")
163
+ end
164
+
165
+ # Rails 7.1+ on PostgreSQL.
166
+ def unique_constraints(table)
167
+ return unless connection.respond_to?(:unique_constraints) && connection.supports_unique_constraints?
168
+
169
+ found = connection.unique_constraints(table).map do |constraint|
170
+ SchemaConventions.unique_constraint_entry(constraint.name, constraint.column, constraint.deferrable)
171
+ end
172
+ found if found.any?
173
+ rescue => e
174
+ RailsAiContext.debug_fail(e, nil, label: "unique_constraints")
175
+ end
176
+
177
+ # The connection's, named as the dump names them: Rails leaves out a chk_rails_ name it made up.
178
+ def table_check_constraints(table)
179
+ found = if connection.supports_check_constraints?
180
+ connection.check_constraints(table).map do |constraint|
181
+ { name: (constraint.name if constraint.export_name_on_schema_dump?), expression: constraint.expression }.compact
182
+ end
183
+ else
184
+ schema_reader.check_constraints.select { |c| c[:table] == table }.map { |c| c.slice(:name, :expression) }
95
185
  end
186
+ found if found.any?
187
+ rescue => e
188
+ RailsAiContext.debug_fail(e, nil, label: "table_check_constraints")
189
+ end
190
+
191
+ def extensions
192
+ found = connection.extensions if connection.respond_to?(:extensions)
193
+ Array(found).map(&:to_s) if found.present?
194
+ rescue => e
195
+ RailsAiContext.debug_fail(e, nil, label: "extensions")
196
+ end
197
+
198
+ # What schema.rb writes for a column beyond its type: the connection's
199
+ # limit, precision and collation, less the defaults the dumper leaves out.
200
+ def column_detail(table, col, bigint)
201
+ native_limit = (connection.native_database_types[col.type] || {})[:limit]
202
+ limit = col.limit unless bigint || col.limit == native_limit || col.sql_type.to_s.match?(/\A(?:tiny|medium|long)?(?:text|blob)\b/i)
203
+ # A datetime's default precision is 6, and MySQL writes none as 0.
204
+ precision = col.precision unless col.type == :datetime && [ nil, 0, 6 ].include?(col.precision) ||
205
+ col.type == :time && col.precision.to_i.zero? && col.sql_type.to_s.start_with?("time")
206
+ {
207
+ limit: limit,
208
+ precision: precision,
209
+ scale: col.scale,
210
+ unsigned: (true if col.respond_to?(:unsigned?) && col.unsigned?),
211
+ size: (SchemaConventions.mysql_text_size(col.sql_type) if mysql?),
212
+ collation: (col.collation unless col.collation.nil? || col.collation == table_collation(table))
213
+ }
214
+ end
215
+
216
+ TABLE_COLLATIONS = "SELECT table_name, table_collation FROM information_schema.tables WHERE table_schema = DATABASE()"
217
+
218
+ # MySQL gives every text column the table's collation; the dump names only a different one.
219
+ def table_collation(table)
220
+ return unless mysql?
221
+
222
+ @table_collations ||= connection.select_rows(TABLE_COLLATIONS).to_h
223
+ @table_collations[table]
96
224
  end
97
225
 
98
226
  def extract_columns(table)
@@ -104,14 +232,17 @@ module RailsAiContext
104
232
  bigint = col.respond_to?(:bigint?) && col.bigint?
105
233
  entry = {
106
234
  name: col.name,
107
- type: bigint ? "bigint" : col.type.to_s,
235
+ type: bigint ? "bigint" : dumped_type(col),
108
236
  null: col.null,
109
237
  default: col.default,
110
- limit: (col.limit unless bigint),
111
- precision: col.precision,
112
- scale: col.scale,
238
+ **column_detail(table, col, bigint),
113
239
  comment: col.comment
114
240
  }
241
+ if col.respond_to?(:virtual?) && col.virtual?
242
+ entry[:generated] = (col.default_function || declared_generated(table, col.name)).to_s
243
+ entry[:stored] = stored_generated?(col)
244
+ end
245
+ entry[:enum_type] = col.sql_type.to_s if col.type == :enum
115
246
  # PostgreSQL gives an array's default as its literal ({}), which the
116
247
  # static tier reads the way Rails dumps it ([]).
117
248
  if col.respond_to?(:array?) && col.array?
@@ -126,29 +257,32 @@ module RailsAiContext
126
257
  end
127
258
  end
128
259
 
260
+ # MySQL's dumper writes an enum or set column by its full SQL type, and a timestamp as one.
261
+ def dumped_type(col)
262
+ return col.type.to_s unless mysql?
263
+ return col.sql_type.to_s if col.sql_type.to_s.match?(/\A(?:enum|set)\b/)
264
+
265
+ col.sql_type.to_s.match?(/\Atimestamp\b/) ? "timestamp" : col.type.to_s
266
+ end
267
+
129
268
  def extract_indexes(table)
130
269
  connection.indexes(table).map do |idx|
131
- {
132
- name: idx.name,
133
- # An expression index's columns come as one String; split into keys as the dump readers do.
134
- columns: idx.columns.is_a?(String) ? StructureSqlReader.index_keys(idx.columns) : idx.columns,
135
- unique: idx.unique,
136
- where: idx.where
137
- }.compact
270
+ # An expression index's columns come as one String; split into keys as the dump readers do.
271
+ columns = idx.columns.is_a?(String) ? StructureSqlReader.index_keys(idx.columns) : idx.columns
272
+ detail = SchemaConventions.index_detail(
273
+ columns, using: idx.using, type: idx.type, order: idx.orders, opclass: idx.opclasses, length: idx.lengths,
274
+ include: (idx.include if idx.respond_to?(:include)),
275
+ nulls_not_distinct: (idx.nulls_not_distinct if idx.respond_to?(:nulls_not_distinct))
276
+ )
277
+ { name: idx.name, columns: columns, unique: idx.unique, where: idx.where }.compact.merge(detail)
138
278
  end
139
279
  end
140
280
 
141
281
  def extract_foreign_keys(table)
142
282
  # PostgreSQL clones a key that references a partitioned table once per partition.
143
283
  connection.foreign_keys(table).reject { |fk| @partitions.to_a.include?(fk.to_table) }.map do |fk|
144
- {
145
- from_table: fk.from_table,
146
- to_table: fk.to_table,
147
- column: fk.column,
148
- primary_key: fk.primary_key,
149
- on_delete: fk.on_delete,
150
- on_update: fk.on_update
151
- }.compact
284
+ SchemaConventions.foreign_key_entry(fk.from_table, fk.to_table, fk.column, fk.primary_key, on_delete: fk.on_delete, on_update: fk.on_update,
285
+ deferrable: fk.deferrable, validate: fk.validate?)
152
286
  end
153
287
  rescue => e
154
288
  # Some adapters don't support foreign_keys.
@@ -166,14 +300,35 @@ module RailsAiContext
166
300
  @schema_reader ||= SchemaReader.new(schema_file_path, partitions: @partitions.to_a)
167
301
  end
168
302
 
169
- # Constraints and enum types are declared in the dump, not reported by
170
- # the adapter, so the live tier reads them from schema.rb too.
171
- def check_constraints
172
- schema_reader.check_constraints
303
+ # The configured dump, whatever its format: a view's SQL and a MySQL generated expression are not on the connection.
304
+ def declared_dump
305
+ format, = present_dump
306
+ format == :sql ? SchemaReader.for(app.root) : schema_reader
307
+ end
308
+
309
+ # MySQL keeps a generated column's expression out of the column, so the dump says it.
310
+ def declared_generated(table, name)
311
+ column = declared_dump.tables.dig(table, :columns)&.find { |c| c[:name] == name }
312
+ column && (column.dig(:options, :as) || column[:generated])
313
+ end
314
+
315
+ # PostgreSQL before Rails 7.1 had only stored generated columns.
316
+ def stored_generated?(col)
317
+ return col.virtual_stored? if col.respond_to?(:virtual_stored?)
318
+ return col.extra.to_s.match?(/\b(?:STORED|PERSISTENT)\b/) if col.respond_to?(:extra)
319
+
320
+ true
173
321
  end
174
322
 
323
+ # PostgreSQL's, from the connection: 7.0 gives the labels as one string, 7.1+ as an array.
175
324
  def enum_types
176
- schema_reader.enums
325
+ return schema_reader.enums unless connection.respond_to?(:enum_types)
326
+
327
+ connection.enum_types.map do |name, values|
328
+ { name: name.to_s, values: values.is_a?(String) ? values.delete("{}").split(",") : Array(values).map(&:to_s) }
329
+ end
330
+ rescue => e
331
+ RailsAiContext.debug_fail(e, schema_reader.enums, label: "enum_types")
177
332
  end
178
333
 
179
334
  # The tables db/schema.rb declares, or nil when there is no dump to
@@ -182,9 +337,10 @@ module RailsAiContext
182
337
  # full parse on every booted call, and a missing note is better than a
183
338
  # slow one.
184
339
  def declared_table_names
185
- return nil unless File.exist?(schema_file_path)
340
+ return nil unless schema_file_path && File.exist?(schema_file_path)
186
341
 
187
- names = schema_reader.tables.keys.map(&:to_s)
342
+ # Virtual tables and tables the dumper skipped count as tables, as the connection lists them.
343
+ names = (schema_reader.tables.keys + schema_reader.virtual_tables.keys + schema_reader.not_dumped.keys).map(&:to_s).uniq
188
344
  names.any? ? names : nil
189
345
  rescue => e
190
346
  RailsAiContext.debug_fail(e, nil, label: "declared_table_names")
@@ -207,12 +363,37 @@ module RailsAiContext
207
363
  RailsAiContext.debug_fail(e, nil, label: "schema_version_for")
208
364
  end
209
365
 
366
+ def dump_candidates
367
+ @dump_candidates ||= SchemaDumpPath.candidates(app.root)
368
+ end
369
+
370
+ # The dump Rails loads for this app, whether or not it is on disk.
371
+ def schema_dump_name
372
+ dump_candidates.first.last
373
+ end
374
+
375
+ # The first dump on disk, as the static tier reads it; the configured name when none is.
376
+ def present_dump
377
+ @present_dump ||= SchemaDumpPath.present(app.root) || dump_candidates.first
378
+ end
379
+
380
+ # The schema.rb dump the readers answer from, or nil when it is structure.sql.
210
381
  def schema_file_path
211
- File.join(app.root, "db", "schema.rb")
382
+ format, path = present_dump
383
+ path if format == :ruby
384
+ end
385
+
386
+ # A secondary dump's name says its database; the primary's, whatever schema_dump calls it, does not.
387
+ def secondary_dump(path)
388
+ path unless dump_candidates.any? { |_, candidate| candidate == path }
389
+ end
390
+
391
+ def migrate_dir_for_dump(path)
392
+ RailsAiContext::PendingMigrations.migrate_dirs_for(app.root, secondary_dump(path))
212
393
  end
213
394
 
214
- def structure_file_path
215
- File.join(app.root, "db", "structure.sql")
395
+ def relative_dump_path(path)
396
+ path.delete_prefix("#{app.root.to_s.chomp('/')}/")
216
397
  end
217
398
 
218
399
  def migrations_dir
@@ -226,57 +407,64 @@ module RailsAiContext
226
407
  end
227
408
 
228
409
  def migration_files
229
- @migration_files ||= MigrationReplay.migration_files(migrations_dirs)
410
+ @migration_files ||= MigrationReplay.migration_files(migrations_dirs, root: app.root)
230
411
  end
231
412
 
232
- # Fallback: parse schema file as text when DB isn't connected.
233
- # Tries db/schema.rb first, then db/structure.sql, then migrations.
234
- # This enables introspection in CI, Claude Code, etc.
413
+ # Fallback when no database answers: the dump file, then the migrations.
235
414
  # Every key the booted answer carries, answered from the files, and
236
- # meaning the same thing: `declared_tables` is what db/schema.rb
237
- # declares, nil for an app whose tables come from structure.sql or the
238
- # migrations.
415
+ # meaning the same thing: `declared_tables` is what the schema.rb dump
416
+ # declares and `declared_in` names that dump, both nil for an app whose
417
+ # tables come from structure.sql or the migrations.
239
418
  def static_schema_parse
240
419
  result = static_schema_sources
241
420
  return result unless result.is_a?(Hash) && result[:tables].is_a?(Hash)
242
421
 
243
- result.merge(declared_tables: declared_table_names)
422
+ result.merge(declaration_keys)
244
423
  end
245
424
 
246
- def static_schema_sources
247
- schema_rb_exists = File.exist?(schema_file_path)
425
+ def declaration_keys
426
+ declared = declared_table_names
427
+ { declared_tables: declared, declared_in: (relative_dump_path(schema_file_path) if declared) }
428
+ end
248
429
 
249
- if schema_rb_exists
250
- result = parse_schema_rb(schema_file_path)
251
- return result if result[:total_tables].to_i > 0
430
+ # The configured dump first (database.yml's schema_dump, schema_format), then the default files.
431
+ def static_schema_sources
432
+ if (reason = RailsAiContext::AppKind.without_active_record(app.root) || RailsAiContext::AppKind.sequel_schema(app.root))
433
+ return { unavailable: "#{reason}; ActiveRecord schema introspection does not apply" }
252
434
  end
253
435
 
254
- if File.exist?(structure_file_path)
255
- result = parse_structure_sql(structure_file_path)
256
- return result if result[:total_tables].to_i > 0
436
+ present = dump_candidates.select { |_, path| File.exist?(path) }
437
+ present.each do |format, path|
438
+ result = parse_dump(format, path)
439
+ return result if result[:tables].present? || result[:error]
257
440
  end
258
441
 
259
442
  return parse_migrations if migration_files.any?
260
443
 
261
- # schema.rb exists but has no tables - happens on fresh Rails apps right
262
- # after `db:create` where no migrations have been run yet. Return a
263
- # legitimate empty-schema state instead of a misleading "not found" error.
264
- if schema_rb_exists
444
+ # An empty schema.rb is a fresh app right after `db:create`, not a missing source.
445
+ format, path = present.first
446
+ if format == :ruby
265
447
  return {
266
448
  total_tables: 0,
267
449
  tables: {},
268
450
  note: "Schema file exists but is empty - no migrations have been run yet. " \
269
- "Run `bin/rails db:migrate` after generating migrations to populate schema.rb."
451
+ "Run `bin/rails db:migrate` after generating migrations to populate #{relative_dump_path(path)}."
270
452
  }
453
+ elsif format == :sql
454
+ return { total_tables: 0, tables: {}, note: "#{relative_dump_path(path)} has no CREATE TABLE statement this reader could read." }
271
455
  end
272
456
 
273
457
  if RailsAiContext::AppKind.mongoid?(app.root)
274
458
  return { unavailable: "this app uses Mongoid; ActiveRecord schema introspection does not apply" }
275
459
  end
276
460
 
461
+ if secondary_database_dumps.any?
462
+ return { total_tables: 0, tables: {}, note: "The primary database has no tables yet: no #{relative_dump_path(schema_dump_name)} or migrations found." }
463
+ end
464
+
277
465
  # An absent data source, not a failure: :unavailable keeps a fresh
278
466
  # greenfield app out of the "introspection failed" warnings banner.
279
- { unavailable: "No db/schema.rb, db/structure.sql, or migrations found" }
467
+ { unavailable: "No #{relative_dump_path(schema_dump_name)} or migrations found" }
280
468
  end
281
469
 
282
470
  # Rails multi-database setups dump each secondary database to its own
@@ -285,24 +473,47 @@ module RailsAiContext
285
473
  # dump keeps the top-level :tables shape; secondaries ride their own
286
474
  # key so single-database consumers are unaffected.
287
475
  def secondary_database_dumps
288
- dumps = {}
289
- Dir.glob(File.join(app.root.to_s, "db", "*_schema.rb")).sort.each do |path|
290
- name = File.basename(path, ".rb").sub(/_schema\z/, "")
291
- parsed = parse_schema_rb(path)
292
- next unless parsed[:total_tables].to_i.positive?
293
-
294
- parsed[:note] = "Parsed from db/#{File.basename(path)} (from committed dump, not a live connection)"
295
- dumps[name] = parsed
476
+ @secondary_database_dumps ||= read_secondary_database_dumps
477
+ end
478
+
479
+ def read_secondary_database_dumps
480
+ primary = dump_candidates.map(&:last)
481
+ configured = SchemaDumpPath.secondaries(app.root).map { |name, (format, path)| [ name, format, path ] }
482
+ taken = primary + configured.map(&:last)
483
+ globbed = SchemaDumpPath::FILE_NAMES.flat_map do |format, file|
484
+ kind, ext = file.split(".")
485
+ Dir.glob(File.join(app.root.to_s, "db", "*_#{kind}.#{ext}")).sort
486
+ .reject { |path| taken.include?(path) }
487
+ .map { |path| [ File.basename(path, ".#{ext}").delete_suffix("_#{kind}"), format, path ] }
488
+ end
489
+ dumps = (configured + globbed).each_with_object({}) do |(name, format, path), found|
490
+ next if found.key?(name) || primary.include?(path) || !File.exist?(path)
491
+
492
+ parsed = parse_dump(format, path)
493
+ next if parsed[:tables].blank?
494
+
495
+ parsed[:note] = "Parsed from #{relative_dump_path(path)} (from committed dump, not a live connection)"
496
+ found[name] = parsed
296
497
  end
297
- Dir.glob(File.join(app.root.to_s, "db", "*_structure.sql")).sort.each do |path|
298
- name = File.basename(path, ".sql").sub(/_structure\z/, "")
498
+ replay_secondary_migrations(dumps)
499
+ end
500
+
501
+ # A database whose dump is not written yet answers from its own migrations_paths.
502
+ def replay_secondary_migrations(dumps)
503
+ RailsAiContext::DatabaseYml.task_secondaries(app.root).each do |name, entry|
299
504
  next if dumps.key?(name)
300
505
 
301
- parsed = parse_structure_sql(path)
302
- next unless parsed[:total_tables].to_i.positive?
506
+ dirs = MigrationReplay.configured_dirs(app.root.to_s, entry) or next
507
+ pk_type = SchemaConventions.implicit_pk_type(app.root.to_s, database: name)
508
+ tables = MigrationReplay.tables(dirs, pk_type: pk_type, root: app.root.to_s)
509
+ next if tables.empty?
303
510
 
304
- parsed[:note] = "Parsed from db/#{File.basename(path)} (from committed dump, not a live connection)"
305
- dumps[name] = parsed
511
+ tables.each_value { |table| SchemaConventions.mark_primary_key(table) }
512
+ _, dump = SchemaDumpPath.secondaries(app.root)[name]
513
+ dumps[name] = {
514
+ adapter: "static_parse", tables: tables, total_tables: SchemaConventions.table_count(tables),
515
+ note: "Reconstructed from the migrations in #{dirs.map { |dir| relative_dump_path(dir) }.join(', ')} (#{connection_state}, no #{dump ? relative_dump_path(dump) : "#{name}_schema.rb"})"
516
+ }
306
517
  end
307
518
  dumps
308
519
  end
@@ -321,9 +532,18 @@ module RailsAiContext
321
532
  def static_column(column)
322
533
  options = column[:options]
323
534
  entry = { name: column[:name], type: column[:type] }
535
+ if column[:virtual]
536
+ entry[:generated] = options[:as].is_a?(String) ? options[:as] : ""
537
+ entry[:stored] = options[:stored] == true
538
+ end
539
+ entry[:enum_type] = options[:enum_type].to_s if options[:enum_type].is_a?(Symbol) || options[:enum_type].is_a?(String)
324
540
  entry[:null] = false if options[:null] == false
325
541
  entry[:default] = column[:default] unless column[:default].nil?
326
542
  entry[:array] = true if options[:array] == true
543
+ %i[limit precision scale].each { |key| entry[key] = options[key] if options[key].is_a?(Integer) }
544
+ entry[:unsigned] = true if options[:unsigned] == true
545
+ entry[:size] = options[:size].to_s if options[:size].is_a?(Symbol)
546
+ entry[:collation] = options[:collation] if options[:collation].is_a?(String)
327
547
  entry[:comment] = options[:comment] if options[:comment].is_a?(String)
328
548
  entry[:primary_key] = true if column[:primary_key]
329
549
  entry
@@ -333,76 +553,97 @@ module RailsAiContext
333
553
  columns = index[:columns]
334
554
  return nil if columns.empty?
335
555
 
556
+ options = index[:options]
336
557
  entry = {
337
- name: index[:options][:name]&.to_s,
558
+ name: options[:name]&.to_s,
338
559
  columns: columns,
339
- unique: index[:options][:unique] == true,
340
- where: (index[:options][:where] if index[:options][:where].is_a?(String))
560
+ unique: options[:unique] == true,
561
+ where: (options[:where] if options[:where].is_a?(String))
341
562
  }
342
563
  # An expression index (e.g. "lower(email)") names no plain column.
343
564
  entry[:expression] = true if columns.size == 1 && !columns.first.match?(/\A\w+\z/)
344
- entry.compact
565
+ detail = options.slice(:using, :type, :include, :order, :opclass, :length, :nulls_not_distinct)
566
+ .reject { |_, value| value == RailsAiContext::Confidence::INFERRED }
567
+ entry.compact.merge(SchemaConventions.index_detail(columns, **detail))
568
+ end
569
+
570
+ def parse_dump(format, path)
571
+ format == :ruby ? parse_schema_rb(path) : parse_structure_sql(path)
345
572
  end
346
573
 
347
574
  def parse_schema_rb(path)
348
575
  content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
349
- return { error: "schema.rb too large (#{File.size(path)} bytes)" } unless content
576
+ return { error: "#{relative_dump_path(path)} too large (#{File.size(path)} bytes, over max_schema_file_size)" } unless content
350
577
 
351
- schema = SchemaReader.new(path, pk_type: SchemaConventions.implicit_pk_type(app.root.to_s, path))
578
+ schema = SchemaReader.new(path, pk_type: SchemaConventions.implicit_pk_type(app.root.to_s, secondary_dump(path)))
352
579
 
353
580
  tables = {}
354
581
  schema.tables.each do |table_name, declared|
355
582
  next if table_name.start_with?("ar_internal_metadata", "schema_migrations")
356
583
 
584
+ comment = declared.dig(:options, :comment)
585
+ unique = Array(declared[:unique_constraints]).map do |constraint|
586
+ SchemaConventions.unique_constraint_entry(constraint.dig(:options, :name), constraint[:columns], constraint.dig(:options, :deferrable))
587
+ end
357
588
  tables[table_name] = {
358
589
  columns: declared[:columns].map { |c| static_column(c) },
359
590
  indexes: declared[:indexes].filter_map { |i| static_index(i) },
360
591
  foreign_keys: [],
592
+ comment: (comment if comment.is_a?(String)),
593
+ unique_constraints: (unique if unique.any?),
361
594
  unread_calls: declared[:unread_calls]
362
595
  }.compact
363
596
  key = declared.dig(:options, :primary_key)
364
597
  tables[table_name][:primary_key] = SchemaConventions.primary_key_value(key) if key
598
+ SchemaConventions.mark_primary_key(tables[table_name])
365
599
  end
366
600
 
367
601
  schema.foreign_keys.each do |fk|
368
602
  tables[fk[:from]]&.dig(:foreign_keys)&.push(
369
- SchemaConventions.foreign_key_entry(fk[:from], fk[:to], fk[:column], fk[:primary_key])
603
+ SchemaConventions.foreign_key_entry(fk[:from], fk[:to], fk[:column], fk[:primary_key], **fk.slice(*SchemaConventions::FOREIGN_KEY_OPTIONS))
370
604
  )
371
605
  end
372
606
 
373
- check_constraints = schema.check_constraints
374
- enum_types = schema.enums
607
+ schema.check_constraints.each do |constraint|
608
+ table = tables[constraint[:table]] or next
609
+ (table[:check_constraints] ||= []) << constraint.slice(:name, :expression)
610
+ end
611
+ views = schema.views.transform_values { |view| view.merge(indexes: Array(view[:indexes]).filter_map { |i| static_index(i) }) }
612
+ SchemaConventions.add_relations(tables, views: views, virtual_tables: schema.virtual_tables, not_dumped: schema.not_dumped)
375
613
 
376
614
  version = schema_version_for(path)
377
615
 
378
616
  result = {
379
617
  adapter: "static_parse",
380
618
  tables: tables,
381
- total_tables: tables.size,
619
+ total_tables: SchemaConventions.table_count(tables),
382
620
  schema_version: version,
383
- check_constraints: check_constraints,
384
- enum_types: enum_types,
385
- generated_columns: generated_columns(schema),
386
- note: "Parsed from db/schema.rb (#{connection_state})"
621
+ check_constraints: SchemaConventions.check_constraints_of(tables),
622
+ enum_types: schema.enums,
623
+ generated_columns: SchemaConventions.generated_columns_of(tables),
624
+ note: "Parsed from #{relative_dump_path(path)} (#{connection_state})"
387
625
  }
626
+ result[:extensions] = schema.extensions if schema.extensions.any?
388
627
  # schema.rb records only the max applied version, so pending here
389
628
  # means "migration files newer than the schema version" - exact for
390
629
  # linear histories, best-effort for out-of-order merges. With no
391
630
  # version recorded there is no answer, so the key stays absent.
392
631
  if version
393
- migrate_dir = RailsAiContext::PendingMigrations.migrate_dir_for(app.root, path)
394
- result[:pending_migrations] = RailsAiContext::PendingMigrations.for(migrate_dir: migrate_dir, applied: version)
632
+ migrate_dir = migrate_dir_for_dump(path)
633
+ result[:pending_migrations] = RailsAiContext::PendingMigrations.for(migrate_dir: migrate_dir, applied: version, root: app.root)
395
634
  end
396
635
  result
397
636
  end
398
637
 
399
638
  def parse_structure_sql(path)
400
639
  content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
401
- return { error: "structure.sql too large (#{File.size(path)} bytes)" } unless content
640
+ return { error: "#{relative_dump_path(path)} too large (#{File.size(path)} bytes, over max_schema_file_size)" } unless content
402
641
 
403
642
  parsed = StructureSqlReader.parse(content)
404
643
  dialect = parsed[:dialect]
405
644
  tables = parsed[:tables]
645
+ tables.each_value { |table| SchemaConventions.mark_primary_key(table) }
646
+ SchemaConventions.add_relations(tables, views: parsed[:views], virtual_tables: parsed[:virtual_tables])
406
647
 
407
648
  applied = RailsAiContext::SchemaVersion.applied_versions(content)
408
649
 
@@ -410,13 +651,17 @@ module RailsAiContext
410
651
  adapter: "static_parse",
411
652
  dialect: dialect.to_s,
412
653
  tables: tables,
413
- total_tables: tables.size,
414
- note: "Parsed from db/structure.sql (#{connection_state})"
654
+ total_tables: SchemaConventions.table_count(tables),
655
+ check_constraints: SchemaConventions.check_constraints_of(tables),
656
+ enum_types: parsed[:enums],
657
+ generated_columns: SchemaConventions.generated_columns_of(tables),
658
+ note: "Parsed from #{relative_dump_path(path)} (#{connection_state})"
415
659
  }
660
+ result[:extensions] = parsed[:extensions] if parsed[:extensions].any?
416
661
  if applied.any?
417
662
  result[:schema_version] = applied.map(&:to_i).max.to_s
418
- migrate_dir = RailsAiContext::PendingMigrations.migrate_dir_for(app.root, path)
419
- result[:pending_migrations] = RailsAiContext::PendingMigrations.for(migrate_dir: migrate_dir, applied: applied)
663
+ migrate_dir = migrate_dir_for_dump(path)
664
+ result[:pending_migrations] = RailsAiContext::PendingMigrations.for(migrate_dir: migrate_dir, applied: applied, root: app.root)
420
665
  end
421
666
  result
422
667
  end
@@ -425,25 +670,13 @@ module RailsAiContext
425
670
  @connection_state || "no DB connection"
426
671
  end
427
672
 
428
- def generated_columns(schema)
429
- schema.tables.flat_map { |table, declared|
430
- declared[:columns].filter_map { |column|
431
- options = column[:options]
432
- next unless options[:virtual] == true || options[:stored] == true
433
-
434
- { table: table, column: column[:name], stored: options[:stored] == true }
435
- }
436
- }
437
- rescue => e
438
- RailsAiContext.debug_fail(e, [], label: "generated_columns")
439
- end
440
-
441
673
  # A table whose create_table names it through the class has no literal to read, so the
442
674
  # count says how many names came from the file instead.
443
675
  def replay_note(tables, counts)
444
676
  inferred = tables.count { |_, data| data[:inferred_name] }
677
+ _, dump = SchemaDumpPath.present(app.root.to_s)
445
678
  note = "Reconstructed from #{CountPhrase.call(migration_files.size, "migration file")} " \
446
- "(#{connection_state}, no schema.rb)"
679
+ "(#{connection_state}, #{dump ? "#{relative_dump_path(dump)} declares no tables" : "no schema.rb"})"
447
680
  note += ", #{CountPhrase.call(inferred, "table name")} read from the file rather than the create_table call" if inferred.positive?
448
681
  note += ", #{CountPhrase.call(counts.unnamed, "create_table call")} left unnamed" if counts.unnamed.positive?
449
682
  note += ", #{CountPhrase.call(counts.unnamed_columns, "added column")} left unnamed" if counts.unnamed_columns.positive?
@@ -457,14 +690,15 @@ module RailsAiContext
457
690
  # rename_table, drop_table, change_column, add_index, add_reference,
458
691
  # add_foreign_key, add_timestamps.
459
692
  def parse_migrations
460
- pk_type = SchemaConventions.implicit_pk_type(app.root.to_s, schema_file_path)
693
+ pk_type = SchemaConventions.implicit_pk_type(app.root.to_s)
461
694
  replayed = MigrationReplay.replayed(migrations_dirs, pk_type: pk_type, root: app.root.to_s)
462
695
  tables = replayed.tables
696
+ tables.each_value { |table| SchemaConventions.mark_primary_key(table) }
463
697
 
464
698
  {
465
699
  adapter: "static_parse",
466
700
  tables: tables,
467
- total_tables: tables.size,
701
+ total_tables: SchemaConventions.table_count(tables),
468
702
  note: replay_note(tables, replayed.counts)
469
703
  }
470
704
  end