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,625 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
require "time"
|
|
6
|
+
require "json"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module RailsAiAdmin
|
|
10
|
+
# Declares one scalar input/output contract. All presentation and query flags
|
|
11
|
+
# only narrow the permissions returned by the host policy.
|
|
12
|
+
class Field
|
|
13
|
+
TYPES = %i[id text textarea integer decimal boolean date datetime enum select belongs_to time email url password json file files rich_text references].freeze
|
|
14
|
+
# Text-like scalars share text coercion, search and equality filters.
|
|
15
|
+
TEXT_TYPES = %i[text textarea id email url].freeze
|
|
16
|
+
# Never listed, searched, sorted or filtered: a password is write-only and
|
|
17
|
+
# JSON has no stable text or ordering.
|
|
18
|
+
UNQUERYABLE_TYPES = %i[password json file files rich_text references].freeze
|
|
19
|
+
# Fields whose value names records of another resource.
|
|
20
|
+
REFERENCE_TYPES = %i[belongs_to references].freeze
|
|
21
|
+
# A references field holds at most this many keys.
|
|
22
|
+
MAX_REFERENCES = 100
|
|
23
|
+
# Registered types refine plain scalars only.
|
|
24
|
+
PLUGIN_EXCLUDED = %i[belongs_to password json file files rich_text references].freeze
|
|
25
|
+
# Upload fields and their default bound on attached files.
|
|
26
|
+
FILE_TYPES = %i[file files].freeze
|
|
27
|
+
MAX_FILES = 20
|
|
28
|
+
# Rich text is stored HTML; its bound is larger than other text.
|
|
29
|
+
MAX_RICH_TEXT = 1_048_576
|
|
30
|
+
# Default upload bound for a file field: 10 MB.
|
|
31
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
32
|
+
CONTENT_TYPE = %r{\A[a-z]+/(?:\*|[a-z0-9][a-z0-9.+-]*)\z}
|
|
33
|
+
TIME_OF_DAY = /\A([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.\d{1,6})?)?\z/
|
|
34
|
+
# A practical address check (Rails' own URI::MailTo pattern), not delivery proof.
|
|
35
|
+
EMAIL = URI::MailTo::EMAIL_REGEXP
|
|
36
|
+
OPTIONS = %i[readonly required nullable values max_length searchable sortable filterable create edit label choices index show hidden help_text tab panel resource foreign_key precision scale accept max_size max_files].freeze
|
|
37
|
+
# A belongs_to value is the target's opaque primary-key text, never a record.
|
|
38
|
+
REFERENCE = /\A[A-Za-z0-9][A-Za-z0-9_-]{0,99}\z/
|
|
39
|
+
NAME = /\A[a-z][a-z0-9_]*\z/
|
|
40
|
+
# Input text that is empty or only whitespace is blank (RAA-002/003).
|
|
41
|
+
BLANK = /\A[[:space:]]*\z/
|
|
42
|
+
# NUL and other C0 controls are never accepted in single-line text; a
|
|
43
|
+
# textarea additionally admits tab, line feed and carriage return (RAA-016).
|
|
44
|
+
TEXT_CONTROLS = /[\x00-\x1F\x7F]/
|
|
45
|
+
TEXTAREA_CONTROLS = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/
|
|
46
|
+
DATETIME = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,6})?)?(?:Z|[+-](\d{2}):(\d{2}))?\z/
|
|
47
|
+
# Upper bound for decimal wire text, matching the integer bound.
|
|
48
|
+
MAX_NUMBER_TEXT = 100
|
|
49
|
+
# Typed list filter operators. Text-like values only support exact equality;
|
|
50
|
+
# contains/prefix matching stays with search.
|
|
51
|
+
FILTER_OPERATORS = %i[eq gte lte].freeze
|
|
52
|
+
RANGE_TYPES = %i[integer decimal date datetime].freeze
|
|
53
|
+
# A registered type's own rejection message; passes through coercion.
|
|
54
|
+
class TypeMessage < ArgumentError; end
|
|
55
|
+
# A coerced password. It is a String for the model's password= writer;
|
|
56
|
+
# input digests store only a keyed hash of it, and it never inspects.
|
|
57
|
+
class Secret < String
|
|
58
|
+
def inspect = "[FILTERED]"
|
|
59
|
+
end
|
|
60
|
+
Descriptor = Data.define(:name, :type, :scalar_type, :options, :scalar_json_schema)
|
|
61
|
+
attr_reader :name, :type, :scalar_type, :options, :dependencies, :filter_operators, :column
|
|
62
|
+
|
|
63
|
+
# source is the declaring Resource class. It is consulted lazily, on the
|
|
64
|
+
# first decimal coercion, for column precision and scale; never at boot.
|
|
65
|
+
def initialize(name, type:, plugin: nil, compute: nil, depends_on: nil, source: nil, **options)
|
|
66
|
+
@name, @type = name.to_sym, type.to_sym
|
|
67
|
+
@plugin = plugin
|
|
68
|
+
@source = source
|
|
69
|
+
@plugin_metadata = plugin && immutable_copy(plugin.metadata)
|
|
70
|
+
@scalar_type = @plugin_metadata ? @plugin_metadata.fetch(:scalar) : @type
|
|
71
|
+
raise ConfigurationError, "Invalid field name" unless @name.to_s.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
72
|
+
raise ConfigurationError, "Unknown field type" unless TYPES.include?(@scalar_type) && (plugin || TYPES.include?(@type))
|
|
73
|
+
raise ConfigurationError, "Unknown field option" unless (options.keys - OPTIONS).empty?
|
|
74
|
+
raise ConfigurationError, "Field types cannot refine belongs_to, password or json" if plugin && PLUGIN_EXCLUDED.include?(@scalar_type)
|
|
75
|
+
configure_computation!(compute, depends_on, options)
|
|
76
|
+
defaults = { readonly: @scalar_type == :id, nullable: true, required: false, max_length: 2000, create: true, edit: true, index: true, show: true, hidden: false }
|
|
77
|
+
defaults.merge!(readonly: true, create: false, edit: false, searchable: false, sortable: false) if virtual?
|
|
78
|
+
defaults.merge!(resource: @name.to_s.pluralize.to_sym, foreign_key: "#{@name}_id") if @scalar_type == :belongs_to
|
|
79
|
+
# Listing keys costs a query per row, so lists leave references out.
|
|
80
|
+
defaults.merge!(resource: @name.to_sym, foreign_key: "#{@name.to_s.singularize}_ids", index: false) if @scalar_type == :references
|
|
81
|
+
# Documents and formatted text crowd a list; show pages carry them.
|
|
82
|
+
defaults.merge!(max_length: 65_536, index: false) if @scalar_type == :json
|
|
83
|
+
defaults.merge!(index: false) if @scalar_type == :rich_text
|
|
84
|
+
defaults.merge!(index: false, show: false) if @scalar_type == :password
|
|
85
|
+
defaults.merge!(max_size: MAX_FILE_SIZE) if @scalar_type == :file
|
|
86
|
+
# Attachments cost a query per row, so lists leave files out.
|
|
87
|
+
defaults.merge!(max_size: MAX_FILE_SIZE, max_files: MAX_FILES, index: false) if @scalar_type == :files
|
|
88
|
+
defaults.merge!(max_length: MAX_RICH_TEXT) if @scalar_type == :rich_text
|
|
89
|
+
@options = immutable_copy(defaults.merge(options))
|
|
90
|
+
validate_options!
|
|
91
|
+
validate_association_options!
|
|
92
|
+
validate_query_options!
|
|
93
|
+
@column = (REFERENCE_TYPES.include?(@scalar_type) ? @options[:foreign_key] : @name.to_s).dup.freeze
|
|
94
|
+
@filter_operators = resolve_filter_operators
|
|
95
|
+
@descriptor = Descriptor.new(@name, @type, @scalar_type, @options, immutable_copy(scalar_json_schema))
|
|
96
|
+
if @plugin
|
|
97
|
+
constraints = invoke(:schema, field: @descriptor)
|
|
98
|
+
schema, @validator = Fields::ScalarSchema.compile(@descriptor.scalar_json_schema, constraints)
|
|
99
|
+
else
|
|
100
|
+
schema = @descriptor.scalar_json_schema
|
|
101
|
+
end
|
|
102
|
+
@json_schema = immutable_copy(@options[:nullable] ? { "anyOf" => [ schema, { "type" => "null" } ] } : schema)
|
|
103
|
+
contract = { name: @name, type: @type, scalar: @scalar_type, options: @options, plugin: @plugin_metadata, schema: @json_schema }
|
|
104
|
+
contract[:computation] = { **@computation_metadata, dependencies: @dependencies } if virtual?
|
|
105
|
+
@contract = immutable_copy(contract)
|
|
106
|
+
freeze
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def virtual? = !@computation.nil?
|
|
110
|
+
def plugin? = !@plugin.nil?
|
|
111
|
+
|
|
112
|
+
def compute(values, time_zone:)
|
|
113
|
+
klass = @computation.handler_class
|
|
114
|
+
raise ConfigurationError, "Computation changed; reload resource definitions" unless klass.metadata == @computation_metadata
|
|
115
|
+
result = ActiveRecord::Base.while_preventing_writes { klass.new.call(values, field: @descriptor, time_zone: time_zone) }
|
|
116
|
+
coerce_output(result, time_zone: time_zone)
|
|
117
|
+
rescue ConfigurationError
|
|
118
|
+
raise
|
|
119
|
+
rescue StandardError
|
|
120
|
+
raise ConfigurationError, "Computed field failed its scalar contract", cause: nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def label
|
|
124
|
+
options[:label] || name.to_s.humanize
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def writable?(operation)
|
|
128
|
+
!hidden? && !options[:readonly] && options[operation.to_sym == :create ? :create : :edit]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def hidden? = options[:hidden]
|
|
132
|
+
|
|
133
|
+
def visible_on?(view)
|
|
134
|
+
return false if hidden?
|
|
135
|
+
raise ConfigurationError, "Unknown field presentation view" unless %i[index show create edit].include?(view)
|
|
136
|
+
options.fetch(view)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Formats an already-authorized scalar for conventional pages.
|
|
140
|
+
def format(value, time_zone: "UTC")
|
|
141
|
+
text = scalar_format(value, time_zone: time_zone)
|
|
142
|
+
text = invoke(:format, text.freeze, field: @descriptor, time_zone: time_zone) if @plugin && !value.nil?
|
|
143
|
+
raise ConfigurationError, "Field formatter must return bounded plain text" unless text.is_a?(String) && text.bytesize <= 16_384 && text.valid_encoding?
|
|
144
|
+
String.new(text)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def scalar_format(value, time_zone: "UTC")
|
|
148
|
+
return "—" if value.nil?
|
|
149
|
+
case scalar_type
|
|
150
|
+
when :boolean then value ? "Yes" : "No"
|
|
151
|
+
when :decimal then format_decimal(value)
|
|
152
|
+
when :datetime then format_time(value.in_time_zone(time_zone))
|
|
153
|
+
when :date then value.iso8601
|
|
154
|
+
when :select then choice_options.find { |_, key| key == value }&.first || value.to_s
|
|
155
|
+
when :time then format_time_of_day(value)
|
|
156
|
+
when :json then format_json(value)
|
|
157
|
+
when :password then "—"
|
|
158
|
+
when :rich_text then plain_text(value.to_s)
|
|
159
|
+
when :references then Array(value).map { |key| "##{key}" }.join(", ")
|
|
160
|
+
when :file then value.respond_to?(:filename) ? "#{value.filename} (#{ActiveSupport::NumberHelper.number_to_human_size(value.byte_size)})" : value.to_s
|
|
161
|
+
when :files then I18n.t(:files, scope: %i[rails_ai_admin fields], count: Array(value).size, default: "#{Array(value).size} files")
|
|
162
|
+
else value.to_s
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# "10 Nov 2026 10:44 UTC" in the context zone (RAA-015); seconds appear
|
|
167
|
+
# only when set. Pages put the exact ISO value in a <time> element.
|
|
168
|
+
def format_time(time)
|
|
169
|
+
time.strftime(time.sec.zero? ? "%-d %b %Y %H:%M %Z" : "%-d %b %Y %H:%M:%S %Z")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# "14:30", or "14:30:15" when seconds are set. Active Record returns a
|
|
173
|
+
# time column as a Time on 2000-01-01; coerced input is "HH:MM:SS".
|
|
174
|
+
def format_time_of_day(value)
|
|
175
|
+
value = value.strftime("%H:%M:%S") if value.respond_to?(:strftime)
|
|
176
|
+
parts = value.to_s.match(TIME_OF_DAY)
|
|
177
|
+
return value.to_s unless parts
|
|
178
|
+
parts[3].nil? || parts[3] == "00" ? "#{parts[1]}:#{parts[2]}" : "#{parts[1]}:#{parts[2]}:#{parts[3]}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Rich text as plain text, for lists, CSV and the assistant.
|
|
182
|
+
def plain_text(html)
|
|
183
|
+
text = defined?(::ActionText::Content) ? ::ActionText::Content.new(html).to_plain_text : Rails::HTML5::FullSanitizer.new.sanitize(html)
|
|
184
|
+
text.to_s.truncate_bytes(16_000)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Indented JSON, cut to the page bound so a large document cannot fail a page.
|
|
188
|
+
def format_json(value)
|
|
189
|
+
value = JSON.parse(value) if value.is_a?(String)
|
|
190
|
+
text = JSON.pretty_generate(value)
|
|
191
|
+
text.bytesize > 16_000 ? "#{text.byteslice(0, 16_000).scrub("")}…" : text
|
|
192
|
+
rescue JSON::ParserError, JSON::GeneratorError
|
|
193
|
+
value.to_s.truncate(16_000)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Decimal text padded to the declared or column scale ("10.50", not
|
|
197
|
+
# "10.5"). It never rounds: a stored value with more digits keeps them, so
|
|
198
|
+
# a form prefilled from it submits the same number back (RAA-014).
|
|
199
|
+
def format_decimal(value)
|
|
200
|
+
number = value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
|
|
201
|
+
return number.to_s("F") unless number.finite?
|
|
202
|
+
scale = decimal_limits[:scale]
|
|
203
|
+
return number.to_s("F") unless scale
|
|
204
|
+
whole, fraction = number.to_s("F").split(".")
|
|
205
|
+
fraction = fraction.to_s.delete_suffix("0") if fraction == "0"
|
|
206
|
+
fraction = fraction.to_s.ljust(scale, "0")
|
|
207
|
+
fraction.empty? ? whole : "#{whole}.#{fraction}"
|
|
208
|
+
rescue ArgumentError, TypeError
|
|
209
|
+
value.to_s
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Coerces form or JSON input values, preserving false, zero and null. Input
|
|
213
|
+
# is strict: blank text, control characters, out-of-scale decimals and
|
|
214
|
+
# impossible calendar dates are rejected with a field message.
|
|
215
|
+
def coerce(value, time_zone: "UTC") = coerce_value(value, time_zone, strict: true)
|
|
216
|
+
|
|
217
|
+
# Internal: types a value produced by host code (a computation or a
|
|
218
|
+
# preflight preview). It checks the scalar type only, so a handler result is
|
|
219
|
+
# never rejected by the input-only rules above.
|
|
220
|
+
def coerce_output(value, time_zone: "UTC") = coerce_value(value, time_zone, strict: false)
|
|
221
|
+
|
|
222
|
+
def coerce_scalar(value, time_zone: "UTC", strict: true)
|
|
223
|
+
if blank_input?(value, strict)
|
|
224
|
+
# Clearing a password would remove the sign-in secret; leave it out instead.
|
|
225
|
+
raise ArgumentError, message(:blank, "cannot be blank") if scalar_type == :password
|
|
226
|
+
raise ArgumentError, message(:blank, "cannot be blank") if strict && (options[:required] || !options[:nullable])
|
|
227
|
+
raise ArgumentError, message(:blank, "cannot be blank") unless options[:nullable]
|
|
228
|
+
return nil
|
|
229
|
+
end
|
|
230
|
+
case scalar_type
|
|
231
|
+
when :belongs_to
|
|
232
|
+
value = value.to_s if value.is_a?(Integer) && value.positive?
|
|
233
|
+
raise ArgumentError, message(:unavailable_choice, "is not an available choice") unless value.is_a?(String) && value.valid_encoding? && value.match?(REFERENCE)
|
|
234
|
+
String.new(value)
|
|
235
|
+
when :text, :textarea, :id, :email, :url, :password, :rich_text
|
|
236
|
+
raise ArgumentError, message(:not_text, "must be text") unless value.is_a?(String)
|
|
237
|
+
raise ArgumentError, message(:too_long, "is too long") if value.length > options[:max_length]
|
|
238
|
+
raise ArgumentError, message(:invalid, "is invalid") unless value.valid_encoding?
|
|
239
|
+
if strict && value.match?(%i[textarea rich_text].include?(scalar_type) ? TEXTAREA_CONTROLS : TEXT_CONTROLS)
|
|
240
|
+
raise ArgumentError, message(:control_characters, "contains control characters")
|
|
241
|
+
end
|
|
242
|
+
if strict && scalar_type == :email && !value.match?(EMAIL)
|
|
243
|
+
raise ArgumentError, message(:not_email, "must be an email address")
|
|
244
|
+
end
|
|
245
|
+
raise ArgumentError, message(:not_url, "must be an http or https address") if strict && scalar_type == :url && !web_address?(value)
|
|
246
|
+
scalar_type == :password ? Secret.new(value) : String.new(value)
|
|
247
|
+
when :time
|
|
248
|
+
value = value.strftime("%H:%M:%S") if value.respond_to?(:strftime) && !value.is_a?(String)
|
|
249
|
+
parts = value.is_a?(String) && value.match(TIME_OF_DAY)
|
|
250
|
+
raise ArgumentError, message(:not_time, "must be a time of day (HH:MM)") unless parts
|
|
251
|
+
"#{parts[1]}:#{parts[2]}:#{parts[3] || '00'}"
|
|
252
|
+
when :file then coerce_file(value)
|
|
253
|
+
when :files then coerce_files(value)
|
|
254
|
+
when :references
|
|
255
|
+
raise ArgumentError, message(:not_list, "must be a list of records") unless value.is_a?(Array) && value.size <= MAX_REFERENCES + 1
|
|
256
|
+
keys = value.map { |item| item.is_a?(Integer) && item.positive? ? item.to_s : item }.reject { |item| item == "" }
|
|
257
|
+
unless keys.all? { |item| item.is_a?(String) && item.valid_encoding? && item.match?(REFERENCE) }
|
|
258
|
+
raise ArgumentError, message(:unavailable_choice, "is not an available choice")
|
|
259
|
+
end
|
|
260
|
+
keys = keys.uniq.sort
|
|
261
|
+
raise ArgumentError, message(:too_many, "can name at most %{count} records", count: MAX_REFERENCES) if keys.size > MAX_REFERENCES
|
|
262
|
+
raise ArgumentError, message(:blank, "cannot be blank") if strict && keys.empty? && options[:required]
|
|
263
|
+
keys.map { |key| String.new(key).freeze }.freeze
|
|
264
|
+
when :json
|
|
265
|
+
value = parse_json(value) if value.is_a?(String)
|
|
266
|
+
raise ArgumentError, message(:not_json, "must be valid JSON") unless json_value?(value)
|
|
267
|
+
raise ArgumentError, message(:too_long, "is too long") if JSON.generate(value).length > options[:max_length]
|
|
268
|
+
value
|
|
269
|
+
when :enum, :select
|
|
270
|
+
raise ArgumentError, message(:not_allowed, "is not an allowed value") unless choice_values.include?(value)
|
|
271
|
+
raise ArgumentError, message(:invalid, "is invalid") unless value.valid_encoding?
|
|
272
|
+
String.new(value)
|
|
273
|
+
when :integer
|
|
274
|
+
raise ArgumentError, message(:not_integer, "must be an integer") unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A[+-]?\d+\z/))
|
|
275
|
+
raise ArgumentError, message(:out_of_range, "is out of range") if value.is_a?(String) && value.length > 100
|
|
276
|
+
number = value.is_a?(String) ? Integer(value, 10) : value
|
|
277
|
+
raise ArgumentError, message(:out_of_range, "is out of range") unless (-(2**63)..(2**63 - 1)).cover?(number)
|
|
278
|
+
number
|
|
279
|
+
when :decimal
|
|
280
|
+
raise ArgumentError, message(:not_decimal, "must be a decimal") unless value.is_a?(Integer) || value.is_a?(BigDecimal) || (value.is_a?(String) && value.match?(/\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)\z/))
|
|
281
|
+
raise ArgumentError, message(:out_of_range, "is out of range") if value.is_a?(String) && value.length > MAX_NUMBER_TEXT
|
|
282
|
+
number = BigDecimal(value.to_s)
|
|
283
|
+
raise ArgumentError, message(:not_finite, "must be finite") unless number.finite?
|
|
284
|
+
check_decimal_limits!(number) if strict
|
|
285
|
+
number
|
|
286
|
+
when :boolean
|
|
287
|
+
return true if [ true, "1", "true" ].include?(value)
|
|
288
|
+
return false if [ false, "0", "false" ].include?(value)
|
|
289
|
+
raise ArgumentError, message(:not_boolean, "must be a boolean")
|
|
290
|
+
when :date
|
|
291
|
+
value = value.iso8601 if value.is_a?(Date) && !value.is_a?(DateTime)
|
|
292
|
+
raise ArgumentError, message(:not_date, "must be an ISO date") unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
293
|
+
Date.iso8601(value)
|
|
294
|
+
when :datetime
|
|
295
|
+
value = value.to_time.getutc.iso8601(6) if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone) || value.is_a?(DateTime)
|
|
296
|
+
parts = value.is_a?(String) && value.match(DATETIME)
|
|
297
|
+
raise ArgumentError, message(:not_timestamp, "must be an ISO timestamp") unless parts
|
|
298
|
+
# Ruby and Active Support roll 2026-02-30 into March (DEP-013); an
|
|
299
|
+
# impossible calendar day or clock time is rejected instead (RAA-010).
|
|
300
|
+
year, month, day, hour, minute, second, offset_hour, offset_minute = parts.captures.map { |part| part&.to_i }
|
|
301
|
+
unless Date.valid_civil?(year, month, day) && hour <= 23 && minute <= 59 && second.to_i <= 59 &&
|
|
302
|
+
offset_hour.to_i <= 23 && offset_minute.to_i <= 59
|
|
303
|
+
raise ArgumentError, message(:invalid_datetime, "is not a valid date and time")
|
|
304
|
+
end
|
|
305
|
+
# datetime-local inputs are interpreted in the authenticated context zone.
|
|
306
|
+
zone = ActiveSupport::TimeZone[time_zone]
|
|
307
|
+
begin
|
|
308
|
+
zone.iso8601(value).utc
|
|
309
|
+
rescue ArgumentError
|
|
310
|
+
raise ArgumentError, message(:invalid_datetime, "is not a valid date and time"), cause: nil
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
rescue Date::Error, TypeError
|
|
314
|
+
raise ArgumentError, message(:invalid, "is invalid")
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Precision and scale come from explicit precision:/scale: options, or else
|
|
318
|
+
# from the declaring resource's decimal column on first use.
|
|
319
|
+
def decimal_limits
|
|
320
|
+
return {} unless scalar_type == :decimal
|
|
321
|
+
explicit = options.slice(:precision, :scale)
|
|
322
|
+
return explicit if explicit.any?
|
|
323
|
+
column_decimal_limits
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Whether a declared accept list admits this content type ("image/*" works).
|
|
327
|
+
def accepts?(content_type)
|
|
328
|
+
list = options[:accept]
|
|
329
|
+
return true if list.nil?
|
|
330
|
+
list.any? { |pattern| pattern.end_with?("/*") ? content_type.start_with?(pattern.delete_suffix("*")) : content_type == pattern }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# An absolute http(s) address, the only kind pages turn into links.
|
|
334
|
+
def web_address?(value)
|
|
335
|
+
uri = URI.parse(value)
|
|
336
|
+
%w[http https].include?(uri.scheme&.downcase) && uri.host.present? && !value.match?(/[[:space:]]/)
|
|
337
|
+
rescue URI::Error
|
|
338
|
+
false
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Schema describes normalized wire values; ordinary form strings are parsed
|
|
342
|
+
# through the same scalar coercion first.
|
|
343
|
+
def json_schema = @json_schema
|
|
344
|
+
def contract = @contract
|
|
345
|
+
def form_partial = @plugin_metadata&.fetch(:form_partial)
|
|
346
|
+
def display_partial(view) = @plugin_metadata&.fetch(view == :index ? :index_partial : :show_partial)
|
|
347
|
+
def choice_options = options[:choices] || choice_values.map { |value| [ value, value ] }
|
|
348
|
+
def choice_values = options[:values] || options[:choices]&.map(&:last) || []
|
|
349
|
+
|
|
350
|
+
def scalar_json_schema
|
|
351
|
+
case scalar_type
|
|
352
|
+
when :integer then { "type" => "integer", "minimum" => -(2**63), "maximum" => 2**63 - 1 }
|
|
353
|
+
when :decimal then { "type" => "string", "pattern" => decimal_pattern }
|
|
354
|
+
when :boolean then { "type" => "boolean" }
|
|
355
|
+
when :enum, :select then { "type" => "string", "enum" => choice_values }
|
|
356
|
+
when :belongs_to then { "type" => "string", "pattern" => "^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$" }
|
|
357
|
+
when :files then { "type" => "object", "additionalProperties" => false, "properties" => { "add" => { "type" => "array", "items" => { "type" => "string" } }, "remove" => { "type" => "array", "items" => { "type" => "string" } } } }
|
|
358
|
+
when :references then { "type" => "array", "maxItems" => MAX_REFERENCES, "items" => { "type" => "string", "pattern" => "^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$" } }
|
|
359
|
+
when :time then { "type" => "string", "pattern" => "^([01][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9](\\.[0-9]{1,6})?)?$" }
|
|
360
|
+
when :url then { "type" => "string", "pattern" => "^[Hh][Tt][Tt][Pp][Ss]?://", "maxLength" => options[:max_length] }
|
|
361
|
+
when :json then { "type" => %w[object array string number boolean] }
|
|
362
|
+
else { "type" => "string", "maxLength" => options[:max_length] }
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
private
|
|
367
|
+
|
|
368
|
+
def coerce_value(value, time_zone, strict:)
|
|
369
|
+
typed = coerce_scalar(value, time_zone: time_zone, strict: strict)
|
|
370
|
+
return typed unless @plugin && !typed.nil?
|
|
371
|
+
normalized = invoke(:normalize, immutable_copy(typed), field: @descriptor, time_zone: time_zone)
|
|
372
|
+
normalized = coerce_scalar(wire(normalized), time_zone: time_zone, strict: strict)
|
|
373
|
+
repeated = invoke(:normalize, immutable_copy(normalized), field: @descriptor, time_zone: time_zone)
|
|
374
|
+
repeated = coerce_scalar(wire(repeated), time_zone: time_zone, strict: strict)
|
|
375
|
+
raise ConfigurationError, "Field normalization must be idempotent" unless normalized == repeated
|
|
376
|
+
valid = invoke(:valid?, immutable_copy(normalized), field: @descriptor, time_zone: time_zone)
|
|
377
|
+
# A type may explain a rejection with a short plain message (RAA-011).
|
|
378
|
+
if valid.is_a?(String)
|
|
379
|
+
message = valid.gsub(/[[:cntrl:]]/, " ").squish
|
|
380
|
+
raise ConfigurationError, "Field validation messages must be 1 to 200 characters of plain text" unless message.valid_encoding? && message.length.between?(1, 200)
|
|
381
|
+
raise TypeMessage, message
|
|
382
|
+
end
|
|
383
|
+
raise ConfigurationError, "Field validation must return a boolean or a message" unless [ true, false ].include?(valid)
|
|
384
|
+
raise ArgumentError, message(:invalid, "is invalid") unless valid && @validator.valid?(wire(normalized))
|
|
385
|
+
normalized
|
|
386
|
+
rescue TypeMessage
|
|
387
|
+
raise
|
|
388
|
+
rescue ArgumentError
|
|
389
|
+
raise ArgumentError, message(:invalid, "is invalid"), cause: nil if @plugin
|
|
390
|
+
raise
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# An upload arrives as the signed id of a new, unattached Active Storage
|
|
394
|
+
# blob. Its identified content type and size must fit the declaration.
|
|
395
|
+
# A files change: new uploads to add and attachment ids to remove. A list
|
|
396
|
+
# is never replaced as a whole, so concurrent additions are kept.
|
|
397
|
+
def coerce_files(value)
|
|
398
|
+
value = value.to_h { |key, item| [ key.to_s, item ] } if value.is_a?(Hash)
|
|
399
|
+
unless value.is_a?(Hash) && (value.keys - %w[add remove]).empty? && value.values.all? { |item| item.is_a?(Array) }
|
|
400
|
+
raise ArgumentError, message(:invalid, "is invalid")
|
|
401
|
+
end
|
|
402
|
+
add = Array(value["add"]).map { |item| coerce_file(item) }
|
|
403
|
+
remove = Array(value["remove"]).map { |item| item.is_a?(Integer) && item.positive? ? item.to_s : item }
|
|
404
|
+
raise ArgumentError, message(:unavailable_choice, "is not an available choice") unless remove.all? { |item| item.is_a?(String) && item.match?(REFERENCE) }
|
|
405
|
+
raise ArgumentError, message(:too_many_files, "can hold at most %{count} files", count: options[:max_files]) if add.size > options[:max_files] || remove.size > MAX_REFERENCES
|
|
406
|
+
{ "add" => add.uniq.freeze, "remove" => remove.uniq.sort.freeze }.freeze
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def coerce_file(value)
|
|
410
|
+
if value.is_a?(Fields::RejectedUpload)
|
|
411
|
+
raise ArgumentError, message(:file_too_large, "is larger than %{size}", size: ActiveSupport::NumberHelper.number_to_human_size(options[:max_size]))
|
|
412
|
+
end
|
|
413
|
+
raise ConfigurationError, "file fields need Active Storage" unless defined?(::ActiveStorage::Blob)
|
|
414
|
+
raise ArgumentError, message(:invalid, "is invalid") unless value.is_a?(String) && value.valid_encoding? && value.length.between?(1, 1000)
|
|
415
|
+
blob = ::ActiveStorage::Blob.find_signed(value)
|
|
416
|
+
raise ArgumentError, message(:file_unavailable, "is not an available upload") unless blob && !blob.attachments.exists?
|
|
417
|
+
raise ArgumentError, message(:file_too_large, "is larger than %{size}", size: ActiveSupport::NumberHelper.number_to_human_size(options[:max_size])) if blob.byte_size > options[:max_size]
|
|
418
|
+
raise ArgumentError, message(:file_type, "is not an accepted file type") unless accepts?(blob.content_type.to_s)
|
|
419
|
+
String.new(value)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def parse_json(text)
|
|
423
|
+
raise ArgumentError, message(:too_long, "is too long") if text.length > options[:max_length]
|
|
424
|
+
JSON.parse(text, max_nesting: 32)
|
|
425
|
+
rescue JSON::ParserError, JSON::NestingError
|
|
426
|
+
raise ArgumentError, message(:not_json, "must be valid JSON"), cause: nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def json_value?(value, depth = 0)
|
|
430
|
+
return false if depth > 32
|
|
431
|
+
case value
|
|
432
|
+
when Hash then value.all? { |key, item| key.is_a?(String) && key.valid_encoding? && json_value?(item, depth + 1) }
|
|
433
|
+
when Array then value.all? { |item| json_value?(item, depth + 1) }
|
|
434
|
+
when String then value.valid_encoding?
|
|
435
|
+
when Integer, true, false, nil then true
|
|
436
|
+
when Float then value.finite?
|
|
437
|
+
else false
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Output values keep the historical rule (only non-text "" is blank) so a
|
|
442
|
+
# computation or preview is typed, never reinterpreted.
|
|
443
|
+
def blank_input?(value, strict)
|
|
444
|
+
return true if value.nil?
|
|
445
|
+
return false unless value.is_a?(String)
|
|
446
|
+
return value.empty? && !%i[text textarea].include?(scalar_type) unless strict
|
|
447
|
+
return true if value.empty?
|
|
448
|
+
(TEXT_TYPES + %i[password time json rich_text]).include?(scalar_type) && value.valid_encoding? && value.match?(BLANK)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Field input messages are translatable under rails_ai_admin.fields.errors.
|
|
452
|
+
# The English default keeps a missing translation from failing a write.
|
|
453
|
+
def self.error_message(key, default, **values)
|
|
454
|
+
I18n.t(key, scope: %i[rails_ai_admin fields errors], default: default, **values)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def message(key, default, **values) = self.class.error_message(key, default, **values)
|
|
458
|
+
|
|
459
|
+
def check_decimal_limits!(number)
|
|
460
|
+
limits = decimal_limits
|
|
461
|
+
scale, precision = limits[:scale], limits[:precision]
|
|
462
|
+
raise ArgumentError, message(:decimal_places, "must have at most %{count} decimal places", count: scale) if scale && number.scale > scale
|
|
463
|
+
return unless precision
|
|
464
|
+
whole_digits = [ number.exponent, 0 ].max
|
|
465
|
+
raise ArgumentError, message(:out_of_range, "is out of range") if whole_digits > precision - (scale || 0)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def column_decimal_limits
|
|
469
|
+
return {} if virtual? || !@source.respond_to?(:model_class)
|
|
470
|
+
column = @source.model_class.columns_hash[@column]
|
|
471
|
+
return {} unless column&.type == :decimal
|
|
472
|
+
limits = { precision: column.precision, scale: column.scale }.compact
|
|
473
|
+
limits.delete(:precision) unless limits[:precision].is_a?(Integer) && limits[:precision].positive?
|
|
474
|
+
limits.delete(:scale) unless limits[:scale].is_a?(Integer) && limits[:scale] >= 0
|
|
475
|
+
limits
|
|
476
|
+
rescue ActiveRecord::ActiveRecordError, ConfigurationError, NameError
|
|
477
|
+
{}
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def decimal_pattern
|
|
481
|
+
precision, scale = options[:precision], options[:scale]
|
|
482
|
+
whole = precision ? (precision - scale.to_i).then { |digits| digits.zero? ? "0*" : "0*[0-9]{1,#{digits}}" } : "[0-9]+"
|
|
483
|
+
fraction = scale ? (scale.zero? ? "(?:\\.0*)?" : "(?:\\.[0-9]{0,#{scale}}0*)?") : "(?:\\.[0-9]*)?"
|
|
484
|
+
bare = scale ? (scale.zero? ? "\\.0+" : "\\.[0-9]{1,#{scale}}0*") : "\\.[0-9]+"
|
|
485
|
+
"^[+-]?(?:#{whole}#{fraction}|#{bare})$"
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def configure_computation!(handler, dependencies, options)
|
|
489
|
+
@dependencies = [].freeze
|
|
490
|
+
return if handler.nil? && dependencies.nil?
|
|
491
|
+
unless handler.is_a?(String) && handler.length <= 200 && handler.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) &&
|
|
492
|
+
dependencies.is_a?(Array) && dependencies.size.between?(1, Fields::DependencyGraph::MAX_DEPENDENCIES) &&
|
|
493
|
+
dependencies.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.match?(/\A[a-z][a-z0-9_]*\z/) }
|
|
494
|
+
raise ConfigurationError, "Virtual fields require a computation class name and bounded declared dependencies"
|
|
495
|
+
end
|
|
496
|
+
@dependencies = dependencies.map(&:to_sym).freeze
|
|
497
|
+
raise ConfigurationError, "Computed dependencies must be distinct" unless @dependencies.uniq.size == @dependencies.size
|
|
498
|
+
if options.any? { |key, value| %i[readonly].include?(key) && value != true || %i[searchable sortable filterable].include?(key) && value != false }
|
|
499
|
+
raise ConfigurationError, "Virtual fields are readonly and cannot search, sort or filter"
|
|
500
|
+
end
|
|
501
|
+
@computation = Fields::Computation::Definition.new(String.new(handler).freeze)
|
|
502
|
+
@computation_metadata = immutable_copy(@computation.metadata)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def validate_options!
|
|
506
|
+
unless %i[readonly nullable required create edit index show hidden].all? { |key| [ true, false ].include?(options[key]) } &&
|
|
507
|
+
options[:max_length].is_a?(Integer) && (1..(scalar_type == :rich_text ? MAX_RICH_TEXT : 65_536)).cover?(options[:max_length]) &&
|
|
508
|
+
(options[:label].nil? || options[:label].is_a?(String) && options[:label].length.between?(1, 200) && options[:label].valid_encoding?)
|
|
509
|
+
raise ConfigurationError, "Field flags, label or length are invalid"
|
|
510
|
+
end
|
|
511
|
+
precision, scale = options[:precision], options[:scale]
|
|
512
|
+
if !precision.nil? || !scale.nil?
|
|
513
|
+
unless scalar_type == :decimal && (precision.nil? || precision.is_a?(Integer) && precision.between?(1, 1000)) &&
|
|
514
|
+
(scale.nil? || scale.is_a?(Integer) && scale.between?(0, precision || 1000))
|
|
515
|
+
raise ConfigurationError, "precision and scale are decimal options with 1..1000 digits and 0 <= scale <= precision"
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
{ help_text: 500, tab: 80, panel: 80 }.each do |key, limit|
|
|
519
|
+
value = options[key]
|
|
520
|
+
unless value.nil? || value.is_a?(String) && value.valid_encoding? && value.length.between?(1, limit)
|
|
521
|
+
raise ConfigurationError, "Field presentation text must be bounded plain text"
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
return unless %i[enum select].include?(scalar_type)
|
|
525
|
+
if options[:choices]
|
|
526
|
+
unless scalar_type == :select && options[:choices].is_a?(Array) && options[:choices].all? { |pair| pair.is_a?(Array) && pair.size == 2 && pair.all? { |item| item.is_a?(String) && item.length.between?(1, 200) && item.valid_encoding? } }
|
|
527
|
+
raise ConfigurationError, "Select choices must be plain label/string-value pairs"
|
|
528
|
+
end
|
|
529
|
+
raise ConfigurationError, "Use either choices or values" if options[:values]
|
|
530
|
+
end
|
|
531
|
+
values = choice_values
|
|
532
|
+
unless values.is_a?(Array) && values.size.between?(1, 100) && values.uniq.size == values.size && values.all? { |value| value.is_a?(String) && value.length.between?(1, 200) && value.valid_encoding? }
|
|
533
|
+
raise ConfigurationError, "Enum/select requires distinct nonempty string values"
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def validate_query_options!
|
|
538
|
+
if UNQUERYABLE_TYPES.include?(scalar_type) && (options[:searchable] || options[:sortable] || options[:filterable])
|
|
539
|
+
raise ConfigurationError, "#{scalar_type} fields cannot search, sort or filter"
|
|
540
|
+
end
|
|
541
|
+
if scalar_type == :time && (options[:searchable] || options[:filterable])
|
|
542
|
+
raise ConfigurationError, "time fields can sort but cannot search or filter"
|
|
543
|
+
end
|
|
544
|
+
if !FILE_TYPES.include?(scalar_type) && (options.key?(:accept) || options.key?(:max_size))
|
|
545
|
+
raise ConfigurationError, "accept and max_size are file options"
|
|
546
|
+
end
|
|
547
|
+
raise ConfigurationError, "max_files is a files option" if scalar_type != :files && options.key?(:max_files)
|
|
548
|
+
if scalar_type == :files
|
|
549
|
+
raise ConfigurationError, "files max_files must be 1..100" unless options[:max_files].is_a?(Integer) && options[:max_files].between?(1, MAX_REFERENCES)
|
|
550
|
+
raise ConfigurationError, "files fields cannot be required" if options[:required]
|
|
551
|
+
end
|
|
552
|
+
if FILE_TYPES.include?(scalar_type)
|
|
553
|
+
accept, size = options[:accept], options[:max_size]
|
|
554
|
+
unless accept.nil? || accept.is_a?(Array) && accept.size.between?(1, 50) && accept.all? { |type| type.is_a?(String) && type.match?(CONTENT_TYPE) }
|
|
555
|
+
raise ConfigurationError, "file accept must list content types such as image/png or image/*"
|
|
556
|
+
end
|
|
557
|
+
raise ConfigurationError, "file max_size must be a positive Integer of bytes" unless size.is_a?(Integer) && size.between?(1, 2**40)
|
|
558
|
+
end
|
|
559
|
+
if scalar_type == :password && (options[:index] || options[:show] || options[:readonly])
|
|
560
|
+
raise ConfigurationError, "password fields are write-only: no index, show or readonly"
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def validate_association_options!
|
|
565
|
+
if !REFERENCE_TYPES.include?(scalar_type)
|
|
566
|
+
raise ConfigurationError, "resource and foreign_key are belongs_to and references options" if options.key?(:resource) || options.key?(:foreign_key)
|
|
567
|
+
return
|
|
568
|
+
end
|
|
569
|
+
resource, foreign_key = options[:resource], options[:foreign_key]
|
|
570
|
+
unless (resource.is_a?(Symbol) || resource.is_a?(String)) && resource.to_s.match?(NAME) && resource.to_s.length <= 100 &&
|
|
571
|
+
foreign_key.is_a?(String) && foreign_key.match?(NAME) && foreign_key.length <= 100 && foreign_key != name.to_s
|
|
572
|
+
raise ConfigurationError, "belongs_to needs a registry key resource and a distinct foreign_key column name"
|
|
573
|
+
end
|
|
574
|
+
# Association search, sort and labels-as-filters need registered joins (LIST-019).
|
|
575
|
+
if options[:searchable] || options[:sortable]
|
|
576
|
+
raise ConfigurationError, "belongs_to fields cannot search or sort yet; filter by the referenced record instead"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# filterable: true selects type defaults; an Array narrows to allowed operators.
|
|
581
|
+
def resolve_filter_operators
|
|
582
|
+
value = options[:filterable]
|
|
583
|
+
return [].freeze if value.nil? || value == false
|
|
584
|
+
allowed = RANGE_TYPES.include?(scalar_type) ? FILTER_OPERATORS : %i[eq]
|
|
585
|
+
return (RANGE_TYPES.include?(scalar_type) ? %i[gte lte] : %i[eq]).freeze if value == true
|
|
586
|
+
unless value.is_a?(Array) && value.any? && value.all? { |operator| operator.is_a?(Symbol) } && value.uniq.size == value.size && (value - allowed).empty?
|
|
587
|
+
raise ConfigurationError, "Field filterable must be true, false or distinct operators from #{allowed.join(', ')}"
|
|
588
|
+
end
|
|
589
|
+
FILTER_OPERATORS.select { |operator| value.include?(operator) }.freeze
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
def immutable_copy(value)
|
|
593
|
+
copy = case value
|
|
594
|
+
when Hash then value.to_h { |key, item| [ key, immutable_copy(item) ] }
|
|
595
|
+
when Array then value.map { |item| immutable_copy(item) }
|
|
596
|
+
when String then String.new(value)
|
|
597
|
+
when Time, ActiveSupport::TimeWithZone, Date then value.dup
|
|
598
|
+
else value
|
|
599
|
+
end
|
|
600
|
+
copy.freeze
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def wire(value)
|
|
604
|
+
case value
|
|
605
|
+
when BigDecimal then value.to_s("F")
|
|
606
|
+
when Time, ActiveSupport::TimeWithZone, DateTime then value.to_time.getutc.iso8601(6)
|
|
607
|
+
when Date then value.iso8601
|
|
608
|
+
else value
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def invoke(method, *args, **keywords)
|
|
613
|
+
klass = @plugin.handler_class
|
|
614
|
+
raise ConfigurationError, "Field type contract changed; reload resource definitions" unless klass.metadata == @plugin_metadata
|
|
615
|
+
ActiveRecord::Base.while_preventing_writes { klass.new.public_send(method, *args, **keywords) }
|
|
616
|
+
rescue ArgumentError
|
|
617
|
+
raise ArgumentError, message(:invalid, "is invalid"), cause: nil if %i[normalize valid?].include?(method)
|
|
618
|
+
raise ConfigurationError, "Field type hook failed", cause: nil
|
|
619
|
+
rescue ConfigurationError
|
|
620
|
+
raise
|
|
621
|
+
rescue StandardError
|
|
622
|
+
raise ConfigurationError, "Field type hook failed", cause: nil
|
|
623
|
+
end
|
|
624
|
+
end
|
|
625
|
+
end
|