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
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # The records a bare `render @post.comments` hands to Rails, and the partial Rails renders for them.
6
+ # Only the conventional model file is read: app/models/<model>.rb.
7
+ module RenderedRecord
8
+ LISTENERS = { associations: Listeners::AssociationsListener, methods: Listeners::MethodsListener }.freeze
9
+ COLLECTIONS = %w[has_many has_and_belongs_to_many].freeze
10
+
11
+ # `associations` by name: [model, collection?], or :unknown for a polymorphic or computed class.
12
+ # `partial_path` is nil when the model's own to_partial_path returns no literal.
13
+ Model = Data.define(:associations, :partial_path)
14
+
15
+ module_function
16
+
17
+ # [the model, whether it is a collection] for a chain such as "post.comments.reverse": the
18
+ # receiver, then each association its model declares. A call that is no association keeps a
19
+ # collection's records (`.first`, `.recent`); on a single record it returns who knows what, so nil.
20
+ # A receiver that is no model (`current_user.posts`) is read from the next name, as `render @posts`
21
+ # is; nil when the app holds no model for that either.
22
+ def resolve(chain, root, memo = {})
23
+ segments = chain.split(".")
24
+ return [ segments.first.singularize, segments.first != segments.first.singularize ] if segments.size == 1
25
+
26
+ segments = segments.drop(1) unless read(segments.first.singularize, root, memo)
27
+ model = segments.first.singularize
28
+ return nil unless read(model, root, memo)
29
+
30
+ collection = segments.first != model
31
+ segments.drop(1).each do |segment|
32
+ target = read(model, root, memo)&.associations&.[](segment)
33
+ return nil if target == :unknown || (target.nil? && !collection)
34
+ model, collection = target if target
35
+ end
36
+ [ model, collection ]
37
+ end
38
+
39
+ # The model's own literal to_partial_path, else ActiveModel's default; nil when it computes one.
40
+ def partial_path(model, root, memo = {})
41
+ found = read(model, root, memo)
42
+ found ? found.partial_path : default_path(model)
43
+ end
44
+
45
+ def default_path(model)
46
+ "#{model.pluralize}/#{model.split('/').last}"
47
+ end
48
+
49
+ def read(model, root, memo)
50
+ memo.fetch(model) do
51
+ memo[model] = begin
52
+ relative = "app/models/#{model}.rb"
53
+ model_file(File.join(root.to_s, relative), model) if SafePath.locate(relative, under: root.to_s).ok?
54
+ end
55
+ end
56
+ rescue => e
57
+ RailsAiContext.debug_fail(e, nil, label: "RenderedRecord.read")
58
+ end
59
+
60
+ def model_file(path, model)
61
+ walked = SourceIntrospector.walk(path, LISTENERS)
62
+ associations = Array(walked[:associations]).to_h { |assoc| [ assoc[:name].to_s, target(assoc) ] }
63
+ own = ActionResolver.own_methods(walked[:methods], model.camelize)
64
+ .find { |m| m[:scope] == :instance && m[:name].to_s == "to_partial_path" }
65
+ Model.new(associations, own ? literal_return(path, own[:offset]) : default_path(model))
66
+ end
67
+
68
+ def target(assoc)
69
+ options = assoc[:options] || {}
70
+ # A through association's class is its source's, declared on another model; a named source is not the name.
71
+ return :unknown if options[:polymorphic] || (options.key?(:source) && !options.key?(:class_name))
72
+
73
+ class_name = options[:class_name]
74
+ return :unknown if options.key?(:class_name) && (!class_name.is_a?(String) || class_name == Confidence::INFERRED)
75
+
76
+ model = class_name ? class_name.delete_prefix("::").underscore : assoc[:name].to_s.singularize
77
+ [ model, COLLECTIONS.include?(assoc[:type].to_s) ]
78
+ end
79
+
80
+ # The string a one-line `def` at `offset` returns.
81
+ def literal_return(path, offset)
82
+ body = AstWalk.def_at(AstCache.parse(path).value, offset)&.body&.body
83
+ body&.size == 1 && body.first.is_a?(Prism::StringNode) ? body.first.unescaped : nil
84
+ end
85
+ end
86
+ end
87
+ end
@@ -2,10 +2,13 @@
2
2
 
3
3
  module RailsAiContext
4
4
  module Introspectors
5
- # A job's retry macro as a reader would write it: the exception list, then `attempts:` and
6
- # `wait:` in that order, read off the call so a multi-line macro reads the same.
5
+ # A job's retry macro as a reader would write it: the exception list, then its options in
6
+ # the order below, read off the call so a multi-line macro reads the same.
7
7
  module RetryPolicy
8
- MACROS = %i[retry_on discard_on sidekiq_options].freeze
8
+ # Sidekiq's backoff and its last-retry handler take only a block.
9
+ BLOCK_MACROS = %i[sidekiq_retry_in sidekiq_retries_exhausted].freeze
10
+ MACROS = (%i[retry_on discard_on sidekiq_options] + BLOCK_MACROS).freeze
11
+ RETRY_ON_OPTIONS = %i[attempts wait queue priority jitter].freeze
9
12
 
10
13
  module_function
11
14
 
@@ -18,13 +21,13 @@ module RailsAiContext
18
21
  when :retry_on then with_options("retry_on #{hit[:values].join(', ')}", options)
19
22
  when :discard_on then "discard_on #{hit[:values].join(', ')}"
20
23
  when :sidekiq_options then "sidekiq retry: #{source_of(options[:retry])}" if options[:retry]
24
+ when *BLOCK_MACROS then "#{hit[:macro]} #{hit[:block]}".strip
21
25
  end
22
26
  end
23
27
  end
24
28
 
25
29
  def with_options(entry, options)
26
- entry += ", attempts: #{source_of(options[:attempts])}" if options[:attempts]
27
- entry += ", wait: #{source_of(options[:wait])}" if options[:wait]
30
+ RETRY_ON_OPTIONS.each { |key| entry += ", #{key}: #{source_of(options[key])}" if options[key] }
28
31
  entry
29
32
  end
30
33
 
@@ -38,9 +38,15 @@ module RailsAiContext
38
38
  result[:dynamic_routes] = dynamic if dynamic.positive?
39
39
  engine_routes = booted_engine_routes
40
40
  result[:engine_routes] = engine_routes if engine_routes.any?
41
+ add_grape_endpoints(result)
41
42
  end
42
43
  end
43
44
 
45
+ def add_grape_endpoints(result)
46
+ grape = GrapeEndpoints.call(app.root, result[:mounted_engines])
47
+ result[:grape_endpoints] = grape if grape.any?
48
+ end
49
+
44
50
  # Static tier: answer route questions from config/routes.rb and the
45
51
  # files it draws.
46
52
  # Output mirrors the runtime shape exactly so tools, resources, and
@@ -53,9 +59,10 @@ module RailsAiContext
53
59
  return { error: "config/routes.rb not found in #{app.root}" } if top_files.empty?
54
60
 
55
61
  records, mounts, files = walk_route_files(top_files)
62
+ records = records.map { |r| r.except(:engine) } if engine_root?
56
63
  in_repo = in_repo_routes(mounts)
57
64
  records += in_repo.values.flat_map(&:first)
58
- mounts = (mounts + in_repo.values.flat_map(&:last)).uniq { |mount| [ mount[:engine], mount[:path] ] }
65
+ mounts = distinct_mounts(mounts + in_repo.values.flat_map(&:last))
59
66
  # What an app draws into an engine's table is the engine's, which the
60
67
  # booted tier's Rails.application.routes holds only as the mount.
61
68
  app_records, engine_records = records.partition { |r| r[:engine].nil? }
@@ -72,12 +79,13 @@ module RailsAiContext
72
79
  total_routes: RouteCoverage.dedupe_put_patch_routes(entries).size,
73
80
  by_controller: group_by_controller(entries),
74
81
  api_namespaces: api_namespaces(entries),
75
- mounted_engines: mounts.map { |m| { engine: m[:engine], path: m[:path] } },
82
+ mounted_engines: mounts.map { |m| { engine: m[:engine], path: m[:path], condition: m[:condition] }.compact },
76
83
  # Every mount parsed from routes.rb is controller-less by
77
84
  # construction, so the booted tier's count has a static answer too.
78
85
  unrouted_mounts: mounts.size,
79
86
  root_route: static_root_route(entries),
80
87
  note: "Parsed statically from #{static_sources_phrase(files, top_files)}" \
88
+ "#{initializers_phrase}" \
81
89
  "#{computed ? ', plus route files config/application.rb computes (not read)' : ''} (app not booted)",
82
90
  confidence: Confidence::STATIC
83
91
  }
@@ -86,9 +94,20 @@ module RailsAiContext
86
94
  result[:engine_routes] = engine_routes if engine_routes.any?
87
95
  unread = (in_repo_route_files - in_repo.keys).size
88
96
  result[:in_repo_route_files] = unread if unread.positive?
97
+ add_grape_endpoints(result)
89
98
  result
90
99
  end
91
100
 
101
+ # Run from a mountable engine's own root there is no app table: the
102
+ # engine's table, drawn in its config/routes.rb, is the project's routes.
103
+ def engine_root?
104
+ return @engine_root unless @engine_root.nil?
105
+
106
+ root = app.root.to_s
107
+ @engine_root = !File.exist?(File.join(root, "config", "application.rb")) &&
108
+ !app_route_file?(File.join(root, "config", "routes.rb"))
109
+ end
110
+
92
111
  # Every routes.rb under an in-repo engine or plugin root. One the app does not mount has
93
112
  # no place in the static table, so the count says it was skipped.
94
113
  def in_repo_route_files
@@ -251,9 +270,13 @@ module RailsAiContext
251
270
  []
252
271
  end
253
272
 
254
- # Both arms of an if/else can mount one app at one path; a mount is
255
- # named once per app and path.
273
+ # Booted, the routes section walks these for mount conditions and the
274
+ # engines section for its mount list, so one run walks them once.
256
275
  def walk_route_files(top_files)
276
+ RunCache.fetch([ :route_walk, app.root.to_s, top_files ]) { walk_route_files!(top_files) }
277
+ end
278
+
279
+ def walk_route_files!(top_files)
257
280
  already_read = []
258
281
  @route_names = Set.new
259
282
  records, mounts, files = top_files.each_with_object([ [], [], [] ]) do |path, (all_records, all_mounts, all_files)|
@@ -264,7 +287,86 @@ module RailsAiContext
264
287
  all_mounts.concat(sub_mounts)
265
288
  all_files.concat(sub_files)
266
289
  end
267
- [ records, mounts.uniq { |mount| [ mount[:engine], mount[:path] ] }, files.uniq ]
290
+ added, added_mounts = walk_route_initializers(already_read)
291
+ # An after_initialize hook adds its blocks after every initializer has added its own.
292
+ prepended, appended = added.sort_by.with_index { |r, i| [ r[:late] ? 1 : 0, i ] }.partition { |r| r[:prepend] }
293
+ records = (prepended + records + appended).map { |r| r.except(:prepend, :late) }
294
+ [ records, distinct_mounts(mounts + added_mounts), files.uniq ]
295
+ end
296
+
297
+ # Both arms of an if/else can mount one app at one path; a mount is named once per app and
298
+ # path, under no condition when a copy has none or the copies fill every arm of one chain.
299
+ def distinct_mounts(mounts)
300
+ mounts.group_by { |mount| [ mount[:engine], mount[:path] ] }.map do |_, same|
301
+ mount = same.first.except(:condition, :arm)
302
+ conditions = same.map { |copy| copy[:condition] }.uniq
303
+ next mount if conditions.include?(nil) || every_arm?(same.map { |copy| copy[:arm] })
304
+
305
+ mount = mount.merge(merged: true) if conditions.size > 1
306
+ mount.merge(condition: conditions.join(" or "))
307
+ end
308
+ end
309
+
310
+ def every_arm?(arms)
311
+ return false if arms.include?(nil)
312
+
313
+ chain = arms.first.first(2)
314
+ arms.all? { |arm| arm.first(2) == chain } && arms.map { |arm| arm[2] }.uniq.size == arms.first.last
315
+ end
316
+
317
+ # Initializers that add to the app's table with `routes.prepend` or
318
+ # `routes.append`, which Rails evaluates before and after the draw.
319
+ def route_initializers
320
+ @route_initializers ||= begin
321
+ root = app.root.to_s
322
+ PathResolver.initializer_paths(root).select do |path|
323
+ # Regex prefilter over every initializer; walk_draw_target's AST walk decides.
324
+ SafeFile.read(path).to_s.match?(/\.routes\.(?:append|prepend)\b/) && app_route_file?(path)
325
+ end
326
+ end
327
+ end
328
+
329
+ def walk_route_initializers(already_read)
330
+ route_initializers.each_with_object([ [], [] ]) do |path, (records, mounts)|
331
+ sub_records, sub_mounts = walk_draw_target(path, already_read, 0, {})
332
+ records.concat(sub_records)
333
+ mounts.concat(sub_mounts)
334
+ end
335
+ end
336
+
337
+ # The controller#action rows of a route set; the engines section counts an engine's table with them.
338
+ def table_routes(route_set)
339
+ route_set.routes.filter_map do |route|
340
+ # Journey::Route exposes the flag as a plain attribute reader
341
+ # (`internal`), not a predicate - a respond_to?(:internal?) guard
342
+ # never matches and would let Rails' info/mailers routes through.
343
+ next if route.respond_to?(:internal) && route.internal
344
+ next if route.defaults[:controller].blank?
345
+
346
+ route_path = route.path.spec.to_s.gsub("(.:format)", "")
347
+ action = route.defaults[:action]
348
+
349
+ entry = {
350
+ verb: route.verb.presence || "ANY",
351
+ path: route_path,
352
+ controller: route.defaults[:controller],
353
+ action: action,
354
+ name: route.name,
355
+ constraints: extract_constraints(route)
356
+ }
357
+
358
+ params = route_path.scan(/:(\w+)/).flatten
359
+ entry[:params] = params if params.any?
360
+
361
+ entry[:restful] = %w[index show new create edit update destroy].include?(action)
362
+
363
+ entry.compact
364
+ end
365
+ end
366
+
367
+ # The redirects and lambdas a route set holds, which no controller#action row lists.
368
+ def dynamic_route_count(route_set)
369
+ controllerless_routes(route_set).count { |r| dynamic_target?(r) }
268
370
  end
269
371
 
270
372
  private
@@ -284,11 +386,11 @@ module RailsAiContext
284
386
  scope = draw[:scope] || []
285
387
  already_read << [ path, scope ]
286
388
  ast = SourceIntrospector.walk(path, {
287
- routes: -> { Listeners::RoutesDslListener.new(scope: scope, route_set: method(:route_set_prefixes), names: @route_names) },
389
+ routes: -> { Listeners::RoutesDslListener.new(scope: scope, route_set: method(:route_set_prefixes), names: @route_names, multi_path: multi_path_routes?) },
288
390
  mounts: -> { Listeners::MountListener.new(prefix: draw[:prefix], name_prefix: draw[:name_prefix]) }
289
391
  })
290
392
  records = ast[:routes] || []
291
- mounts = ast[:mounts] || []
393
+ mounts = (ast[:mounts] || []).map { |m| m[:arm] ? m.merge(arm: [ path, *m[:arm] ]) : m }
292
394
  files = [ path ]
293
395
 
294
396
  records.select { |r| r[:type] == :dynamic && r[:macro] == :draw }.each do |record|
@@ -374,6 +476,16 @@ module RailsAiContext
374
476
  RailsAiContext.debug_fail(e, [ [], [], [] ], label: "draw target #{target}")
375
477
  end
376
478
 
479
+ # Rails 8.1 raises on `get "/a", "/b"`; with no version locked the routes are not guessed.
480
+ def multi_path_routes?
481
+ return @multi_path_routes if defined?(@multi_path_routes)
482
+
483
+ locked = GemLock.for(app.root.to_s).version("actionpack")
484
+ @multi_path_routes = !locked.nil? && Gem::Version.new(locked) < Gem::Version.new("8.1.0.a")
485
+ rescue ArgumentError
486
+ @multi_path_routes = false
487
+ end
488
+
377
489
  # `draw(:"admin/users")` is legal and resolves under config/routes/, but
378
490
  # the name reaches here from source text, so the resolved path has to be
379
491
  # confirmed inside that directory before it is read.
@@ -407,6 +519,13 @@ module RailsAiContext
407
519
  "#{lead} and #{CountPhrase.call(drawn, "file")} #{tops.size == 1 ? 'it draws' : 'they draw'}"
408
520
  end
409
521
 
522
+ def initializers_phrase
523
+ return "" if route_initializers.empty?
524
+
525
+ root = "#{app.root}#{File::SEPARATOR}"
526
+ ", plus routes appended or prepended in #{route_initializers.map { |f| f.delete_prefix(root) }.join(', ')}"
527
+ end
528
+
410
529
  def extract_routes
411
530
  # Force Rails to reload routes if routes.rb has changed
412
531
  app.routes_reloader&.execute_if_updated rescue nil
@@ -441,38 +560,10 @@ module RailsAiContext
441
560
  RailsAiContext.debug_fail(e, [], label: "booted_engine_routes")
442
561
  end
443
562
 
444
- def table_routes(route_set)
445
- route_set.routes.filter_map do |route|
446
- # Journey::Route exposes the flag as a plain attribute reader
447
- # (`internal`), not a predicate - a respond_to?(:internal?) guard
448
- # never matches and would let Rails' info/mailers routes through.
449
- next if route.respond_to?(:internal) && route.internal
450
- next if route.defaults[:controller].blank?
451
-
452
- route_path = route.path.spec.to_s.gsub("(.:format)", "")
453
- action = route.defaults[:action]
454
-
455
- entry = {
456
- verb: route.verb.presence || "ANY",
457
- path: route_path,
458
- controller: route.defaults[:controller],
459
- action: action,
460
- name: route.name,
461
- constraints: extract_constraints(route)
462
- }
463
-
464
- params = route_path.scan(/:(\w+)/).flatten
465
- entry[:params] = params if params.any?
466
-
467
- entry[:restful] = %w[index show new create edit update destroy].include?(action)
468
-
469
- entry.compact
470
- end
471
- end
472
-
563
+ # What `bin/rails routes` prints beside the route, written alike on every Ruby.
473
564
  def extract_constraints(route)
474
- constraints = route.constraints.to_s
475
- constraints.empty? ? nil : constraints
565
+ shown = route.requirements.except(:controller, :action)
566
+ "{#{shown.map { |key, value| "#{key}: #{value.inspect}" }.join(', ')}}" if shown.any?
476
567
  rescue => e
477
568
  RailsAiContext.debug_fail(e, nil, label: "extract_constraints")
478
569
  end
@@ -483,6 +574,8 @@ module RailsAiContext
483
574
  entry = { verb: r[:verb], path: r[:path], action: r[:action], name: r[:name] }
484
575
  entry[:params] = r[:params] if r[:params]
485
576
  entry[:restful] = r[:restful] unless r[:restful].nil?
577
+ entry[:condition] = r[:condition] if r[:condition]
578
+ entry[:constraints] = r[:constraints] if r[:constraints]
486
579
  entry.compact
487
580
  end
488
581
  end
@@ -495,13 +588,13 @@ module RailsAiContext
495
588
  end
496
589
 
497
590
  def count_controllerless_constructs
498
- controllerless_routes.count { |r| dynamic_target?(r) }
591
+ dynamic_route_count(app.routes)
499
592
  rescue => e
500
593
  RailsAiContext.debug_fail(e, 0, label: "count_controllerless_constructs")
501
594
  end
502
595
 
503
- def controllerless_routes
504
- app.routes.routes.reject do |r|
596
+ def controllerless_routes(route_set = app.routes)
597
+ route_set.routes.reject do |r|
505
598
  (r.respond_to?(:internal) && r.internal) || r.defaults[:controller].present?
506
599
  end
507
600
  end
@@ -524,6 +617,8 @@ module RailsAiContext
524
617
  # count includes it either way and a header that disagrees with the list
525
618
  # below it is the thing this pairing exists to prevent.
526
619
  def detect_mounted_engines
620
+ # The live table holds only what this environment drew; the source holds the condition it drew it under.
621
+ source = static_mounts
527
622
  mounted_routes.map do |r|
528
623
  mounted = r.app.respond_to?(:app) ? r.app.app : r.app
529
624
  name = mounted.is_a?(Class) ? mounted.name : mounted.class.name
@@ -531,12 +626,28 @@ module RailsAiContext
531
626
  # print and is still one of the endpoints the count counts, so it is
532
627
  # named for what it is rather than dropped into a disagreement
533
628
  # between the two numbers.
534
- { engine: name || "(anonymous Rack app)", path: mount_path(r) }
629
+ path = mount_path(r)
630
+ { engine: name || "(anonymous Rack app)", path: path, condition: source_mount(source, name, path)&.dig(:condition) }.compact
535
631
  rescue => e
536
632
  RailsAiContext.debug_fail(e, { engine: "(unreadable Rack app)", path: nil }, label: "detect_mounted_engines")
537
633
  end
538
634
  end
539
635
 
636
+ # Source names an instance or a factory mount by what it wrote
637
+ # (`MetricsApp.new`) and a computed path not at all, so after the exact
638
+ # app and path, the one mount at that path, then the one of that app
639
+ # with no literal path. Two candidates (an if/else at one path, or one
640
+ # entry merged from several mounts) are a guess, so none is taken.
641
+ def source_mount(source, name, path)
642
+ source.find { |m| m[:engine] == name && m[:path] == path } ||
643
+ sole(source.select { |m| m[:path] == path }) ||
644
+ sole(source.select { |m| m[:engine] == name && m[:path].nil? })
645
+ end
646
+
647
+ def sole(candidates)
648
+ candidates.first if candidates.one? && !candidates.first[:merged]
649
+ end
650
+
540
651
  # Routable, controller-less, and not a redirect or a lambda: what is
541
652
  # left is a Rack app attached at a path. Walked once: the count and the
542
653
  # list are the same set, and reading it twice is reading the whole route
@@ -22,19 +22,58 @@ module RailsAiContext
22
22
  composite_pk = !pk_opt.nil? && !pk_opt.is_a?(String) && !pk_opt.is_a?(Symbol)
23
23
  return [] if options[:id] == false || composite_pk
24
24
 
25
- id_type = options[:id].is_a?(String) || options[:id].is_a?(Symbol) ? options[:id].to_s : pk_type
25
+ # set_primary_key (7.0 to 8.1): an id: hash gives the type, its other keys the column's options.
26
+ id = options[:id]
27
+ id_options = id.is_a?(Hash) ? id.except(:type).reject { |_, value| value == RailsAiContext::Confidence::INFERRED } : {}
28
+ id = id[:type] if id.is_a?(Hash)
29
+ id_type = (id.is_a?(String) || id.is_a?(Symbol)) && id.to_s != "primary_key" ? id.to_s : pk_type
26
30
  [ { name: pk_opt ? pk_opt.to_s : "id", type: id_type, default: nil,
27
- options: { null: false }, primary_key: true } ]
31
+ options: { null: false }.merge(id_options), primary_key: true } ]
28
32
  end
29
33
 
30
- # Rails omits column:/primary_key: only where the convention holds, so
31
- # the fallback is what was declared rather than a guess.
32
- def foreign_key_entry(from, to, column, primary_key)
34
+ # The dump leaves out column:, primary_key:, validate: true and deferrable: false where they are Rails'
35
+ # defaults; Rails 7.0 reads DEFERRABLE INITIALLY IMMEDIATE back as true, which 7.1 names :immediate.
36
+ def foreign_key_entry(from, to, column, primary_key, on_delete: nil, on_update: nil, deferrable: nil, validate: nil)
37
+ known = ->(value) { value unless value == RailsAiContext::Confidence::INFERRED }
33
38
  {
34
39
  from_table: from, to_table: to,
35
- column: primary_key_value(column) || "#{to.to_s.singularize}_id",
36
- primary_key: primary_key_value(primary_key) || "id"
37
- }
40
+ column: primary_key_value(column) || "#{to.to_s.split('.').last.to_s.singularize}_id",
41
+ primary_key: primary_key_value(primary_key) || "id",
42
+ on_delete: known.(on_delete)&.to_s, on_update: known.(on_update)&.to_s,
43
+ deferrable: (deferrable == true ? "immediate" : known.(deferrable)&.to_s if deferrable), validate: (false if validate == false)
44
+ }.compact
45
+ end
46
+
47
+ FOREIGN_KEY_OPTIONS = %i[on_delete on_update deferrable validate].freeze
48
+
49
+ # An index's options past name, columns, unique and where, as schema.rb
50
+ # writes them; btree is every adapter's default, so the dump leaves it out.
51
+ def index_detail(columns, using: nil, type: nil, include: nil, order: nil, opclass: nil, length: nil, nulls_not_distinct: nil)
52
+ {
53
+ using: (using.to_s unless using.nil? || using.to_s == "btree"),
54
+ type: (type.to_s unless type.nil? || type.to_s.empty?),
55
+ include: (Array(include).map(&:to_s) unless Array(include).empty?),
56
+ order: per_key(order, columns),
57
+ opclass: per_key(opclass, columns),
58
+ length: per_key(length, columns),
59
+ nulls_not_distinct: (true if nulls_not_distinct == true)
60
+ }.compact
61
+ end
62
+
63
+ # An index option given per key, or once for every key (`order: :desc`).
64
+ def per_key(value, columns)
65
+ value = Array(columns).to_h { |column| [ column, value ] } unless value.is_a?(Hash)
66
+ value = value.reject { |_, v| v.nil? || v.to_s.empty? }
67
+ value.to_h { |key, v| [ key.to_s, v.is_a?(Integer) ? v : v.to_s ] } unless value.empty?
68
+ end
69
+
70
+ # deferrable: false is the default the dump leaves out.
71
+ def unique_constraint_entry(name, columns, deferrable)
72
+ {
73
+ name: name&.to_s,
74
+ columns: Array(columns).map(&:to_s),
75
+ deferrable: (deferrable.to_s if deferrable)
76
+ }.compact
38
77
  end
39
78
 
40
79
  # Rails' limit before it shortens an index name (max_index_name_size).
@@ -134,44 +173,26 @@ module RailsAiContext
134
173
  # since Rails 5.1, except SQLite where it stays integer. The dump does
135
174
  # not record it, but config/database.yml names the adapter - looked up
136
175
  # per database, because a multi-db app can mix adapters (postgres
137
- # primary, sqlite queue) and each dump must be typed by its own.
138
- def implicit_pk_type(root, dump_path)
139
- db_name = File.basename(dump_path.to_s).sub(/\.(rb|sql)\z/, "").sub(/_?(schema|structure)\z/, "")
140
- db_name = "primary" if db_name.empty?
141
-
142
- adapter = database_adapter_for(root, db_name)
176
+ # primary, sqlite queue) and each dump must be typed by its own. dump_path
177
+ # names a secondary database's dump, nil the primary's; database: names the database outright.
178
+ def implicit_pk_type(root, dump_path = nil, database: SchemaDumpPath.database_name(root, dump_path))
179
+ adapter = database_adapter_for(root, database)
143
180
  adapter&.start_with?("sqlite") ? "integer" : "bigint"
144
181
  end
145
182
 
146
- # Best-effort adapter lookup from config/database.yml without booting:
147
- # a keyed entry for this database name wins; a file with exactly one
148
- # distinct adapter is unambiguous; anything else falls back to the
149
- # first adapter (the primary comes first in generated configs).
183
+ # The running environment's adapter for one database, as database.yml and a merged URL name it.
184
+ # A database it does not configure (Rails 8's queue outside production) takes the adapter of the
185
+ # environment that does, else the running primary's.
150
186
  def database_adapter_for(root, db_name)
151
- content = database_yml_content(root)
152
- return nil if content.empty?
153
-
154
- adapters = content.scan(/^\s*adapter:\s*(\w+)/).flatten
155
- return adapters.first if adapters.uniq.size <= 1
156
-
157
- # Mixed adapters: find the block keyed by this database's name and
158
- # take the first adapter that follows at deeper indentation. The
159
- # block ends at the first non-blank line at the key's indent or
160
- # shallower; blank/whitespace-only lines don't end it (and must not
161
- # let it bleed into a sibling block).
162
- if (m = content.match(/^([ \t]*)#{Regexp.escape(db_name)}:[ \t]*\n((?:(?:[ \t]*|\1[ \t]+\S[^\n]*)\n)*)/))
163
- block_adapter = m[2][/^[ \t]*adapter:[ \t]*(\w+)/, 1]
164
- return block_adapter if block_adapter
165
- end
166
- adapters.first
187
+ yml = RailsAiContext::DatabaseYml
188
+ entry = yml.entry(root, db_name) || yml.elsewhere(root, db_name)&.last
189
+ yml.adapter(db_name, entry).first || (yml.adapter("primary", yml.primary(root)).first unless entry)
167
190
  end
168
191
 
169
- # Normalized so the line-anchored block regex above works on files with
170
- # Windows endings or no final newline.
171
- def database_yml_content(root)
172
- db_yml = File.join(root.to_s, "config", "database.yml")
173
- content = RailsAiContext::SafeFile.read(db_yml).to_s.gsub("\r\n", "\n")
174
- content.empty? || content.end_with?("\n") ? content : "#{content}\n"
192
+ # A dump of more than one schema qualifies every name (relation_name, 8.1);
193
+ # the app sees a public table by its bare name under the default search_path.
194
+ def local_name(name)
195
+ name.delete_prefix("public.")
175
196
  end
176
197
 
177
198
  # A primary key as connection.primary_key gives it: the column's name, or
@@ -181,6 +202,72 @@ module RailsAiContext
181
202
  names.size > 1 ? names : names.first
182
203
  end
183
204
 
205
+ # The key on the table and a flag on each of its columns, from whichever
206
+ # side the source gave; a table with no key keeps neither.
207
+ def mark_primary_key(table)
208
+ table[:primary_key] ||= primary_key_value(Array(table[:columns]).select { |c| c[:primary_key] }.map { |c| c[:name] })
209
+ keys = Array(table[:primary_key]).map(&:to_s)
210
+ Array(table[:columns]).each { |column| column[:primary_key] = true if keys.include?(column[:name].to_s) }
211
+ table.delete(:primary_key) if table[:primary_key].nil?
212
+ table
213
+ end
214
+
215
+ # Every table's check constraints in one list, each naming its table.
216
+ def check_constraints_of(tables)
217
+ tables.flat_map { |name, table| Array(table[:check_constraints]).map { |constraint| { table: name, **constraint } } }
218
+ end
219
+
220
+ def generated_columns_of(tables)
221
+ tables.flat_map do |name, table|
222
+ Array(table[:columns]).filter_map do |column|
223
+ { table: name, column: column[:name], expression: column[:generated], stored: column[:stored] }.compact if column.key?(:generated)
224
+ end
225
+ end
226
+ end
227
+
228
+ # Views, virtual tables and skipped tables beside the tables. A view replaces a table
229
+ # of its name: mysqldump writes a placeholder table before the view.
230
+ def add_relations(tables, views: {}, virtual_tables: {}, not_dumped: {})
231
+ views.each { |name, view| tables[name] = view_entry(view[:sql], materialized: view[:materialized], indexes: view[:indexes]) }
232
+ virtual_tables.each { |name, table| tables[name] = virtual_table_entry(table[:module], table[:arguments]) }
233
+ not_dumped.each { |name, reason| tables[name] ||= { columns: [], indexes: [], foreign_keys: [], not_dumped: reason } }
234
+ tables
235
+ end
236
+
237
+ def view?(table)
238
+ table.is_a?(Hash) && %w[view materialized_view].include?(table[:kind])
239
+ end
240
+
241
+ # Views sit beside the tables in one Hash, and a table count leaves them out.
242
+ def table_count(tables)
243
+ tables.count { |_, table| !view?(table) }
244
+ end
245
+
246
+ # How a header counts the listed entries: "116 tables and 2 views".
247
+ def relations_phrase(tables)
248
+ tables ||= {}
249
+ count = table_count(tables)
250
+ phrase = CountPhrase.call(count, "table")
251
+ views = tables.size - count
252
+ views.positive? ? "#{phrase} and #{CountPhrase.call(views, "view")}" : phrase
253
+ end
254
+
255
+ # A dump holds a view's SQL, not its columns; only a connection lists those.
256
+ def view_entry(sql, materialized:, columns: [], indexes: [])
257
+ { kind: materialized ? "materialized_view" : "view", columns: columns, indexes: Array(indexes), foreign_keys: [], sql: sql }.compact
258
+ end
259
+
260
+ # A virtual table's columns are its module arguments that set no option (fts5's tokenize=...).
261
+ def virtual_table_entry(mod, arguments)
262
+ columns = Array(arguments).filter_map { |arg| arg.strip[/\A["`]?(\w+)["`]?(?:\s+UNINDEXED)?\z/i, 1] }
263
+ { kind: "virtual_table", module: mod, columns: columns.map { |name| { name: name } }, indexes: [], foreign_keys: [] }.compact
264
+ end
265
+
266
+ # MySQL's dumper writes a tiny, medium or long text or blob type as size:.
267
+ def mysql_text_size(sql_type)
268
+ sql_type.to_s[/\A(tiny|medium|long)(?:text|blob)/i, 1]&.downcase
269
+ end
270
+
184
271
  # How a primary key reads to a person: `id`, or `tag_id, account_id`.
185
272
  def primary_key_label(key)
186
273
  Array(key || "id").join(", ")