typed_eav 0.6.0 → 0.7.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.
@@ -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
@@ -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
@@ -3,16 +3,10 @@
3
3
  module TypedEAV
4
4
  # Replaces the per-type Finder class hierarchy from active_fields.
5
5
  #
6
- # Because values live in native typed columns, ActiveRecord already knows
7
- # the column types from the schema. Arel predicates (eq, gt, lt, matches, etc.)
8
- # automatically go through the column's ActiveRecord::Type for casting.
9
- #
10
- # This means:
11
- # where(integer_value: "42") -> Rails casts "42" to 42 automatically
12
- # arel[:date_value].gt(value) -> Rails casts string dates to Date objects
13
- #
14
- # No manual CAST() calls. No per-type caster classes for queries.
15
- # One module handles all field types.
6
+ # Field owns operand casting and operator validation before this layer builds
7
+ # Arel. Active Record supplies bind plumbing for the already-normalized
8
+ # typed value; no manual SQL CAST() calls or per-type query caster classes
9
+ # are needed here.
16
10
  #
17
11
  # Usage:
18
12
  # QueryBuilder.filter(field, :gt, 42)
@@ -53,6 +47,8 @@ module TypedEAV
53
47
  arel_col = values_table[col]
54
48
 
55
49
  base = value_scope(field)
50
+ excluded = %i[is_null is_not_null contains not_contains starts_with ends_with]
51
+ operand = field.cast_query_operand(operator, value) unless excluded.include?(operator)
56
52
 
57
53
  case operator
58
54
  when :eq, :currency_eq
@@ -64,9 +60,9 @@ module TypedEAV
64
60
  # dispatch resolved correctly. The operator-validation gate at
65
61
  # the top of #filter still narrows :currency_eq to Field::Currency
66
62
  # only — no other field type accepts it.
67
- eq_predicate(base, arel_col, col, value)
63
+ eq_predicate(base, arel_col, col, operand)
68
64
  when :not_eq
69
- not_eq_predicate(base, arel_col, col, value)
65
+ not_eq_predicate(base, arel_col, col, operand)
70
66
  when :references
71
67
  # Phase 5 Reference field. `value` may be an Integer FK OR an
72
68
  # AR record instance — `field.cast` normalizes both to an
@@ -79,27 +75,21 @@ module TypedEAV
79
75
  # The :references operator is registered ONLY on Field::Reference
80
76
  # (the operator-validation gate above keeps it from leaking to
81
77
  # other types).
82
- fk, invalid = field.cast(value)
83
- if invalid || fk.nil?
78
+ if operand.nil?
84
79
  base.none
85
80
  else
86
- base.where(arel_col.eq(fk))
81
+ base.where(arel_col.eq(operand))
87
82
  end
88
83
  when :gt
89
- base.where(arel_col.gt(value))
84
+ base.where(arel_col.gt(operand))
90
85
  when :gteq
91
- base.where(arel_col.gteq(value))
86
+ base.where(arel_col.gteq(operand))
92
87
  when :lt
93
- base.where(arel_col.lt(value))
88
+ base.where(arel_col.lt(operand))
94
89
  when :lteq
95
- base.where(arel_col.lteq(value))
90
+ base.where(arel_col.lteq(operand))
96
91
  when :between
97
- unless value.respond_to?(:first) && value.respond_to?(:last)
98
- raise ArgumentError,
99
- ":between expects a Range or two-element Array"
100
- end
101
-
102
- base.where(arel_col.between(value.first..value.last))
92
+ base.where(arel_col.between(operand))
103
93
  when :contains
104
94
  base.where(arel_col.matches("%#{sanitize_like(value)}%"))
105
95
  when :not_contains
@@ -114,10 +104,10 @@ module TypedEAV
114
104
  base.where.not(col => nil)
115
105
  when :any_eq
116
106
  # For json_value arrays: contains the given element
117
- base.where("#{col} @> ?", [value].to_json)
107
+ base.where("#{col} @> ?", [operand].to_json)
118
108
  when :all_eq
119
109
  # For json_value arrays: contains all given elements
120
- base.where("#{col} @> ?", Array(value).to_json)
110
+ base.where("#{col} @> ?", operand.to_json)
121
111
  else
122
112
  raise ArgumentError, "Unhandled operator: #{operator}"
123
113
  end
@@ -25,12 +25,11 @@ module TypedEAV
25
25
  # Register an entity type with optional type restrictions and optional
26
26
  # versioning opt-in.
27
27
  #
28
- # `versioned:` is the per-entity Phase 04 opt-in flag. When true, AND
29
- # `Config.versioning = true` at engine load (gem-level master switch),
30
- # the Phase 04 subscriber writes a TypedEAV::ValueVersion row per
31
- # Value mutation on this entity_type. Default false — apps not using
32
- # versioning pay zero cost (one Hash#dig per write at most when
33
- # `Config.versioning = true`, nothing when off).
28
+ # `versioned:` is the per-entity opt-in flag. When true and the
29
+ # boot-latched transactional callbacks are installed, the version
30
+ # writer records a TypedEAV::ValueVersion row per Value mutation on
31
+ # this entity_type. Default false — apps not using versioning pay zero
32
+ # version-row work.
34
33
  #
35
34
  # Backward compat: existing callers `register(name, types: types)`
36
35
  # continue to work — the new kwarg defaults to false. The entry hash
@@ -69,8 +68,8 @@ module TypedEAV
69
68
  # Returns the stored boolean for opted-in entities; false for
70
69
  # unregistered entities (defensive — callers might query before
71
70
  # `has_typed_eav` runs in a particular load order). The Phase 04
72
- # subscriber calls this on every Value write when `Config.versioning =
73
- # true` performance is one Hash#dig per write, negligible.
71
+ # transactional writer calls this only after its boot-latched callback
72
+ # has been installed; this lookup remains a small Hash#dig per write.
74
73
  #
75
74
  # `entities.dig(entity_type, :versioned)` returns nil when
76
75
  # `entities[entity_type]` is missing (no register call) OR when the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end