typed_eav 0.2.1 → 0.3.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +165 -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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module TypedEAV
6
+ module Field
7
+ # Intermediate STI base for string-valued field families that share a
8
+ # `min_length` / `max_length` / `pattern` validation surface.
9
+ #
10
+ # Leaves: `Field::Text`, `Field::Email`, `Field::Url`.
11
+ #
12
+ # Hoists the `string_value` storage declaration, the
13
+ # `store_accessor :options, :min_length, :max_length, :pattern` line,
14
+ # the `min_length` / `max_length` numericality validators, the
15
+ # `max_gte_min_length` guard, the `validate_pattern_syntax` guard, and
16
+ # the protected `validate_length` / `validate_pattern` helpers — all
17
+ # previously duplicated across Text/Email/Url with one drift gap
18
+ # (`max_gte_min_length` was only on Text).
19
+ #
20
+ # Default `validate_typed_value` runs `validate_length` plus
21
+ # `validate_pattern if pattern.present?`. Leaves override and call
22
+ # `super` to layer on their format-specific check (Email's
23
+ # `EMAIL_FORMAT`, Url's `URL_FORMAT`).
24
+ #
25
+ # Public extension point: external authors can subclass this directly
26
+ # to inherit the full min/max/pattern surface (see README §"Custom field
27
+ # types"). STI dispatch is unaffected — leaves still store their own
28
+ # class names in the `type` column.
29
+ class ValidatedString < Base
30
+ value_column :string_value
31
+
32
+ store_accessor :options, :min_length, :max_length, :pattern
33
+
34
+ validates :min_length, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
35
+ validates :max_length, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
36
+ validate :max_gte_min_length
37
+ validate :validate_pattern_syntax
38
+
39
+ def validate_typed_value(record, val)
40
+ validate_length(record, val)
41
+ validate_pattern(record, val) if pattern.present?
42
+ end
43
+
44
+ protected
45
+
46
+ def validate_length(record, val)
47
+ opts = options_hash
48
+ str = val.to_s
49
+ if opts[:min_length] && str.length < opts[:min_length].to_i
50
+ record.errors.add(:value, :too_short, count: opts[:min_length])
51
+ end
52
+ return unless opts[:max_length] && str.length > opts[:max_length].to_i
53
+
54
+ record.errors.add(:value, :too_long, count: opts[:max_length])
55
+ end
56
+
57
+ def validate_pattern(record, val)
58
+ opts = options_hash
59
+ pattern = opts[:pattern]
60
+ return if pattern.blank?
61
+
62
+ matched = Timeout.timeout(1) { Regexp.new(pattern).match?(val.to_s) }
63
+ record.errors.add(:value, :invalid) unless matched
64
+ rescue RegexpError
65
+ record.errors.add(:value, "has an invalid pattern configured")
66
+ rescue Timeout::Error
67
+ record.errors.add(:value, "pattern validation timed out")
68
+ end
69
+
70
+ private
71
+
72
+ def max_gte_min_length
73
+ return unless min_length && max_length
74
+
75
+ errors.add(:max_length, "must be >= min_length") if max_length < min_length
76
+ end
77
+
78
+ def validate_pattern_syntax
79
+ return if pattern.blank?
80
+
81
+ Regexp.new(pattern)
82
+ rescue RegexpError => e
83
+ errors.add(:pattern, "is invalid: #{e.message}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -86,7 +86,7 @@ module TypedEAV
86
86
  def value
87
87
  return nil unless field
88
88
 
89
- field.storage_contract.read(self)
89
+ field.read_value(self)
90
90
  end
91
91
 
92
92
  def value=(val)
@@ -114,7 +114,7 @@ module TypedEAV
114
114
  # decimal_value, raising TypeMismatch at save time.
115
115
  # Rails will further cast each column on save via its column type.
116
116
  casted, invalid = field.cast(val)
117
- field.storage_contract.write(self, casted)
117
+ field.write_value(self, casted)
118
118
  @cast_was_invalid = invalid
119
119
  else
120
120
  # Field not yet assigned - stash for later
@@ -276,7 +276,7 @@ module TypedEAV
276
276
  # Currency in Phase 05). Reconstructing that shape from
277
277
  # before_value's per-column hash adds complexity for zero benefit
278
278
  # since the per-column values are exactly what we need.
279
- field.storage_contract.value_columns.each do |col|
279
+ field.class.value_columns.each do |col|
280
280
  self[col] = version.before_value[col.to_s]
281
281
  end
282
282
 
@@ -369,20 +369,20 @@ module TypedEAV
369
369
  end
370
370
 
371
371
  # Writes the field's configured default to the typed column(s) via the
372
- # `field.apply_default_to(self)` dispatch. Does NOT route through value=
372
+ # `field.apply_default(self)` dispatch. Does NOT route through value=
373
373
  # because field.default_value is already cast via
374
374
  # cast(default_value_meta["v"]).first — re-casting would be redundant.
375
375
  # Field-side validate_default_value (field/base.rb) catches invalid raw
376
- # defaults at field save time, so what apply_default_to writes is always
376
+ # defaults at field save time, so what apply_default writes is always
377
377
  # either a castable value or nil.
378
378
  #
379
379
  # Multi-cell forward-compat: single-cell types fall through to
380
- # `self[value_column] = field.default_value` (Field::Base default).
381
- # Currency / future multi-cell types override `apply_default_to` to
380
+ # `self[value_columns.first] = field.default_value` (TypedStorage default).
381
+ # Currency / future multi-cell types override `apply_default` to
382
382
  # populate multiple columns from a composite default. The dispatch
383
383
  # preserves the bypass-Value#value= contract end-to-end.
384
384
  def apply_field_default
385
- field.storage_contract.apply_default(self)
385
+ field.apply_default(self)
386
386
  end
387
387
 
388
388
  def validate_value
@@ -540,7 +540,7 @@ module TypedEAV
540
540
  # code) cell would silently be missed by the dispatch gate, and
541
541
  # Phase 04 versioning would never see it (Scout §3 / Discrepancy D3
542
542
  # from plan 04-01).
543
- return unless field.storage_contract.changed?(self)
543
+ return unless field.value_changed?(self)
544
544
 
545
545
  TypedEAV::EventDispatcher.dispatch_value_change(self, :update)
546
546
  end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Bulk-read query object. Returns `{ record_id => { field_name => value } }`
5
+ # for an Enumerable of host records — the class-method bulk variant of
6
+ # `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
7
+ # record count or field count.
8
+ #
9
+ # ## Pipeline (one query per unique partition tuple + one bulk value preload)
10
+ #
11
+ # 1. validate_records! — nil -> ArgumentError; single-class invariant
12
+ # 2. group_by_tuple — `[typed_eav_scope, typed_eav_parent_scope]`
13
+ # 3. winning_ids_by_tuple — `typed_eav_definitions` per unique tuple via
14
+ # `Partition.definitions_by_name` (shared
15
+ # collision-precedence helper)
16
+ # 4. preload_values — single SELECT across ALL records
17
+ # 5. build_result_hash — per-record inner hash; orphan-skip + winning-id
18
+ # precedence mirrored from the instance path.
19
+ #
20
+ # ## Query bound
21
+ #
22
+ # - 1 SELECT typed_eav_values WHERE entity_type=? AND entity_id IN (?)
23
+ # - 1 SELECT typed_eav_fields WHERE id IN (?) (via includes)
24
+ # - 1 SELECT typed_eav_fields per unique partition tuple
25
+ #
26
+ # Total: 2 + (unique partition tuples) queries — independent of record count.
27
+ #
28
+ # ## Single-class invariant
29
+ #
30
+ # The polymorphic value query (`entity_type: host_class.name`) targets ONE
31
+ # class; mixed-class input would silently miss rows of the other class. STI
32
+ # subclasses pass via `records.all?(host_class)`.
33
+ class BulkRead
34
+ def initialize(host_class:, records:)
35
+ @host_class = host_class
36
+ @records = records
37
+ end
38
+
39
+ def to_hash
40
+ records = coerce_records
41
+ return {} if records.empty?
42
+
43
+ validate_record_classes!(records)
44
+
45
+ tuples_by_record = group_by_tuple(records)
46
+ winning_ids_by_tuple = winning_ids_by_tuple(tuples_by_record.values.uniq)
47
+ values_by_record_id = preload_values(records)
48
+
49
+ build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :host_class
55
+
56
+ def coerce_records
57
+ raise ArgumentError, "typed_eav_hash_for requires an Enumerable of records, got nil" if @records.nil?
58
+
59
+ @records.to_a
60
+ end
61
+
62
+ def validate_record_classes!(records)
63
+ return if records.all?(host_class)
64
+
65
+ classes = records.map { |r| r.class.name }.uniq
66
+ raise ArgumentError,
67
+ "typed_eav_hash_for expects records of class #{host_class.name} (or its subclasses); " \
68
+ "got mixed classes: #{classes.join(", ")}"
69
+ end
70
+
71
+ def group_by_tuple(records)
72
+ # Memo of record -> tuple key so each record only computes its tuple once.
73
+ records.index_with { |r| [r.typed_eav_scope, r.typed_eav_parent_scope] }
74
+ end
75
+
76
+ def winning_ids_by_tuple(tuples)
77
+ tuples.each_with_object({}) do |(s, ps), memo|
78
+ defs = host_class.typed_eav_definitions(scope: s, parent_scope: ps)
79
+ memo[[s, ps]] = TypedEAV::Partition.definitions_by_name(defs).transform_values(&:id)
80
+ end
81
+ end
82
+
83
+ def preload_values(records)
84
+ rows = TypedEAV::Value
85
+ .includes(:field)
86
+ .where(entity_type: host_class.name, entity_id: records.map(&:id))
87
+ .to_a
88
+ rows.group_by(&:entity_id)
89
+ end
90
+
91
+ def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
92
+ records.each_with_object({}) do |record, result|
93
+ tuple_key = tuples_by_record[record]
94
+ winning_ids_by_name = winning_ids_by_tuple.fetch(tuple_key, {})
95
+ rows = values_by_record_id.fetch(record.id, [])
96
+ result[record.id] = inner_hash_for(rows, winning_ids_by_name)
97
+ end
98
+ end
99
+
100
+ # Builds the inner `{ field_name => value }` hash for a single record.
101
+ #
102
+ # Skips orphans (`tv.field` nil — definition deleted via raw SQL or a
103
+ # Phase 02 `:nullify` cascade). When a winning field_id is registered
104
+ # for the name, only its row may surface (scoped-beats-global collision
105
+ # precedence). When no winner is registered (definition deleted while
106
+ # values remain), fall back to first-wins so the hash isn't lossy.
107
+ def inner_hash_for(value_rows, winning_ids_by_name)
108
+ value_rows.each_with_object({}) do |tv, inner|
109
+ next unless tv.field
110
+
111
+ name = tv.field.name
112
+ winning_id = winning_ids_by_name[name]
113
+ next assign_with_precedence(inner, name, tv, winning_id) if winning_id
114
+
115
+ inner[name] = tv.value unless inner.key?(name)
116
+ end
117
+ end
118
+
119
+ def assign_with_precedence(inner, name, value_row, winning_id)
120
+ effective_id = value_row.field_id || value_row.field&.id
121
+ inner[name] = value_row.value if effective_id == winning_id
122
+ end
123
+ end
124
+ end
@@ -5,7 +5,7 @@ module TypedEAV
5
5
  isolate_namespace TypedEAV
6
6
 
7
7
  initializer "typed_eav.autoload" do
8
- require_relative "column_mapping"
8
+ require_relative "field/typed_storage"
9
9
  require_relative "config"
10
10
  require_relative "registry"
11
11
  # Eager-loaded (not autoloaded) — Phase 04 versioning will register on
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Class-level query orchestration extended onto host AR models by the
5
+ # `has_typed_eav` macro. Owns the `UNSET_SCOPE` / `ALL_SCOPES` sentinels
6
+ # and the `resolve_scope` chain; delegates the heavy lifting to
7
+ # `FilterQuery` (multi-filter SQL composition) and `BulkRead` (bulk
8
+ # per-record reads). `bulk_set_typed_eav_values` stays as a 3-line wrapper
9
+ # around the existing `BulkWrite` executor.
10
+ module EntityQuery
11
+ # Sentinel for the `scope:` kwarg default. Distinguishes "kwarg not
12
+ # passed -> resolve from ambient" (UNSET_SCOPE) from "explicitly nil ->
13
+ # filter to global-only fields" (preserves prior behavior).
14
+ UNSET_SCOPE = Object.new.freeze
15
+
16
+ # Sentinel returned by `resolve_scope` inside an `unscoped { }` block.
17
+ # Signals the caller to skip the scope filter entirely (return fields
18
+ # across all partitions, not just global).
19
+ ALL_SCOPES = Object.new.freeze
20
+
21
+ # Query by custom field values. Accepts an array of filter hashes
22
+ # or a hash of hashes (from form params).
23
+ #
24
+ # Each filter needs:
25
+ # :name or :n - the field name
26
+ # :op or :operator - the operator (default: :eq)
27
+ # :value or :v - the comparison value
28
+ #
29
+ # Contact.where_typed_eav(
30
+ # { name: "age", op: :gt, value: 21 },
31
+ # { name: "city", value: "Portland" } # op defaults to :eq
32
+ # )
33
+ #
34
+ # `scope:` and `parent_scope:` behavior:
35
+ # - omitted -> resolve from ambient (`with_scope` -> resolver -> raise/nil)
36
+ # - passed a value -> use verbatim (explicit override; admin/test path)
37
+ # - passed nil -> filter to global-only on that axis (prior behavior)
38
+ def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
39
+ resolved = resolve_scope(scope, parent_scope)
40
+ effective_scope, effective_parent = scope_pair(resolved)
41
+
42
+ TypedEAV::FilterQuery.new(
43
+ model: self,
44
+ filters: filters,
45
+ scope: effective_scope,
46
+ parent_scope: effective_parent,
47
+ ).to_relation
48
+ end
49
+
50
+ # Shorthand for single-field queries.
51
+ #
52
+ # Contact.with_field("age", :gt, 21)
53
+ # Contact.with_field("active", true) # op defaults to :eq
54
+ # Contact.with_field("name", :contains, "smith")
55
+ #
56
+ # Accepts both `scope:` and `parent_scope:` kwargs with the same
57
+ # ambient/explicit/nil semantics as `where_typed_eav`. Single-scope
58
+ # callers (no `parent_scope:`) are unaffected.
59
+ def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
60
+ filter = if value.nil? && !operator_or_value.is_a?(Symbol)
61
+ # Two-arg form: with_field("name", "value") implies :eq
62
+ { name: name, op: :eq, value: operator_or_value }
63
+ else
64
+ { name: name, op: operator_or_value, value: value }
65
+ end
66
+ where_typed_eav(filter, scope: scope, parent_scope: parent_scope)
67
+ end
68
+
69
+ # Returns field definitions for this entity type.
70
+ #
71
+ # `scope:` and `parent_scope:` behavior:
72
+ # - omitted -> resolve from ambient (`with_scope` -> resolver -> raise/nil)
73
+ # - passed a value -> use verbatim (explicit override; admin/test path)
74
+ # - passed nil -> filter to global-only on that axis (prior behavior preserved)
75
+ def typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
76
+ resolved = resolve_scope(scope, parent_scope)
77
+ if resolved.equal?(ALL_SCOPES)
78
+ TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
79
+ else
80
+ s, ps = resolved
81
+ TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
82
+ end
83
+ end
84
+
85
+ # Bulk read API. Returns `{ record_id => { field_name => value } }` for
86
+ # an Enumerable of host records — the class-method bulk variant of
87
+ # `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
88
+ # record count or field count. See `TypedEAV::BulkRead` for the pipeline
89
+ # and query bound.
90
+ def typed_eav_hash_for(records)
91
+ TypedEAV::BulkRead.new(host_class: self, records: records).to_hash
92
+ end
93
+
94
+ # Bulk write API. Sets the same `values_by_field_name` Hash on every
95
+ # record in `records` inside ONE outer ActiveRecord transaction with a
96
+ # SAVEPOINT-PER-RECORD failure-isolation envelope. See `TypedEAV::BulkWrite`
97
+ # for the transaction shape, error-aggregation contract, and the
98
+ # `version_grouping:` semantics.
99
+ def bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default)
100
+ TypedEAV::BulkWrite.execute(
101
+ host_class: self,
102
+ records: records,
103
+ values_by_field_name: values_by_field_name,
104
+ version_grouping: version_grouping,
105
+ )
106
+ end
107
+
108
+ private
109
+
110
+ # Translates a resolved scope into the `(scope, parent_scope)` pair
111
+ # passed to `FilterQuery`. Preserves the `ALL_SCOPES` sentinel through
112
+ # to `FilterQuery` (it routes to the multimap branch); for resolved
113
+ # tuples, returns the pair verbatim.
114
+ def scope_pair(resolved)
115
+ return [ALL_SCOPES, nil] if resolved.equal?(ALL_SCOPES)
116
+
117
+ resolved
118
+ end
119
+
120
+ # Resolves the scope and parent_scope kwargs into a concrete tuple for
121
+ # field-definition lookup. Returns one of:
122
+ # - `ALL_SCOPES` — inside `TypedEAV.unscoped { }`, atomic bypass.
123
+ # - `[scope, parent_scope]` — both elements are String or nil.
124
+ # Raises:
125
+ # - `TypedEAV::ScopeRequired` when the model declares `scope_method:`
126
+ # but ambient scope can't be resolved and `require_scope` is true.
127
+ def resolve_scope(explicit_scope, explicit_parent_scope)
128
+ return ALL_SCOPES if TypedEAV.unscoped?
129
+
130
+ if explicit_given?(explicit_scope, explicit_parent_scope)
131
+ return resolve_explicit_scope(explicit_scope, explicit_parent_scope)
132
+ end
133
+
134
+ return [nil, nil] unless typed_eav_scope_method
135
+
136
+ resolve_ambient_scope
137
+ end
138
+
139
+ def explicit_given?(explicit_scope, explicit_parent_scope)
140
+ !explicit_scope.equal?(UNSET_SCOPE) || !explicit_parent_scope.equal?(UNSET_SCOPE)
141
+ end
142
+
143
+ # Per-slot normalize via `ScopeTuple.normalize_permissive` to coerce
144
+ # scalars/AR-records to strings, with UNSET_SCOPE collapsing to nil for
145
+ # the corresponding slot. Orphan-parent invariant: a request for
146
+ # parent_scope without scope is dead-letter — silently narrow ps to nil.
147
+ def resolve_explicit_scope(explicit_scope, explicit_parent_scope)
148
+ s = normalize_explicit_or_nil(explicit_scope, slot: :scope)
149
+ ps = normalize_explicit_or_nil(explicit_parent_scope, slot: :parent)
150
+ ps = nil unless TypedEAV::ScopeTuple.invariant_satisfied?(s, ps)
151
+ [s, ps]
152
+ end
153
+
154
+ def normalize_explicit_or_nil(value, slot:)
155
+ return nil if value.equal?(UNSET_SCOPE)
156
+
157
+ normalize_explicit_slot(value, slot: slot)
158
+ end
159
+
160
+ def normalize_explicit_slot(value, slot:)
161
+ tuple = slot == :scope ? [value, nil] : [nil, value]
162
+ index = slot == :scope ? :first : :last
163
+ TypedEAV::ScopeTuple.normalize_permissive(tuple)&.public_send(index)
164
+ end
165
+
166
+ # Ambient resolver path (via `with_scope` stack or configured lambda).
167
+ # `TypedEAV.current_scope` already validates the return shape to
168
+ # `nil | [a, b]` — no shape check is duplicated here.
169
+ def resolve_ambient_scope
170
+ resolved = TypedEAV.current_scope
171
+ return resolved if resolved
172
+
173
+ raise_scope_required if TypedEAV.config.require_scope
174
+
175
+ [nil, nil]
176
+ end
177
+
178
+ def raise_scope_required
179
+ raise TypedEAV::ScopeRequired,
180
+ "No ambient scope resolvable for #{name}. " \
181
+ "Wrap the call in `TypedEAV.with_scope(value) { ... }`, " \
182
+ "configure `TypedEAV.config.scope_resolver`, or use " \
183
+ "`TypedEAV.unscoped { ... }` to deliberately bypass."
184
+ end
185
+ end
186
+ end
@@ -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