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
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
3
5
|
module RailsAiContext
|
|
4
6
|
module Introspectors
|
|
5
7
|
# Reads a structure.sql dump into the same table shape the other static
|
|
@@ -11,57 +13,243 @@ module RailsAiContext
|
|
|
11
13
|
# @return [Hash] { dialect: Symbol, tables: { name => { columns:, indexes:, foreign_keys: } } }
|
|
12
14
|
def parse(content)
|
|
13
15
|
tables = {}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# tables) close with ");" on the same line and miss the multi-line
|
|
33
|
-
# scan above.
|
|
34
|
-
content.scan(/CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(?:public\.)?[`"]?(\w+)[`"]?\s*\(([^\n]*)\);/) do |table_name, body|
|
|
35
|
-
next if table_name.start_with?("ar_internal_metadata", "schema_migrations")
|
|
36
|
-
|
|
37
|
-
tables[table_name] ||= parse_sql_table_body(body, table_name)
|
|
16
|
+
dialect = detect_sql_dialect(content)
|
|
17
|
+
enums = enum_types(content)
|
|
18
|
+
|
|
19
|
+
# Every table the file creates, by schema-qualified name, so a parent
|
|
20
|
+
# outside the listed tables still resolves.
|
|
21
|
+
all = {}
|
|
22
|
+
each_create_table(content) do |qualified, body, inherits, trailer|
|
|
23
|
+
name = qualified_name(qualified)
|
|
24
|
+
shown = shown_name(name)
|
|
25
|
+
next if shown.start_with?("ar_internal_metadata", "schema_migrations")
|
|
26
|
+
# SQLite's own tables; Rails' data_sources leaves them out.
|
|
27
|
+
next if dialect == :sqlite && shown.start_with?("sqlite_")
|
|
28
|
+
|
|
29
|
+
table, raw_types = parse_sql_table_body(body, shown, dialect, enums)
|
|
30
|
+
comment = trailer[/\bCOMMENT\s*=\s*'((?:[^']|'')*)'/i, 1]
|
|
31
|
+
table[:comment] = comment.gsub("''", "'") if comment
|
|
32
|
+
all[name] = { table: table, raw_types: raw_types, parents: inherits && split_top_level(inherits).map { |parent| qualified_name(parent) } }
|
|
33
|
+
tables[shown] = table if shown.match?(/\A\w+\z/)
|
|
38
34
|
end
|
|
39
35
|
|
|
40
|
-
|
|
36
|
+
found_views = views(content)
|
|
37
|
+
content.scan(/CREATE (UNIQUE )?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF NOT EXISTS\s+)?[`"]?(\w+)[`"]?\s+ON\s+(?:ONLY\s+)?#{QUALIFIED_NAME}((?:(?!#{NEXT_STATEMENT})[^;])*)/m) do |unique, idx_name, table, rest|
|
|
41
38
|
group = first_paren_group(rest)
|
|
42
39
|
keys = index_keys(group)
|
|
43
40
|
next if keys.empty?
|
|
44
41
|
|
|
45
|
-
# The
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
# The method precedes the key list; INCLUDE, NULLS NOT DISTINCT and the condition follow it.
|
|
43
|
+
open = rest.index("(")
|
|
44
|
+
after = rest[(open + group.length + 2)..]
|
|
45
|
+
where = after[/\bWHERE\s+(.+)\z/mi, 1]&.strip
|
|
46
|
+
options = after.sub(/\bWHERE\b.*\z/mi, "")
|
|
47
|
+
include = options[/\A\s*INCLUDE\s*\(([^)]*)\)/i, 1]
|
|
48
|
+
detail = SchemaConventions.index_detail(keys, using: rest[0, open][/\bUSING\s+(\w+)/i, 1]&.downcase, include: include && index_keys(include),
|
|
49
|
+
nulls_not_distinct: options.match?(/\bNULLS\s+NOT\s+DISTINCT\b/i), **key_options(group))
|
|
50
|
+
target = all.dig(qualified_name(table), :table) || found_views[shown_name(qualified_name(table))]
|
|
51
|
+
(target[:indexes] ||= []) << { name: idx_name, columns: keys, unique: !!unique, where: where }.compact.merge(detail) if target
|
|
48
52
|
end
|
|
49
53
|
|
|
50
|
-
content.scan(/ALTER TABLE\s+(?:ONLY\s+)
|
|
51
|
-
|
|
54
|
+
content.scan(/ALTER TABLE\s+(?:ONLY\s+)?#{QUALIFIED_NAME}\s+ADD CONSTRAINT\s+("[^"]+"|\w+)\s+UNIQUE\s*(?:NULLS\s+(?:NOT\s+)?DISTINCT\s*)?\(([^)]*)\)([^;]*)/m) do |table, name, cols, tail|
|
|
55
|
+
table = all.dig(qualified_name(table), :table) or next
|
|
56
|
+
(table[:unique_constraints] ||= []) << SchemaConventions.unique_constraint_entry(name.delete('"'), cols.scan(/\w+/), deferrable_mode(tail))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
content.scan(/ALTER TABLE\s+(?:ONLY\s+)?#{QUALIFIED_NAME}\s+ADD CONSTRAINT[^;]*?PRIMARY KEY\s*\(([^)]*)\)/m) do |table, cols|
|
|
60
|
+
table = all.dig(qualified_name(table), :table)
|
|
61
|
+
table[:primary_key] = SchemaConventions.primary_key_value(cols.scan(/\w+/)) if table
|
|
52
62
|
end
|
|
53
63
|
|
|
54
64
|
# [^;]*? keeps the match inside one statement: with .*? a pkey-only
|
|
55
65
|
# ADD CONSTRAINT would swallow up to the FOREIGN KEY of a LATER
|
|
56
66
|
# statement and attribute the FK to the wrong table.
|
|
57
|
-
content.scan(/ALTER TABLE\s+(?:ONLY\s+)
|
|
58
|
-
|
|
67
|
+
content.scan(/ALTER TABLE\s+(?:ONLY\s+)?#{QUALIFIED_NAME}\s+ADD CONSTRAINT[^;]*?FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+#{QUALIFIED_NAME}\s*\(([^)]*)\)([^;]*)/m) do |from, cols, to, pks, tail|
|
|
68
|
+
from = qualified_name(from)
|
|
69
|
+
all.dig(from, :table, :foreign_keys)&.push(SchemaConventions.foreign_key_entry(shown_name(from), shown_name(qualified_name(to)), cols.scan(/\w+/), pks.scan(/\w+/), **foreign_key_actions(tail)))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
alters = Hash.new { |h, k| h[k] = [] }
|
|
73
|
+
content.scan(/^ALTER TABLE (?:ONLY )?#{QUALIFIED_NAME} ALTER COLUMN "?(\w+)"? SET (NOT NULL|DEFAULT .+);$/) do |table, column, change|
|
|
74
|
+
alters[qualified_name(table)] << [ column, change ]
|
|
75
|
+
end
|
|
76
|
+
resolved = {}
|
|
77
|
+
all.each_key { |name| resolve_columns(name, all, alters, resolved) }
|
|
78
|
+
|
|
79
|
+
content.scan(/^COMMENT ON TABLE #{QUALIFIED_NAME} IS '((?:[^']|'')*)';/) do |table, text|
|
|
80
|
+
table = all.dig(qualified_name(table), :table)
|
|
81
|
+
table[:comment] = text.gsub("''", "'") if table
|
|
82
|
+
end
|
|
83
|
+
content.scan(/^COMMENT ON COLUMN ((?:(?:"[^"]+"|\w+)\.)+)("[^"]+"|\w+) IS '((?:[^']|'')*)';/) do |table, column, text|
|
|
84
|
+
column = column.delete('"')
|
|
85
|
+
found = all.dig(qualified_name(table.chomp(".")), :table, :columns)&.find { |c| c[:name] == column }
|
|
86
|
+
found[:comment] = text.gsub("''", "'") if found
|
|
59
87
|
end
|
|
60
88
|
|
|
61
89
|
# pg_dump writes each partition as a table, then attaches it in exactly this form.
|
|
62
|
-
content.scan(/^ALTER TABLE ONLY .+? ATTACH PARTITION
|
|
90
|
+
content.scan(/^ALTER TABLE ONLY .+? ATTACH PARTITION #{QUALIFIED_NAME} /) do |(partition)|
|
|
91
|
+
name = qualified_name(partition)
|
|
92
|
+
tables.delete(shown_name(name)) if name.start_with?("public.")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
{ dialect: dialect, tables: tables, enums: enums.map { |name, values| { name: name, values: values } },
|
|
96
|
+
views: found_views, virtual_tables: virtual_tables(content), extensions: extensions(content) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# As PostgreSQL's connection names them: qualified unless in the public schema.
|
|
100
|
+
def extensions(content)
|
|
101
|
+
content.scan(/^CREATE EXTENSION (?:IF NOT EXISTS )?("[^"]+"|\w+)(?: WITH SCHEMA ("[^"]+"|\w+))?/).map do |name, schema|
|
|
102
|
+
[ (schema.delete('"') unless schema.nil? || schema == "public"), name.delete('"') ].compact.join(".")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
63
105
|
|
|
64
|
-
|
|
106
|
+
# Each view by the name the app reads it under, a later definition of a name replacing a placeholder.
|
|
107
|
+
def views(content)
|
|
108
|
+
content.scan(VIEW).each_with_object({}) do |(materialized, name, sql), found|
|
|
109
|
+
name = qualified_name(name)
|
|
110
|
+
found[shown_name(name)] = { materialized: !materialized.nil?, sql: sql.strip } if shown_name(name).match?(/\A\w+\z/)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def virtual_tables(content)
|
|
115
|
+
content.scan(VIRTUAL_TABLE).to_h do |name, mod, arguments|
|
|
116
|
+
[ shown_name(qualified_name(name)), { module: mod, arguments: split_top_level(arguments.to_s) } ]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# PostgreSQL's enum types, by the name schema.rb gives them, with their labels.
|
|
121
|
+
def enum_types(content)
|
|
122
|
+
content.scan(/CREATE TYPE\s+#{QUALIFIED_NAME}\s+AS\s+ENUM\s*\(([^;]*)\)\s*;/i).to_h do |name, labels|
|
|
123
|
+
[ shown_name(qualified_name(name)), split_top_level(labels).map { |label| label.delete_prefix("'").delete_suffix("'").gsub("''", "'") } ]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The actions Rails names (cascade, nullify, restrict); NO ACTION is its default.
|
|
128
|
+
FK_ACTIONS = { "CASCADE" => "cascade", "SET NULL" => "nullify", "RESTRICT" => "restrict" }.freeze
|
|
129
|
+
|
|
130
|
+
def foreign_key_actions(text)
|
|
131
|
+
actions = { on_delete: :DELETE, on_update: :UPDATE }.to_h do |key, word|
|
|
132
|
+
[ key, FK_ACTIONS[text.to_s[/\bON\s+#{word}\s+(CASCADE|SET\s+NULL|RESTRICT)\b/i, 1]&.upcase&.squeeze(" ")] ]
|
|
133
|
+
end
|
|
134
|
+
actions.merge(deferrable: deferrable_mode(text), validate: (false if text.to_s.match?(/\bNOT\s+VALID\b/i))).compact
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# How Rails names a DEFERRABLE clause; nil for NOT DEFERRABLE or none.
|
|
138
|
+
def deferrable_mode(text)
|
|
139
|
+
return nil unless text.to_s.match?(/(?<!NOT )\bDEFERRABLE\b/i)
|
|
140
|
+
|
|
141
|
+
text.to_s.match?(/\bINITIALLY\s+DEFERRED\b/i) ? "deferred" : "immediate"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Where a statement with no semicolon ends: the next one starting a line.
|
|
145
|
+
NEXT_STATEMENT = /\n\s*(?:CREATE|ALTER|COMMENT|INSERT|DROP|SET|SELECT)\b/i
|
|
146
|
+
# A view's body runs on to a SELECT line, so only another statement's keyword ends it.
|
|
147
|
+
NEXT_DDL = /\n\s*(?:CREATE\s+(?:(?:OR\s+REPLACE|UNIQUE|TEMP(?:ORARY)?|MATERIALIZED|VIRTUAL)\s+)*(?:TABLE|VIEW|INDEX|TRIGGER|SEQUENCE|TYPE|FUNCTION|EXTENSION|SCHEMA)\b|ALTER\s+(?:TABLE|SEQUENCE|TYPE|VIEW|INDEX)\b|COMMENT\s+ON\b|INSERT\s+INTO\b|DROP\s+(?:TABLE|VIEW|INDEX|TRIGGER)\b|SET\s+[\w.]+\s*(?:=|TO\b))/i
|
|
148
|
+
|
|
149
|
+
# Each CREATE TABLE's name, body and INHERITS list. The body ends at its closing
|
|
150
|
+
# parenthesis, since SQLite, MySQL and a semicolon-less dump each end the line differently.
|
|
151
|
+
def each_create_table(content)
|
|
152
|
+
scanner = StringScanner.new(content)
|
|
153
|
+
while scanner.skip_until(CREATE_TABLE)
|
|
154
|
+
qualified = scanner[1]
|
|
155
|
+
close = closing_paren(content, scanner.pos) or next
|
|
156
|
+
body = content.byteslice(scanner.pos + 1, close - scanner.pos - 1)
|
|
157
|
+
scanner.pos = close + 1
|
|
158
|
+
inherits = scanner[1] if scanner.scan(INHERITS)
|
|
159
|
+
yield qualified, body, inherits, scanner.check(/[^;\n]*/).to_s
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
QUOTE_BYTES = "'\"`".bytes.freeze
|
|
164
|
+
WHITESPACE_BYTES = " \t\n\r\f\v".bytes.freeze
|
|
165
|
+
|
|
166
|
+
# The byte index of the parenthesis closing the one at `open`, quotes
|
|
167
|
+
# respected; nil when it never closes. Byte-wise, so a long dump costs one pass.
|
|
168
|
+
def closing_paren(text, open)
|
|
169
|
+
depth = 0
|
|
170
|
+
quote = nil
|
|
171
|
+
index = open
|
|
172
|
+
while (byte = text.getbyte(index))
|
|
173
|
+
if quote
|
|
174
|
+
quote = nil if byte == quote
|
|
175
|
+
elsif QUOTE_BYTES.include?(byte)
|
|
176
|
+
quote = byte
|
|
177
|
+
elsif byte == 40
|
|
178
|
+
depth += 1
|
|
179
|
+
elsif byte == 41
|
|
180
|
+
depth -= 1
|
|
181
|
+
return index if depth.zero?
|
|
182
|
+
end
|
|
183
|
+
index += 1
|
|
184
|
+
end
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A table name, optionally schema-qualified, each part bare or quoted.
|
|
189
|
+
QUALIFIED_NAME = /((?:(?:"[^"]+"|`[^`]+`|\w+)\.)?(?:"[^"]+"|`[^`]+`|\w+))/
|
|
190
|
+
INHERITS_KEYWORD = /\s*INHERITS\b/i
|
|
191
|
+
# The optional INHERITS list after a CREATE TABLE body.
|
|
192
|
+
INHERITS = /(?:#{INHERITS_KEYWORD}\s*\(([^)]*)\))?/
|
|
193
|
+
CREATE_TABLE = /CREATE TABLE\s+(?:IF NOT EXISTS\s+)?#{QUALIFIED_NAME}\s*(?=\()/i
|
|
194
|
+
# mysqldump wraps the statement in version comments and names an algorithm, definer and security first;
|
|
195
|
+
# sqlite3 closes it with a /* name(columns) */ comment.
|
|
196
|
+
VIEW = /\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:(?:ALGORITHM|DEFINER|SQL\s+SECURITY)\b[^;]*?\s)?(?:TEMP(?:ORARY)?\s+)?(MATERIALIZED\s+)?VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?#{QUALIFIED_NAME}(?:\s*\([^)]*\))?\s+AS\s+(.*?)(?:\s+WITH\s+(?:NO\s+)?DATA)?(?:\s*\/\*\s*[\w"`]+\([^)]*\)\s*\*\/)?\s*(?:\*\/)?\s*(?:;|(?=#{NEXT_DDL})|\z)/im
|
|
197
|
+
VIRTUAL_TABLE = /CREATE\s+VIRTUAL\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?#{QUALIFIED_NAME}\s+USING\s+(\w+)\s*(?:\(([^)]*)\))?/i
|
|
198
|
+
|
|
199
|
+
# "schema.name" with quotes removed; an unqualified name is in public.
|
|
200
|
+
def qualified_name(text)
|
|
201
|
+
parts = text.strip.scan(/"([^"]+)"|`([^`]+)`|(\w+)/).map { |groups| groups.compact.first }
|
|
202
|
+
parts.unshift("public") if parts.size == 1
|
|
203
|
+
parts.join(".")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def shown_name(name)
|
|
207
|
+
SchemaConventions.local_name(name)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Each parent's columns, then the child's own. pg_dump writes only local
|
|
211
|
+
# columns and sets an inherited one's NOT NULL and default by ALTER.
|
|
212
|
+
def resolve_columns(name, all, alters, resolved)
|
|
213
|
+
entry = all[name]
|
|
214
|
+
return entry if resolved[name]
|
|
215
|
+
|
|
216
|
+
resolved[name] = true
|
|
217
|
+
columns = []
|
|
218
|
+
raw_types = {}
|
|
219
|
+
unresolved = []
|
|
220
|
+
entry[:parents]&.each do |parent|
|
|
221
|
+
unless all[parent]
|
|
222
|
+
unresolved << shown_name(parent)
|
|
223
|
+
next
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
resolved_parent = resolve_columns(parent, all, alters, resolved)
|
|
227
|
+
resolved_parent[:table][:columns].each { |column| merge_column(columns, column.dup) }
|
|
228
|
+
raw_types = resolved_parent[:raw_types].merge(raw_types)
|
|
229
|
+
unresolved.concat(Array(resolved_parent[:table][:inherits_unresolved]))
|
|
230
|
+
end
|
|
231
|
+
entry[:table][:columns].each { |column| merge_column(columns, column) }
|
|
232
|
+
entry[:raw_types] = raw_types.merge(entry[:raw_types])
|
|
233
|
+
|
|
234
|
+
alters[name].each do |column_name, change|
|
|
235
|
+
column = columns.find { |c| c[:name] == column_name } or next
|
|
236
|
+
if change == "NOT NULL"
|
|
237
|
+
column[:null] = false
|
|
238
|
+
else
|
|
239
|
+
default = sql_default(change, column[:type], entry[:raw_types].fetch(column_name))
|
|
240
|
+
default.nil? ? column.delete(:default) : column[:default] = default
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
entry[:table][:columns] = columns
|
|
244
|
+
entry[:table][:inherits_unresolved] = unresolved.uniq if unresolved.any?
|
|
245
|
+
entry
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def merge_column(columns, column)
|
|
249
|
+
at = columns.index { |c| c[:name] == column[:name] }
|
|
250
|
+
return columns << column unless at
|
|
251
|
+
|
|
252
|
+
columns[at] = columns[at].merge(column, null: columns[at][:null] && column[:null])
|
|
65
253
|
end
|
|
66
254
|
|
|
67
255
|
# mysqldump always terminates CREATE TABLE with ") ENGINE=..." and
|
|
@@ -82,13 +270,13 @@ module RailsAiContext
|
|
|
82
270
|
# MySQL keeps indexes and foreign keys inside the CREATE TABLE body as
|
|
83
271
|
# KEY / UNIQUE KEY / CONSTRAINT lines; the other dialects emit separate
|
|
84
272
|
# statements, so those lines simply never match here.
|
|
85
|
-
def parse_sql_table_body(body, table_name)
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
|
|
89
|
-
body = split_single_line_sql_body(body) unless body.include?("\n")
|
|
273
|
+
def parse_sql_table_body(body, table_name, dialect = nil, enums = {})
|
|
274
|
+
# One definition per line whatever the dump's layout: SQLite writes a
|
|
275
|
+
# table on one line and its foreign key clause across three.
|
|
276
|
+
body = split_top_level(body).map { |definition| definition.gsub(/\s*\n\s*/, " ") }.join("\n")
|
|
90
277
|
|
|
91
|
-
|
|
278
|
+
columns, raw_types = parse_sql_columns(body, dialect, enums)
|
|
279
|
+
table = { columns: columns, indexes: [], foreign_keys: [] }
|
|
92
280
|
if (key = body[/^\s*PRIMARY KEY\s*\(([^)]*)\)/i, 1])
|
|
93
281
|
table[:primary_key] = SchemaConventions.primary_key_value(key.scan(/\w+/))
|
|
94
282
|
end
|
|
@@ -96,25 +284,55 @@ module RailsAiContext
|
|
|
96
284
|
body.each_line do |line|
|
|
97
285
|
line = line.strip.chomp(",")
|
|
98
286
|
case line
|
|
99
|
-
when /\
|
|
100
|
-
columns, to, keys = $1, $2, $3
|
|
101
|
-
table[:foreign_keys] << SchemaConventions.foreign_key_entry(table_name, to, columns.scan(/\w+/), keys.scan(/\w+/))
|
|
102
|
-
when /\A(
|
|
287
|
+
when /\A(?:CONSTRAINT\s+[`"]?\w+[`"]?\s+)?FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+[`"]?(\w+)[`"]?\s*\(([^)]*)\)(.*)/i
|
|
288
|
+
columns, to, keys, tail = $1, $2, $3, $4
|
|
289
|
+
table[:foreign_keys] << SchemaConventions.foreign_key_entry(table_name, to, columns.scan(/\w+/), keys.scan(/\w+/), **foreign_key_actions(tail))
|
|
290
|
+
when /\A(?:CONSTRAINT\s+[`"]?(\w+)[`"]?\s+)?CHECK\s*(\(.*)/i
|
|
291
|
+
name = $1
|
|
292
|
+
expression = first_paren_group($2)&.strip
|
|
293
|
+
# The MySQL adapter drops the parentheses MySQL wraps a check clause in.
|
|
294
|
+
expression = unwrap_parens(expression) if dialect == :mysql && expression
|
|
295
|
+
(table[:check_constraints] ||= []) << { name: name, expression: expression }.compact if expression
|
|
296
|
+
when /\A(UNIQUE\s+|FULLTEXT\s+|SPATIAL\s+)?(?:KEY|INDEX)\s+[`"](\w+)[`"]\s*(\(.*)/i
|
|
103
297
|
# Captured to locals first: the parsing below runs more regexes,
|
|
104
298
|
# which would clobber $~ before the hash literal reads it.
|
|
105
|
-
|
|
299
|
+
kind = $1.to_s.strip.downcase
|
|
106
300
|
idx_name = $2
|
|
107
|
-
|
|
108
|
-
|
|
301
|
+
rest = $3
|
|
302
|
+
group = first_paren_group(rest)
|
|
303
|
+
keys = index_keys(group)
|
|
304
|
+
# Rails names a MySQL fulltext or spatial index by type:, any other by its method.
|
|
305
|
+
typed = %w[fulltext spatial].include?(kind)
|
|
306
|
+
using = rest[(group.to_s.length + 2)..].to_s[/\bUSING\s+(\w+)/i, 1]&.downcase unless typed
|
|
307
|
+
index = { name: idx_name, columns: keys, unique: kind == "unique" }
|
|
308
|
+
index.merge!(SchemaConventions.index_detail(keys, type: (kind if typed), using: using, **key_options(group)))
|
|
309
|
+
table[:indexes] << index if keys.any?
|
|
109
310
|
end
|
|
110
311
|
end
|
|
111
312
|
|
|
112
|
-
table
|
|
313
|
+
[ table, raw_types ]
|
|
113
314
|
end
|
|
114
315
|
|
|
115
316
|
# A key that is a column, with any prefix length, operator class, sort
|
|
116
317
|
# order or collation after it. Anything else is an expression.
|
|
117
|
-
COLUMN_KEY = /\A[`"]?(\w+)[`"]?(?:\s*\(\d
|
|
318
|
+
COLUMN_KEY = /\A[`"]?(\w+)[`"]?(?:\s*\((\d+)\))?((?:\s+(?:[\w.]+|"[^"]*"))*)\z/
|
|
319
|
+
|
|
320
|
+
# Each column key's prefix length, sort order and operator class, as the connection reads them.
|
|
321
|
+
def key_options(list)
|
|
322
|
+
options = { length: {}, order: {}, opclass: {} }
|
|
323
|
+
split_top_level(list.to_s).each do |key|
|
|
324
|
+
match = COLUMN_KEY.match(key.strip) or next
|
|
325
|
+
column, length, words = match[1], match[2], match[3].split
|
|
326
|
+
options[:length][column] = length.to_i if length
|
|
327
|
+
opclass = words.map { |word| word.split(".").last }.find { |word| word.match?(/\A\w+_ops(?:_\w+)?\z/) }
|
|
328
|
+
options[:opclass][column] = opclass if opclass
|
|
329
|
+
desc = "DESC" if words.any? { |word| word.casecmp?("DESC") }
|
|
330
|
+
nulls = match[3][/\bNULLS\s+(?:FIRST|LAST)\b/i]&.upcase&.squeeze(" ")
|
|
331
|
+
order = nulls ? [ desc, nulls ].compact.join(" ") : desc&.downcase
|
|
332
|
+
options[:order][column] = order if order
|
|
333
|
+
end
|
|
334
|
+
options
|
|
335
|
+
end
|
|
118
336
|
|
|
119
337
|
# An index's keys, split on top-level commas: a column name, or an
|
|
120
338
|
# expression as the dump spells it (COALESCE(a, '-1'::integer) is one key).
|
|
@@ -129,20 +347,30 @@ module RailsAiContext
|
|
|
129
347
|
end
|
|
130
348
|
end
|
|
131
349
|
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
|
|
350
|
+
# Runs of text with nothing a top-level scan stops at, skipped in one regex
|
|
351
|
+
# step so a megabyte dump is not walked a character at a time in Ruby.
|
|
352
|
+
PAREN_RUN = /[^'"`()]+/
|
|
353
|
+
SPLIT_RUN = /[^'"`(),]+/
|
|
354
|
+
DEFAULT_RUN = /[^'"()\s]+/
|
|
355
|
+
QUOTE_ENDS = { 39 => /'/, 34 => /"/, 96 => /`/ }.freeze
|
|
356
|
+
|
|
357
|
+
# Each byte outside quotes that `run` does not skip, its byte offset, and
|
|
358
|
+
# the parenthesis depth once it is read. Every byte `run` stops at is ASCII.
|
|
359
|
+
def scan_top_level(text, run = SPLIT_RUN)
|
|
360
|
+
scanner = StringScanner.new(text)
|
|
135
361
|
depth = 0
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
362
|
+
until scanner.eos?
|
|
363
|
+
next if scanner.skip(run)
|
|
364
|
+
|
|
365
|
+
index = scanner.pos
|
|
366
|
+
byte = text.getbyte(index)
|
|
367
|
+
scanner.pos = index + 1
|
|
368
|
+
if (closing = QUOTE_ENDS[byte])
|
|
369
|
+
scanner.skip_until(closing) or break
|
|
142
370
|
else
|
|
143
|
-
depth += 1 if
|
|
144
|
-
depth -= 1 if
|
|
145
|
-
yield
|
|
371
|
+
depth += 1 if byte == 40
|
|
372
|
+
depth -= 1 if byte == 41
|
|
373
|
+
yield byte, index, depth
|
|
146
374
|
end
|
|
147
375
|
end
|
|
148
376
|
end
|
|
@@ -151,33 +379,34 @@ module RailsAiContext
|
|
|
151
379
|
def first_paren_group(text)
|
|
152
380
|
start = text.to_s.index("(") or return nil
|
|
153
381
|
group = text[start..]
|
|
154
|
-
scan_top_level(group) { |
|
|
382
|
+
scan_top_level(group, PAREN_RUN) { |byte, i, depth| return group.byteslice(1, i - 1) if byte == 41 && depth.zero? }
|
|
155
383
|
nil
|
|
156
384
|
end
|
|
157
385
|
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
386
|
+
# The expression inside one pair of parentheses that encloses all of it.
|
|
387
|
+
def unwrap_parens(expression)
|
|
388
|
+
inner = first_paren_group(expression) if expression.start_with?("(")
|
|
389
|
+
inner && inner.bytesize == expression.bytesize - 2 ? inner.strip : expression
|
|
162
390
|
end
|
|
163
391
|
|
|
164
392
|
def split_top_level(body)
|
|
165
393
|
parts = []
|
|
166
394
|
from = 0
|
|
167
|
-
scan_top_level(body) do |
|
|
168
|
-
next unless
|
|
395
|
+
scan_top_level(body) do |byte, i, depth|
|
|
396
|
+
next unless byte == 44 && depth.zero?
|
|
169
397
|
|
|
170
|
-
parts << body
|
|
398
|
+
parts << body.byteslice(from, i - from)
|
|
171
399
|
from = i + 1
|
|
172
400
|
end
|
|
173
|
-
rest = body
|
|
401
|
+
rest = body.byteslice(from, body.bytesize - from)
|
|
174
402
|
parts << rest unless rest.strip.empty?
|
|
175
403
|
parts.map(&:strip)
|
|
176
404
|
end
|
|
177
405
|
|
|
178
|
-
#
|
|
179
|
-
def parse_sql_columns(body)
|
|
406
|
+
# Column definitions from a CREATE TABLE body, and each column's type as the dump spells it.
|
|
407
|
+
def parse_sql_columns(body, dialect = nil, enums = {})
|
|
180
408
|
columns = []
|
|
409
|
+
raw_types = {}
|
|
181
410
|
body.each_line do |line|
|
|
182
411
|
line = line.strip.chomp(",").strip
|
|
183
412
|
next if line.empty?
|
|
@@ -187,7 +416,7 @@ module RailsAiContext
|
|
|
187
416
|
# inline index names ("KEY `name` (...)"), so a quoted name after
|
|
188
417
|
# KEY/INDEX is the reliable signal that this line is an index
|
|
189
418
|
# definition rather than a column named "key" or "index".
|
|
190
|
-
next if line.match?(/\A(?:UNIQUE\s+)?(?:KEY|INDEX)\s+[`"]/i)
|
|
419
|
+
next if line.match?(/\A(?:UNIQUE\s+|FULLTEXT\s+|SPATIAL\s+)?(?:KEY|INDEX)\s+[`"]/i)
|
|
191
420
|
|
|
192
421
|
# Match: column_name type_with_params [constraints]
|
|
193
422
|
if (match = line.match(/\A[`"]?(\w+)[`"]?\s+(.+)/))
|
|
@@ -202,15 +431,29 @@ module RailsAiContext
|
|
|
202
431
|
# dump-visible nullability signals.
|
|
203
432
|
nullable = !rest.match?(/\bNOT\s+NULL\b|\bPRIMARY\s+KEY\b/i)
|
|
204
433
|
# An array is its element type with the flag, as schema.rb dumps it.
|
|
205
|
-
type = normalize_sql_type(col_type.delete_suffix("[]"))
|
|
434
|
+
type = normalize_sql_type(col_type.delete_suffix("[]"), dialect)
|
|
435
|
+
enum_type = shown_name(qualified_name(col_type.delete_suffix("[]"))) if col_type.match?(/\A(?:"[^"]+"|[\w.]+)(?:\[\])?\z/)
|
|
436
|
+
type = "enum" if enum_type && enums.key?(enum_type)
|
|
437
|
+
raw_types[col_name] = col_type
|
|
206
438
|
column = { name: col_name, type: type, null: nullable }
|
|
207
439
|
default = sql_default(rest, type, col_type)
|
|
208
440
|
column[:default] = default unless default.nil?
|
|
209
441
|
column[:array] = true if col_type.end_with?("[]")
|
|
442
|
+
column[:primary_key] = true if rest.match?(/\bPRIMARY\s+KEY\b/i)
|
|
443
|
+
if (at = rest =~ /\bGENERATED\s+ALWAYS\s+AS\s*\(/i) && (expression = first_paren_group(rest[at..]))
|
|
444
|
+
column[:generated] = expression.strip
|
|
445
|
+
column[:stored] = rest.match?(/\)\s*(?:STORED|PERSISTENT)\b/i)
|
|
446
|
+
end
|
|
447
|
+
column[:enum_type] = enum_type if type == "enum"
|
|
448
|
+
column.merge!(type_detail(col_type, type, dialect))
|
|
449
|
+
comment = rest[/\bCOMMENT\s+'((?:[^']|'')*)'/i, 1]
|
|
450
|
+
column[:comment] = comment.gsub("''", "'") if comment
|
|
451
|
+
collation = rest[/\bCOLLATE\s+(?:pg_catalog\.)?"?([\w.-]+)"?/i, 1]
|
|
452
|
+
column[:collation] = collation if collation
|
|
210
453
|
columns << column
|
|
211
454
|
end
|
|
212
455
|
end
|
|
213
|
-
columns
|
|
456
|
+
[ columns, raw_types ]
|
|
214
457
|
end
|
|
215
458
|
|
|
216
459
|
# Where a DEFAULT clause ends: the next constraint keyword at the top level.
|
|
@@ -239,8 +482,8 @@ module RailsAiContext
|
|
|
239
482
|
|
|
240
483
|
# The DEFAULT value's text, up to the next constraint at the top level.
|
|
241
484
|
def default_text(text)
|
|
242
|
-
scan_top_level(text,
|
|
243
|
-
return text
|
|
485
|
+
scan_top_level(text, DEFAULT_RUN) do |byte, i, depth|
|
|
486
|
+
return text.byteslice(0, i).strip if depth.zero? && WHITESPACE_BYTES.include?(byte) && text.byteslice(i, text.bytesize - i).match?(DEFAULT_END)
|
|
244
487
|
end
|
|
245
488
|
text.strip
|
|
246
489
|
end
|
|
@@ -256,33 +499,64 @@ module RailsAiContext
|
|
|
256
499
|
numeric ? items.map { |item| item.include?(".") ? item.to_f : item.to_i } : items
|
|
257
500
|
end
|
|
258
501
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
502
|
+
# The size a type was given, as schema.rb writes it: varchar(255) is
|
|
503
|
+
# MySQL's default string, 6 a datetime's default precision.
|
|
504
|
+
def type_detail(raw_type, type, dialect)
|
|
505
|
+
detail = {}
|
|
506
|
+
detail[:unsigned] = true if raw_type.match?(/\bunsigned\b/i)
|
|
507
|
+
size = SchemaConventions.mysql_text_size(raw_type) if dialect == :mysql
|
|
508
|
+
detail[:size] = size if size
|
|
509
|
+
limit = INTEGER_LIMITS[raw_type[/\A\w+/]]
|
|
510
|
+
detail[:limit] = limit if type == "integer" && limit
|
|
511
|
+
sizes = raw_type[/\((\d+(?:\s*,\s*\d+)?)\)/, 1]&.split(",")&.map(&:to_i)
|
|
512
|
+
return detail unless sizes
|
|
513
|
+
|
|
514
|
+
detail.merge(sized(type, sizes, dialect))
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# The integer limit Rails reads off a smaller integer type.
|
|
518
|
+
INTEGER_LIMITS = { "smallint" => 2, "int2" => 2, "tinyint" => 1, "mediumint" => 3 }.freeze
|
|
519
|
+
|
|
520
|
+
def sized(type, sizes, dialect)
|
|
521
|
+
case type
|
|
522
|
+
when "string"
|
|
523
|
+
sizes.first == 255 && dialect == :mysql ? {} : { limit: sizes.first }
|
|
524
|
+
when "binary" then { limit: sizes.first }
|
|
525
|
+
when "decimal" then { precision: sizes.first, scale: sizes[1] || 0 }
|
|
526
|
+
when "datetime" then [ 0, 6 ].include?(sizes.first) ? {} : { precision: sizes.first }
|
|
527
|
+
when "time" then sizes.first.zero? ? {} : { precision: sizes.first }
|
|
528
|
+
else {}
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# The type schema.rb names: pg_dump qualifies an extension's type with its
|
|
533
|
+
# schema, MySQL appends unsigned, and a size never changes the name.
|
|
534
|
+
def normalize_sql_type(type, dialect = nil)
|
|
535
|
+
# MySQL's boolean columns are a sized tinyint; bare tinyint is a real 1-byte integer.
|
|
263
536
|
return "boolean" if type.start_with?("tinyint(1)")
|
|
264
537
|
|
|
265
|
-
|
|
538
|
+
type = type.sub(/\A(?:"[^"]+"|\w+)\.(?=[\w"])/, "")
|
|
539
|
+
base = type.gsub(/\s*\([^)]*\)/, "").gsub(/\s+(?:unsigned|signed|zerofill)\b/i, "").strip
|
|
266
540
|
|
|
267
541
|
case base
|
|
268
|
-
when /\
|
|
269
|
-
when /\
|
|
270
|
-
when /\
|
|
271
|
-
when /\
|
|
272
|
-
when /\
|
|
273
|
-
when /\
|
|
274
|
-
|
|
542
|
+
when /\A(?:integer|int|int4|smallint|int2|tinyint|mediumint)\z/i then "integer"
|
|
543
|
+
when /\A(?:bigint|int8)\z/i then "bigint"
|
|
544
|
+
when /\A(?:character varying|varchar|character|char|bpchar)\z/i then "string"
|
|
545
|
+
when /\A(?:text|longtext|mediumtext|tinytext)\z/i then "text"
|
|
546
|
+
when /\A(?:boolean|bool)\z/i then "boolean"
|
|
547
|
+
when /\A(?:timestamp with time zone|timestamptz)\z/i then "timestamptz"
|
|
548
|
+
# MySQL's dumper keeps a timestamp column a timestamp.
|
|
549
|
+
when /\Atimestamp(?: without time zone)?\z/i then dialect == :mysql ? "timestamp" : "datetime"
|
|
550
|
+
when /\Adatetime\z/i then "datetime"
|
|
275
551
|
when /\Adate\z/i then "date"
|
|
276
|
-
when /\Atime
|
|
277
|
-
when /\
|
|
278
|
-
when /\
|
|
279
|
-
when /\Ajsonb
|
|
280
|
-
when /\
|
|
281
|
-
when /\
|
|
282
|
-
when /\
|
|
552
|
+
when /\Atime(?: without time zone)?\z/i then "time"
|
|
553
|
+
when /\A(?:numeric|decimal)\z/i then "decimal"
|
|
554
|
+
when /\A(?:float|double|double precision|real|float4|float8)\z/i then "float"
|
|
555
|
+
when /\Ajsonb\z/i then "jsonb"
|
|
556
|
+
when /\Ajson\z/i then "json"
|
|
557
|
+
when /\A(?:bytea|longblob|mediumblob|tinyblob|blob|binary|varbinary)\z/i then "binary"
|
|
558
|
+
when /\A(?:uuid|inet|citext|hstore)\z/i then base.downcase
|
|
283
559
|
when /\Aarray\z/i then "array"
|
|
284
|
-
when /\Ahstore\z/i then "hstore"
|
|
285
|
-
when /\Alongblob\z/i, /\Amediumblob\z/i, /\Ablob\z/i, /\Abinary\z/i, /\Avarbinary\z/i then "binary"
|
|
286
560
|
else type
|
|
287
561
|
end
|
|
288
562
|
end
|