typed_eav 0.2.1 → 0.3.1

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -0
  3. data/README.md +287 -47
  4. data/app/models/typed_eav/field/base.rb +28 -159
  5. data/app/models/typed_eav/field/currency.rb +54 -20
  6. data/app/models/typed_eav/field/date.rb +16 -1
  7. data/app/models/typed_eav/field/date_time.rb +16 -1
  8. data/app/models/typed_eav/field/decimal.rb +9 -1
  9. data/app/models/typed_eav/field/email.rb +13 -16
  10. data/app/models/typed_eav/field/integer.rb +6 -1
  11. data/app/models/typed_eav/field/long_text.rb +17 -1
  12. data/app/models/typed_eav/field/multi_select.rb +12 -9
  13. data/app/models/typed_eav/field/optionable.rb +59 -0
  14. data/app/models/typed_eav/field/percentage.rb +6 -6
  15. data/app/models/typed_eav/field/range_bounded.rb +71 -0
  16. data/app/models/typed_eav/field/select.rb +9 -10
  17. data/app/models/typed_eav/field/text.rb +11 -29
  18. data/app/models/typed_eav/field/url.rb +14 -16
  19. data/app/models/typed_eav/field/validated_string.rb +87 -0
  20. data/app/models/typed_eav/value.rb +9 -9
  21. data/lib/typed_eav/bulk_read.rb +124 -0
  22. data/lib/typed_eav/engine.rb +1 -1
  23. data/lib/typed_eav/entity_query.rb +186 -0
  24. data/lib/typed_eav/field/typed_storage.rb +205 -0
  25. data/lib/typed_eav/filter_query.rb +148 -0
  26. data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
  27. data/lib/typed_eav/has_typed_eav.rb +29 -793
  28. data/lib/typed_eav/partition.rb +51 -11
  29. data/lib/typed_eav/query_builder.rb +6 -7
  30. data/lib/typed_eav/scope_tuple.rb +116 -0
  31. data/lib/typed_eav/version.rb +1 -1
  32. data/lib/typed_eav/versioning/subscriber.rb +7 -6
  33. data/lib/typed_eav.rb +23 -64
  34. metadata +10 -4
  35. data/lib/typed_eav/column_mapping.rb +0 -110
  36. data/lib/typed_eav/currency_storage_contract.rb +0 -46
  37. data/lib/typed_eav/field_storage_contract.rb +0 -68
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # One concern owns the entire native-typed-column storage seam.
6
+ #
7
+ # Field types declare WHICH typed column(s) hold their value via the
8
+ # class-level DSL (`value_column`, `value_columns`, `operators`,
9
+ # `operator_column`), and override three instance methods to compose
10
+ # multi-cell value shapes (`read_value`, `write_value`, `apply_default`).
11
+ # Snapshot/change-detection helpers (`value_changed?`, `before_snapshot`,
12
+ # `after_snapshot`) are concrete and derive from `value_columns`; they
13
+ # are NOT overridable — the snapshot shape is a versioning-coupled
14
+ # invariant.
15
+ #
16
+ # ## Class-level DSL
17
+ #
18
+ # value_column :integer_value # single-cell sugar; primary cell
19
+ # value_columns :decimal_value, :string_value # plural form
20
+ # operators :eq, :gt, :is_null # restrict supported operators
21
+ # operator_column(:currency_eq) # override to route ops to cells
22
+ #
23
+ # Both `value_column` and `value_columns` share the same `@value_columns`
24
+ # class instance variable. `value_column` returns the first element of
25
+ # `value_columns`, preserving the single-cell sugar shape.
26
+ #
27
+ # ## Override-point instance methods (the entire extension surface)
28
+ #
29
+ # - `read_value(record)` — compose the logical value from the cells.
30
+ # - `write_value(record, casted)` — unpack the casted value across cells.
31
+ # - `apply_default(record)` — populate cells from the field's default.
32
+ #
33
+ # The default implementations target `value_columns.first` (single-cell
34
+ # behavior). Multi-cell types override ALL THREE — overriding just one
35
+ # creates an asymmetry where reads see the multi-cell shape but writes
36
+ # / defaults populate only one column (or vice versa).
37
+ #
38
+ # ## Concrete (non-overridable) snapshot helpers
39
+ #
40
+ # - `value_changed?(record)` — true iff ANY value_columns column has a
41
+ # saved_change_to_attribute? — used by the Value :update dispatch gate
42
+ # so multi-cell types fire the event when only the second cell changed.
43
+ # - `before_snapshot(record, change_type)` — per-column hash keyed by
44
+ # string column names. `:create` returns `{}`.
45
+ # - `after_snapshot(record, change_type)` — per-column hash keyed by
46
+ # string column names. `:destroy` returns `{}`.
47
+ #
48
+ # Snapshot keys are stringified so query patterns like
49
+ # `WHERE before_value->>'integer_value' = '42'` work uniformly.
50
+ module TypedStorage
51
+ extend ActiveSupport::Concern
52
+
53
+ DEFAULT_OPERATORS_BY_COLUMN = {
54
+ boolean_value: %i[eq not_eq is_null is_not_null],
55
+ string_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
56
+ text_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
57
+ integer_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
58
+ decimal_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
59
+ date_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
60
+ datetime_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
61
+ json_value: %i[contains is_null is_not_null],
62
+ }.freeze
63
+ FALLBACK_OPERATORS = %i[eq not_eq is_null is_not_null].freeze
64
+
65
+ class_methods do
66
+ # Declare the typed column(s) this field type stores its value in.
67
+ #
68
+ # `value_column :col` — single-cell sugar; equivalent to
69
+ # `value_columns :col`. Returns `:col` when called without arguments.
70
+ # Raises NotImplementedError when called without arguments AND no
71
+ # column has been declared (the "subclass must declare" enforcement).
72
+ #
73
+ # Both `value_column` and `value_columns` write to the same
74
+ # `@value_columns` class instance variable on the declaring class
75
+ # (Ruby class ivars are NOT inherited, so each subclass that calls
76
+ # the setter installs its own). `Field::Percentage` re-declares
77
+ # `value_column :decimal_value` to work around this non-inheritance.
78
+ def value_column(column_name = nil)
79
+ if column_name
80
+ @value_columns = [column_name.to_sym]
81
+ else
82
+ cols = value_columns
83
+ cols.first
84
+ end
85
+ end
86
+
87
+ # Declare the typed columns this field type stores across (multi-cell
88
+ # form). Returns the configured Array when called without arguments.
89
+ # Raises NotImplementedError when no column(s) have been declared
90
+ # on this class — the same enforcement as `value_column`.
91
+ #
92
+ # The primary cell is `value_columns.first`; defaults for
93
+ # `read_value` / `write_value` / `apply_default` target it.
94
+ def value_columns(*cols)
95
+ if cols.any?
96
+ @value_columns = cols.map(&:to_sym)
97
+ else
98
+ @value_columns || raise(NotImplementedError,
99
+ "#{name} must declare `value_column :column_name`")
100
+ end
101
+ end
102
+
103
+ # The physical column this operator acts on. Defaults to
104
+ # `value_columns.first` for single-cell field types. Multi-cell
105
+ # types (Currency: `:currency_eq` → `:string_value`; everything
106
+ # else → `:decimal_value`) override this to route operators to
107
+ # different cells.
108
+ #
109
+ # Called by `QueryBuilder.filter` AFTER the
110
+ # `supported_operators.include?(operator)` validation gate, so
111
+ # `_operator` is always one the field explicitly supports.
112
+ def operator_column(_operator)
113
+ value_columns.first
114
+ end
115
+
116
+ # Operators this field type supports for querying. Defaults to the
117
+ # column-aware default set. Override via `.operators(*ops)`.
118
+ def supported_operators
119
+ @supported_operators || default_operators_for(value_columns.first)
120
+ end
121
+
122
+ def operators(*ops)
123
+ @supported_operators = ops.map(&:to_sym)
124
+ end
125
+
126
+ private
127
+
128
+ def default_operators_for(col)
129
+ DEFAULT_OPERATORS_BY_COLUMN.fetch(col, FALLBACK_OPERATORS)
130
+ end
131
+ end
132
+
133
+ # ── Override-point instance methods (the entire multi-cell surface) ──
134
+
135
+ # Returns the logical value for this field as stored on `value_record`.
136
+ # Default reads the primary cell. Override in multi-cell types to
137
+ # compose a hash (e.g., `Field::Currency` returns
138
+ # `{amount: r[:decimal_value], currency: r[:string_value]}`).
139
+ def read_value(value_record)
140
+ value_record[self.class.value_columns.first]
141
+ end
142
+
143
+ # Writes a casted value to `value_record`. Default writes the primary
144
+ # cell. Override in multi-cell types to unpack the casted value
145
+ # across multiple cells.
146
+ def write_value(value_record, casted)
147
+ value_record[self.class.value_columns.first] = casted
148
+ end
149
+
150
+ # Writes this field's configured default to `value_record`. Default
151
+ # writes `default_value` to the primary cell, bypassing Value#value=
152
+ # to avoid re-casting an already-cast default. Override in multi-cell
153
+ # types to populate multiple cells from a composite default.
154
+ def apply_default(value_record)
155
+ value_record[self.class.value_columns.first] = default_value
156
+ end
157
+
158
+ # ── Concrete snapshot helpers (NOT overridable) ──
159
+
160
+ # True iff ANY of the field's value_columns had a saved change in the
161
+ # just-committed save. Used by Value's :update dispatch gate so
162
+ # multi-cell types correctly fire the event when only the second cell
163
+ # changed (regression case Phase 5 D3).
164
+ def value_changed?(value_record)
165
+ self.class.value_columns.any? do |column|
166
+ value_record.saved_change_to_attribute?(column)
167
+ end
168
+ end
169
+
170
+ # Pre-change snapshot keyed by string column names.
171
+ # - :create → {} (no before state)
172
+ # - :update → {col => attribute_before_last_save(col)}
173
+ # - :destroy → {col => value_record[col]} (in-memory on the destroyed
174
+ # AR record per Phase 03 P04 live-validation)
175
+ def before_snapshot(value_record, change_type)
176
+ case change_type.to_sym
177
+ when :create
178
+ {}
179
+ when :update
180
+ self.class.value_columns.to_h do |column|
181
+ [column.to_s, value_record.attribute_before_last_save(column.to_s)]
182
+ end
183
+ when :destroy
184
+ self.class.value_columns.to_h { |column| [column.to_s, value_record[column]] }
185
+ else
186
+ raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
187
+ end
188
+ end
189
+
190
+ # Post-change snapshot keyed by string column names.
191
+ # - :create / :update → {col => value_record[col]}
192
+ # - :destroy → {} (no after state)
193
+ def after_snapshot(value_record, change_type)
194
+ case change_type.to_sym
195
+ when :create, :update
196
+ self.class.value_columns.to_h { |column| [column.to_s, value_record[column]] }
197
+ when :destroy
198
+ {}
199
+ else
200
+ raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Multi-filter query orchestrator. Given a model + a list of filter hashes
5
+ # + an already-resolved scope tuple (or `EntityQuery::ALL_SCOPES`), composes
6
+ # an `ActiveRecord::Relation` by fanning out across filters and dispatching
7
+ # each per-filter predicate to `TypedEAV::QueryBuilder`.
8
+ #
9
+ # FilterQuery.new(
10
+ # model: Contact,
11
+ # filters: [{ name: "age", op: :gt, value: 21 }],
12
+ # scope: "t1",
13
+ # parent_scope: nil,
14
+ # ).to_relation
15
+ #
16
+ # ## Two altitudes (ADR-0002)
17
+ #
18
+ # `QueryBuilder` stays the per-field SQL primitive (knows nothing about
19
+ # multi-filter composition or scope collision). `FilterQuery` is the
20
+ # orchestrator that knows about input shape, partition lookup, collision
21
+ # precedence, and per-filter chaining.
22
+ #
23
+ # ## Scope shape
24
+ #
25
+ # `scope:` is either `EntityQuery::ALL_SCOPES` (atomic-bypass under
26
+ # `TypedEAV.unscoped { }` — the multimap branch) or a `String | nil`
27
+ # already-resolved scope value. `parent_scope:` is `String | nil`. Scope
28
+ # resolution and sentinel handling live in `EntityQuery#resolve_scope`;
29
+ # this class works on resolved tuples only.
30
+ class FilterQuery
31
+ FILTER_KEYS = %w[name n op operator value v].freeze
32
+ private_constant :FILTER_KEYS
33
+
34
+ def initialize(model:, filters:, scope:, parent_scope:)
35
+ @model = model
36
+ @raw_filters = filters
37
+ @scope = scope
38
+ @parent_scope = parent_scope
39
+ end
40
+
41
+ def to_relation
42
+ filters = normalize_filters(@raw_filters)
43
+ defs = lookup_definitions
44
+
45
+ if all_scopes?
46
+ apply_multimap_filters(filters, TypedEAV::Partition.definitions_multimap_by_name(defs))
47
+ else
48
+ apply_single_scope_filters(filters, TypedEAV::Partition.definitions_by_name(defs))
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :model
55
+
56
+ # ── Input normalization ─────────────────────────────────────────────
57
+
58
+ # Accepts splat args, a single Array, a single filter Hash, a hash-of-
59
+ # hashes (form params), or `ActionController::Parameters`. Returns an
60
+ # Array of plain Hashes (each a filter spec with :name/:n, :op/:operator,
61
+ # :value/:v keys).
62
+ def normalize_filters(filters)
63
+ flattened = filters.map { |f| coerce_to_h(f) }
64
+ flattened = expand_single_argument(flattened) if flattened.size == 1
65
+ Array(flattened)
66
+ end
67
+
68
+ def coerce_to_h(filter)
69
+ filter.respond_to?(:to_unsafe_h) ? filter.to_unsafe_h : filter
70
+ end
71
+
72
+ def expand_single_argument(filters)
73
+ inner = coerce_to_h(filters.first)
74
+ return inner if inner.is_a?(Array)
75
+ return [inner] unless inner.is_a?(Hash)
76
+ return [inner] if filter_hash?(inner)
77
+
78
+ inner.values
79
+ end
80
+
81
+ def filter_hash?(hash)
82
+ hash.keys.any? { |k| FILTER_KEYS.include?(k.to_s) }
83
+ end
84
+
85
+ # ── Partition lookup ───────────────────────────────────────────────
86
+
87
+ def all_scopes?
88
+ @scope.equal?(TypedEAV::EntityQuery::ALL_SCOPES)
89
+ end
90
+
91
+ def lookup_definitions
92
+ if all_scopes?
93
+ TypedEAV::Partition.visible_fields(entity_type: model.name, mode: :all_partitions)
94
+ else
95
+ TypedEAV::Partition.visible_fields(
96
+ entity_type: model.name,
97
+ scope: @scope,
98
+ parent_scope: @parent_scope,
99
+ )
100
+ end
101
+ end
102
+
103
+ # ── Filter dispatch ────────────────────────────────────────────────
104
+
105
+ def apply_single_scope_filters(filters, fields_by_name)
106
+ filters.inject(model.all) do |query, filter|
107
+ spec = parse_filter(filter)
108
+ field = fields_by_name[spec[:name]] || raise_unknown_field(spec[:name], fields_by_name.keys)
109
+ matching_ids = TypedEAV::QueryBuilder.entity_ids(field, spec[:operator], spec[:value])
110
+ query.where(id: matching_ids)
111
+ end
112
+ end
113
+
114
+ def apply_multimap_filters(filters, fields_multimap)
115
+ filters.inject(model.all) do |query, filter|
116
+ spec = parse_filter(filter)
117
+ fields = fields_multimap[spec[:name]]
118
+ raise_unknown_field(spec[:name], fields_multimap.keys) unless fields&.any?
119
+
120
+ union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
121
+ query.where(id: union_ids)
122
+ end
123
+ end
124
+
125
+ # OR-across all field_ids that share the same name (across tenants),
126
+ # while preserving AND between filters via the chained `.where`. Use the
127
+ # underlying Value scope (`.filter`) and `pluck(:entity_id)` to collapse
128
+ # to a plain integer array we can union across tenants.
129
+ def union_entity_ids(fields, operator, value)
130
+ fields.flat_map { |f| TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id) }.uniq
131
+ end
132
+
133
+ def parse_filter(filter)
134
+ h = filter.to_h.with_indifferent_access
135
+ {
136
+ name: (h[:n] || h[:name]).to_s,
137
+ operator: (h[:op] || h[:operator] || :eq).to_sym,
138
+ value: h.key?(:v) ? h[:v] : h[:value],
139
+ }
140
+ end
141
+
142
+ def raise_unknown_field(name, available)
143
+ raise ArgumentError,
144
+ "Unknown typed field '#{name}' for #{model.name}. " \
145
+ "Available fields: #{available.join(", ")}"
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module HasTypedEAV
5
+ # Per-record API mixed into host AR models by the `has_typed_eav` macro.
6
+ # Reads/writes typed values via field name, returns scope/parent_scope
7
+ # via the configured accessor methods, and builds the collision-collapsed
8
+ # per-instance definition map (delegating to `Partition.definitions_by_name`
9
+ # so the class-query path and the instance path share one source of truth).
10
+ module InstanceMethods
11
+ # The field definitions available for this record
12
+ def typed_eav_definitions
13
+ self.class.typed_eav_definitions(
14
+ scope: typed_eav_scope,
15
+ parent_scope: typed_eav_parent_scope,
16
+ )
17
+ end
18
+
19
+ # Current scope value (for multi-tenant)
20
+ def typed_eav_scope
21
+ return nil unless self.class.typed_eav_scope_method
22
+
23
+ send(self.class.typed_eav_scope_method)&.to_s
24
+ end
25
+
26
+ # Current parent_scope value (for two-level partitioning).
27
+ #
28
+ # Returns nil for models that did not declare `parent_scope_method:` —
29
+ # the method is defined unconditionally so callers (e.g. the Value-side
30
+ # cross-axis validator) can `respond_to?` and read uniformly without
31
+ # branching on `parent_scope_method` configuration. Mirrors the
32
+ # `&.to_s` normalization on `typed_eav_scope`.
33
+ def typed_eav_parent_scope
34
+ return nil unless self.class.typed_eav_parent_scope_method
35
+
36
+ send(self.class.typed_eav_parent_scope_method)&.to_s
37
+ end
38
+
39
+ # Build missing values with defaults for all available fields.
40
+ # Useful in forms to show all fields even when no value exists yet.
41
+ #
42
+ # Iterates the collision-collapsed view (`typed_eav_defs_by_name`)
43
+ # rather than the raw definitions list. Otherwise, when a record's
44
+ # scope partition has both a global (scope=NULL) and a same-name
45
+ # scoped field, `for_entity` returns BOTH rows and the form would
46
+ # render two inputs for the same name — but only the scoped one
47
+ # round-trips on save (it wins in `typed_eav_defs_by_name`).
48
+ def initialize_typed_values
49
+ existing_field_ids = typed_values.loaded? ? typed_values.map(&:field_id) : typed_values.pluck(:field_id)
50
+
51
+ typed_eav_defs_by_name.each_value do |field|
52
+ next if existing_field_ids.include?(field.id)
53
+
54
+ typed_values.build(field: field, value: field.default_value)
55
+ end
56
+
57
+ typed_values
58
+ end
59
+
60
+ # Bulk assign values by field NAME. Coexists with (rather than replaces)
61
+ # the `accepts_nested_attributes_for :typed_values` setter declared
62
+ # on the host model, which accepts entries keyed by field ID.
63
+ #
64
+ # The nested-attributes setter is the standard Rails form contract
65
+ # (forms post `field_id` as a hidden input per value row). This setter
66
+ # takes entries keyed by field *name* and translates them to field
67
+ # IDs before handing off to the nested-attributes setter. It also
68
+ # enforces the `types:` restriction declared on `has_typed_eav` and
69
+ # supports `_destroy: true` for removing a value by name.
70
+ #
71
+ # record.typed_eav_attributes = [
72
+ # { name: "age", value: 30 },
73
+ # { name: "email", value: "test@example.com" },
74
+ # { name: "old_field", _destroy: true },
75
+ # ]
76
+ #
77
+ # Pick the one that fits: forms -> typed_values_attributes=, scripting
78
+ # -> typed_eav_attributes=. They can't both run in the same save.
79
+ def typed_eav_attributes=(attributes)
80
+ fields_by_name = typed_eav_defs_by_name
81
+ values_by_field_id = typed_values.index_by(&:field_id)
82
+
83
+ nested = normalize_typed_eav_attributes(attributes).filter_map do |attrs|
84
+ build_or_update_typed_value(attrs, fields_by_name, values_by_field_id)
85
+ end
86
+
87
+ self.typed_values_attributes = nested if nested.any?
88
+ end
89
+
90
+ alias typed_eav= typed_eav_attributes=
91
+
92
+ # Get a specific field's value by name. Honors an already-loaded
93
+ # `typed_values` association so list-page callers that preloaded
94
+ # `typed_values: :field` don't trigger a fresh query per record.
95
+ #
96
+ # On a global+scoped name collision, prefer the value bound to the
97
+ # winning field_id (scoped wins). Without this guard, a stray value
98
+ # row attached to a shadowed global field would surface here even
99
+ # though writes route through the scoped winner.
100
+ def typed_eav_value(name)
101
+ winning = typed_eav_defs_by_name[name.to_s]
102
+ # Skip orphans (`v.field` nil — definition deleted out from under
103
+ # the value via raw SQL or a missing FK cascade) so a stray row
104
+ # can't crash the read path with NoMethodError.
105
+ candidates = loaded_typed_values_with_fields.select { |v| v.field && v.field.name == name.to_s }
106
+ select_winning_value(candidates, winning)&.value
107
+ end
108
+
109
+ # Set a specific field's value by name
110
+ def set_typed_eav_value(name, value)
111
+ field = typed_eav_defs_by_name[name.to_s]
112
+ return unless field
113
+
114
+ existing = typed_values.detect { |v| v.field_id == field.id }
115
+ if existing
116
+ existing.value = value
117
+ else
118
+ typed_values.build(field: field, value: value)
119
+ end
120
+ end
121
+
122
+ # Hash of all field values: { "field_name" => value, ... }. Same
123
+ # preload semantics as `typed_eav_value` — respects already-loaded
124
+ # associations instead of rebuilding the relation.
125
+ #
126
+ # Collision-safe: on a global+scoped name overlap, the value attached
127
+ # to the winning field_id wins (scoped). Without this guard, a stray
128
+ # row tied to a shadowed global field could surface here even though
129
+ # writes route through the scoped winner.
130
+ def typed_eav_hash
131
+ winning_ids_by_name = typed_eav_defs_by_name.transform_values(&:id)
132
+
133
+ loaded_typed_values_with_fields.each_with_object({}) do |tv, hash|
134
+ # Skip orphans (`tv.field` nil — definition deleted out from under
135
+ # the value) so the hash isn't crashy when stale rows linger.
136
+ next unless tv.field
137
+
138
+ assign_hash_value(hash, tv, winning_ids_by_name)
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ # Selects the candidate value for `typed_eav_value`. On a collision,
145
+ # prefer the row attached to the winning field_id; otherwise fall back
146
+ # to the first orphan/non-collision candidate.
147
+ def select_winning_value(candidates, winning)
148
+ return candidates.first unless winning
149
+
150
+ candidates.detect { |v| (v.field_id || v.field&.id) == winning.id } || candidates.first
151
+ end
152
+
153
+ # Hash-builder helper for `typed_eav_hash`. When a winner is registered
154
+ # for the name, only its row may surface (scoped-beats-global). When
155
+ # no winner is registered (definition deleted while values remain),
156
+ # fall back to first-wins so the hash isn't lossy.
157
+ def assign_hash_value(hash, value_row, winning_ids_by_name)
158
+ name = value_row.field.name
159
+ winning_id = winning_ids_by_name[name]
160
+ effective_id = value_row.field_id || value_row.field&.id
161
+
162
+ if winning_id
163
+ hash[name] = value_row.value if effective_id == winning_id
164
+ else
165
+ hash[name] = value_row.value unless hash.key?(name)
166
+ end
167
+ end
168
+
169
+ # Normalize the input to `typed_eav_attributes=` into an Array of
170
+ # plain Hashes. Accepts ActionController::Parameters, hash-of-hashes
171
+ # (form params), Array, or any Enumerable.
172
+ def normalize_typed_eav_attributes(attributes)
173
+ attributes = attributes.to_h if attributes.respond_to?(:permitted?)
174
+ attributes = attributes.values if attributes.is_a?(Hash)
175
+ Array(attributes)
176
+ end
177
+
178
+ # Translate a single name-keyed attribute hash into the corresponding
179
+ # nested-attributes entry (id-keyed), or builds a new value row in-place.
180
+ # Returns nil when the field is unknown, the field type is excluded by
181
+ # the host's `types:` restriction, or when the new-row path already
182
+ # added to the association (no nested-attributes entry needed).
183
+ def build_or_update_typed_value(attrs, fields_by_name, values_by_field_id)
184
+ attrs = attrs.to_h.with_indifferent_access
185
+ field = fields_by_name[attrs[:name]]
186
+ return nil unless field
187
+ return nil if typed_eav_type_disallowed?(field)
188
+
189
+ existing = values_by_field_id[field.id]
190
+ return { id: existing&.id, _destroy: true } if destroy_flag?(attrs)
191
+ return { id: existing.id, value: attrs[:value] } if existing
192
+
193
+ typed_values.build(field: field, value: attrs[:value])
194
+ nil
195
+ end
196
+
197
+ def typed_eav_type_disallowed?(field)
198
+ allowed = self.class.allowed_typed_eav_types
199
+ allowed&.exclude?(field.field_type_name)
200
+ end
201
+
202
+ def destroy_flag?(attrs)
203
+ ActiveRecord::Type::Boolean.new.cast(attrs[:_destroy])
204
+ end
205
+
206
+ # Returns typed_values with their fields, preferring already-loaded
207
+ # associations. Callers on list pages should preload with
208
+ # `includes(typed_values: :field)`; this method keeps the happy path
209
+ # fast without forcing that contract.
210
+ def loaded_typed_values_with_fields
211
+ if typed_values.loaded?
212
+ # Don't re-query if the caller already preloaded; ensure each value's
213
+ # field is materialized (fall back to per-row load if the nested
214
+ # `:field` was not preloaded).
215
+ typed_values.to_a
216
+ else
217
+ typed_values.includes(:field).to_a
218
+ end
219
+ end
220
+
221
+ # Field definitions indexed by name with deterministic collision
222
+ # handling: when both a global (scope=NULL) and a scoped field share
223
+ # a name, the most-specific (scoped) definition wins. Delegates to
224
+ # `TypedEAV::Partition.definitions_by_name` so the class-query path
225
+ # and the instance path share one source of truth.
226
+ #
227
+ # ## Bulk-write memoization (Phase 06 plan 05)
228
+ #
229
+ # `bulk_set_typed_eav_values` sets `Thread.current[:typed_eav_bulk_defs_memo]`
230
+ # to a Hash before its records loop. We consult it here so the per-
231
+ # record `typed_eav_attributes=` call does NOT issue a fresh
232
+ # `typed_eav_definitions` SELECT per record. AR's per-block query
233
+ # cache (`ActiveRecord::Base.cache`) is invalidated by every write —
234
+ # because each record's INSERT clears the cache — so cache-do alone
235
+ # cannot keep field-definition reads N+1-free across the bulk loop.
236
+ # The thread-local memo is the explicit fallback documented in plan
237
+ # 06-05 §T3 notes; it pre-warms once per `[host_class, scope,
238
+ # parent_scope]` tuple and reuses across every record in that tuple.
239
+ #
240
+ # Outside a bulk operation the memo is nil and we fall through to
241
+ # the standard read path — zero overhead.
242
+ def typed_eav_defs_by_name
243
+ memo = Thread.current[:typed_eav_bulk_defs_memo]
244
+ if memo
245
+ key = [self.class.name, typed_eav_scope, typed_eav_parent_scope]
246
+ memo[key] ||= TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
247
+ else
248
+ TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end