typed_eav 0.5.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +207 -0
  3. data/README.md +271 -61
  4. data/app/models/typed_eav/field/base.rb +90 -33
  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/option.rb +0 -8
  10. data/app/models/typed_eav/section.rb +11 -5
  11. data/app/models/typed_eav/value.rb +95 -32
  12. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
  13. data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +54 -0
  14. data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
  15. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +1 -9
  16. data/lib/typed_eav/bulk_read.rb +41 -9
  17. data/lib/typed_eav/bulk_upsert.rb +141 -0
  18. data/lib/typed_eav/bulk_write.rb +65 -39
  19. data/lib/typed_eav/config.rb +19 -21
  20. data/lib/typed_eav/csv_mapper.rb +1 -1
  21. data/lib/typed_eav/engine.rb +16 -27
  22. data/lib/typed_eav/entity_query.rb +42 -8
  23. data/lib/typed_eav/event_dispatcher.rb +21 -30
  24. data/lib/typed_eav/field/typed_storage.rb +48 -0
  25. data/lib/typed_eav/field_deletion.rb +75 -0
  26. data/lib/typed_eav/filter_query.rb +9 -7
  27. data/lib/typed_eav/has_typed_eav/instance_methods.rb +11 -8
  28. data/lib/typed_eav/partition.rb +8 -3
  29. data/lib/typed_eav/query_builder.rb +17 -27
  30. data/lib/typed_eav/registry.rb +7 -8
  31. data/lib/typed_eav/schema_portability/import_index.rb +58 -0
  32. data/lib/typed_eav/schema_portability.rb +22 -25
  33. data/lib/typed_eav/version.rb +1 -1
  34. data/lib/typed_eav/versioning/subscriber.rb +25 -29
  35. data/lib/typed_eav/versioning.rb +59 -41
  36. data/lib/typed_eav.rb +2 -0
  37. metadata +19 -5
@@ -16,7 +16,8 @@ module TypedEAV
16
16
  # allocate field UUIDs, and then hand off to `execute_pairs(pairs,
17
17
  # effective_grouping, field_uuids)` — a single shared loop that takes
18
18
  # ordered `[record, vbn]` pairs and runs the outer-transaction-plus-
19
- # savepoint-per-record envelope.
19
+ # savepoint-per-record envelope; `transaction: :chunks` repeats that
20
+ # envelope per chunk so completed chunks can commit independently.
20
21
  #
21
22
  # Pair-shaped (not Hash-shaped) so `execute`'s `[record, vbn]` list can
22
23
  # carry duplicate in-memory instances of the same persisted row without
@@ -24,19 +25,25 @@ module TypedEAV
24
25
  # `execute`'s byte-for-byte behavior contract.
25
26
  module BulkWrite
26
27
  class << self
27
- def execute(host_class:, records:, values_by_field_name:, version_grouping: :default)
28
- validate_inputs!(records, values_by_field_name, version_grouping)
28
+ # rubocop:disable Metrics/ParameterLists -- transaction controls are explicit API contract.
29
+ def execute(
30
+ host_class:, records:, values_by_field_name:, version_grouping: :default, transaction: :all, chunk_size: nil
31
+ )
32
+ validate_inputs!(records, values_by_field_name, version_grouping, transaction, chunk_size)
29
33
 
30
34
  records = records.to_a
31
35
  return { successes: [], errors_by_record: {} } if records.empty?
32
36
 
33
37
  validate_record_classes!(host_class, records)
38
+ ensure_connection_owner!(host_class)
34
39
 
35
40
  effective_grouping = resolve_grouping(version_grouping)
36
41
  vbn = values_by_field_name.transform_keys(&:to_s)
37
42
  field_uuids = effective_grouping == :per_field ? vbn.keys.index_with { SecureRandom.uuid } : nil
38
43
 
39
- execute_pairs(records.map { |r| [r, vbn] }, effective_grouping, field_uuids)
44
+ execute_pairs(
45
+ host_class, records.map { |r| [r, vbn] }, effective_grouping, field_uuids, transaction, chunk_size
46
+ )
40
47
  end
41
48
 
42
49
  # Per-record-varying sibling to `execute`. Accepts a `Hash<host_record,
@@ -45,12 +52,15 @@ module TypedEAV
45
52
  #
46
53
  # Empty `values_by_record` short-circuits to the empty result without
47
54
  # opening a transaction (matches `execute`'s empty-records contract).
48
- def execute_per_record(host_class:, values_by_record:, version_grouping: :default)
49
- validate_per_record_inputs!(values_by_record, version_grouping)
55
+ def execute_per_record(
56
+ host_class:, values_by_record:, version_grouping: :default, transaction: :all, chunk_size: nil
57
+ )
58
+ validate_per_record_inputs!(values_by_record, version_grouping, transaction, chunk_size)
50
59
 
51
60
  return { successes: [], errors_by_record: {} } if values_by_record.empty?
52
61
 
53
62
  validate_record_classes!(host_class, values_by_record.keys, method: :bulk_set_typed_eav_values_per_record)
63
+ ensure_connection_owner!(host_class)
54
64
 
55
65
  effective_grouping = resolve_grouping(version_grouping)
56
66
  pairs = values_by_record.map { |record, vbn| [record, vbn.transform_keys(&:to_s)] }
@@ -58,15 +68,11 @@ module TypedEAV
58
68
  pairs.flat_map { |(_record, vbn)| vbn.keys }.uniq.index_with { SecureRandom.uuid }
59
69
  end
60
70
 
61
- execute_pairs(pairs, effective_grouping, field_uuids)
71
+ execute_pairs(host_class, pairs, effective_grouping, field_uuids, transaction, chunk_size)
62
72
  end
73
+ # rubocop:enable Metrics/ParameterLists
63
74
 
64
75
  def apply_record_save(record:, vbn:, effective_grouping:, uuids:, accumulator:)
65
- push_uuid = case effective_grouping
66
- when :per_record then uuids[:record]
67
- when :per_field then uuids[:field].values.first
68
- end
69
-
70
76
  do_save = lambda do
71
77
  record.typed_eav_attributes = vbn.map { |name, value| typed_eav_entry_for(name, value) }
72
78
  stamp_pending_version_group_ids(record, effective_grouping, uuids)
@@ -79,11 +85,7 @@ module TypedEAV
79
85
  end
80
86
  end
81
87
 
82
- if push_uuid
83
- TypedEAV.with_context(version_group_id: push_uuid, &do_save)
84
- else
85
- do_save.call
86
- end
88
+ do_save.call
87
89
  end
88
90
 
89
91
  private
@@ -97,26 +99,31 @@ module TypedEAV
97
99
  # the same persisted row iterate each instance separately — matters
98
100
  # for `execute`'s byte-for-byte behavior contract on
99
101
  # `[Entity.find(1), Entity.find(1)]`-shaped input.
100
- def execute_pairs(pairs, effective_grouping, field_uuids)
102
+ # rubocop:disable Metrics/MethodLength -- transaction wrapper retains one readable semantic loop.
103
+ # rubocop:disable Metrics/ParameterLists -- internal transaction controls stay explicit.
104
+ def execute_pairs(host_class, pairs, effective_grouping, field_uuids, transaction, chunk_size)
101
105
  successes = []
102
106
  errors_by_record = {}
103
107
 
104
108
  with_bulk_definitions_memo do
105
- ActiveRecord::Base.cache do
106
- ActiveRecord::Base.transaction do
107
- pairs.each do |(record, vbn)|
108
- record_uuid = effective_grouping == :per_record ? SecureRandom.uuid : nil
109
- record_field_uuids = record_scoped_field_uuids(field_uuids, vbn)
110
-
111
- with_record_scope(record) do
112
- ActiveRecord::Base.transaction(requires_new: true) do
113
- apply_record_save(
114
- record: record,
115
- vbn: vbn,
116
- effective_grouping: effective_grouping,
117
- uuids: { record: record_uuid, field: record_field_uuids },
118
- accumulator: { successes: successes, errors_by_record: errors_by_record },
119
- )
109
+ host_class.cache do
110
+ units = transaction == :all ? [pairs] : pairs.each_slice(chunk_size)
111
+ units.each do |unit|
112
+ host_class.transaction do
113
+ unit.each do |(record, vbn)|
114
+ record_uuid = effective_grouping == :per_record ? SecureRandom.uuid : nil
115
+ record_field_uuids = record_scoped_field_uuids(field_uuids, vbn)
116
+
117
+ with_record_scope(record) do
118
+ host_class.transaction(requires_new: true) do
119
+ apply_record_save(
120
+ record: record,
121
+ vbn: vbn,
122
+ effective_grouping: effective_grouping,
123
+ uuids: { record: record_uuid, field: record_field_uuids },
124
+ accumulator: { successes: successes, errors_by_record: errors_by_record },
125
+ )
126
+ end
120
127
  end
121
128
  end
122
129
  end
@@ -126,6 +133,8 @@ module TypedEAV
126
133
 
127
134
  { successes: successes, errors_by_record: errors_by_record }
128
135
  end
136
+ # rubocop:enable Metrics/ParameterLists
137
+ # rubocop:enable Metrics/MethodLength
129
138
 
130
139
  # For `:per_field`, the global `field_uuids` map covers the union of
131
140
  # field names across all records' value hashes. The per-record save
@@ -184,7 +193,7 @@ module TypedEAV
184
193
  ActiveRecord::Type::Boolean.new.cast(flag)
185
194
  end
186
195
 
187
- def validate_inputs!(records, values_by_field_name, version_grouping)
196
+ def validate_inputs!(records, values_by_field_name, version_grouping, transaction, chunk_size)
188
197
  if records.nil?
189
198
  raise ArgumentError,
190
199
  "bulk_set_typed_eav_values requires an Enumerable of records, got nil"
@@ -197,9 +206,17 @@ module TypedEAV
197
206
  end
198
207
 
199
208
  validate_grouping!(version_grouping)
209
+ validate_transaction!(transaction, chunk_size)
200
210
  end
201
211
 
202
- def validate_per_record_inputs!(values_by_record, version_grouping)
212
+ def ensure_connection_owner!(host_class)
213
+ pools = [host_class.connection_pool, TypedEAV::Value.connection_pool, TypedEAV::Field::Base.connection_pool]
214
+ return if pools.map(&:object_id).uniq.one?
215
+
216
+ raise ArgumentError, "bulk_write requires host, value, and field connections to share a pool"
217
+ end
218
+
219
+ def validate_per_record_inputs!(values_by_record, version_grouping, transaction, chunk_size)
203
220
  unless values_by_record.is_a?(Hash)
204
221
  raise ArgumentError,
205
222
  "bulk_set_typed_eav_values_per_record requires a Hash of values_by_record, " \
@@ -215,6 +232,14 @@ module TypedEAV
215
232
  end
216
233
 
217
234
  validate_grouping!(version_grouping)
235
+ validate_transaction!(transaction, chunk_size)
236
+ end
237
+
238
+ def validate_transaction!(transaction, chunk_size)
239
+ return if transaction == :all
240
+ return if transaction == :chunks && chunk_size.is_a?(Integer) && chunk_size.positive?
241
+
242
+ raise ArgumentError, "transaction must be :all or :chunks with a positive chunk_size"
218
243
  end
219
244
 
220
245
  def validate_grouping!(version_grouping)
@@ -225,11 +250,12 @@ module TypedEAV
225
250
  "Supported values: #{valid_grouping.map(&:inspect).join(", ")}."
226
251
  end
227
252
 
228
- return unless %i[per_record per_field].include?(version_grouping) && !TypedEAV.config.versioning
253
+ return unless %i[per_record per_field].include?(version_grouping) &&
254
+ !TypedEAV::Versioning.atomic_callbacks_installed?
229
255
 
230
256
  raise ArgumentError,
231
- "version_grouping: #{version_grouping.inspect} was passed but versioning is disabled. " \
232
- "Set TypedEAV.config.versioning = true in your initializer, or pass " \
257
+ "version_grouping: #{version_grouping.inspect} was passed but versioning is disabled or not installed. " \
258
+ "Enable versioning at boot, or pass " \
233
259
  "version_grouping: :none to opt out explicitly, or omit the kwarg to silently no-op."
234
260
  end
235
261
 
@@ -244,7 +270,7 @@ module TypedEAV
244
270
 
245
271
  def resolve_grouping(version_grouping)
246
272
  if version_grouping == :default
247
- TypedEAV.config.versioning ? :per_record : :none
273
+ TypedEAV::Versioning.atomic_callbacks_installed? ? :per_record : :none
248
274
  else
249
275
  version_grouping
250
276
  end
@@ -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
@@ -75,7 +75,15 @@ module TypedEAV
75
75
  # short: meaningful only with `:is_null` (Reading A "no non-NULL
76
76
  # value," includes no-row hosts), no-op with `:is_not_null`, silently
77
77
  # ignored otherwise.
78
- def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false)
78
+ # rubocop:disable Metrics/ParameterLists -- preserves the public positional and partition keyword API.
79
+ def with_field(
80
+ name,
81
+ operator_or_value = nil,
82
+ value = nil,
83
+ scope: UNSET_SCOPE,
84
+ parent_scope: UNSET_SCOPE,
85
+ include_missing: false
86
+ )
79
87
  filter = if value.nil? && !operator_or_value.is_a?(Symbol)
80
88
  # Two-arg form: with_field("name", "value") implies :eq
81
89
  { name: name, op: :eq, value: operator_or_value }
@@ -84,6 +92,7 @@ module TypedEAV
84
92
  end
85
93
  where_typed_eav(filter, scope: scope, parent_scope: parent_scope, include_missing: include_missing)
86
94
  end
95
+ # rubocop:enable Metrics/ParameterLists
87
96
 
88
97
  # Returns field definitions for this entity type.
89
98
  #
@@ -111,22 +120,26 @@ module TypedEAV
111
120
  end
112
121
 
113
122
  # Bulk write API. Sets the same `values_by_field_name` Hash on every
114
- # record in `records` inside ONE outer ActiveRecord transaction with a
115
- # SAVEPOINT-PER-RECORD failure-isolation envelope. See `TypedEAV::BulkWrite`
116
- # for the transaction shape, error-aggregation contract, and the
117
- # `version_grouping:` semantics.
118
- 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
+ )
119
130
  TypedEAV::BulkWrite.execute(
120
131
  host_class: self,
121
132
  records: records,
122
133
  values_by_field_name: values_by_field_name,
123
134
  version_grouping: version_grouping,
135
+ transaction: transaction,
136
+ chunk_size: chunk_size,
124
137
  )
125
138
  end
126
139
 
127
140
  # Per-record-varying bulk write API. Sibling to `bulk_set_typed_eav_values`
128
141
  # for callers (sync importers, per-row updaters) where each record carries
129
- # its own values hash. Routes through the same outer-transaction-plus-
142
+ # its own values hash. Routes through the same transaction/chunk and
130
143
  # savepoint envelope and returns the same
131
144
  # `{ successes: [...], errors_by_record: { record => errors_hash } }`
132
145
  # shape. See `TypedEAV::BulkWrite` for the transaction shape and the
@@ -186,11 +199,32 @@ module TypedEAV
186
199
  # write `"name"` share one UUID for that cell; a record writing
187
200
  # `"city"` (that no other record writes) gets its own UUID for
188
201
  # `"city"`. Overlapping fields share a version group across records.
189
- 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
+ )
190
205
  TypedEAV::BulkWrite.execute_per_record(
191
206
  host_class: self,
192
207
  values_by_record: values_by_record,
193
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,
194
228
  )
195
229
  end
196
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 = []