rails_ai_admin 0.1.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 +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- metadata +328 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Queries
|
|
5
|
+
# Bounded, permission-aware scalar index query. No SQL expressions enter it.
|
|
6
|
+
class List
|
|
7
|
+
# scope is the applied named scope's name, or nil for the policy relation.
|
|
8
|
+
# sort/direction describe the applied order (nil sort: primary key only).
|
|
9
|
+
# count is nil unless the resource opts in; count_capped marks COUNT_LIMIT.
|
|
10
|
+
Page = Data.define(:records, :number, :has_next, :scope, :sort, :direction, :page_size, :count, :count_capped)
|
|
11
|
+
# Default page size; resources may choose 1..100 through `index`.
|
|
12
|
+
PAGE_SIZE = 25
|
|
13
|
+
COUNT_LIMIT = Presentation::IndexSettings::COUNT_LIMIT
|
|
14
|
+
MAX_FILTER_CONDITIONS = 20
|
|
15
|
+
FILTER_VALUE = ->(value) { value.is_a?(String) && value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?) && value.length.between?(1, 200) && !value.match?(/[\x00-\x1f\x7f]/) }
|
|
16
|
+
AREL_PREDICATES = { "eq" => :eq, "gte" => :gteq, "lte" => :lteq }.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(context, resource)
|
|
19
|
+
@access = Authorization::Access.new(context, resource)
|
|
20
|
+
@resource = resource
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# scope: nil uses the declared default when it is currently granted;
|
|
24
|
+
# NamedScope::NONE applies no named scope at all.
|
|
25
|
+
# A named scope must be declared and granted; it is never discarded here.
|
|
26
|
+
# filters: { "field" => { "eq" | "gte" | "lte" => "wire string" } }, ANDed.
|
|
27
|
+
# Every field needs a current :filter grant and a declared operator.
|
|
28
|
+
def call(search: nil, sort: nil, direction: "asc", page: "1", recover_empty_page: false, scope: nil, filters: nil, page_size: nil, offset: nil)
|
|
29
|
+
@access.authorize!(:index)
|
|
30
|
+
unless (page.is_a?(String) || page.is_a?(Integer)) && page.to_s.valid_encoding? && page.to_s.ascii_only? && page.to_s.match?(/\A[1-9]\d{0,3}\z/) && page.to_i <= 1000
|
|
31
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
32
|
+
end
|
|
33
|
+
raise AccessDenied, "INVALID_QUERY" unless %w[asc desc].include?(direction)
|
|
34
|
+
relation, named = filtered_relation(search: search, scope: scope, filters: filters)
|
|
35
|
+
settings = @resource.index
|
|
36
|
+
size = page_size || settings.page_size
|
|
37
|
+
raise AccessDenied, "INVALID_QUERY" unless size.is_a?(Integer) && size.between?(1, Presentation::IndexSettings::MAX_PAGE_SIZE)
|
|
38
|
+
raise AccessDenied, "INVALID_QUERY" unless offset.nil? || offset.is_a?(Integer) && offset.between?(0, 99_999) && !recover_empty_page
|
|
39
|
+
relation, field, direction = sorted(relation, sort, direction)
|
|
40
|
+
records = relation.offset(offset || (page.to_i - 1) * size).limit(size + 1).to_a
|
|
41
|
+
number = page.to_i
|
|
42
|
+
if recover_empty_page && records.empty? && number > 1
|
|
43
|
+
count = relation.except(:order).limit(number * size).count
|
|
44
|
+
number = [ 1, (count - 1).div(size) + 1 ].max
|
|
45
|
+
records = relation.offset((number - 1) * size).limit(size + 1).to_a
|
|
46
|
+
end
|
|
47
|
+
total = relation.except(:order).limit(COUNT_LIMIT + 1).count if settings.count == :exact
|
|
48
|
+
Page.new(records: records.first(size), number: number, has_next: records.size > size, scope: named&.name&.to_s,
|
|
49
|
+
sort: field&.name&.to_s, direction: field ? direction : "asc", page_size: size,
|
|
50
|
+
count: total && [ total, COUNT_LIMIT ].min, count_capped: !!total && total > COUNT_LIMIT)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The complete ordered relation the index shows across all its pages,
|
|
54
|
+
# with the same grants and validation. Callers bound it themselves.
|
|
55
|
+
def relation(search: nil, sort: nil, direction: "asc", scope: nil, filters: nil)
|
|
56
|
+
@access.authorize!(:index)
|
|
57
|
+
raise AccessDenied, "INVALID_QUERY" unless %w[asc desc].include?(direction)
|
|
58
|
+
relation, = filtered_relation(search: search, scope: scope, filters: filters)
|
|
59
|
+
sorted(relation, sort, direction).first
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The bounded number of records the same search/scope/filters match,
|
|
63
|
+
# capped like the index count. Same grants and validation as call.
|
|
64
|
+
CountResult = Data.define(:count, :capped, :scope)
|
|
65
|
+
def count(search: nil, scope: nil, filters: nil)
|
|
66
|
+
@access.authorize!(:index)
|
|
67
|
+
relation, named = filtered_relation(search: search, scope: scope, filters: filters)
|
|
68
|
+
total = relation.except(:order).limit(COUNT_LIMIT + 1).count
|
|
69
|
+
CountResult.new([ total, COUNT_LIMIT ].min, total > COUNT_LIMIT, named&.name&.to_s)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Coerces each wire value through the field's scalar contract. Values are
|
|
73
|
+
# bound by Arel; no operator, column or SQL text comes from the request.
|
|
74
|
+
def self.filter_value(field, operator, raw, time_zone:)
|
|
75
|
+
raise AccessDenied, "INVALID_QUERY" unless field.filter_operators.include?(operator.to_sym) && FILTER_VALUE.call(raw)
|
|
76
|
+
value = field.coerce(raw, time_zone: time_zone)
|
|
77
|
+
raise AccessDenied, "INVALID_QUERY" if value.nil?
|
|
78
|
+
value
|
|
79
|
+
rescue ArgumentError
|
|
80
|
+
raise AccessDenied, "INVALID_QUERY", cause: nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def sorted(relation, sort, direction)
|
|
86
|
+
settings = @resource.index
|
|
87
|
+
table = @resource.model_class.arel_table
|
|
88
|
+
primary = @resource.model_class.primary_key
|
|
89
|
+
sortable = @access.fields(:sort)
|
|
90
|
+
field = if sort.present?
|
|
91
|
+
sortable.find { |entry| entry.name.to_s == sort } || raise(AccessDenied, "INVALID_QUERY")
|
|
92
|
+
elsif settings.default_sort
|
|
93
|
+
# An ungranted default would reveal order on a hidden basis; skip it.
|
|
94
|
+
direction = settings.default_direction
|
|
95
|
+
sortable.find { |entry| entry.name == settings.default_sort }
|
|
96
|
+
end
|
|
97
|
+
if field
|
|
98
|
+
# Empty values sort last in both directions on every database (RAA-028).
|
|
99
|
+
relation = relation.reorder(table[field.column].public_send(direction).nulls_last)
|
|
100
|
+
relation = relation.order(table[primary].asc) unless field.column == primary
|
|
101
|
+
else
|
|
102
|
+
relation = relation.reorder(table[primary].asc)
|
|
103
|
+
end
|
|
104
|
+
[ relation, field, direction ]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def filtered_relation(search:, scope:, filters:)
|
|
108
|
+
# Leading and trailing spaces are never part of the term (RAA-021).
|
|
109
|
+
search = search.strip if search.is_a?(String) && search.valid_encoding?
|
|
110
|
+
unless search.nil? || search.is_a?(String) && search.valid_encoding? && (search.encoding == Encoding::UTF_8 || search.ascii_only?) && search.length <= 200 && !search.match?(/[\x00-\x1f\x7f]/)
|
|
111
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
112
|
+
end
|
|
113
|
+
unless scope.nil? || scope == NamedScope::NONE || scope.is_a?(String) && scope.valid_encoding? && scope.ascii_only? && scope.match?(NamedScope::NAME)
|
|
114
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
115
|
+
end
|
|
116
|
+
granted = @access.named_scopes
|
|
117
|
+
unscoped = scope == NamedScope::NONE
|
|
118
|
+
named = if unscoped then nil
|
|
119
|
+
elsif scope.nil? then granted.find(&:default?)
|
|
120
|
+
else granted.find { |definition| definition.name.to_s == scope }
|
|
121
|
+
end
|
|
122
|
+
raise AccessDenied, "INVALID_QUERY" if scope && !unscoped && !named
|
|
123
|
+
relation = @access.relation
|
|
124
|
+
relation = named.apply(relation) if named
|
|
125
|
+
relation = apply_filters(relation, filters)
|
|
126
|
+
if search.present?
|
|
127
|
+
fields = @access.fields(:search)
|
|
128
|
+
raise AccessDenied, "INVALID_QUERY" if fields.empty?
|
|
129
|
+
predicates = fields.map do |field|
|
|
130
|
+
term = ActiveRecord::Base.sanitize_sql_like(search)
|
|
131
|
+
pattern = case field.options[:searchable]
|
|
132
|
+
when :exact then term
|
|
133
|
+
when :contains then "%#{term}%"
|
|
134
|
+
else "#{term}%"
|
|
135
|
+
end
|
|
136
|
+
@resource.model_class.arel_table[field.column].matches(pattern, "\\")
|
|
137
|
+
end
|
|
138
|
+
relation = relation.where(predicates.reduce(&:or))
|
|
139
|
+
end
|
|
140
|
+
[ relation, named ]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def apply_filters(relation, filters)
|
|
144
|
+
return relation if filters.nil?
|
|
145
|
+
unless filters.is_a?(Hash) && filters.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } &&
|
|
146
|
+
filters.values.all? { |conditions| conditions.is_a?(Hash) && conditions.any? && conditions.keys.all? { |key| AREL_PREDICATES.key?(key.to_s) } } &&
|
|
147
|
+
filters.values.sum(&:size) <= MAX_FILTER_CONDITIONS
|
|
148
|
+
raise AccessDenied, "INVALID_QUERY"
|
|
149
|
+
end
|
|
150
|
+
granted = @access.fields(:filter).index_by { |field| field.name.to_s }
|
|
151
|
+
table = @resource.model_class.arel_table
|
|
152
|
+
filters.reduce(relation) do |scoped, (name, conditions)|
|
|
153
|
+
field = granted[name.to_s] or raise AccessDenied, "INVALID_QUERY"
|
|
154
|
+
conditions.reduce(scoped) do |inner, (operator, raw)|
|
|
155
|
+
value = self.class.filter_value(field, operator.to_s, raw, time_zone: @access.context.time_zone)
|
|
156
|
+
inner.where(table[field.column].public_send(AREL_PREDICATES.fetch(operator.to_s), value))
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Queries
|
|
5
|
+
# A declared list tab. It can only narrow the current authorized relation:
|
|
6
|
+
# its body builds a separate candidate relation whose primary keys are then
|
|
7
|
+
# intersected with the policy scope, so `unscoped`, `or` or a replaced
|
|
8
|
+
# condition inside the body cannot widen the result.
|
|
9
|
+
class NamedScope
|
|
10
|
+
NAME = /\A[a-z][a-z0-9_]{0,39}\z/
|
|
11
|
+
# List state for "no list tab": the whole policy relation even when a
|
|
12
|
+
# default scope exists (RAA-006). It cannot collide with NAME, and it
|
|
13
|
+
# narrows nothing the policy relation does not already allow.
|
|
14
|
+
NONE = "_none"
|
|
15
|
+
MAX_SCOPES = 20
|
|
16
|
+
attr_reader :name, :label, :body
|
|
17
|
+
|
|
18
|
+
def initialize(name, body = nil, label: nil, default: false)
|
|
19
|
+
unless (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.match?(NAME)
|
|
20
|
+
raise ConfigurationError, "Named scope needs a lowercase identifier of at most 40 characters"
|
|
21
|
+
end
|
|
22
|
+
unless body.nil? || body.is_a?(Proc) && body.lambda? && body.arity.zero?
|
|
23
|
+
raise ConfigurationError, "Named scope body must be a zero-argument lambda"
|
|
24
|
+
end
|
|
25
|
+
raise ConfigurationError, "Named scope default must be boolean" unless [ true, false ].include?(default)
|
|
26
|
+
@name = name.to_sym
|
|
27
|
+
@label = Navigation::Settings.caption(label) || Navigation::Settings.caption(@name.to_s.humanize)
|
|
28
|
+
@body = body
|
|
29
|
+
@default = default
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default? = @default
|
|
34
|
+
|
|
35
|
+
# Returns the policy relation narrowed to this scope. Trusted host Ruby
|
|
36
|
+
# still runs here; writes are prevented and failures stay generic.
|
|
37
|
+
def apply(relation)
|
|
38
|
+
return relation if body.nil?
|
|
39
|
+
model = relation.klass
|
|
40
|
+
primary_key = model.primary_key
|
|
41
|
+
raise ConfigurationError, "Named scopes require a single-column primary key" unless primary_key.is_a?(String)
|
|
42
|
+
candidate = build(model)
|
|
43
|
+
unless candidate.is_a?(ActiveRecord::Relation) && candidate.klass == model
|
|
44
|
+
raise ConfigurationError, "Named scope #{name} must return the resource's ActiveRecord relation"
|
|
45
|
+
end
|
|
46
|
+
if candidate.limit_value || candidate.offset_value || candidate.select_values.any? || candidate.group_values.any? ||
|
|
47
|
+
!candidate.having_clause.empty? || !candidate.from_clause.empty? ||
|
|
48
|
+
candidate.includes_values.any? || candidate.eager_load_values.any? || candidate.preload_values.any?
|
|
49
|
+
raise ConfigurationError, "Named scope #{name} must be a filter relation without limit, offset, select, group, having, from or eager loading"
|
|
50
|
+
end
|
|
51
|
+
relation.where(primary_key => candidate.unscope(:order).select(model.arel_table[primary_key]))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build(model)
|
|
57
|
+
ActiveRecord::Base.while_preventing_writes { model.all.instance_exec(&body) }
|
|
58
|
+
rescue ConfigurationError
|
|
59
|
+
raise
|
|
60
|
+
rescue StandardError
|
|
61
|
+
raise ConfigurationError, "Named scope #{name} failed", cause: nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# The optimistic version a form, intent, selection or work item carries for
|
|
5
|
+
# one record. Models with Rails optimistic locking use lock_version. Other
|
|
6
|
+
# models use updated_at, as whole microseconds since the epoch, compared
|
|
7
|
+
# after the record is loaded with a row lock inside the write transaction.
|
|
8
|
+
module RecordVersion
|
|
9
|
+
STRATEGIES = %i[lock_version updated_at].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# :lock_version, :updated_at or nil when the model cannot carry a version.
|
|
14
|
+
def strategy(model)
|
|
15
|
+
return :lock_version if model.locking_enabled? && model.locking_column == "lock_version"
|
|
16
|
+
column = model.columns_hash["updated_at"]
|
|
17
|
+
return :updated_at if column && %i[datetime timestamp].include?(column.type) && model.record_timestamps
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def supported?(model)
|
|
22
|
+
!strategy(model).nil? && !model.composite_primary_key?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A nonnegative Integer. A record whose updated_at is still NULL is
|
|
26
|
+
# version 0 until its first write sets it.
|
|
27
|
+
def of(record)
|
|
28
|
+
case strategy(record.class)
|
|
29
|
+
when :lock_version then record.lock_version
|
|
30
|
+
when :updated_at
|
|
31
|
+
time = record.read_attribute("updated_at")
|
|
32
|
+
time.nil? ? 0 : (time.to_r * 1_000_000).to_i
|
|
33
|
+
else raise ConfigurationError, "Writes require lock_version or an updated_at timestamp"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The column a resource must never let a person write directly.
|
|
38
|
+
def column(model) = strategy(model)&.to_s
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Maps stable slugs to explicit reloadable resources, never arbitrary constants.
|
|
5
|
+
class Registry
|
|
6
|
+
RESERVED = %w[assistant audit dashboards exports imports jobs selections settings doctor assets search mcp].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(names)
|
|
9
|
+
@entries = {}
|
|
10
|
+
names.each do |name|
|
|
11
|
+
resource = name.constantize
|
|
12
|
+
raise ConfigurationError, "#{name} must inherit Resource" unless resource < Resource
|
|
13
|
+
key = resource.key.to_s
|
|
14
|
+
if !key.match?(/\A[a-z][a-z0-9_]*\z/) || RESERVED.include?(key)
|
|
15
|
+
raise ConfigurationError, "Invalid or reserved resource key #{key}"
|
|
16
|
+
end
|
|
17
|
+
raise ConfigurationError, "Duplicate resource key #{key}" if @entries.key?(key)
|
|
18
|
+
@entries[key] = resource.validate!
|
|
19
|
+
end
|
|
20
|
+
# Targets resolve at boot without loading models; reflection agreement
|
|
21
|
+
# is checked on first use and by doctor.
|
|
22
|
+
@entries.each_value do |resource|
|
|
23
|
+
resource.fields.each do |field|
|
|
24
|
+
next unless Field::REFERENCE_TYPES.include?(field.scalar_type) && !@entries.key?(field.options[:resource].to_s)
|
|
25
|
+
raise ConfigurationError, "#{resource.name} #{field.scalar_type} #{field.name} names unregistered resource #{field.options[:resource]}"
|
|
26
|
+
end
|
|
27
|
+
resource.has_many_declarations.each do |panel|
|
|
28
|
+
child = @entries[panel.resource.to_s]
|
|
29
|
+
via = child&.fields&.find { |field| field.name == panel.via(resource) }
|
|
30
|
+
unless via&.scalar_type == :belongs_to
|
|
31
|
+
raise ConfigurationError, "#{resource.name} has_many #{panel.name} needs registered resource #{panel.resource} with belongs_to #{panel.via(resource)}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
@entries.freeze
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def fetch(key)
|
|
40
|
+
@entries.fetch(key.to_s) { raise AccessDenied }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resources = @entries.values
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Redacts admin query values before Rails logs the initial request URL.
|
|
5
|
+
# The documented mount name is required; its path can be customized/reloaded.
|
|
6
|
+
class RequestLogFilter
|
|
7
|
+
def initialize(app)
|
|
8
|
+
@app = app
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(env)
|
|
12
|
+
helpers = Rails.application.routes.url_helpers
|
|
13
|
+
if helpers.respond_to?(:rails_ai_admin_path)
|
|
14
|
+
prefix = helpers.rails_ai_admin_path(script_name: "").delete_suffix("/")
|
|
15
|
+
path = env["PATH_INFO"].to_s
|
|
16
|
+
if path == prefix || path.start_with?("#{prefix}/")
|
|
17
|
+
filter = /\A(?!(?:controller|action|resource|id|format)\z)/
|
|
18
|
+
env["action_dispatch.parameter_filter"] = Array(env["action_dispatch.parameter_filter"]) + [ filter ]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
@app.call(env)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Describes explicit exposure of a host model. No attributes are auto-published.
|
|
5
|
+
class Resource
|
|
6
|
+
class << self
|
|
7
|
+
def inherited(child)
|
|
8
|
+
super
|
|
9
|
+
child.instance_variable_set(:@fields, {})
|
|
10
|
+
child.instance_variable_set(:@scopes, {})
|
|
11
|
+
child.instance_variable_set(:@index, Presentation::IndexSettings.new)
|
|
12
|
+
child.instance_variable_set(:@disabled_actions, [])
|
|
13
|
+
child.instance_variable_set(:@operations, {})
|
|
14
|
+
child.instance_variable_set(:@operation_scopes, {})
|
|
15
|
+
child.instance_variable_set(:@surface_operations, [])
|
|
16
|
+
child.instance_variable_set(:@assistant_operations, [])
|
|
17
|
+
child.instance_variable_set(:@navigation, Navigation::Settings.new)
|
|
18
|
+
child.instance_variable_set(:@has_many, {})
|
|
19
|
+
child.instance_variable_set(:@dashboard, [])
|
|
20
|
+
child.instance_variable_set(:@assistant_changes, [])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def model(name = nil)
|
|
24
|
+
@model_name = name if name
|
|
25
|
+
@model_name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def key(value = nil)
|
|
29
|
+
@key = value.to_sym if value
|
|
30
|
+
@key
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def label(value = nil)
|
|
34
|
+
@label = value if value
|
|
35
|
+
@label || key.to_s.humanize
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def title(value = nil)
|
|
39
|
+
@title = value.to_sym if value
|
|
40
|
+
@title
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Names an explicit optional policy for this resource, independent of aliases.
|
|
44
|
+
def policy(name = nil)
|
|
45
|
+
@policy_name = name if name
|
|
46
|
+
@policy_name
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Cosmetic configuration never grants or revokes direct resource access.
|
|
50
|
+
def navigation(value = :read, **options)
|
|
51
|
+
return @navigation if value == :read && options.empty?
|
|
52
|
+
unless value == :read || value == false && options.empty?
|
|
53
|
+
raise ConfigurationError, "Use navigation options or navigation false"
|
|
54
|
+
end
|
|
55
|
+
@navigation = Navigation::Settings.new(**(value == false ? { hidden: true } : options))
|
|
56
|
+
rescue ArgumentError
|
|
57
|
+
raise ConfigurationError, "Unknown navigation option", cause: nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def readonly(value = true)
|
|
61
|
+
@readonly = value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def readonly? = @readonly == true
|
|
65
|
+
|
|
66
|
+
def disable(*actions)
|
|
67
|
+
@disabled_actions.concat(actions.map(&:to_sym))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def action_enabled?(action)
|
|
71
|
+
action = action.to_sym
|
|
72
|
+
known = %i[index show create update destroy].include?(action) || @operations.key?(action)
|
|
73
|
+
known && !@disabled_actions.include?(action) && !(readonly? && (%i[create update destroy].include?(action) || @operations.key?(action)))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# surface: true also allows reviewing a native member intent in an
|
|
77
|
+
# admin-owned rails_tool_views Surface; assistant: true further lets an
|
|
78
|
+
# assistant propose (never execute) it through that Surface. Neither adds
|
|
79
|
+
# authority: the current policy and a human confirmation still decide.
|
|
80
|
+
def operation(key, handler:, scope: :member, confirmation: :required, surface: false, assistant: false)
|
|
81
|
+
unless key.to_s.match?(/\A[a-z][a-z0-9_]*\z/) && !%w[index show create update destroy new edit].include?(key.to_s)
|
|
82
|
+
raise ConfigurationError, "Custom operation needs a distinct stable key"
|
|
83
|
+
end
|
|
84
|
+
unless handler.is_a?(String) && handler.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) && %i[member collection].include?(scope) && confirmation == :required
|
|
85
|
+
raise ConfigurationError, "Use a handler class-name string, member/collection scope and required confirmation"
|
|
86
|
+
end
|
|
87
|
+
raise ConfigurationError, "Duplicate operation #{key}" if @operations.key?(key.to_sym)
|
|
88
|
+
raise ConfigurationError, "Operation surface must be boolean and is only for member operations" unless [ true, false ].include?(surface) && (!surface || scope == :member)
|
|
89
|
+
raise ConfigurationError, "Operation assistant must be boolean and requires surface: true" unless [ true, false ].include?(assistant) && (!assistant || surface)
|
|
90
|
+
@operations[key.to_sym] = handler.freeze
|
|
91
|
+
@operation_scopes[key.to_sym] = scope
|
|
92
|
+
@surface_operations << key.to_sym if surface
|
|
93
|
+
@assistant_operations << key.to_sym if assistant
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A readonly child list on the show page. The child resource's own policy,
|
|
97
|
+
# index grant and a readable belongs_to field (via) decide what appears;
|
|
98
|
+
# declaring it grants nothing and adds no write path.
|
|
99
|
+
def has_many(name, resource: name, via: nil, label: nil, limit: 10)
|
|
100
|
+
definition = Presentation::AssociationPanel::Definition.build(name, resource: resource, via: via, label: label, limit: limit)
|
|
101
|
+
raise ConfigurationError, "Duplicate has_many #{definition.name}" if @has_many.key?(definition.name)
|
|
102
|
+
raise ConfigurationError, "A resource declares at most #{Presentation::AssociationPanel::MAX_PANELS} has_many panels" if @has_many.size >= Presentation::AssociationPanel::MAX_PANELS
|
|
103
|
+
@has_many[definition.name] = definition
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A readonly panel for the one child record that points back here (a
|
|
107
|
+
# model has_one). The same grants as has_many apply.
|
|
108
|
+
def has_one(name, resource: name.to_s.pluralize, via: nil, label: nil)
|
|
109
|
+
definition = Presentation::AssociationPanel::Definition.build(name, resource: resource, via: via, label: label, limit: 1, single: true)
|
|
110
|
+
raise ConfigurationError, "Duplicate association panel #{definition.name}" if @has_many.key?(definition.name)
|
|
111
|
+
raise ConfigurationError, "A resource declares at most #{Presentation::AssociationPanel::MAX_PANELS} association panels" if @has_many.size >= Presentation::AssociationPanel::MAX_PANELS
|
|
112
|
+
@has_many[definition.name] = definition
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def has_many_declarations = @has_many.values
|
|
116
|
+
|
|
117
|
+
# Native changes the admin assistant may propose. Each proposal is shown
|
|
118
|
+
# as a review the human confirms; it runs with the human's own grants.
|
|
119
|
+
def assistant_changes(*actions)
|
|
120
|
+
return @assistant_changes if actions.empty?
|
|
121
|
+
actions = actions.map { |action| action.to_s.to_sym }
|
|
122
|
+
raise ConfigurationError, "assistant_changes accepts :create, :update and :destroy" unless (actions - %i[create update destroy]).empty?
|
|
123
|
+
@assistant_changes = (@assistant_changes | actions).freeze
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def assistant_change?(action) = @assistant_changes.include?(action.to_s.to_sym) && action_enabled?(action)
|
|
127
|
+
|
|
128
|
+
def operation_declarations = @operations
|
|
129
|
+
def surface_operation?(key) = @surface_operations.include?(key.to_sym)
|
|
130
|
+
def assistant_operation?(key) = @assistant_operations.include?(key.to_sym)
|
|
131
|
+
def assistant_operations = @assistant_operations
|
|
132
|
+
def operation_scopes = @operation_scopes
|
|
133
|
+
|
|
134
|
+
def fields(&block)
|
|
135
|
+
FieldBuilder.new(@fields, source: self).instance_eval(&block) if block
|
|
136
|
+
@fields.values
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Overview widgets: metric counts over a list tab and daily trends over
|
|
140
|
+
# a date field. They read through the same grants as the list.
|
|
141
|
+
def dashboard(&block)
|
|
142
|
+
return @dashboard unless block
|
|
143
|
+
builder = Presentation::Dashboard::Builder.new
|
|
144
|
+
begin
|
|
145
|
+
builder.instance_eval(&block)
|
|
146
|
+
rescue NoMethodError => error
|
|
147
|
+
raise ConfigurationError, "Unknown dashboard option #{error.name}", cause: nil
|
|
148
|
+
end
|
|
149
|
+
@dashboard = builder.widgets.freeze
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Static index table presentation: column order, default sort, page size
|
|
153
|
+
# and count strategy. It is not part of operation or Selection identity.
|
|
154
|
+
def index(&block)
|
|
155
|
+
return @index unless block
|
|
156
|
+
raise ConfigurationError, "Index settings are declared twice" if @index_declared
|
|
157
|
+
builder = Presentation::IndexBuilder.new
|
|
158
|
+
begin
|
|
159
|
+
builder.instance_eval(&block)
|
|
160
|
+
rescue NoMethodError => error
|
|
161
|
+
raise ConfigurationError, "Unknown index option #{error.name}", cause: nil
|
|
162
|
+
end
|
|
163
|
+
@index = Presentation::IndexSettings.new(**builder.options)
|
|
164
|
+
@index_declared = true
|
|
165
|
+
@index
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Named list tabs narrow the policy relation; they never grant or widen it.
|
|
169
|
+
def scopes(&block)
|
|
170
|
+
ScopeBuilder.new(@scopes).instance_eval(&block) if block
|
|
171
|
+
@scopes.values
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def model_class
|
|
175
|
+
raise ConfigurationError, "model must be a constant name string" unless model.is_a?(String)
|
|
176
|
+
klass = model.constantize
|
|
177
|
+
raise ConfigurationError, "model must inherit ActiveRecord::Base" unless klass < ActiveRecord::Base
|
|
178
|
+
klass
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def validate!
|
|
182
|
+
raise ConfigurationError, "model must be a constant name string" unless model.is_a?(String)
|
|
183
|
+
if policy && (!policy.is_a?(String) || !policy.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/))
|
|
184
|
+
raise ConfigurationError, "policy must be a constant name string"
|
|
185
|
+
end
|
|
186
|
+
raise ConfigurationError, "A resource must declare fields and a title" if fields.empty? || !title || !@fields.key?(title)
|
|
187
|
+
raise ConfigurationError, "A resource cannot declare lock_version as a field" if @fields.key?(:lock_version)
|
|
188
|
+
raise ConfigurationError, "A password field cannot be the title" if @fields[title].scalar_type == :password
|
|
189
|
+
Fields::DependencyGraph.validate!(fields)
|
|
190
|
+
index.validate!(fields)
|
|
191
|
+
Presentation::Dashboard.validate!(self)
|
|
192
|
+
# Fail at registration rather than on every menu render; explicit labels
|
|
193
|
+
# were already bounded when declared.
|
|
194
|
+
Navigation::Settings.caption(label) unless navigation.hidden || navigation.label
|
|
195
|
+
@policy_name&.freeze
|
|
196
|
+
@fields.freeze
|
|
197
|
+
@scopes.freeze
|
|
198
|
+
@disabled_actions.freeze
|
|
199
|
+
@operations.freeze
|
|
200
|
+
@operation_scopes.freeze
|
|
201
|
+
@surface_operations.freeze
|
|
202
|
+
@has_many.freeze
|
|
203
|
+
@assistant_operations.freeze
|
|
204
|
+
self
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Collects named list scopes. Bodies are not evaluated at declaration time.
|
|
209
|
+
class ScopeBuilder
|
|
210
|
+
def initialize(scopes)
|
|
211
|
+
@scopes = scopes
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def scope(name, body = nil, label: nil, default: false)
|
|
215
|
+
definition = Queries::NamedScope.new(name, body, label: label, default: default)
|
|
216
|
+
raise ConfigurationError, "Duplicate named scope #{definition.name}" if @scopes.key?(definition.name)
|
|
217
|
+
raise ConfigurationError, "A resource declares at most #{Queries::NamedScope::MAX_SCOPES} named scopes" if @scopes.size >= Queries::NamedScope::MAX_SCOPES
|
|
218
|
+
raise ConfigurationError, "A resource declares at most one default named scope" if definition.default? && @scopes.values.any?(&:default?)
|
|
219
|
+
@scopes[definition.name] = definition
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Collects field declarations without consulting the database at boot.
|
|
224
|
+
class FieldBuilder
|
|
225
|
+
# source is the declaring Resource, consulted lazily for column limits.
|
|
226
|
+
def initialize(fields, source: nil)
|
|
227
|
+
@fields = fields
|
|
228
|
+
@source = source
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def field(name, type:, **options)
|
|
232
|
+
raise ConfigurationError, "Duplicate field #{name}" if @fields.key?(name.to_sym)
|
|
233
|
+
@fields[name.to_sym] = RailsAiAdmin.field_registry.build(name, type: type, source: @source, **options)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def virtual(name, type:, depends_on:, compute:, **options)
|
|
237
|
+
field(name, type: type, compute: compute, depends_on: depends_on, **options)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def readonly(name, type: :text, **options)
|
|
241
|
+
field(name, type: type, **options.merge(readonly: true))
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
Field::TYPES.each do |type|
|
|
245
|
+
define_method(type) { |name, **options| field(name, type: type, **options) }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
# Stable across processes, but changes when a model's record IDs change meaning.
|
|
5
|
+
module ResourceIdentity
|
|
6
|
+
def self.call(resource)
|
|
7
|
+
model = resource.model_class
|
|
8
|
+
primary_key = model.primary_key
|
|
9
|
+
column = primary_key.is_a?(String) ? model.columns_hash[primary_key] : nil
|
|
10
|
+
{ model: resource.model, table: model.table_name, primary_key: primary_key,
|
|
11
|
+
column_type: column&.type, sql_type: column&.sql_type,
|
|
12
|
+
attribute_type: primary_key.is_a?(String) ? model.type_for_attribute(primary_key).type : nil }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|