rails-ai-context 5.30.2 → 5.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. metadata +34 -3
@@ -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
- # Identifier quoting differs per dump tool: pg_dump uses bare or
16
- # "quoted" names with a public. prefix, mysqldump uses `backticks` and
17
- # terminates CREATE TABLE with ") ENGINE=...;", sqlite uses "quotes"
18
- # and IF NOT EXISTS. The body is captured up to the closing paren at
19
- # line start because MySQL's trailer means ");" alone never appears.
20
- # The negative lookahead in the body keeps a single-line CREATE TABLE
21
- # (e.g. sqlite's schema_migrations) from swallowing every table that
22
- # follows it: without it, the lazy scan has no "\n)" to stop at inside
23
- # that one-line statement, so it keeps consuming lines - including the
24
- # next CREATE TABLE - until it finds one.
25
- content.scan(/CREATE TABLE\s+(?:IF NOT EXISTS\s+)?(?:public\.)?[`"]?(\w+)[`"]?\s*\(((?:(?!CREATE TABLE).)*?)^\)/m) do |table_name, body|
26
- next if table_name.start_with?("ar_internal_metadata", "schema_migrations")
27
-
28
- tables[table_name] = parse_sql_table_body(body, table_name)
29
- end
30
-
31
- # Single-line CREATE TABLE statements (sqlite emits these for tiny
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
- content.scan(/CREATE (UNIQUE )?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF NOT EXISTS\s+)?[`"]?(\w+)[`"]?\s+ON\s+(?:ONLY\s+)?(?:public\.)?[`"]?(\w+)[`"]?([^;]*)/m) do |unique, idx_name, table, rest|
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 condition follows the key list, never inside it.
46
- where = rest[(rest.index("(") + group.length + 2)..][/\bWHERE\s+(.+)\z/mi, 1]&.strip
47
- tables[table]&.dig(:indexes)&.push({ name: idx_name, columns: keys, unique: !!unique, where: where }.compact)
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+)?(?:public\.)?[`"]?(\w+)[`"]?\s+ADD CONSTRAINT[^;]*?PRIMARY KEY\s*\(([^)]*)\)/m) do |table, cols|
51
- tables[table][:primary_key] = SchemaConventions.primary_key_value(cols.scan(/\w+/)) if tables[table]
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+)?(?:public\.)?[`"]?(\w+)[`"]?\s+ADD CONSTRAINT[^;]*?FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+(?:public\.)?[`"]?(\w+)[`"]?\s*\(([^)]*)\)/m) do |from, cols, to, pks|
58
- tables[from]&.dig(:foreign_keys)&.push(SchemaConventions.foreign_key_entry(from, to, cols.scan(/\w+/), pks.scan(/\w+/)))
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 (?:public\.)?(?:"([^"]+)"|(\w+)) /) { |quoted, bare| tables.delete(quoted || bare) }
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
- { dialect: detect_sql_dialect(content), tables: tables }
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
- # sqlite's .schema emits whole CREATE TABLE statements on one line;
87
- # the per-line parsers below would then see a single "line" and keep
88
- # only its first column. Split such bodies on top-level commas first.
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
- table = { columns: parse_sql_columns(body), indexes: [], foreign_keys: [] }
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 /\ACONSTRAINT\s+[`"]?\w+[`"]?\s+FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+[`"]?(\w+)[`"]?\s*\(([^)]*)\)/i
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(UNIQUE\s+)?(?:KEY|INDEX)\s+[`"](\w+)[`"]\s*(\(.*)/i
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
- unique = !$1.nil?
299
+ kind = $1.to_s.strip.downcase
106
300
  idx_name = $2
107
- keys = index_keys(first_paren_group($3))
108
- table[:indexes] << { name: idx_name, columns: keys, unique: unique } if keys.any?
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+\))?(?:\s+(?:[\w.]+|"[^"]*"))*\z/
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
- # Each character outside quotes, its index, and the parenthesis depth
133
- # once it is read. `quotes` are the characters that open a quoted run.
134
- def scan_top_level(text, quotes = "'\"`")
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
- quote = nil
137
- text.each_char.with_index do |ch, i|
138
- if quote
139
- quote = nil if ch == quote
140
- elsif quotes.include?(ch)
141
- quote = ch
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 ch == "("
144
- depth -= 1 if ch == ")"
145
- yield ch, i, depth
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) { |ch, i, depth| return group[1, i - 1] if ch == ")" && depth.zero? }
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
- # A one-line CREATE TABLE body as one definition per line, split on the
159
- # commas outside parentheses and quotes, so numeric(10,2) survives.
160
- def split_single_line_sql_body(body)
161
- split_top_level(body).join("\n")
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 |ch, i, depth|
168
- next unless ch == "," && depth.zero?
395
+ scan_top_level(body) do |byte, i, depth|
396
+ next unless byte == 44 && depth.zero?
169
397
 
170
- parts << body[from...i]
398
+ parts << body.byteslice(from, i - from)
171
399
  from = i + 1
172
400
  end
173
- rest = body[from..]
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
- # Parse column definitions from a CREATE TABLE body
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, "'\"") do |ch, i, depth|
243
- return text[0, i].strip if depth.zero? && ch.match?(/\s/) && text[i..].match?(DEFAULT_END)
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
- def normalize_sql_type(type)
260
- # MySQL's boolean columns are a sized tinyint. This has to run
261
- # before the size-stripping below (and before the generic tinyint
262
- # match) because bare tinyint is a real 1-byte integer column.
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
- base = type.sub(/\(.+\z/m, "").strip
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 /\Ainteger\z/i, /\Aint\z/i, /\Aint4\z/i, /\Atinyint\z/i, /\Amediumint\z/i then "integer"
269
- when /\Abigint\z/i, /\Aint8\z/i then "bigint"
270
- when /\Asmallint\z/i, /\Aint2\z/i then "smallint"
271
- when /\Acharacter varying\z/i, /\Avarchar\z/i then "string"
272
- when /\Atext\z/i, /\Alongtext\z/i, /\Amediumtext\z/i, /\Atinytext\z/i then "text"
273
- when /\Aboolean\z/i, /\Abool\z/i then "boolean"
274
- when /\Atimestamp/i, /\Adatetime\z/i then "datetime"
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\z/i then "time"
277
- when /\Anumeric\z/i, /\Adecimal\z/i then "decimal"
278
- when /\Afloat/i, /\Adouble/i then "float"
279
- when /\Ajsonb?\z/i then "json"
280
- when /\Auuid\z/i then "uuid"
281
- when /\Ainet\z/i then "inet"
282
- when /\Acitext\z/i then "citext"
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