typed_eav 0.6.0 → 0.7.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +187 -0
  3. data/README.md +258 -62
  4. data/app/models/typed_eav/field/base.rb +77 -27
  5. data/app/models/typed_eav/field/currency.rb +43 -0
  6. data/app/models/typed_eav/field/file.rb +1 -1
  7. data/app/models/typed_eav/field/image.rb +1 -1
  8. data/app/models/typed_eav/field/reference.rb +11 -0
  9. data/app/models/typed_eav/section.rb +11 -5
  10. data/app/models/typed_eav/value.rb +95 -32
  11. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
  12. data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +4 -0
  13. data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
  14. data/lib/typed_eav/bulk_read.rb +13 -14
  15. data/lib/typed_eav/bulk_upsert.rb +137 -0
  16. data/lib/typed_eav/bulk_write.rb +65 -39
  17. data/lib/typed_eav/config.rb +19 -21
  18. data/lib/typed_eav/csv_mapper.rb +1 -1
  19. data/lib/typed_eav/engine.rb +16 -27
  20. data/lib/typed_eav/entity_query.rb +34 -9
  21. data/lib/typed_eav/event_dispatcher.rb +21 -30
  22. data/lib/typed_eav/field/typed_storage.rb +48 -0
  23. data/lib/typed_eav/field_deletion.rb +75 -0
  24. data/lib/typed_eav/filter_query.rb +2 -2
  25. data/lib/typed_eav/has_typed_eav/instance_methods.rb +2 -2
  26. data/lib/typed_eav/has_typed_eav.rb +1 -1
  27. data/lib/typed_eav/partition/definition_batch.rb +70 -0
  28. data/lib/typed_eav/partition.rb +2 -0
  29. data/lib/typed_eav/query_builder.rb +17 -27
  30. data/lib/typed_eav/registry.rb +7 -8
  31. data/lib/typed_eav/version.rb +1 -1
  32. data/lib/typed_eav/versioned.rb +8 -6
  33. data/lib/typed_eav/versioning/subscriber.rb +25 -29
  34. data/lib/typed_eav/versioning.rb +59 -41
  35. data/lib/typed_eav.rb +2 -0
  36. metadata +5 -1
@@ -122,10 +122,9 @@ module TypedEAV
122
122
  end
123
123
  attr_writer :require_scope # rubocop:disable Style/AccessorGrouping
124
124
 
125
- # Master kill-switch for Phase 04 versioning. When false (default), the
126
- # Phase 04 internal subscriber is NOT registered with EventDispatcher
127
- # at engine boot zero overhead for apps that don't use versioning.
128
- # When true, the subscriber registers but only writes a version row
125
+ # Boot-time switch for transactional versioning. When false (default),
126
+ # Value's version callbacks are not installed. When true, the callbacks
127
+ # install after the host configuration is loaded, but only write a row
129
128
  # when value.entity_type belongs to a host model that opted in via
130
129
  # `has_typed_eav versioned: true` (per-entity opt-in flows through
131
130
  # Registry; both layers land in plan 04-02).
@@ -138,7 +137,7 @@ module TypedEAV
138
137
  # Default false because the schema migration only matters for apps that
139
138
  # opt in. A v0.1.x deployment that pulls in Phase 04 without changing
140
139
  # any config or model declarations sees no behavior change — the
141
- # subscriber doesn't register, no version rows are written, no perf
140
+ # callbacks don't register, no version rows are written, no perf
142
141
  # impact at all. The migration is still copied (idempotent), but the
143
142
  # table sits empty.
144
143
  def versioning
@@ -186,13 +185,13 @@ module TypedEAV
186
185
  #
187
186
  # Errors raised inside this proc are rescued by EventDispatcher and
188
187
  # logged via Rails.logger.error — they do NOT propagate to the
189
- # user's save call (the row is already committed). Internal subscribers
190
- # (Phase 04 versioning, Phase 07 matview) fire BEFORE this proc and
188
+ # user's save call (the row is already committed). Generic internal
189
+ # observers fire BEFORE this proc and
191
190
  # their errors DO propagate. See 03-CONTEXT.md §User-callback error policy.
192
191
  #
193
- # Reassignment after gem initialization does NOT disable internal
194
- # subscribers those live on EventDispatcher.value_change_internals,
195
- # not here.
192
+ # Reassigning this public callback does NOT disable transactional
193
+ # versioning callbacks; they are installed on Value at boot and are
194
+ # independent of this public slot.
196
195
  attr_accessor :on_value_change
197
196
 
198
197
  # Public single-proc slot for field-change events.
@@ -207,7 +206,8 @@ module TypedEAV
207
206
  #
208
207
  # :rename fires when `name` is among Field#saved_changes, even
209
208
  # combined with other attr changes (sort_order, options, etc.) —
210
- # Phase 07 matview needs the rename signal to regenerate column names
209
+ # Registered consumers may use the rename signal to refresh their own
210
+ # name-to-field mappings.
211
211
  # even when the rename was bundled with other edits.
212
212
  attr_accessor :on_field_change
213
213
 
@@ -215,7 +215,7 @@ module TypedEAV
215
215
  # Field::Image-typed Value gains (or replaces) an attachment. Receives
216
216
  # `(value, blob)`. Default nil — no-op when not configured.
217
217
  #
218
- # Hook ordering: fires AFTER versioning (Phase 04) and AFTER
218
+ # Hook ordering: fires AFTER the transactional version write and AFTER
219
219
  # on_value_change (Phase 03). The hook is informational ("an image
220
220
  # was attached"), not mutational; running it last avoids polluting
221
221
  # earlier hooks' snapshots / context with attachment-derived state.
@@ -259,22 +259,20 @@ module TypedEAV
259
259
  self.field_types = BUILTIN_FIELD_TYPES.dup
260
260
  self.scope_resolver = DEFAULT_SCOPE_RESOLVER
261
261
  self.require_scope = true
262
- # Phase 04 versioning master switch + actor resolver. Reset to defaults
262
+ # Transactional versioning boot switch + actor resolver. Reset to defaults
263
263
  # (false / nil) so test isolation matches `Config.on_value_change` / etc.
264
- # Internal subscribers (TypedEAV::Versioning::Subscriber, registered
265
- # at engine load by plan 04-02) are deliberately NOT cleared here —
266
- # they live on EventDispatcher.value_change_internals and survive
264
+ # Transactional Value callbacks are deliberately NOT cleared here —
265
+ # callback installation is boot-latched and survives
267
266
  # Config.reset! by design (the snapshot/restore split is locked at
268
267
  # 03-CONTEXT.md §Reset split). Test teardown that needs to clear
269
- # subscribers too calls EventDispatcher.reset!.
268
+ # generic observers too calls EventDispatcher.reset!.
270
269
  self.versioning = false
271
270
  self.actor_resolver = nil
272
271
  # Test isolation: scoping_spec/field_spec/etc. call Config.reset! in
273
272
  # `after` hooks — this ensures user procs set in earlier tests don't
274
- # leak across examples. Internal subscribers
275
- # (EventDispatcher.value_change_internals/field_change_internals) are
276
- # deliberately NOT reset here — they're populated at engine load by
277
- # Phase 04+ and must persist across Config.reset!. Test teardown
273
+ # leak across examples. Generic EventDispatcher observers
274
+ # (value_change_internals/field_change_internals) are deliberately NOT
275
+ # reset here — they persist across Config.reset!. Test teardown
278
276
  # that needs to clear EVERYTHING calls EventDispatcher.reset! too.
279
277
  self.on_value_change = nil
280
278
  self.on_field_change = nil
@@ -70,7 +70,7 @@ module TypedEAV
70
70
  # `errors.empty?`. Callers that need to combine multiple row Results
71
71
  # into a batch view do so by composing the immutable Hashes in their
72
72
  # own code (e.g., `results.flat_map(&:errors).reduce({}, :merge)`); the
73
- # mapper does not provide a "merge" helper in v0.6.0.
73
+ # mapper does not provide a "merge" helper.
74
74
  class Result
75
75
  attr_reader :attributes, :errors
76
76
 
@@ -8,12 +8,10 @@ module TypedEAV
8
8
  require_relative "field/typed_storage"
9
9
  require_relative "config"
10
10
  require_relative "registry"
11
- # Eager-loaded (not autoloaded) — Phase 04 versioning will register on
12
- # EventDispatcher at engine boot, before any model reference triggers
13
- # autoload. Without this require_relative, Phase 04's engine-time
14
- # `register_internal_value_change` call would const-resolve the module
15
- # for the first time and run a fresh `@value_change_internals = []`
16
- # AFTER versioning had already pushed onto a different instance.
11
+ # Eager-loaded (not autoloaded) — transactional versioning registers its
12
+ # Value callbacks at engine boot, before any model reference triggers
13
+ # autoload. Keeping the dispatcher loaded early gives public and
14
+ # generic observers one stable broker instance.
17
15
  require_relative "event_dispatcher"
18
16
  end
19
17
 
@@ -24,11 +22,11 @@ module TypedEAV
24
22
  end
25
23
  end
26
24
 
27
- # Phase 04 versioning subscriber registration.
25
+ # Transactional versioning callback registration.
28
26
  #
29
27
  # CONDITIONAL on TypedEAV.config.versioning. When false (the default
30
- # for apps that don't enable versioning), no subscriber is registered:
31
- # zero callable in EventDispatcher.value_change_internals, zero per-write
28
+ # for apps that don't enable versioning), no Value callback is registered:
29
+ # zero transactional callback overhead.
32
30
  # dispatch overhead, zero config reads on the hot path. This is the
33
31
  # locked CONTEXT contract — line 17 says "zero overhead for apps that
34
32
  # don't use versioning", which means literally no callable, not "callable
@@ -46,21 +44,17 @@ module TypedEAV
46
44
  # has fired (e.g., a Rails console session that monkey-patches Config,
47
45
  # or a feature-flag flip mid-process) will NOT get versioning until
48
46
  # process restart. Runtime toggle is not a documented use case — adding
49
- # a register/deregister API is out of scope for Phase 04. The Risk §1
47
+ # a register/deregister API is out of scope for boot-latched versioning. The Risk §1
50
48
  # late-toggle concern from RESEARCH is acceptably narrowed by this
51
49
  # trade-off.
52
50
  #
53
- # Slot 0 ordering: Phase 07 (future matview) will register its
54
- # subscriber via its own `config.after_initialize` block declared LATER
55
- # in this same engine file. Rails runs `after_initialize` blocks in
56
- # declaration order within a single Engine class, so versioning's block
57
- # fires first → slot 0. The regression spec (plan 04-03 P03) is the
58
- # ongoing guard.
51
+ # Version rows are written in the source transaction. Public and generic
52
+ # observer ordering remains owned by EventDispatcher after commit.
59
53
  #
60
54
  # Why a one-line callable to a class method (not inline registration):
61
55
  # `TypedEAV::Versioning.register_if_enabled` is the testable seam. The
62
- # slot-0 regression spec (plan 04-03 P03) and the zero-overhead
63
- # verification spec (this plan, subscriber_spec) cannot reboot the Rails
56
+ # callback-chain regression spec and the zero-overhead verification spec
57
+ # cannot reboot the Rails
64
58
  # process inside RSpec — but they CAN call the helper directly against
65
59
  # a fresh internals array to exercise both branches (versioning on/off)
66
60
  # in-process. Inlining the `if` here would force tests to either reboot
@@ -78,7 +72,7 @@ module TypedEAV
78
72
  # that don't use Image/File field types pay zero overhead, AND the
79
73
  # gemspec stays free of an activestorage hard-dependency.
80
74
  #
81
- # When AS IS loaded (Rails 7.1+ with the rails meta-gem, or an
75
+ # When AS is loaded (on supported Rails versions with the rails meta-gem, or an
82
76
  # explicit `gem 'activestorage'` line), TypedEAV::Value gains a
83
77
  # single :attachment has_one_attached association that covers BOTH
84
78
  # Field::Image and Field::File typed Values. The Image vs File
@@ -94,20 +88,15 @@ module TypedEAV
94
88
  # Value row (Text, Integer, etc.), even when no attachment is in
95
89
  # play. RESEARCH §Risk 3 documents this rationale.
96
90
  #
97
- # Second after_initialize block (versioning's is the first): Rails
98
- # runs after_initialize blocks in declaration order within a single
99
- # Engine class. Versioning's slot-0 dispatcher position at the
100
- # EventDispatcher level is preserved (dispatcher slots are an
101
- # EventDispatcher-internal concern; the engine's after_initialize
102
- # ordering is independent). Phase 07 matview will append its own
103
- # block after this one.
91
+ # This second after_initialize block is independent of transactional
92
+ # versioning; it only handles Active Storage association setup.
104
93
  #
105
94
  # Why a one-line callable to a class method (testable seam): the
106
95
  # active_storage_soft_detect_spec cannot reboot Rails inside RSpec
107
96
  # to exercise both branches. By extracting the body into
108
97
  # `Engine.register_attachment_associations!`, specs call the helper
109
98
  # directly with whatever ::ActiveStorage state they need to test.
110
- # Pattern matches Phase 04's `Versioning.register_if_enabled`.
99
+ # Pattern matches `Versioning.register_if_enabled`.
111
100
  config.after_initialize do
112
101
  TypedEAV::Engine.register_attachment_associations!
113
102
  end
@@ -103,10 +103,10 @@ module TypedEAV
103
103
  def typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
104
104
  resolved = resolve_scope(scope, parent_scope)
105
105
  if resolved.equal?(ALL_SCOPES)
106
- TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
106
+ TypedEAV::Partition.visible_fields(entity_type: polymorphic_name, mode: :all_partitions)
107
107
  else
108
108
  s, ps = resolved
109
- TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
109
+ TypedEAV::Partition.visible_fields(entity_type: polymorphic_name, scope: s, parent_scope: ps)
110
110
  end
111
111
  end
112
112
 
@@ -120,22 +120,26 @@ module TypedEAV
120
120
  end
121
121
 
122
122
  # Bulk write API. Sets the same `values_by_field_name` Hash on every
123
- # record in `records` inside ONE outer ActiveRecord transaction with a
124
- # SAVEPOINT-PER-RECORD failure-isolation envelope. See `TypedEAV::BulkWrite`
125
- # for the transaction shape, error-aggregation contract, and the
126
- # `version_grouping:` semantics.
127
- def bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default)
123
+ # record in `records` using an outer transaction with per-record savepoints
124
+ # (`transaction: :all`) or one such envelope per committed chunk
125
+ # (`transaction: :chunks`). See `TypedEAV::BulkWrite` for the error and
126
+ # `version_grouping:` contracts.
127
+ def bulk_set_typed_eav_values(
128
+ records, values_by_field_name, version_grouping: :default, transaction: :all, chunk_size: nil
129
+ )
128
130
  TypedEAV::BulkWrite.execute(
129
131
  host_class: self,
130
132
  records: records,
131
133
  values_by_field_name: values_by_field_name,
132
134
  version_grouping: version_grouping,
135
+ transaction: transaction,
136
+ chunk_size: chunk_size,
133
137
  )
134
138
  end
135
139
 
136
140
  # Per-record-varying bulk write API. Sibling to `bulk_set_typed_eav_values`
137
141
  # for callers (sync importers, per-row updaters) where each record carries
138
- # its own values hash. Routes through the same outer-transaction-plus-
142
+ # its own values hash. Routes through the same transaction/chunk and
139
143
  # savepoint envelope and returns the same
140
144
  # `{ successes: [...], errors_by_record: { record => errors_hash } }`
141
145
  # shape. See `TypedEAV::BulkWrite` for the transaction shape and the
@@ -195,11 +199,32 @@ module TypedEAV
195
199
  # write `"name"` share one UUID for that cell; a record writing
196
200
  # `"city"` (that no other record writes) gets its own UUID for
197
201
  # `"city"`. Overlapping fields share a version group across records.
198
- def bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default)
202
+ def bulk_set_typed_eav_values_per_record(
203
+ values_by_record, version_grouping: :default, transaction: :all, chunk_size: nil
204
+ )
199
205
  TypedEAV::BulkWrite.execute_per_record(
200
206
  host_class: self,
201
207
  values_by_record: values_by_record,
202
208
  version_grouping: version_grouping,
209
+ transaction: transaction,
210
+ chunk_size: chunk_size,
211
+ )
212
+ end
213
+
214
+ # Reduced-semantics SQL bulk upsert. This deliberately separate API
215
+ # requires `acknowledge_reduced_semantics: true`; it retains Value
216
+ # prevalidation/casting and domain/partition checks while skipping host and
217
+ # Value persistence lifecycle callbacks, versioning, and delete shorthand.
218
+ def bulk_upsert_typed_eav_values(
219
+ records, values_by_field_name, acknowledge_reduced_semantics: false, transaction: :all, chunk_size: nil
220
+ )
221
+ TypedEAV::BulkUpsert.execute(
222
+ host_class: self,
223
+ records: records,
224
+ values_by_field_name: values_by_field_name,
225
+ acknowledge_reduced_semantics: acknowledge_reduced_semantics,
226
+ transaction: transaction,
227
+ chunk_size: chunk_size,
203
228
  )
204
229
  end
205
230
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  module TypedEAV
4
4
  # In-process event-dispatch hub for Value and Field after_commit lifecycle
5
- # events. Implements the contract that Phase 04 versioning and Phase 07
6
- # materialized index both depend on.
5
+ # events. It carries public callbacks and generic in-gem observers;
6
+ # durable version rows are written by transactional Value callbacks.
7
7
  #
8
8
  # ## Contract surface
9
9
  #
@@ -11,20 +11,15 @@ module TypedEAV
11
11
  # proc slots (nil-default), backed by ActiveSupport::Configurable. Users
12
12
  # set them via `TypedEAV.configure { |c| c.on_value_change = ->(...) }`.
13
13
  # - `register_internal_value_change(callable)` / `register_internal_field_change(callable)`
14
- # are FIRST-PARTY hooks for in-gem features (Phase 04 versioning, Phase 07
15
- # matview DDL regen). They are not private_class_method because Phase 04
16
- # lives in `TypedEAV::Versioning::*` and cannot reach a truly-private class
17
- # method — the `register_internal_*` naming + this comment block signal
18
- # first-party-only intent.
14
+ # are FIRST-PARTY hooks for in-gem observers. The explicit registration
15
+ # names signal their intended scope.
19
16
  # - Internal subscribers fire FIRST, in registration order. User proc fires
20
- # LAST. Phase 04 reserves slot 0 of `value_change_internals` by convention.
17
+ # LAST. Durable ValueVersion writing is installed on Value transactions;
18
+ # this dispatcher does not own that write.
21
19
  #
22
20
  # ## Error policy (split, locked at 03-CONTEXT.md §User-callback error policy)
23
21
  #
24
- # - Internal subscribers: exceptions PROPAGATE (fail-closed). Versioning
25
- # corruption must be loud — silent failure leaves typed_eav_value_versions
26
- # inconsistent with the live row. Without propagation, Phase 04 bugs
27
- # would be invisible until someone audited the version table.
22
+ # - Internal observers: exceptions PROPAGATE (fail-closed).
28
23
  # - User proc: rescued via `rescue StandardError`, logged via
29
24
  # `Rails.logger.error`, and SWALLOWED. The Value/Field row is already
30
25
  # committed by the time the after_commit fires, so re-raising here would
@@ -43,9 +38,8 @@ module TypedEAV
43
38
  # design decisions this module implements.
44
39
  module EventDispatcher
45
40
  class << self
46
- # Internal subscribers for Value lifecycle events. Populated at engine
47
- # boot by Phase 04 versioning (slot 0) and Phase 07 matview (subsequent
48
- # slots). Exposed as a reader for test introspection — first-party
41
+ # Internal subscribers for Value lifecycle observers. Exposed as a
42
+ # reader for test introspection first-party
49
43
  # registration goes through `register_internal_value_change`.
50
44
  def value_change_internals
51
45
  @value_change_internals ||= []
@@ -57,15 +51,12 @@ module TypedEAV
57
51
  @field_change_internals ||= []
58
52
  end
59
53
 
60
- # Register an in-gem value-change subscriber. Called at engine boot by
61
- # Phase 04 versioning and Phase 07 matview. Subscribers are invoked in
54
+ # Register an in-gem value-change observer. Observers are invoked in
62
55
  # registration order with `(value, change_type, context)`. Exceptions
63
- # raised here PROPAGATE fail-closed because versioning corruption
64
- # must be loud. See module-level comment §"Error policy".
56
+ # raised here PROPAGATE. See module-level comment §"Error policy".
65
57
  #
66
- # NOT private_class_method: Phase 04 lives in TypedEAV::Versioning::*
67
- # and cannot call a truly-private class method. The `register_internal_*`
68
- # naming + this comment signal first-party-only intent.
58
+ # NOT private_class_method: first-party code uses this named seam, and
59
+ # the `register_internal_*` naming signals its intended scope.
69
60
  def register_internal_value_change(callable)
70
61
  value_change_internals << callable
71
62
  end
@@ -87,9 +78,10 @@ module TypedEAV
87
78
  # `change_type` is one of `:create | :update | :destroy`.
88
79
  def dispatch_value_change(value, change_type)
89
80
  context = TypedEAV.current_context
90
- # Internals fire first, in registration order. Exceptions propagate
91
- # versioning failure (Phase 04) must surface, never be silent.
92
- value_change_internals.each { |cb| cb.call(value, change_type, context) }
81
+ # Internals fire first, in registration order. Exceptions propagate.
82
+ value_change_internals.each do |cb|
83
+ cb.call(value, change_type, context)
84
+ end
93
85
 
94
86
  user = TypedEAV::Config.on_value_change
95
87
  return unless user
@@ -137,11 +129,10 @@ module TypedEAV
137
129
  # `Config.on_value_change` / `Config.on_field_change` — `Config.reset!`
138
130
  # owns the user-proc state.
139
131
  #
140
- # Splitting reset is load-bearing: Phase 04 versioning registers on the
141
- # internal list at engine load. Calling `EventDispatcher.reset!` must
142
- # NOT require re-running engine load to restore versioning. Test
143
- # teardown that needs to clear EVERYTHING calls Config.reset! AND
144
- # EventDispatcher.reset! — see 03-CONTEXT.md §"Reset split".
132
+ # Transactional versioning callbacks are independently boot-latched on
133
+ # Value; resetting this dispatcher only resets observer registrations.
134
+ # Test teardown that needs to clear EVERYTHING calls Config.reset! AND
135
+ # EventDispatcher.reset!.
145
136
  def reset!
146
137
  @value_change_internals = []
147
138
  @field_change_internals = []
@@ -155,6 +155,41 @@ module TypedEAV
155
155
  value_record[self.class.value_columns.first] = default_value
156
156
  end
157
157
 
158
+ # A logical value is missing only when every physical storage cell is
159
+ # nil. Multi-cell fields may legitimately be partially populated; such
160
+ # a row is present and must not be overwritten by a default backfill.
161
+ def logical_value_missing?(value_record)
162
+ self.class.value_columns.all? { |column| value_record[column].nil? }
163
+ end
164
+
165
+ # Normalize values before QueryBuilder emits SQL. Keeping this beside
166
+ # the write caster makes query and write semantics use the same rules.
167
+ def cast_query_operand(operator, raw)
168
+ case operator.to_sym
169
+ when :between
170
+ bounds = if raw.is_a?(Range)
171
+ [raw.begin, raw.end]
172
+ elsif raw.is_a?(Array) && raw.length == 2
173
+ raw
174
+ end
175
+ raise ArgumentError, ":between expects a Range or two-element Array" unless bounds
176
+
177
+ casted = bounds.map { |bound| cast_query_value(bound) }
178
+ casted.first..casted.last
179
+ when :any_eq
180
+ raise ArgumentError, ":any_eq expects a single array element" if raw.is_a?(Array)
181
+
182
+ values = cast_query_value([raw])
183
+ values&.first
184
+ when :all_eq
185
+ raise ArgumentError, ":all_eq expects an Array" unless raw.is_a?(Array)
186
+
187
+ cast_query_value(raw)
188
+ else
189
+ cast_query_value(raw)
190
+ end
191
+ end
192
+
158
193
  # ── Concrete snapshot helpers (NOT overridable) ──
159
194
 
160
195
  # True iff ANY of the field's value_columns had a saved change in the
@@ -200,6 +235,19 @@ module TypedEAV
200
235
  raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
201
236
  end
202
237
  end
238
+
239
+ private
240
+
241
+ def cast_query_value(raw)
242
+ casted, invalid = cast(raw)
243
+ raise ArgumentError, "Invalid #{self.class.name} query operand: #{raw.inspect}" if invalid
244
+
245
+ casted
246
+ rescue TypeError, ArgumentError => e
247
+ raise e if e.is_a?(ArgumentError) && e.message.start_with?("Invalid ")
248
+
249
+ raise ArgumentError, "Invalid #{self.class.name} query operand: #{raw.inspect}"
250
+ end
203
251
  end
204
252
  end
205
253
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Explicit, resumable deletion for large Field value populations. The public
5
+ # Field#destroy_with_values_in_batches! contract requires a persisted
6
+ # `field_dependent: :destroy` Field, positive batch size, no open transaction,
7
+ # and one shared Field/Value/ValueVersion connection pool.
8
+ module FieldDeletion
9
+ module_function
10
+
11
+ def destroy!(field, batch_size: 1_000)
12
+ validate!(field, batch_size)
13
+ last_id = 0
14
+
15
+ loop do
16
+ deleted = delete_batch!(field, last_id, batch_size)
17
+ break if deleted.empty?
18
+
19
+ last_id = deleted.last
20
+ end
21
+
22
+ finalize!(field, batch_size)
23
+ end
24
+
25
+ def validate!(field, batch_size)
26
+ raise ArgumentError, "field must be persisted" unless field.persisted?
27
+ raise ArgumentError, "field_dependent must be destroy" unless field.field_dependent == "destroy"
28
+ unless batch_size.is_a?(Integer) && batch_size.positive?
29
+ raise ArgumentError, "batch_size must be a positive Integer"
30
+ end
31
+ if field.class.connection.transaction_open?
32
+ raise ArgumentError, "destroy_with_values_in_batches! cannot run inside an open transaction"
33
+ end
34
+
35
+ pools = [field.class.connection_pool, TypedEAV::Value.connection_pool, TypedEAV::ValueVersion.connection_pool]
36
+ return if pools.uniq.size == 1
37
+
38
+ raise ArgumentError, "field deletion requires Field, Value, and ValueVersion to share a connection pool"
39
+ end
40
+ private_class_method :validate!
41
+
42
+ def delete_batch!(field, last_id, batch_size)
43
+ field.class.transaction do
44
+ values = locked_values(field.id, last_id, batch_size).to_a
45
+ values.each(&:destroy!)
46
+ values.map(&:id)
47
+ end
48
+ end
49
+ private_class_method :delete_batch!
50
+
51
+ def locked_values(field_id, last_id, limit)
52
+ TypedEAV::Value
53
+ .where(field_id: field_id)
54
+ .where("id > ?", last_id)
55
+ .order(:id)
56
+ .limit(limit)
57
+ .lock
58
+ end
59
+ private_class_method :locked_values
60
+
61
+ def finalize!(field, batch_size)
62
+ field.class.transaction do
63
+ field.lock!
64
+ values = locked_values(field.id, 0, batch_size + 1).to_a
65
+ raise "field deletion residual drain exceeded batch_size" if values.size > batch_size
66
+
67
+ values.each(&:destroy!)
68
+ raise "field deletion residual proof failed" if TypedEAV::Value.exists?(field_id: field.id)
69
+
70
+ field.destroy!
71
+ end
72
+ end
73
+ private_class_method :finalize!
74
+ end
75
+ end
@@ -106,10 +106,10 @@ module TypedEAV
106
106
 
107
107
  def lookup_definitions
108
108
  if all_scopes?
109
- TypedEAV::Partition.visible_fields(entity_type: model.name, mode: :all_partitions)
109
+ TypedEAV::Partition.visible_fields(entity_type: model.polymorphic_name, mode: :all_partitions)
110
110
  else
111
111
  TypedEAV::Partition.visible_fields(
112
- entity_type: model.name,
112
+ entity_type: model.polymorphic_name,
113
113
  scope: @scope,
114
114
  parent_scope: @parent_scope,
115
115
  )
@@ -262,7 +262,7 @@ module TypedEAV
262
262
  # because each record's INSERT clears the cache — so cache-do alone
263
263
  # cannot keep field-definition reads N+1-free across the bulk loop.
264
264
  # The thread-local memo is the explicit fallback documented in plan
265
- # 06-05 §T3 notes; it pre-warms once per `[host_class, scope,
265
+ # 06-05 §T3 notes; it pre-warms once per `[polymorphic_name, scope,
266
266
  # parent_scope]` tuple and reuses across every record in that tuple.
267
267
  #
268
268
  # Outside a bulk operation the memo is nil and we fall through to
@@ -270,7 +270,7 @@ module TypedEAV
270
270
  def typed_eav_defs_by_name
271
271
  memo = Thread.current[:typed_eav_bulk_defs_memo]
272
272
  if memo
273
- key = [self.class.name, typed_eav_scope, typed_eav_parent_scope]
273
+ key = [self.class.polymorphic_name, typed_eav_scope, typed_eav_parent_scope]
274
274
  memo[key] ||= TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
275
275
  else
276
276
  TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
@@ -93,7 +93,7 @@ module TypedEAV
93
93
 
94
94
  accepts_nested_attributes_for :typed_values, allow_destroy: true
95
95
 
96
- TypedEAV.registry.register(name, types: types, versioned: versioned)
96
+ TypedEAV.registry.register(polymorphic_name, types: types, versioned: versioned)
97
97
  end
98
98
 
99
99
  private
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Partition
5
+ # Internal batched resolver for field definitions visible to many exact
6
+ # partition tuples. It performs one definition SELECT, then applies the
7
+ # same global -> scope -> full-tuple precedence as `definitions_by_name`
8
+ # independently for every requested tuple.
9
+ class DefinitionBatch
10
+ class << self
11
+ def resolve(entity_type:, tuples:)
12
+ new(entity_type: entity_type, tuples: tuples).resolve
13
+ end
14
+ end
15
+
16
+ def initialize(entity_type:, tuples:)
17
+ @entity_type = entity_type
18
+ @tuples = tuples.uniq
19
+ end
20
+
21
+ def resolve
22
+ tuples.each { |scope, parent_scope| validate_tuple!(scope, parent_scope) }
23
+ return {} if tuples.empty?
24
+
25
+ definitions_by_tuple = load_definitions.group_by do |definition|
26
+ [definition.scope, definition.parent_scope]
27
+ end
28
+
29
+ tuples.to_h do |tuple|
30
+ visible = candidate_tuples(tuple).flat_map { |candidate| definitions_by_tuple.fetch(candidate, []) }
31
+ [tuple, TypedEAV::Partition.definitions_by_name(visible)]
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :entity_type, :tuples
38
+
39
+ def load_definitions
40
+ requested = tuples.map { |scope, parent_scope| { "scope" => scope, "parent_scope" => parent_scope } }
41
+ TypedEAV::Field::Base.where(entity_type: entity_type).where(<<~SQL.squish, requested.to_json).to_a
42
+ typed_eav_fields.scope IS NULL AND typed_eav_fields.parent_scope IS NULL
43
+ OR EXISTS (
44
+ SELECT 1
45
+ FROM jsonb_to_recordset(?::jsonb) AS requested(scope text, parent_scope text)
46
+ WHERE (
47
+ typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
48
+ AND typed_eav_fields.parent_scope IS NOT DISTINCT FROM requested.parent_scope
49
+ )
50
+ OR (
51
+ typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
52
+ AND typed_eav_fields.parent_scope IS NULL
53
+ )
54
+ )
55
+ SQL
56
+ end
57
+
58
+ def candidate_tuples(tuple)
59
+ scope, parent_scope = tuple
60
+ [[nil, nil], [scope, nil], [scope, parent_scope]].uniq
61
+ end
62
+
63
+ def validate_tuple!(scope, parent_scope)
64
+ return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
65
+
66
+ raise ArgumentError, "parent_scope cannot be set when scope is blank"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -8,6 +8,8 @@ module TypedEAV
8
8
  # values. Ambient resolution (`TypedEAV.current_scope`, `with_scope`,
9
9
  # `unscoped`) stays with the adapters that know their calling context.
10
10
  module Partition
11
+ autoload :DefinitionBatch, "typed_eav/partition/definition_batch"
12
+
11
13
  # Frozen orphan-parent ArgumentError message. Kept as a module constant
12
14
  # so both `visible_fields` and `visible_sections` raise the same string
13
15
  # without re-allocating per call. The string is the wire-stable BC error