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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +157 -0
- data/README.md +241 -60
- data/app/models/typed_eav/field/base.rb +77 -27
- 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/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 +4 -0
- data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
- 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 +32 -7
- 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/query_builder.rb +17 -27
- data/lib/typed_eav/registry.rb +7 -8
- 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 +4 -1
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
|
|
@@ -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`
|
|
124
|
-
#
|
|
125
|
-
# for the
|
|
126
|
-
# `version_grouping:`
|
|
127
|
-
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
|
+
)
|
|
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
|
|
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(
|
|
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.
|
|
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 = []
|
|
@@ -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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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,
|
|
63
|
+
eq_predicate(base, arel_col, col, operand)
|
|
68
64
|
when :not_eq
|
|
69
|
-
not_eq_predicate(base, arel_col, col,
|
|
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
|
-
|
|
83
|
-
if invalid || fk.nil?
|
|
78
|
+
if operand.nil?
|
|
84
79
|
base.none
|
|
85
80
|
else
|
|
86
|
-
base.where(arel_col.eq(
|
|
81
|
+
base.where(arel_col.eq(operand))
|
|
87
82
|
end
|
|
88
83
|
when :gt
|
|
89
|
-
base.where(arel_col.gt(
|
|
84
|
+
base.where(arel_col.gt(operand))
|
|
90
85
|
when :gteq
|
|
91
|
-
base.where(arel_col.gteq(
|
|
86
|
+
base.where(arel_col.gteq(operand))
|
|
92
87
|
when :lt
|
|
93
|
-
base.where(arel_col.lt(
|
|
88
|
+
base.where(arel_col.lt(operand))
|
|
94
89
|
when :lteq
|
|
95
|
-
base.where(arel_col.lteq(
|
|
90
|
+
base.where(arel_col.lteq(operand))
|
|
96
91
|
when :between
|
|
97
|
-
|
|
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} @> ?", [
|
|
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} @> ?",
|
|
110
|
+
base.where("#{col} @> ?", operand.to_json)
|
|
121
111
|
else
|
|
122
112
|
raise ArgumentError, "Unhandled operator: #{operator}"
|
|
123
113
|
end
|
data/lib/typed_eav/registry.rb
CHANGED
|
@@ -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
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
-
#
|
|
73
|
-
#
|
|
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
|
data/lib/typed_eav/version.rb
CHANGED