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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. 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