rails-ai-context 5.30.2 → 5.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- metadata +34 -3
|
@@ -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
|
|
6
|
-
#
|
|
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
|
-
|
|
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 += ",
|
|
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))
|
|
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
|
-
#
|
|
255
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
31
|
-
#
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
#
|
|
170
|
-
#
|
|
171
|
-
def
|
|
172
|
-
|
|
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(", ")
|