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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +207 -0
- data/README.md +271 -61
- data/app/models/typed_eav/field/base.rb +90 -33
- data/app/models/typed_eav/field/currency.rb +43 -0
- data/app/models/typed_eav/field/file.rb +1 -1
- data/app/models/typed_eav/field/image.rb +1 -1
- data/app/models/typed_eav/field/reference.rb +11 -0
- data/app/models/typed_eav/option.rb +0 -8
- data/app/models/typed_eav/section.rb +11 -5
- data/app/models/typed_eav/value.rb +95 -32
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
- data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +54 -0
- data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +1 -9
- data/lib/typed_eav/bulk_read.rb +41 -9
- data/lib/typed_eav/bulk_upsert.rb +141 -0
- data/lib/typed_eav/bulk_write.rb +65 -39
- data/lib/typed_eav/config.rb +19 -21
- data/lib/typed_eav/csv_mapper.rb +1 -1
- data/lib/typed_eav/engine.rb +16 -27
- data/lib/typed_eav/entity_query.rb +42 -8
- data/lib/typed_eav/event_dispatcher.rb +21 -30
- data/lib/typed_eav/field/typed_storage.rb +48 -0
- data/lib/typed_eav/field_deletion.rb +75 -0
- data/lib/typed_eav/filter_query.rb +9 -7
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +11 -8
- data/lib/typed_eav/partition.rb +8 -3
- data/lib/typed_eav/query_builder.rb +17 -27
- data/lib/typed_eav/registry.rb +7 -8
- data/lib/typed_eav/schema_portability/import_index.rb +58 -0
- data/lib/typed_eav/schema_portability.rb +22 -25
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +25 -29
- data/lib/typed_eav/versioning.rb +59 -41
- data/lib/typed_eav.rb +2 -0
- metadata +19 -5
data/lib/typed_eav/bulk_write.rb
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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(
|
|
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(
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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) &&
|
|
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
|
-
"
|
|
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.
|
|
273
|
+
TypedEAV::Versioning.atomic_callbacks_installed? ? :per_record : :none
|
|
248
274
|
else
|
|
249
275
|
version_grouping
|
|
250
276
|
end
|
data/lib/typed_eav/config.rb
CHANGED
|
@@ -122,10 +122,9 @@ module TypedEAV
|
|
|
122
122
|
end
|
|
123
123
|
attr_writer :require_scope # rubocop:disable Style/AccessorGrouping
|
|
124
124
|
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
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
|
-
#
|
|
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).
|
|
190
|
-
#
|
|
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
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
262
|
+
# Transactional versioning boot switch + actor resolver. Reset to defaults
|
|
263
263
|
# (false / nil) so test isolation matches `Config.on_value_change` / etc.
|
|
264
|
-
#
|
|
265
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
275
|
-
# (
|
|
276
|
-
#
|
|
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
|
data/lib/typed_eav/csv_mapper.rb
CHANGED
|
@@ -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
|
|
73
|
+
# mapper does not provide a "merge" helper.
|
|
74
74
|
class Result
|
|
75
75
|
attr_reader :attributes, :errors
|
|
76
76
|
|
data/lib/typed_eav/engine.rb
CHANGED
|
@@ -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) —
|
|
12
|
-
#
|
|
13
|
-
# autoload.
|
|
14
|
-
#
|
|
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
|
-
#
|
|
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
|
|
31
|
-
# zero
|
|
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
|
|
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
|
-
#
|
|
54
|
-
#
|
|
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
|
-
#
|
|
63
|
-
#
|
|
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
|
|
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
|
-
#
|
|
98
|
-
#
|
|
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
|
|
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
|
-
|
|
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`
|
|
115
|
-
#
|
|
116
|
-
# for the
|
|
117
|
-
# `version_grouping:`
|
|
118
|
-
def bulk_set_typed_eav_values(
|
|
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
|
|
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(
|
|
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.
|
|
6
|
-
#
|
|
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
|
|
15
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
47
|
-
#
|
|
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
|
|
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
|
|
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:
|
|
67
|
-
#
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
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 = []
|