typed_eav 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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +494 -0
  5. data/Rakefile +13 -0
  6. data/app/models/typed_eav/application_record.rb +7 -0
  7. data/app/models/typed_eav/field/base.rb +234 -0
  8. data/app/models/typed_eav/field/boolean.rb +22 -0
  9. data/app/models/typed_eav/field/color.rb +16 -0
  10. data/app/models/typed_eav/field/date.rb +24 -0
  11. data/app/models/typed_eav/field/date_array.rb +34 -0
  12. data/app/models/typed_eav/field/date_time.rb +29 -0
  13. data/app/models/typed_eav/field/decimal.rb +30 -0
  14. data/app/models/typed_eav/field/decimal_array.rb +31 -0
  15. data/app/models/typed_eav/field/email.rb +40 -0
  16. data/app/models/typed_eav/field/integer.rb +30 -0
  17. data/app/models/typed_eav/field/integer_array.rb +68 -0
  18. data/app/models/typed_eav/field/json.rb +26 -0
  19. data/app/models/typed_eav/field/long_text.rb +19 -0
  20. data/app/models/typed_eav/field/multi_select.rb +41 -0
  21. data/app/models/typed_eav/field/select.rb +28 -0
  22. data/app/models/typed_eav/field/text.rb +41 -0
  23. data/app/models/typed_eav/field/text_array.rb +36 -0
  24. data/app/models/typed_eav/field/url.rb +40 -0
  25. data/app/models/typed_eav/option.rb +24 -0
  26. data/app/models/typed_eav/section.rb +25 -0
  27. data/app/models/typed_eav/value.rb +149 -0
  28. data/db/migrate/20260330000000_create_typed_eav_tables.rb +132 -0
  29. data/lib/generators/typed_eav/install/install_generator.rb +28 -0
  30. data/lib/generators/typed_eav/scaffold/scaffold_generator.rb +106 -0
  31. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +45 -0
  32. data/lib/generators/typed_eav/scaffold/templates/controllers/concerns/typed_eav_controller_concern.rb +24 -0
  33. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +231 -0
  34. data/lib/generators/typed_eav/scaffold/templates/helpers/typed_eav_helper.rb +150 -0
  35. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/array_field_controller.js +64 -0
  36. data/lib/generators/typed_eav/scaffold/templates/javascript/controllers/typed_eav_form_controller.js +32 -0
  37. data/lib/generators/typed_eav/scaffold/templates/views/shared/_array_field.html.erb +23 -0
  38. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/edit.html.erb +47 -0
  39. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/finders/_form.html.erb +80 -0
  40. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_boolean.html.erb +12 -0
  41. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_color.html.erb +11 -0
  42. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +57 -0
  43. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date.html.erb +21 -0
  44. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_array.html.erb +16 -0
  45. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_date_time.html.erb +21 -0
  46. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal.html.erb +21 -0
  47. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_decimal_array.html.erb +16 -0
  48. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_email.html.erb +11 -0
  49. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer.html.erb +21 -0
  50. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_integer_array.html.erb +16 -0
  51. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_json.html.erb +11 -0
  52. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_long_text.html.erb +21 -0
  53. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_multi_select.html.erb +6 -0
  54. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_select.html.erb +14 -0
  55. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text.html.erb +26 -0
  56. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_text_array.html.erb +16 -0
  57. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_url.html.erb +11 -0
  58. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +42 -0
  59. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/new.html.erb +7 -0
  60. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +44 -0
  61. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_boolean.html.erb +10 -0
  62. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_color.html.erb +4 -0
  63. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date.html.erb +6 -0
  64. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_array.html.erb +9 -0
  65. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_date_time.html.erb +5 -0
  66. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal.html.erb +6 -0
  67. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_decimal_array.html.erb +9 -0
  68. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_email.html.erb +5 -0
  69. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer.html.erb +6 -0
  70. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_integer_array.html.erb +9 -0
  71. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_json.html.erb +7 -0
  72. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_long_text.html.erb +7 -0
  73. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_multi_select.html.erb +7 -0
  74. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_select.html.erb +7 -0
  75. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text.html.erb +6 -0
  76. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb +9 -0
  77. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb +5 -0
  78. data/lib/typed_eav/column_mapping.rb +64 -0
  79. data/lib/typed_eav/config.rb +91 -0
  80. data/lib/typed_eav/engine.rb +20 -0
  81. data/lib/typed_eav/has_typed_eav.rb +484 -0
  82. data/lib/typed_eav/query_builder.rb +133 -0
  83. data/lib/typed_eav/registry.rb +52 -0
  84. data/lib/typed_eav/version.rb +5 -0
  85. data/lib/typed_eav.rb +86 -0
  86. metadata +146 -0
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/configurable"
4
+
5
+ module TypedEAV
6
+ # Gem-level configuration for field type registration.
7
+ #
8
+ # TypedEAV.configure do |c|
9
+ # c.register_field_type :phone, "MyApp::Fields::Phone"
10
+ # end
11
+ #
12
+ # Accessible from anywhere via `TypedEAV.config` (which returns this
13
+ # class; class-level `field_types` / `register_field_type` / `field_class_for`
14
+ # / `type_names` methods are defined below).
15
+ class Config
16
+ include ActiveSupport::Configurable
17
+
18
+ # Default ambient-scope resolver. Auto-detects `acts_as_tenant` when
19
+ # loaded so AAT users get zero-config behavior. Apps using any other
20
+ # multi-tenancy primitive (Rails `Current` attributes, a subdomain
21
+ # lookup, a thread-local, etc.) override via `TypedEAV.configure`.
22
+ DEFAULT_SCOPE_RESOLVER = lambda {
23
+ ::ActsAsTenant.current_tenant if defined?(::ActsAsTenant)
24
+ }
25
+
26
+ # Map of type names to their STI class names.
27
+ # Add custom types via TypedEAV.configure.
28
+ BUILTIN_FIELD_TYPES = {
29
+ text: "TypedEAV::Field::Text",
30
+ long_text: "TypedEAV::Field::LongText",
31
+ integer: "TypedEAV::Field::Integer",
32
+ decimal: "TypedEAV::Field::Decimal",
33
+ boolean: "TypedEAV::Field::Boolean",
34
+ date: "TypedEAV::Field::Date",
35
+ date_time: "TypedEAV::Field::DateTime",
36
+ select: "TypedEAV::Field::Select",
37
+ multi_select: "TypedEAV::Field::MultiSelect",
38
+ integer_array: "TypedEAV::Field::IntegerArray",
39
+ decimal_array: "TypedEAV::Field::DecimalArray",
40
+ text_array: "TypedEAV::Field::TextArray",
41
+ date_array: "TypedEAV::Field::DateArray",
42
+ email: "TypedEAV::Field::Email",
43
+ url: "TypedEAV::Field::Url",
44
+ color: "TypedEAV::Field::Color",
45
+ json: "TypedEAV::Field::Json",
46
+ }.freeze
47
+
48
+ # Mutable registry of type_name => class_name pairs. Seeded from
49
+ # BUILTIN_FIELD_TYPES on first access; extended via register_field_type.
50
+ config_accessor(:field_types) { BUILTIN_FIELD_TYPES.dup }
51
+
52
+ # Callable returning the ambient scope (partition key) for class-level
53
+ # queries. Invoked by `TypedEAV.current_scope` when no explicit
54
+ # `scope:` kwarg is passed and no `with_scope` block is active.
55
+ config_accessor :scope_resolver, default: DEFAULT_SCOPE_RESOLVER
56
+
57
+ # When true, class-level queries on a model that declared
58
+ # `has_typed_eav scope_method: ...` raise `TypedEAV::ScopeRequired`
59
+ # if no scope can be resolved (explicit arg, active `with_scope` block,
60
+ # or configured resolver all returned nil). Bypass per-call via
61
+ # `TypedEAV.unscoped { ... }`.
62
+ config_accessor :require_scope, default: true
63
+
64
+ class << self
65
+ # Register a custom field type.
66
+ def register_field_type(name, class_name)
67
+ field_types[name.to_sym] = class_name
68
+ end
69
+
70
+ # Resolve a type name to its STI class.
71
+ def field_class_for(type_name)
72
+ class_name = field_types[type_name.to_sym]
73
+ raise ArgumentError, "Unknown field type: #{type_name}" unless class_name
74
+
75
+ class_name.constantize
76
+ end
77
+
78
+ # All registered type names.
79
+ def type_names
80
+ field_types.keys
81
+ end
82
+
83
+ # Restore defaults (test isolation).
84
+ def reset!
85
+ self.field_types = BUILTIN_FIELD_TYPES.dup
86
+ self.scope_resolver = DEFAULT_SCOPE_RESOLVER
87
+ self.require_scope = true
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace TypedEAV
6
+
7
+ initializer "typed_eav.autoload" do
8
+ require_relative "column_mapping"
9
+ require_relative "config"
10
+ require_relative "registry"
11
+ end
12
+
13
+ # Make `has_typed_eav` available on all ActiveRecord models
14
+ initializer "typed_eav.active_record" do
15
+ ActiveSupport.on_load(:active_record) do
16
+ include TypedEAV::HasTypedEAV
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,484 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Include this in any ActiveRecord model to give it typed custom fields.
5
+ #
6
+ # class Contact < ApplicationRecord
7
+ # has_typed_eav
8
+ # end
9
+ #
10
+ # class Contact < ApplicationRecord
11
+ # has_typed_eav scope_method: :tenant_id
12
+ # end
13
+ #
14
+ # This gives you:
15
+ #
16
+ # # Reading/writing values
17
+ # contact.typed_values # => collection
18
+ # contact.initialize_typed_values # => builds missing values with defaults
19
+ # contact.typed_eav_attributes = [...] # => bulk assign via nested attributes
20
+ #
21
+ # # Querying (the good stuff)
22
+ # Contact.where_typed_eav(
23
+ # { name: "age", op: :gt, value: 21 },
24
+ # { name: "status", op: :eq, value: "active" }
25
+ # )
26
+ #
27
+ # # Or the short form with a hash:
28
+ # Contact.with_field("age", :gt, 21)
29
+ # Contact.with_field("status", "active") # :eq is default
30
+ #
31
+ module HasTypedEAV
32
+ extend ActiveSupport::Concern
33
+
34
+ # Indexes field definitions by name with deterministic collision
35
+ # resolution: when a global (scope=NULL) and a scoped field share a
36
+ # name, the scoped row wins. `for_entity(name, scope:)` returns both
37
+ # rows on a collision, and a bare `index_by(&:name)` would let DB row
38
+ # order pick the winner. Shared by the class-query path
39
+ # (ClassQueryMethods#where_typed_eav) and the instance path
40
+ # (InstanceMethods#typed_eav_defs_by_name) so the two can't drift.
41
+ def self.definitions_by_name(defs)
42
+ defs.to_a.sort_by { |d| d.scope.nil? ? 0 : 1 }.index_by(&:name)
43
+ end
44
+
45
+ # Indexes field definitions by name into a multi-map (one name →
46
+ # array of fields). Used by the class-query path under
47
+ # `TypedEAV.unscoped { }`, where the same field name may legitimately
48
+ # exist across multiple tenant partitions and we must OR-across all
49
+ # matching field_ids per filter rather than collapse to a single row.
50
+ def self.definitions_multimap_by_name(defs)
51
+ defs.to_a.group_by(&:name)
52
+ end
53
+
54
+ class_methods do
55
+ # Register this model as having typed fields.
56
+ #
57
+ # Options:
58
+ # scope_method: - method name that returns a scope value (e.g. :tenant_id)
59
+ # for multi-tenant field isolation
60
+ # types: - restrict which field types are allowed (array of symbols)
61
+ # e.g. [:text, :integer, :boolean]
62
+ # default: all types
63
+ # Public DSL macro modeled on `acts_as_*`; renaming would break callers.
64
+ def has_typed_eav(scope_method: nil, types: nil) # rubocop:disable Naming/PredicatePrefix
65
+ # class_attribute rather than cattr_accessor: class variables are
66
+ # copied-on-write across subclasses and reload well under Rails'
67
+ # code reloader. Normalize the types list to strings once so hot
68
+ # paths (type-restriction validation, `typed_eav_attributes=`)
69
+ # don't have to re-map per call.
70
+ class_attribute :typed_eav_scope_method, instance_accessor: false,
71
+ default: scope_method
72
+ class_attribute :allowed_typed_eav_types, instance_accessor: false,
73
+ default: types && types.map(&:to_s).freeze
74
+
75
+ include InstanceMethods
76
+ extend ClassQueryMethods
77
+
78
+ has_many :typed_values,
79
+ class_name: "TypedEAV::Value",
80
+ as: :entity,
81
+ inverse_of: :entity,
82
+ autosave: true,
83
+ dependent: :destroy
84
+
85
+ accepts_nested_attributes_for :typed_values, allow_destroy: true
86
+
87
+ # Register with the global registry
88
+ TypedEAV.registry.register(name, types: types)
89
+ end
90
+ end
91
+
92
+ # ──────────────────────────────────────────────────
93
+ # Class-level query methods
94
+ # ──────────────────────────────────────────────────
95
+ module ClassQueryMethods
96
+ # Sentinel for the `scope:` kwarg default. Distinguishes "kwarg not
97
+ # passed → resolve from ambient" (UNSET_SCOPE) from "explicitly nil →
98
+ # filter to global-only fields" (preserves prior behavior).
99
+ UNSET_SCOPE = Object.new.freeze
100
+
101
+ # Sentinel returned by `resolve_scope` inside an `unscoped { }` block.
102
+ # Signals the caller to skip the scope filter entirely (return fields
103
+ # across all partitions, not just global).
104
+ ALL_SCOPES = Object.new.freeze
105
+
106
+ # Query by custom field values. Accepts an array of filter hashes
107
+ # or a hash of hashes (from form params).
108
+ #
109
+ # Each filter needs:
110
+ # :name or :n - the field name
111
+ # :op or :operator - the operator (default: :eq)
112
+ # :value or :v - the comparison value
113
+ #
114
+ # Contact.where_typed_eav(
115
+ # { name: "age", op: :gt, value: 21 },
116
+ # { name: "city", value: "Portland" } # op defaults to :eq
117
+ # )
118
+ #
119
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- input normalization + multimap branch + filter dispatch genuinely belong together; splitting hurts readability of the scope-collision logic.
120
+ def where_typed_eav(*filters, scope: UNSET_SCOPE)
121
+ # Normalize input: accept splat args, a single array, a single filter hash,
122
+ # a hash-of-hashes (form params), or ActionController::Parameters.
123
+ filters = filters.map { |f| f.respond_to?(:to_unsafe_h) ? f.to_unsafe_h : f }
124
+
125
+ if filters.size == 1
126
+ inner = filters.first
127
+ inner = inner.to_unsafe_h if inner.respond_to?(:to_unsafe_h)
128
+
129
+ if inner.is_a?(Array)
130
+ filters = inner
131
+ elsif inner.is_a?(Hash)
132
+ # A single filter hash has keys like :name/:n, :op, :value/:v.
133
+ # A hash-of-hashes (form params) has values that are all hashes.
134
+ filter_keys = %i[name n op operator value v].map(&:to_s)
135
+ filters = if inner.keys.any? { |k| filter_keys.include?(k.to_s) }
136
+ [inner]
137
+ else
138
+ inner.values
139
+ end
140
+ end
141
+ end
142
+
143
+ filters = Array(filters)
144
+
145
+ # Resolve the scope once so we can branch on whether we're inside
146
+ # `TypedEAV.unscoped { }` (ALL_SCOPES) or a normal single-scope
147
+ # query. Under ALL_SCOPES the same name can legitimately appear
148
+ # across multiple tenant partitions; collapsing to one definition
149
+ # would silently drop all but one tenant's matches. See the
150
+ # multimap branch below.
151
+ resolved = resolve_scope(scope)
152
+ all_scopes = resolved.equal?(ALL_SCOPES)
153
+
154
+ defs = if all_scopes
155
+ TypedEAV::Field::Base.where(entity_type: name)
156
+ else
157
+ TypedEAV::Field::Base.for_entity(name, scope: resolved)
158
+ end
159
+
160
+ if all_scopes
161
+ fields_multimap = HasTypedEAV.definitions_multimap_by_name(defs)
162
+
163
+ filters.inject(all) do |query, filter|
164
+ filter = filter.to_h.with_indifferent_access
165
+
166
+ name = filter[:n] || filter[:name]
167
+ operator = (filter[:op] || filter[:operator] || :eq).to_sym
168
+ value = filter.key?(:v) ? filter[:v] : filter[:value]
169
+
170
+ matching_fields = fields_multimap[name.to_s]
171
+ unless matching_fields&.any?
172
+ raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
173
+ "Available fields: #{fields_multimap.keys.join(", ")}"
174
+ end
175
+
176
+ # OR-across all field_ids that share this name (across tenants),
177
+ # while preserving AND between filters via the chained `.where`.
178
+ # Use the underlying Value scope (`.filter(...)`) and pluck
179
+ # entity_ids — `entity_ids` returns a relation, and pluck collapses
180
+ # it to a plain integer array we can union across tenants.
181
+ union_ids = matching_fields.flat_map do |f|
182
+ TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id)
183
+ end.uniq
184
+
185
+ query.where(id: union_ids)
186
+ end
187
+ else
188
+ fields_by_name = HasTypedEAV.definitions_by_name(defs)
189
+
190
+ filters.inject(all) do |query, filter|
191
+ filter = filter.to_h.with_indifferent_access
192
+
193
+ name = filter[:n] || filter[:name]
194
+ operator = (filter[:op] || filter[:operator] || :eq).to_sym
195
+ value = filter.key?(:v) ? filter[:v] : filter[:value]
196
+
197
+ field = fields_by_name[name.to_s]
198
+ unless field
199
+ raise ArgumentError, "Unknown typed field '#{name}' for #{self.name}. " \
200
+ "Available fields: #{fields_by_name.keys.join(", ")}"
201
+ end
202
+
203
+ matching_ids = TypedEAV::QueryBuilder.entity_ids(field, operator, value)
204
+ query.where(id: matching_ids)
205
+ end
206
+ end
207
+ end
208
+
209
+ # Shorthand for single-field queries.
210
+ #
211
+ # Contact.with_field("age", :gt, 21)
212
+ # Contact.with_field("active", true) # op defaults to :eq
213
+ # Contact.with_field("name", :contains, "smith")
214
+ #
215
+ def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE)
216
+ if value.nil? && !operator_or_value.is_a?(Symbol)
217
+ # Two-arg form: with_field("name", "value") implies :eq
218
+ where_typed_eav({ name: name, op: :eq, value: operator_or_value }, scope: scope)
219
+ else
220
+ where_typed_eav({ name: name, op: operator_or_value, value: value }, scope: scope)
221
+ end
222
+ end
223
+
224
+ # Returns field definitions for this entity type.
225
+ #
226
+ # `scope:` behavior:
227
+ # - omitted → resolve from ambient (`with_scope` → resolver → raise/nil)
228
+ # - passed a value → use verbatim (explicit override; admin/test path)
229
+ # - passed nil → filter to global-only fields (prior behavior preserved)
230
+ def typed_eav_definitions(scope: UNSET_SCOPE)
231
+ resolved = resolve_scope(scope)
232
+ if resolved.equal?(ALL_SCOPES)
233
+ TypedEAV::Field::Base.where(entity_type: name)
234
+ else
235
+ TypedEAV::Field::Base.for_entity(name, scope: resolved)
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ # Resolves the scope kwarg into a concrete value for field-definition
242
+ # lookup. See `typed_eav_definitions` docs for kwarg semantics.
243
+ # Raises `TypedEAV::ScopeRequired` when the model declares
244
+ # `scope_method:` but ambient scope can't be resolved and fail-closed
245
+ # mode is enabled.
246
+ def resolve_scope(explicit)
247
+ # Explicit override (including explicit nil) — use verbatim.
248
+ return TypedEAV.normalize_scope(explicit) unless explicit.equal?(UNSET_SCOPE)
249
+
250
+ # Inside `TypedEAV.unscoped { }` — skip the scope filter entirely.
251
+ return ALL_SCOPES if TypedEAV.unscoped?
252
+
253
+ # Models that did NOT opt into scoping must NOT see ambient scope.
254
+ # If the host declared `has_typed_eav` without `scope_method:`, it
255
+ # has no per-instance scope accessor, so `Value#validate_field_scope_matches_entity`
256
+ # would reject any attempt to attach a scoped field anyway. Honoring
257
+ # ambient scope here would surface scoped field definitions that the
258
+ # model can never actually use — confusing in admin/forms — and would
259
+ # leak cross-model ambient state into a model that never opted in.
260
+ # An explicit `scope:` kwarg (handled above) still overrides this, so
261
+ # admin/test paths retain the ability to query arbitrary scopes.
262
+ return nil unless typed_eav_scope_method
263
+
264
+ # Ambient resolver (via `with_scope` stack or configured lambda).
265
+ resolved = TypedEAV.current_scope
266
+ return resolved unless resolved.nil?
267
+
268
+ # Fail-closed: the model opted into scoping (`scope_method:` declared)
269
+ # but nothing resolved. Raise so data can't leak across partitions.
270
+ if typed_eav_scope_method && TypedEAV.config.require_scope
271
+ raise TypedEAV::ScopeRequired,
272
+ "No ambient scope resolvable for #{name}. " \
273
+ "Wrap the call in `TypedEAV.with_scope(value) { ... }`, " \
274
+ "configure `TypedEAV.config.scope_resolver`, or use " \
275
+ "`TypedEAV.unscoped { ... }` to deliberately bypass."
276
+ end
277
+
278
+ nil
279
+ end
280
+ end
281
+
282
+ # ──────────────────────────────────────────────────
283
+ # Instance methods
284
+ # ──────────────────────────────────────────────────
285
+ module InstanceMethods
286
+ # The field definitions available for this record
287
+ def typed_eav_definitions
288
+ self.class.typed_eav_definitions(scope: typed_eav_scope)
289
+ end
290
+
291
+ # Current scope value (for multi-tenant)
292
+ def typed_eav_scope
293
+ return nil unless self.class.typed_eav_scope_method
294
+
295
+ send(self.class.typed_eav_scope_method)&.to_s
296
+ end
297
+
298
+ # Build missing values with defaults for all available fields.
299
+ # Useful in forms to show all fields even when no value exists yet.
300
+ #
301
+ # Iterates the collision-collapsed view (`typed_eav_defs_by_name`)
302
+ # rather than the raw definitions list. Otherwise, when a record's
303
+ # scope partition has both a global (scope=NULL) and a same-name
304
+ # scoped field, `for_entity` returns BOTH rows and the form would
305
+ # render two inputs for the same name — but only the scoped one
306
+ # round-trips on save (it wins in `typed_eav_defs_by_name`).
307
+ def initialize_typed_values
308
+ existing_field_ids = typed_values.loaded? ? typed_values.map(&:field_id) : typed_values.pluck(:field_id)
309
+
310
+ typed_eav_defs_by_name.each_value do |field|
311
+ next if existing_field_ids.include?(field.id)
312
+
313
+ typed_values.build(field: field, value: field.default_value)
314
+ end
315
+
316
+ typed_values
317
+ end
318
+
319
+ # Bulk assign values by field NAME. Coexists with (rather than replaces)
320
+ # the `accepts_nested_attributes_for :typed_values` setter declared above,
321
+ # which accepts entries keyed by field ID.
322
+ #
323
+ # Why both exist:
324
+ #
325
+ # * The nested-attributes setter (`typed_values_attributes=`) is the
326
+ # standard Rails form contract. HTML form builders emit `field_id`
327
+ # as a hidden input per value row, so when a form posts back, the
328
+ # params look like:
329
+ # { typed_values_attributes: [
330
+ # { id: 12, field_id: 4, value: "40" }, ...
331
+ # ] }
332
+ # `accepts_nested_attributes_for` matches existing values by `id`.
333
+ #
334
+ # * This setter (`typed_eav_attributes=` / `typed_eav=`) takes
335
+ # entries keyed by field *name* and translates them to field IDs
336
+ # before handing off to the nested-attributes setter. It also
337
+ # enforces the `types:` restriction declared on `has_typed_eav`
338
+ # (rejecting entries for disallowed field types) and supports
339
+ # `_destroy: true` for removing a value by name. This is the
340
+ # ergonomic path for console/seed code:
341
+ # record.typed_eav_attributes = [
342
+ # { name: "age", value: 30 },
343
+ # { name: "email", value: "test@example.com" },
344
+ # { name: "old_field", _destroy: true },
345
+ # ]
346
+ #
347
+ # Pick the one that fits: forms -> typed_values_attributes=, scripting
348
+ # -> typed_eav_attributes=. They can't both run in the same save.
349
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
350
+
351
+ # rubocop:disable Metrics/AbcSize -- branches on existing/new/destroy and type-restriction in one place; splitting would obscure the precedence rules.
352
+ def typed_eav_attributes=(attributes)
353
+ attributes = attributes.to_h if attributes.respond_to?(:permitted?)
354
+ attributes = attributes.values if attributes.is_a?(Hash)
355
+ attributes = Array(attributes)
356
+
357
+ fields_by_name = typed_eav_defs_by_name
358
+ values_by_field_id = typed_values.index_by(&:field_id)
359
+
360
+ nested = attributes.filter_map do |attrs|
361
+ attrs = attrs.to_h.with_indifferent_access
362
+
363
+ field = fields_by_name[attrs[:name]]
364
+ next unless field
365
+
366
+ # Enforce type restrictions. Normalized to strings at registration
367
+ # time (see `has_typed_eav`), so no per-call mapping.
368
+ allowed = self.class.allowed_typed_eav_types
369
+ next if allowed&.exclude?(field.field_type_name)
370
+
371
+ existing = values_by_field_id[field.id]
372
+
373
+ if ActiveRecord::Type::Boolean.new.cast(attrs[:_destroy])
374
+ { id: existing&.id, _destroy: true }
375
+ elsif existing
376
+ { id: existing.id, value: attrs[:value] }
377
+ else
378
+ typed_values.build(field: field, value: attrs[:value])
379
+ nil # build already added it, skip nested_attributes
380
+ end
381
+ end.compact
382
+
383
+ self.typed_values_attributes = nested if nested.any?
384
+ end
385
+
386
+ # rubocop:enable Metrics/AbcSize
387
+ alias typed_eav= typed_eav_attributes=
388
+
389
+ # Get a specific field's value by name. Honors an already-loaded
390
+ # `typed_values` association so list-page callers that preloaded
391
+ # `typed_values: :field` don't trigger a fresh query per record.
392
+ #
393
+ # On a global+scoped name collision, prefer the value bound to the
394
+ # winning field_id (scoped wins). Without this guard, a stray value
395
+ # row attached to a shadowed global field would surface here even
396
+ # though writes route through the scoped winner.
397
+ # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity -- name-collision precedence + orphan guard + already-loaded preload reuse.
398
+ def typed_eav_value(name)
399
+ winning = typed_eav_defs_by_name[name.to_s]
400
+ # Skip orphans (`v.field` nil — definition deleted out from under the
401
+ # value via raw SQL or a missing FK cascade) so a stray row can't
402
+ # crash the read path with NoMethodError.
403
+ candidates = loaded_typed_values_with_fields.select { |v| v.field && v.field.name == name.to_s }
404
+ tv = if winning && candidates.any? { |v| (v.field_id || v.field&.id) == winning.id }
405
+ candidates.detect { |v| (v.field_id || v.field&.id) == winning.id }
406
+ else
407
+ candidates.first
408
+ end
409
+ tv&.value
410
+ end
411
+ # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
412
+
413
+ # Set a specific field's value by name
414
+ def set_typed_eav_value(name, value)
415
+ field = typed_eav_defs_by_name[name.to_s]
416
+ return unless field
417
+
418
+ existing = typed_values.detect { |v| v.field_id == field.id }
419
+ if existing
420
+ existing.value = value
421
+ else
422
+ typed_values.build(field: field, value: value)
423
+ end
424
+ end
425
+
426
+ # Hash of all field values: { "field_name" => value, ... }. Same
427
+ # preload semantics as `typed_eav_value` — respects already-loaded
428
+ # associations instead of rebuilding the relation.
429
+ #
430
+ # Collision-safe: on a global+scoped name overlap, the value attached
431
+ # to the winning field_id wins (scoped). Without this guard, a stray
432
+ # row tied to a shadowed global field could surface here even though
433
+ # writes route through the scoped winner.
434
+ def typed_eav_hash
435
+ winning_ids_by_name = typed_eav_defs_by_name.transform_values(&:id)
436
+ rows = loaded_typed_values_with_fields
437
+
438
+ rows.each_with_object({}) do |tv, hash|
439
+ # Skip orphans (`tv.field` nil — definition deleted out from under
440
+ # the value) so the hash isn't crashy when stale rows linger.
441
+ next unless tv.field
442
+
443
+ name = tv.field.name
444
+ winning_id = winning_ids_by_name[name]
445
+ effective_id = tv.field_id || tv.field&.id
446
+
447
+ # A winner is registered for this name: only its row is allowed.
448
+ # If no winner is registered (definition deleted while values
449
+ # remain), fall back to first-wins so the hash isn't lossy.
450
+ if winning_id
451
+ hash[name] = tv.value if effective_id == winning_id
452
+ else
453
+ hash[name] = tv.value unless hash.key?(name)
454
+ end
455
+ end
456
+ end
457
+
458
+ private
459
+
460
+ # Returns typed_values with their fields, preferring already-loaded
461
+ # associations. Callers on list pages should preload with
462
+ # `includes(typed_values: :field)`; this method keeps the happy path
463
+ # fast without forcing that contract.
464
+ def loaded_typed_values_with_fields
465
+ if typed_values.loaded?
466
+ # Don't re-query if the caller already preloaded; ensure each value's
467
+ # field is materialized (fall back to per-row load if the nested
468
+ # `:field` was not preloaded).
469
+ typed_values.to_a
470
+ else
471
+ typed_values.includes(:field).to_a
472
+ end
473
+ end
474
+
475
+ # Field definitions indexed by name with deterministic collision handling:
476
+ # when both a global (scope=NULL) and a scoped field share a name, the
477
+ # scoped definition wins. Delegates to `HasTypedEAV.definitions_by_name`
478
+ # so the class-query path and the instance path share one source of truth.
479
+ def typed_eav_defs_by_name
480
+ HasTypedEAV.definitions_by_name(typed_eav_definitions)
481
+ end
482
+ end
483
+ end
484
+ end