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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- 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:
|
|
25
|
-
total_tables:
|
|
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
|
-
|
|
32
|
-
check_constraints:
|
|
32
|
+
**declaration_keys,
|
|
33
|
+
check_constraints: SchemaConventions.check_constraints_of(tables),
|
|
33
34
|
enum_types: enum_types,
|
|
34
|
-
generated_columns:
|
|
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.
|
|
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
|
|
235
|
+
type: bigint ? "bigint" : dumped_type(col),
|
|
108
236
|
null: col.null,
|
|
109
237
|
default: col.default,
|
|
110
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
columns: idx.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
schema_reader
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
215
|
-
|
|
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
|
|
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
|
|
237
|
-
# declares, nil for an app whose
|
|
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(
|
|
422
|
+
result.merge(declaration_keys)
|
|
244
423
|
end
|
|
245
424
|
|
|
246
|
-
def
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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:
|
|
558
|
+
name: options[:name]&.to_s,
|
|
338
559
|
columns: columns,
|
|
339
|
-
unique:
|
|
340
|
-
where: (
|
|
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
|
-
|
|
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: "
|
|
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
|
|
374
|
-
|
|
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
|
|
619
|
+
total_tables: SchemaConventions.table_count(tables),
|
|
382
620
|
schema_version: version,
|
|
383
|
-
check_constraints:
|
|
384
|
-
enum_types:
|
|
385
|
-
generated_columns:
|
|
386
|
-
note: "Parsed from
|
|
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 =
|
|
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: "
|
|
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
|
|
414
|
-
|
|
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 =
|
|
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
|
|
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
|
|
701
|
+
total_tables: SchemaConventions.table_count(tables),
|
|
468
702
|
note: replay_note(tables, replayed.counts)
|
|
469
703
|
}
|
|
470
704
|
end
|