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
|
@@ -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
|
|
@@ -147,9 +147,7 @@ module TypedEAV
|
|
|
147
147
|
# field def has a non-NULL value for it. Union the non-missing
|
|
148
148
|
# entity_ids across all per-tenant field defs, then complement at
|
|
149
149
|
# the host level. ADR-0006.
|
|
150
|
-
non_missing_ids = fields
|
|
151
|
-
TypedEAV::QueryBuilder.entity_ids(f, :is_not_null, nil).pluck(:entity_id)
|
|
152
|
-
end.uniq
|
|
150
|
+
non_missing_ids = union_entity_ids(fields, :is_not_null, nil)
|
|
153
151
|
query.where.not(id: non_missing_ids)
|
|
154
152
|
else
|
|
155
153
|
union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
|
|
@@ -168,11 +166,15 @@ module TypedEAV
|
|
|
168
166
|
end
|
|
169
167
|
|
|
170
168
|
# OR-across all field_ids that share the same name (across tenants),
|
|
171
|
-
# while preserving AND between filters via the chained `.where`.
|
|
172
|
-
#
|
|
173
|
-
#
|
|
169
|
+
# while preserving AND between filters via the chained `.where`. Keep the
|
|
170
|
+
# union as a composable entity-id subquery so matching IDs never need to
|
|
171
|
+
# be materialized in Ruby.
|
|
174
172
|
def union_entity_ids(fields, operator, value)
|
|
175
|
-
fields
|
|
173
|
+
fields
|
|
174
|
+
.map { |field| TypedEAV::QueryBuilder.filter(field, operator, value) }
|
|
175
|
+
.reduce { |union, relation| union.or(relation) }
|
|
176
|
+
.distinct
|
|
177
|
+
.select(:entity_id)
|
|
176
178
|
end
|
|
177
179
|
|
|
178
180
|
def parse_filter(filter)
|
|
@@ -166,13 +166,14 @@ module TypedEAV
|
|
|
166
166
|
typed_values.filter_map { |tv| tv.field_id || tv.field&.id }
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
-
# Selects the candidate value for `typed_eav_value`.
|
|
170
|
-
#
|
|
171
|
-
#
|
|
169
|
+
# Selects the candidate value for `typed_eav_value`. When a definition
|
|
170
|
+
# wins for the current partition, only a row attached to that field_id
|
|
171
|
+
# may surface. Retained rows from a host's former partition must not be
|
|
172
|
+
# used as a fallback when the current winner has no value yet.
|
|
172
173
|
def select_winning_value(candidates, winning)
|
|
173
174
|
return candidates.first unless winning
|
|
174
175
|
|
|
175
|
-
candidates.detect { |v| (v.field_id || v.field&.id) == winning.id }
|
|
176
|
+
candidates.detect { |v| (v.field_id || v.field&.id) == winning.id }
|
|
176
177
|
end
|
|
177
178
|
|
|
178
179
|
# Hash-builder helper for `typed_eav_hash`. When a winner is registered
|
|
@@ -234,10 +235,12 @@ module TypedEAV
|
|
|
234
235
|
# fast without forcing that contract.
|
|
235
236
|
def loaded_typed_values_with_fields
|
|
236
237
|
if typed_values.loaded?
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
238
|
+
values = typed_values.to_a
|
|
239
|
+
missing_fields = values.reject { |value| value.association(:field).loaded? }
|
|
240
|
+
if missing_fields.any?
|
|
241
|
+
ActiveRecord::Associations::Preloader.new(records: missing_fields, associations: :field).call
|
|
242
|
+
end
|
|
243
|
+
values
|
|
241
244
|
else
|
|
242
245
|
typed_values.includes(:field).to_a
|
|
243
246
|
end
|
data/lib/typed_eav/partition.rb
CHANGED
|
@@ -21,13 +21,18 @@ module TypedEAV
|
|
|
21
21
|
# scope-only rows, and full-tuple rows. Passing mode: :all_partitions is
|
|
22
22
|
# the deliberate admin bypass; it is distinct from `scope: nil`, which
|
|
23
23
|
# means the global partition only.
|
|
24
|
-
def visible_fields(entity_type
|
|
24
|
+
def visible_fields(entity_type: nil, scope: nil, parent_scope: nil, mode: :partition)
|
|
25
25
|
validate_mode!(mode)
|
|
26
|
-
|
|
26
|
+
fields = TypedEAV::Field::Base.all
|
|
27
|
+
fields = fields.where(entity_type: entity_type) if entity_type
|
|
28
|
+
return fields if mode == :all_partitions
|
|
27
29
|
|
|
28
30
|
raise ArgumentError, ORPHAN_PARENT_MESSAGE unless ScopeTuple.invariant_satisfied?(scope, parent_scope)
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
fields.where(
|
|
33
|
+
scope: [scope, nil].uniq,
|
|
34
|
+
parent_scope: [parent_scope, nil].uniq,
|
|
35
|
+
)
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
# One visible field per name after collision resolution. Most-specific
|
|
@@ -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
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module SchemaPortability
|
|
5
|
+
class ImportIndex
|
|
6
|
+
attr_reader :fields, :sections
|
|
7
|
+
|
|
8
|
+
def initialize(field_entries, section_entries)
|
|
9
|
+
@fields = preload_fields(field_entries)
|
|
10
|
+
@sections = preload_sections(section_entries)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def field_identity(field_or_entry)
|
|
14
|
+
identity(field_or_entry, :name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def section_identity(section_or_entry)
|
|
18
|
+
identity(section_or_entry, :code)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def preload_fields(entries)
|
|
24
|
+
return {} if entries.empty?
|
|
25
|
+
|
|
26
|
+
identity_relation(TypedEAV::Field::Base, entries, :name)
|
|
27
|
+
.includes(:field_options)
|
|
28
|
+
.index_by { |field| field_identity(field) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def preload_sections(entries)
|
|
32
|
+
return {} if entries.empty?
|
|
33
|
+
|
|
34
|
+
identity_relation(TypedEAV::Section, entries, :code)
|
|
35
|
+
.index_by { |section| section_identity(section) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def identity_relation(model, entries, key)
|
|
39
|
+
relations = entries.uniq { |entry| identity(entry, key) }.map do |entry|
|
|
40
|
+
model.where(
|
|
41
|
+
key => entry[key.to_s],
|
|
42
|
+
entity_type: entry["entity_type"],
|
|
43
|
+
scope: entry["scope"],
|
|
44
|
+
parent_scope: entry["parent_scope"],
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
relations.reduce { |union, relation| union.or(relation) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def identity(record_or_entry, key)
|
|
51
|
+
attributes = [key, :entity_type, :scope, :parent_scope]
|
|
52
|
+
return attributes.map { |attribute| record_or_entry[attribute.to_s] } if record_or_entry.is_a?(Hash)
|
|
53
|
+
|
|
54
|
+
attributes.map { |attribute| record_or_entry.public_send(attribute) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "schema_portability/import_index"
|
|
4
|
+
|
|
3
5
|
module TypedEAV
|
|
4
6
|
# Export and import field + section definitions for an exact partition
|
|
5
7
|
# tuple. Value rows are intentionally out of scope.
|
|
@@ -89,14 +91,18 @@ module TypedEAV
|
|
|
89
91
|
validate_conflict_policy!(on_conflict)
|
|
90
92
|
|
|
91
93
|
result = { "created" => 0, "updated" => 0, "skipped" => 0, "unchanged" => 0, "errors" => [] }
|
|
94
|
+
field_entries = Array(hash["fields"])
|
|
95
|
+
section_entries = Array(hash["sections"])
|
|
92
96
|
|
|
93
97
|
TypedEAV::Field::Base.transaction do
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
import_index = ImportIndex.new(field_entries, section_entries)
|
|
99
|
+
|
|
100
|
+
field_entries.each do |entry|
|
|
101
|
+
import_field_entry(entry, on_conflict, result, import_index)
|
|
96
102
|
end
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
import_section_entry(entry, on_conflict, result)
|
|
104
|
+
section_entries.each do |entry|
|
|
105
|
+
import_section_entry(entry, on_conflict, result, import_index)
|
|
100
106
|
end
|
|
101
107
|
end
|
|
102
108
|
|
|
@@ -105,7 +111,7 @@ module TypedEAV
|
|
|
105
111
|
|
|
106
112
|
private
|
|
107
113
|
|
|
108
|
-
# rubocop:disable Metrics/AbcSize -- flat projection is the canonical field export shape.
|
|
114
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- flat projection is the canonical field export shape.
|
|
109
115
|
def export_field_entry(field)
|
|
110
116
|
entry = {
|
|
111
117
|
"name" => field.name,
|
|
@@ -141,7 +147,7 @@ module TypedEAV
|
|
|
141
147
|
|
|
142
148
|
entry
|
|
143
149
|
end
|
|
144
|
-
# rubocop:enable Metrics/AbcSize
|
|
150
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
145
151
|
|
|
146
152
|
# Lean per-field projection used by {.export_snapshot_schema}. Mirrors
|
|
147
153
|
# the option-row ordering rule from {.export_field_entry} — sort
|
|
@@ -207,13 +213,9 @@ module TypedEAV
|
|
|
207
213
|
"Supported: #{valid_policies.map { |policy| ":#{policy}" }.join(", ")}."
|
|
208
214
|
end
|
|
209
215
|
|
|
210
|
-
def import_field_entry(entry, on_conflict, result)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
entity_type: entry["entity_type"],
|
|
214
|
-
scope: entry["scope"],
|
|
215
|
-
parent_scope: entry["parent_scope"],
|
|
216
|
-
)
|
|
216
|
+
def import_field_entry(entry, on_conflict, result, import_index)
|
|
217
|
+
identity = import_index.field_identity(entry)
|
|
218
|
+
existing = import_index.fields[identity]
|
|
217
219
|
|
|
218
220
|
if existing
|
|
219
221
|
reject_type_swap!(existing, entry)
|
|
@@ -233,7 +235,7 @@ module TypedEAV
|
|
|
233
235
|
result["updated"] += 1
|
|
234
236
|
end
|
|
235
237
|
else
|
|
236
|
-
create_field!(entry)
|
|
238
|
+
import_index.fields[identity] = create_field!(entry)
|
|
237
239
|
result["created"] += 1
|
|
238
240
|
end
|
|
239
241
|
end
|
|
@@ -282,7 +284,7 @@ module TypedEAV
|
|
|
282
284
|
|
|
283
285
|
def create_field!(entry)
|
|
284
286
|
field = TypedEAV::Field::Base.create!(entry.except("options_data"))
|
|
285
|
-
return unless field.optionable?
|
|
287
|
+
return field unless field.optionable?
|
|
286
288
|
|
|
287
289
|
Array(entry["options_data"]).each do |option|
|
|
288
290
|
field.field_options.create!(
|
|
@@ -291,16 +293,12 @@ module TypedEAV
|
|
|
291
293
|
sort_order: option["sort_order"],
|
|
292
294
|
)
|
|
293
295
|
end
|
|
296
|
+
field
|
|
294
297
|
end
|
|
295
298
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
existing =
|
|
299
|
-
code: entry["code"],
|
|
300
|
-
entity_type: entry["entity_type"],
|
|
301
|
-
scope: entry["scope"],
|
|
302
|
-
parent_scope: entry["parent_scope"],
|
|
303
|
-
)
|
|
299
|
+
def import_section_entry(entry, on_conflict, result, import_index)
|
|
300
|
+
identity = import_index.section_identity(entry)
|
|
301
|
+
existing = import_index.sections[identity]
|
|
304
302
|
|
|
305
303
|
if existing
|
|
306
304
|
if section_export_row_equal?(existing, entry)
|
|
@@ -322,11 +320,10 @@ module TypedEAV
|
|
|
322
320
|
result["updated"] += 1
|
|
323
321
|
end
|
|
324
322
|
else
|
|
325
|
-
TypedEAV::Section.create!(entry)
|
|
323
|
+
import_index.sections[identity] = TypedEAV::Section.create!(entry)
|
|
326
324
|
result["created"] += 1
|
|
327
325
|
end
|
|
328
326
|
end
|
|
329
|
-
# rubocop:enable Metrics/MethodLength
|
|
330
327
|
|
|
331
328
|
def raise_divergent_section!(entry)
|
|
332
329
|
raise ArgumentError,
|
data/lib/typed_eav/version.rb
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
module Versioning
|
|
5
|
-
# The
|
|
6
|
-
#
|
|
7
|
-
# `TypedEAV::Versioning.register_if_enabled
|
|
8
|
-
#
|
|
5
|
+
# The transactional version writer. Conditionally installed on Value's
|
|
6
|
+
# create/update `after` and destroy `before` callbacks at engine boot via
|
|
7
|
+
# `TypedEAV::Versioning.register_if_enabled`; every write is therefore
|
|
8
|
+
# part of the source transaction.
|
|
9
9
|
#
|
|
10
10
|
# ## Contract
|
|
11
11
|
#
|
|
12
|
-
# `call(value, change_type, context)` — called by
|
|
13
|
-
# Returns nil (return value is ignored
|
|
12
|
+
# `call(value, change_type, context)` — called by Value's lifecycle
|
|
13
|
+
# callback. Returns nil (the return value is ignored; the
|
|
14
14
|
# method's job is the side effect of writing a ValueVersion row).
|
|
15
15
|
#
|
|
16
16
|
# Two-gate short-circuit (the master switch is enforced at
|
|
@@ -54,16 +54,13 @@ module TypedEAV
|
|
|
54
54
|
# nil").
|
|
55
55
|
module Subscriber
|
|
56
56
|
class << self
|
|
57
|
-
# Public entry point.
|
|
58
|
-
# 3-arg signature `(value, change_type, context)`.
|
|
57
|
+
# Public entry point. Value's transactional callbacks call this with
|
|
58
|
+
# the locked 3-arg signature `(value, change_type, context)`.
|
|
59
59
|
#
|
|
60
|
-
# NOTE: there is NO `Config.versioning` gate here. The subscriber
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
# lib/typed_eav/engine.rb's `config.after_initialize` block). If
|
|
65
|
-
# versioning is off, the subscriber is never registered and never
|
|
66
|
-
# reached. The remaining gates are:
|
|
60
|
+
# NOTE: there is NO `Config.versioning` gate here. The subscriber is
|
|
61
|
+
# installed only when `Config.versioning` was true at engine
|
|
62
|
+
# `config.after_initialize` time. If versioning is off, the callback
|
|
63
|
+
# is never installed and this method is never reached. The remaining gates are:
|
|
67
64
|
# 1. field-presence (orphan guard — Value's field_id may have
|
|
68
65
|
# been NULLed by Phase 02's ON DELETE SET NULL cascade).
|
|
69
66
|
# 2. per-entity opt-in (Registry.versioned?).
|
|
@@ -72,6 +69,8 @@ module TypedEAV
|
|
|
72
69
|
return unless TypedEAV.registry.versioned?(value.entity_type)
|
|
73
70
|
|
|
74
71
|
write_version_row(value, change_type, context)
|
|
72
|
+
ensure
|
|
73
|
+
value.consume_pending_version_group_id if value.respond_to?(:consume_pending_version_group_id)
|
|
75
74
|
end
|
|
76
75
|
|
|
77
76
|
private
|
|
@@ -85,15 +84,9 @@ module TypedEAV
|
|
|
85
84
|
before_value = field.before_snapshot(value, change_type)
|
|
86
85
|
after_value = field.after_snapshot(value, change_type)
|
|
87
86
|
|
|
88
|
-
# CRITICAL: for :destroy events, write `value_id: nil`.
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
# commits the DELETE before invoking after_commit callbacks).
|
|
92
|
-
# The FK is `ON DELETE SET NULL`, but at the moment we INSERT
|
|
93
|
-
# the version row, Postgres validates the FK against the
|
|
94
|
-
# current state of typed_eav_values — which no longer contains
|
|
95
|
-
# the parent. Writing `value.id` (still readable in-memory on
|
|
96
|
-
# the destroyed AR record) would FK-fail at INSERT.
|
|
87
|
+
# CRITICAL: for :destroy events, write `value_id: nil`. The row is
|
|
88
|
+
# written before the source DELETE and must not retain an identity
|
|
89
|
+
# that the FK will null when the Value is removed.
|
|
97
90
|
#
|
|
98
91
|
# The audit trail for destroy events stays queryable via:
|
|
99
92
|
# - entity_type + entity_id (host record identity)
|
|
@@ -102,8 +95,7 @@ module TypedEAV
|
|
|
102
95
|
# `field_id` remains populated because destroying a Value does
|
|
103
96
|
# not destroy its Field — `value.field_id` is a live reference.
|
|
104
97
|
#
|
|
105
|
-
# For :create and :update
|
|
106
|
-
# correct (parent row exists at after_commit time).
|
|
98
|
+
# For :create and :update, `value_id: value.id` is correct.
|
|
107
99
|
version_value_id = change_type == :destroy ? nil : value.id
|
|
108
100
|
|
|
109
101
|
# Phase 06 bulk-operations correlation tag. Two delivery paths:
|
|
@@ -113,7 +105,7 @@ module TypedEAV
|
|
|
113
105
|
# on each affected Value object BEFORE `record.save`, INSIDE
|
|
114
106
|
# the per-record `with_context` block. Reading the snapshot
|
|
115
107
|
# here (not `current_context`) guarantees the UUID survives
|
|
116
|
-
# the
|
|
108
|
+
# the source transaction boundary — by the time
|
|
117
109
|
# we run, the lexical `with_context` block has unwound and
|
|
118
110
|
# `current_context` would be empty, but the per-Value ivar
|
|
119
111
|
# persists. Mirrors the existing in-memory ivar pattern at
|
|
@@ -127,12 +119,16 @@ module TypedEAV
|
|
|
127
119
|
# request id). For those paths the Value ivar is unset; the
|
|
128
120
|
# `||` falls through to `context[:version_group_id]`. Also
|
|
129
121
|
# used as the belt-and-suspenders fallback for any future
|
|
130
|
-
#
|
|
122
|
+
# savepoint path that bulk writes
|
|
131
123
|
# might leverage.
|
|
132
124
|
#
|
|
133
125
|
# When neither path supplies a UUID the column (added in
|
|
134
126
|
# `db/migrate/20260506000001`) stays NULL — backward-compatible:
|
|
135
127
|
# unchanged subscribers and unchanged callers continue to work.
|
|
128
|
+
pending_group_id = if value.respond_to?(:consume_pending_version_group_id)
|
|
129
|
+
value.consume_pending_version_group_id
|
|
130
|
+
end
|
|
131
|
+
|
|
136
132
|
TypedEAV::ValueVersion.create!(
|
|
137
133
|
value_id: version_value_id,
|
|
138
134
|
field_id: value.field_id,
|
|
@@ -142,7 +138,7 @@ module TypedEAV
|
|
|
142
138
|
before_value: before_value,
|
|
143
139
|
after_value: after_value,
|
|
144
140
|
context: context.to_h, # frozen → unfrozen jsonb-serializable hash
|
|
145
|
-
version_group_id:
|
|
141
|
+
version_group_id: pending_group_id || context[:version_group_id],
|
|
146
142
|
change_type: change_type.to_s,
|
|
147
143
|
changed_at: Time.current,
|
|
148
144
|
)
|