typed_eav 0.1.0 → 0.2.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 +80 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- metadata +35 -1
|
@@ -4,6 +4,11 @@ require "timeout"
|
|
|
4
4
|
|
|
5
5
|
module TypedEAV
|
|
6
6
|
module Field
|
|
7
|
+
# rubocop:disable Metrics/ClassLength -- Field::Base is the central STI parent: associations,
|
|
8
|
+
# validations, cascade dispatch, partition-aware ordering helpers, default-value handling,
|
|
9
|
+
# and the partition-aware backfill all live here together because they share the (entity_type,
|
|
10
|
+
# scope, parent_scope) partition contract. Splitting into concerns would scatter that contract
|
|
11
|
+
# and obscure the cross-cutting invariants the validators and helpers enforce together.
|
|
7
12
|
class Base < ApplicationRecord
|
|
8
13
|
self.table_name = "typed_eav_fields"
|
|
9
14
|
|
|
@@ -16,11 +21,15 @@ module TypedEAV
|
|
|
16
21
|
optional: true,
|
|
17
22
|
inverse_of: :fields
|
|
18
23
|
|
|
24
|
+
# No `dependent: :destroy` here: cascade behavior is now driven by the
|
|
25
|
+
# `field_dependent` column and dispatched in the `before_destroy`
|
|
26
|
+
# callback below. Keeping `dependent: :destroy` would double-fire for
|
|
27
|
+
# the "destroy" policy and short-circuit the `:nullify` /
|
|
28
|
+
# `:restrict_with_error` policies entirely.
|
|
19
29
|
has_many :values,
|
|
20
30
|
class_name: "TypedEAV::Value",
|
|
21
31
|
foreign_key: :field_id,
|
|
22
|
-
inverse_of: :field
|
|
23
|
-
dependent: :destroy
|
|
32
|
+
inverse_of: :field
|
|
24
33
|
|
|
25
34
|
has_many :field_options,
|
|
26
35
|
class_name: "TypedEAV::Option",
|
|
@@ -32,23 +41,180 @@ module TypedEAV
|
|
|
32
41
|
|
|
33
42
|
RESERVED_NAMES = %w[id type class created_at updated_at].freeze
|
|
34
43
|
|
|
35
|
-
validates :name, presence: true, uniqueness: { scope: %i[entity_type scope] }
|
|
44
|
+
validates :name, presence: true, uniqueness: { scope: %i[entity_type scope parent_scope] }
|
|
36
45
|
validates :name, exclusion: { in: RESERVED_NAMES, message: "is reserved" }
|
|
37
46
|
validates :type, presence: true
|
|
38
47
|
validates :entity_type, presence: true
|
|
39
48
|
validate :validate_default_value
|
|
40
49
|
validate :validate_type_allowed_for_entity
|
|
50
|
+
validate :validate_parent_scope_invariant
|
|
51
|
+
|
|
52
|
+
# Cascade policy for Value rows when this Field is destroyed. Stored as
|
|
53
|
+
# a string (not enum) so future policies don't require an additive enum
|
|
54
|
+
# remap. Default "destroy" matches v0.1.0 behavior — see Phase 02
|
|
55
|
+
# CONTEXT §"Cascade behavior wiring + schema delivery".
|
|
56
|
+
validates :field_dependent, inclusion: {
|
|
57
|
+
in: %w[destroy nullify restrict_with_error],
|
|
58
|
+
message: "must be one of: destroy, nullify, restrict_with_error",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# ── Callbacks ──
|
|
62
|
+
|
|
63
|
+
# Dispatch on `field_dependent`. Runs BEFORE the field row is deleted so
|
|
64
|
+
# the "destroy" policy can clean up Value rows while the FK still points
|
|
65
|
+
# somewhere valid. With the FK on ON DELETE SET NULL (post-Phase-02
|
|
66
|
+
# migration), the AR-level `destroy_all` is the canonical mechanism for
|
|
67
|
+
# "destroy" — without it, the FK would NULL the rows out instead and
|
|
68
|
+
# leave orphans behind. The "nullify" branch is intentionally a no-op:
|
|
69
|
+
# the FK does the work. The "restrict_with_error" branch adds an error
|
|
70
|
+
# and `throw(:abort)`s, mirroring AR's `dependent: :restrict_with_error`.
|
|
71
|
+
before_destroy :dispatch_field_dependent
|
|
72
|
+
|
|
73
|
+
# Phase 03 event dispatch. SINGLE callback (no `on:` filter) that
|
|
74
|
+
# branches across the four change_types in `_dispatch_field_change`.
|
|
75
|
+
# Three reasons for one-callback-with-branch over the three-callback-
|
|
76
|
+
# with-on:-filter form used on Value:
|
|
77
|
+
#
|
|
78
|
+
# 1. :rename is a sub-case of :update (it requires
|
|
79
|
+
# `saved_change_to_attribute?(:name)` which is only meaningful
|
|
80
|
+
# in the update lifecycle). Splitting :rename and :update across
|
|
81
|
+
# two declarations duplicates the branch logic.
|
|
82
|
+
# 2. :create / :destroy / :update / :rename are mutually exclusive
|
|
83
|
+
# per save — `created? && destroyed?` cannot both be true — so
|
|
84
|
+
# a single if/elsif chain expresses the contract directly.
|
|
85
|
+
# 3. STI: this declaration on Field::Base fires for every subclass
|
|
86
|
+
# instance (Text, Integer, Select, MultiSelect, IntegerArray,
|
|
87
|
+
# etc.). One callback covers them all without per-subclass
|
|
88
|
+
# duplication.
|
|
89
|
+
#
|
|
90
|
+
# Note: this is NOT the alias-collision form — `after_commit :method`
|
|
91
|
+
# (no `on:`) is one callback, one method, no aliasing. The Rails 8.1
|
|
92
|
+
# alias-collision bug only bites when the convenience alias forms
|
|
93
|
+
# (after_create_commit / after_update_commit / after_destroy_commit)
|
|
94
|
+
# are reused with the same method name.
|
|
95
|
+
after_commit :_dispatch_field_change
|
|
41
96
|
|
|
42
97
|
# ── Scopes ──
|
|
43
98
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
99
|
+
# Partition lookup across the three-key tuple `(entity_type, scope,
|
|
100
|
+
# parent_scope)`. Both scope kwargs default to nil and are expanded via
|
|
101
|
+
# `[val, nil].uniq` so a caller asking for "tenant t1, parent p1" also
|
|
102
|
+
# picks up the partial-overlap rows: `(t1, nil)` and `(nil, nil)`.
|
|
103
|
+
#
|
|
104
|
+
# The orphan-parent invariant (`scope.nil? ⇒ parent_scope.nil?`,
|
|
105
|
+
# enforced by `validate_parent_scope_invariant` below and the paired
|
|
106
|
+
# partial unique indexes from the parent_scope migration) lets us write
|
|
107
|
+
# `parent_scope: [parent_scope, nil].uniq` unconditionally — the
|
|
108
|
+
# `(nil, parent_scope)` tuple cannot exist in the table, so widening
|
|
109
|
+
# the IN-list never matches a stray orphan row.
|
|
110
|
+
#
|
|
111
|
+
# Backwards compatibility: single-scope callers `for_entity(et, scope: s)`
|
|
112
|
+
# continue to work with no code change because `parent_scope` defaults
|
|
113
|
+
# to nil and the `[nil, nil].uniq == [nil]` filter selects only rows
|
|
114
|
+
# with parent_scope IS NULL — the only rows a single-scope tenant ever
|
|
115
|
+
# has.
|
|
116
|
+
scope :for_entity, lambda { |entity_type, scope: nil, parent_scope: nil|
|
|
117
|
+
where(
|
|
118
|
+
entity_type: entity_type,
|
|
119
|
+
scope: [scope, nil].uniq,
|
|
120
|
+
parent_scope: [parent_scope, nil].uniq,
|
|
121
|
+
)
|
|
47
122
|
}
|
|
48
123
|
|
|
49
124
|
scope :sorted, -> { order(sort_order: :asc, name: :asc) }
|
|
50
125
|
scope :required_fields, -> { where(required: true) }
|
|
51
126
|
|
|
127
|
+
# ── Display ordering ──
|
|
128
|
+
#
|
|
129
|
+
# Partition-aware ordering helpers, keyed by (entity_type, scope,
|
|
130
|
+
# parent_scope). Names mirror acts_as_list for muscle memory; the
|
|
131
|
+
# implementation is in-house per CONVENTIONS.md "one hard dep, soft-detect
|
|
132
|
+
# everything else" — adopting acts_as_list as a runtime dep would force
|
|
133
|
+
# every consumer to pull it in.
|
|
134
|
+
#
|
|
135
|
+
# Race semantics: each operation runs inside an AR transaction and
|
|
136
|
+
# acquires a partition-level row lock via
|
|
137
|
+
# `for_entity(...).order(:id).lock("FOR UPDATE")`. This issues
|
|
138
|
+
# SELECT ... FOR UPDATE on every member of the partition (including
|
|
139
|
+
# self) in deterministic ID order — concurrent reorders within the same
|
|
140
|
+
# partition serialize on the lock acquisition, and the deterministic
|
|
141
|
+
# order prevents deadlocks across threads. Cross-partition operations
|
|
142
|
+
# never block each other because they lock disjoint row sets.
|
|
143
|
+
#
|
|
144
|
+
# Why a partition-level lock (not with_lock on self): two threads
|
|
145
|
+
# moving DIFFERENT records within the SAME partition would both pass a
|
|
146
|
+
# per-record lock on self and race on the sibling list / normalization.
|
|
147
|
+
# The partition-level FOR UPDATE is the only correct serialization
|
|
148
|
+
# boundary.
|
|
149
|
+
#
|
|
150
|
+
# Sort-order semantics: every operation normalizes the partition's
|
|
151
|
+
# sort_order column to consecutive integers 1..N (no gaps) on completion.
|
|
152
|
+
# Records with sort_order: nil are positioned after all positioned rows
|
|
153
|
+
# during normalization (Postgres NULLS LAST).
|
|
154
|
+
#
|
|
155
|
+
# Boundary moves are no-ops, not errors. move_higher on the top item
|
|
156
|
+
# returns without raising; move_lower on the bottom item likewise.
|
|
157
|
+
|
|
158
|
+
def move_higher
|
|
159
|
+
reorder_within_partition do |siblings|
|
|
160
|
+
idx = siblings.index { |r| r.id == id }
|
|
161
|
+
next siblings if idx.nil? || idx.zero? # already at top, or not in partition
|
|
162
|
+
|
|
163
|
+
siblings[idx], siblings[idx - 1] = siblings[idx - 1], siblings[idx]
|
|
164
|
+
siblings
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def move_lower
|
|
169
|
+
reorder_within_partition do |siblings|
|
|
170
|
+
idx = siblings.index { |r| r.id == id }
|
|
171
|
+
next siblings if idx.nil? || idx == siblings.size - 1 # already at bottom
|
|
172
|
+
|
|
173
|
+
siblings[idx], siblings[idx + 1] = siblings[idx + 1], siblings[idx]
|
|
174
|
+
siblings
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def move_to_top
|
|
179
|
+
reorder_within_partition do |siblings|
|
|
180
|
+
idx = siblings.index { |r| r.id == id }
|
|
181
|
+
next siblings if idx.nil? || idx.zero?
|
|
182
|
+
|
|
183
|
+
moving = siblings.delete_at(idx)
|
|
184
|
+
siblings.unshift(moving)
|
|
185
|
+
siblings
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def move_to_bottom
|
|
190
|
+
reorder_within_partition do |siblings|
|
|
191
|
+
idx = siblings.index { |r| r.id == id }
|
|
192
|
+
next siblings if idx.nil? || idx == siblings.size - 1
|
|
193
|
+
|
|
194
|
+
moving = siblings.delete_at(idx)
|
|
195
|
+
siblings.push(moving)
|
|
196
|
+
siblings
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Insert at 1-based position. Clamps position to [1, partition_count]:
|
|
201
|
+
# insert_at(0) and any non-positive value behaves as move_to_top;
|
|
202
|
+
# insert_at(999) on a 5-item partition behaves as move_to_bottom.
|
|
203
|
+
# Mirrors acts_as_list's clamp behavior.
|
|
204
|
+
def insert_at(position)
|
|
205
|
+
reorder_within_partition do |siblings|
|
|
206
|
+
idx = siblings.index { |r| r.id == id }
|
|
207
|
+
next siblings if idx.nil?
|
|
208
|
+
|
|
209
|
+
target = position.clamp(1, siblings.size) - 1
|
|
210
|
+
next siblings if idx == target
|
|
211
|
+
|
|
212
|
+
moving = siblings.delete_at(idx)
|
|
213
|
+
siblings.insert(target, moving)
|
|
214
|
+
siblings
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
52
218
|
# ── Default value handling ──
|
|
53
219
|
# Stored in default_value_meta as {"v": <raw_value>} so the jsonb
|
|
54
220
|
# column can hold any type's default without an extra typed column.
|
|
@@ -78,8 +244,84 @@ module TypedEAV
|
|
|
78
244
|
[raw, false]
|
|
79
245
|
end
|
|
80
246
|
|
|
247
|
+
# ── Phase 05 multi-cell extension points ──
|
|
248
|
+
#
|
|
249
|
+
# These three instance methods are the field-side surface that resolves
|
|
250
|
+
# Value#value semantics, the write path, and the default-application
|
|
251
|
+
# path. Single-cell field types (every built-in as of Phase 04) inherit
|
|
252
|
+
# the defaults below and behave identically to the pre-Phase-05 direct-
|
|
253
|
+
# column-access shape.
|
|
254
|
+
#
|
|
255
|
+
# Multi-cell field types (Phase 05: Currency stores `{amount, currency}`
|
|
256
|
+
# across decimal_value + string_value) override these to compose /
|
|
257
|
+
# unpack the logical value across multiple physical columns. The
|
|
258
|
+
# dispatch keeps Value#value, Value#value=, and Value#apply_field_default
|
|
259
|
+
# oblivious to multi-cell — they always go through the field, so adding
|
|
260
|
+
# new multi-cell types in the future requires no Value-side changes.
|
|
261
|
+
#
|
|
262
|
+
# IMPORTANT: read_value, write_value, and apply_default_to are paired.
|
|
263
|
+
# Currency overrides ALL THREE — overriding only one creates an
|
|
264
|
+
# asymmetry where reads see the multi-cell shape but writes / defaults
|
|
265
|
+
# populate only one column (or vice versa).
|
|
266
|
+
|
|
267
|
+
# Returns the logical value for this field as stored on the given
|
|
268
|
+
# Value record. Default reads `value_record[self.class.value_column]`.
|
|
269
|
+
# Override in multi-cell field types to compose a hash from multiple
|
|
270
|
+
# columns (e.g., Field::Currency returns
|
|
271
|
+
# `{amount: r[:decimal_value], currency: r[:string_value]}`).
|
|
272
|
+
#
|
|
273
|
+
# Called from Value#value. The Value#value `return nil unless field`
|
|
274
|
+
# guard runs before this method, so `self` is always set.
|
|
275
|
+
def read_value(value_record)
|
|
276
|
+
value_record[self.class.value_column]
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Writes a casted value to the given Value record. Default writes
|
|
280
|
+
# `value_record[self.class.value_column] = casted`. Override in multi-
|
|
281
|
+
# cell types to unpack a composite casted value into multiple columns
|
|
282
|
+
# (e.g., Field::Currency unpacks `{amount: BigDecimal, currency: String}`
|
|
283
|
+
# into decimal_value + string_value).
|
|
284
|
+
#
|
|
285
|
+
# Called from Value#value=. The cast invariant is preserved: `casted`
|
|
286
|
+
# is whatever the field's `cast(raw)` returned as the first element.
|
|
287
|
+
# For single-cell types that's a scalar; for Currency it's a Hash.
|
|
288
|
+
# Without this dispatch, a Currency cast result (a Hash) would be
|
|
289
|
+
# written to a single typed column, raising TypeMismatch at save time.
|
|
290
|
+
def write_value(value_record, casted)
|
|
291
|
+
value_record[self.class.value_column] = casted
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Writes this field's configured default to the given Value record.
|
|
295
|
+
# Default writes `value_record[self.class.value_column] = default_value`,
|
|
296
|
+
# bypassing Value#value= to avoid re-casting an already-cast default
|
|
297
|
+
# (default_value is cast at field save time via validate_default_value).
|
|
298
|
+
# Override in multi-cell types to populate multiple columns from a
|
|
299
|
+
# composite default (e.g., Field::Currency unpacks `default_value`'s
|
|
300
|
+
# `{amount: ..., currency: ...}` hash into decimal_value + string_value).
|
|
301
|
+
#
|
|
302
|
+
# Called from Value#apply_field_default in two contexts:
|
|
303
|
+
# 1. Initial value assignment when no `value:` kwarg was passed
|
|
304
|
+
# (UNSET_VALUE sentinel resolution path).
|
|
305
|
+
# 2. Pending-value resolution (apply_pending_value branch where
|
|
306
|
+
# @pending_value was UNSET_VALUE and the field arrived later).
|
|
307
|
+
def apply_default_to(value_record)
|
|
308
|
+
value_record[self.class.value_column] = default_value
|
|
309
|
+
end
|
|
310
|
+
|
|
81
311
|
# ── Introspection ──
|
|
82
312
|
|
|
313
|
+
def self.storage_contract_class(contract_class = nil)
|
|
314
|
+
if contract_class
|
|
315
|
+
@storage_contract_class = contract_class
|
|
316
|
+
else
|
|
317
|
+
@storage_contract_class || TypedEAV::FieldStorageContract
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def storage_contract
|
|
322
|
+
@storage_contract ||= self.class.storage_contract_class.new(self)
|
|
323
|
+
end
|
|
324
|
+
|
|
83
325
|
def field_type_name
|
|
84
326
|
self.class.name.demodulize.underscore
|
|
85
327
|
end
|
|
@@ -108,6 +350,94 @@ module TypedEAV
|
|
|
108
350
|
# no-op
|
|
109
351
|
end
|
|
110
352
|
|
|
353
|
+
# ── Backfill ──
|
|
354
|
+
|
|
355
|
+
# Backfills existing entities with this field's configured default value.
|
|
356
|
+
# Iterates entities of `entity_type` in batches of 1000 via
|
|
357
|
+
# `find_in_batches`, filtering each batch member by the field's
|
|
358
|
+
# (scope, parent_scope) partition. Each WHOLE batch runs inside one
|
|
359
|
+
# transaction so:
|
|
360
|
+
# - a long-running backfill can be interrupted and resumed (each
|
|
361
|
+
# completed batch is committed; the caller re-runs to pick up where
|
|
362
|
+
# they stopped — the skip rule re-checks each batch member),
|
|
363
|
+
# - per-batch transaction overhead is bounded: at 1M entities × 1000
|
|
364
|
+
# per batch, this is ~1000 transactions, not 1M.
|
|
365
|
+
#
|
|
366
|
+
# Skip rule (per-record, applied INSIDE the batch loop): skip when the
|
|
367
|
+
# entity already has a non-nil typed value for this field. A Value row
|
|
368
|
+
# whose typed column is nil is still a candidate for backfill — the
|
|
369
|
+
# skip rule is "non-nil typed column," not "Value row exists" (matches
|
|
370
|
+
# CONTEXT.md).
|
|
371
|
+
#
|
|
372
|
+
# Partition match: when field.scope is non-nil, the entity must respond
|
|
373
|
+
# to typed_eav_scope and the value must match field.scope (as String).
|
|
374
|
+
# When field.parent_scope is non-nil, same check for typed_eav_parent_scope.
|
|
375
|
+
# When field.scope is nil (global field), no scope filter — iterate all
|
|
376
|
+
# entities of entity_type.
|
|
377
|
+
#
|
|
378
|
+
# Why find_in_batches (not find_each): we need the batch as a unit so
|
|
379
|
+
# the transaction boundary aligns with the batch boundary. find_each
|
|
380
|
+
# yields records one-at-a-time, which would either force per-record
|
|
381
|
+
# transactions (wrong — burns overhead, contradicts CONTEXT.md) or
|
|
382
|
+
# require us to buffer batches manually outside AR's batching logic.
|
|
383
|
+
#
|
|
384
|
+
# Why explicit `value: default_value` (not the UNSET_VALUE sentinel):
|
|
385
|
+
# backfill knows the default, so passing it explicitly bypasses the
|
|
386
|
+
# sentinel resolution path on Value#value=. Explicit `value: x`
|
|
387
|
+
# continues to store x in both pre-sentinel and post-sentinel code,
|
|
388
|
+
# which keeps backfill BC-safe regardless of plan ordering.
|
|
389
|
+
#
|
|
390
|
+
# Synchronous by default. For async dispatch, define your own job:
|
|
391
|
+
#
|
|
392
|
+
# class BackfillJob < ApplicationJob
|
|
393
|
+
# def perform(field_id) = TypedEAV::Field::Base.find(field_id).backfill_default!
|
|
394
|
+
# end
|
|
395
|
+
# BackfillJob.perform_later(field.id)
|
|
396
|
+
#
|
|
397
|
+
# (Documented inline as RDoc; not built-in to keep the gem dep-free.)
|
|
398
|
+
def backfill_default!
|
|
399
|
+
# Short-circuit: nothing to backfill if no default configured. We
|
|
400
|
+
# explicitly do NOT write nil rows — backfill is for propagating a
|
|
401
|
+
# configured default, not for materializing empty Value rows.
|
|
402
|
+
return if default_value.nil?
|
|
403
|
+
|
|
404
|
+
entity_class = entity_type.constantize
|
|
405
|
+
column = self.class.value_column
|
|
406
|
+
|
|
407
|
+
entity_class.find_in_batches(batch_size: 1000) do |batch|
|
|
408
|
+
# One transaction per batch (NOT per record). If the transaction
|
|
409
|
+
# raises mid-batch, the WHOLE batch rolls back and the exception
|
|
410
|
+
# surfaces; prior batches stay committed. Caller re-runs idempotently
|
|
411
|
+
# because the per-record skip rule re-checks each entity.
|
|
412
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
413
|
+
batch.each do |entity|
|
|
414
|
+
next unless partition_matches?(entity)
|
|
415
|
+
|
|
416
|
+
backfill_one(entity, column)
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# ── Schema export / import ──
|
|
423
|
+
|
|
424
|
+
# Backward-compatible public entry point. Implementation lives in
|
|
425
|
+
# SchemaPortability so Field::Base does not carry schema projection,
|
|
426
|
+
# conflict policy, option replacement, and section import details.
|
|
427
|
+
def self.export_schema(entity_type:, scope: nil, parent_scope: nil)
|
|
428
|
+
TypedEAV::SchemaPortability.export_schema(
|
|
429
|
+
entity_type: entity_type,
|
|
430
|
+
scope: scope,
|
|
431
|
+
parent_scope: parent_scope,
|
|
432
|
+
)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Backward-compatible public entry point. Implementation lives in
|
|
436
|
+
# SchemaPortability; this delegator preserves existing callers.
|
|
437
|
+
def self.import_schema(hash, on_conflict: :error)
|
|
438
|
+
TypedEAV::SchemaPortability.import_schema(hash, on_conflict: on_conflict)
|
|
439
|
+
end
|
|
440
|
+
|
|
111
441
|
# ── Per-type value validation (polymorphic dispatch from Value) ──
|
|
112
442
|
#
|
|
113
443
|
# Default no-op. Subclasses override to enforce their constraints
|
|
@@ -229,6 +559,222 @@ module TypedEAV
|
|
|
229
559
|
|
|
230
560
|
errors.add(:type, "#{field_type_name} is not allowed for #{entity_type}")
|
|
231
561
|
end
|
|
562
|
+
|
|
563
|
+
# Orphan-parent invariant: when `scope` is nil (global field),
|
|
564
|
+
# `parent_scope` MUST also be nil. A "global parent_scope" makes no
|
|
565
|
+
# semantic sense — a field that's global across tenants cannot be
|
|
566
|
+
# partitioned within one tenant.
|
|
567
|
+
#
|
|
568
|
+
# Without this, the paired-partial unique indexes from the
|
|
569
|
+
# parent_scope migration would silently allow `(scope=nil,
|
|
570
|
+
# parent_scope='p1')` rows: the global partial (`scope IS NULL`)
|
|
571
|
+
# omits parent_scope from its column list and the scoped partials
|
|
572
|
+
# only fire when `scope IS NOT NULL`. The model-level check is the
|
|
573
|
+
# canonical guard — once enforced, the Value-side
|
|
574
|
+
# `validate_field_scope_matches_entity` (plan 05) never has to
|
|
575
|
+
# handle an orphan-field case.
|
|
576
|
+
#
|
|
577
|
+
# `blank?` rather than `nil?` rejects empty-string `parent_scope`
|
|
578
|
+
# too, which would otherwise slip past a `.nil?` check and produce
|
|
579
|
+
# the same incoherent state as a literal NULL. Same reasoning for
|
|
580
|
+
# `scope.present?`.
|
|
581
|
+
def validate_parent_scope_invariant
|
|
582
|
+
return if parent_scope.blank?
|
|
583
|
+
return if scope.present?
|
|
584
|
+
|
|
585
|
+
errors.add(:parent_scope, "cannot be set when scope is blank")
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# before_destroy hook. Reads the `field_dependent` policy column and
|
|
589
|
+
# acts before the field row is deleted (and before the FK ON DELETE SET
|
|
590
|
+
# NULL fires). The three branches are exhaustive because the
|
|
591
|
+
# `field_dependent` inclusion validator narrows to exactly these
|
|
592
|
+
# values; an unrecognized value would have failed save earlier and
|
|
593
|
+
# cannot reach destroy.
|
|
594
|
+
def dispatch_field_dependent
|
|
595
|
+
case field_dependent
|
|
596
|
+
when "destroy"
|
|
597
|
+
# Explicit `destroy_all`: with ON DELETE SET NULL on the FK, the
|
|
598
|
+
# database would otherwise NULL field_id out instead of deleting
|
|
599
|
+
# the rows. AR callbacks run inside the destroy transaction, so
|
|
600
|
+
# this is atomic with the field row deletion.
|
|
601
|
+
values.destroy_all
|
|
602
|
+
when "nullify"
|
|
603
|
+
# No-op: the FK ON DELETE SET NULL nulls field_id automatically.
|
|
604
|
+
# Read-path orphan guards in `InstanceMethods#typed_eav_value` and
|
|
605
|
+
# `#typed_eav_hash` silently skip these rows — the documented
|
|
606
|
+
# fail-soft path (see PATTERNS.md §"Defend the read path").
|
|
607
|
+
when "restrict_with_error"
|
|
608
|
+
return unless values.exists?
|
|
609
|
+
|
|
610
|
+
# Errors tell you how to fix it (CONVENTIONS.md): list the two
|
|
611
|
+
# recovery paths — change the policy or remove the values first.
|
|
612
|
+
errors.add(
|
|
613
|
+
:base,
|
|
614
|
+
"Cannot delete field that has values. Use field_dependent: :nullify or destroy values first.",
|
|
615
|
+
)
|
|
616
|
+
throw(:abort)
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Wraps a block in: (1) an AR transaction, (2) a partition-level row
|
|
621
|
+
# lock acquired via `for_entity(...).order(:id).lock("FOR UPDATE")`,
|
|
622
|
+
# (3) re-ordering of the locked array by sort_order ASC NULLS LAST then
|
|
623
|
+
# name ASC (the canonical display order), (4) the caller's mutation of
|
|
624
|
+
# the resulting siblings array, (5) normalization back to 1..N.
|
|
625
|
+
#
|
|
626
|
+
# The :id ordering of the lock acquisition is load-bearing — without it,
|
|
627
|
+
# two threads acquiring locks on the same partition could deadlock on
|
|
628
|
+
# different acquisition orders. Postgres documents `FOR UPDATE` ordering
|
|
629
|
+
# as the canonical deadlock-avoidance technique.
|
|
630
|
+
#
|
|
631
|
+
# Yielding the siblings array (already locked) to the caller lets each
|
|
632
|
+
# move helper express its mutation declaratively while sharing the
|
|
633
|
+
# locking + normalization scaffold.
|
|
634
|
+
def reorder_within_partition
|
|
635
|
+
self.class.transaction do
|
|
636
|
+
locked = self.class
|
|
637
|
+
.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
|
|
638
|
+
.order(:id)
|
|
639
|
+
.lock("FOR UPDATE")
|
|
640
|
+
.to_a
|
|
641
|
+
|
|
642
|
+
# Sort the locked snapshot into display order (the lock was acquired
|
|
643
|
+
# in :id order for deadlock safety; we reorder in memory for the
|
|
644
|
+
# mutation step).
|
|
645
|
+
siblings = locked.sort_by { |r| [r.sort_order.nil? ? 1 : 0, r.sort_order || 0, r.name.to_s] }
|
|
646
|
+
|
|
647
|
+
siblings = yield(siblings)
|
|
648
|
+
normalize_partition_sort_order(siblings)
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Normalizes the partition to consecutive integers 1..N. Issues one
|
|
653
|
+
# UPDATE per row whose sort_order changed (the in-memory comparison
|
|
654
|
+
# avoids no-op writes). Runs inside the caller's transaction.
|
|
655
|
+
def normalize_partition_sort_order(siblings)
|
|
656
|
+
siblings.each_with_index do |record, index|
|
|
657
|
+
desired = index + 1
|
|
658
|
+
next if record.sort_order == desired
|
|
659
|
+
|
|
660
|
+
record.update_columns(sort_order: desired) # rubocop:disable Rails/SkipsModelValidations -- intentional: this is partition normalization, not a user-facing edit; validations don't apply to sort_order shuffling.
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Partition-match check used by backfill_default!. Skips entities whose
|
|
665
|
+
# scope axis disagrees with the field's. The check is symmetric to the
|
|
666
|
+
# Value#validate_field_scope_matches_entity validator (which guards the
|
|
667
|
+
# write path) — backfill must not write Values that the validator would
|
|
668
|
+
# reject.
|
|
669
|
+
def partition_matches?(entity)
|
|
670
|
+
return false unless entity_partition_axis_matches?(entity, :scope)
|
|
671
|
+
return false unless entity_partition_axis_matches?(entity, :parent_scope)
|
|
672
|
+
|
|
673
|
+
true
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Per-axis matcher. `axis` is :scope or :parent_scope. When the field's
|
|
677
|
+
# value on that axis is blank (global on this axis), every entity
|
|
678
|
+
# matches. Otherwise the entity must respond to the corresponding
|
|
679
|
+
# `typed_eav_<axis>` reader (defined by has_typed_eav InstanceMethods),
|
|
680
|
+
# have a non-nil value there, and that value (stringified) must equal
|
|
681
|
+
# the field's value (stringified). Stringification mirrors the
|
|
682
|
+
# `&.to_s` normalization in InstanceMethods#typed_eav_scope /
|
|
683
|
+
# #typed_eav_parent_scope so a numeric tenant_id and a String "1"
|
|
684
|
+
# match correctly.
|
|
685
|
+
def entity_partition_axis_matches?(entity, axis)
|
|
686
|
+
field_axis_value = public_send(axis) # field.scope or field.parent_scope
|
|
687
|
+
return true if field_axis_value.blank? # global on this axis: any entity matches
|
|
688
|
+
|
|
689
|
+
reader_method = :"typed_eav_#{axis}"
|
|
690
|
+
return false unless entity.respond_to?(reader_method)
|
|
691
|
+
|
|
692
|
+
entity_value = entity.public_send(reader_method)
|
|
693
|
+
return false if entity_value.nil?
|
|
694
|
+
|
|
695
|
+
field_axis_value.to_s == entity_value.to_s
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Backfills a single entity. Existing-row detection uses
|
|
699
|
+
# WHERE field_id = id (not the AR `field:` association) so the lookup
|
|
700
|
+
# works even when the Value row was written before this Field instance
|
|
701
|
+
# was loaded. The Value-side uniqueness validator on (entity_type,
|
|
702
|
+
# entity_id, field_id) guarantees at most one row per (entity, field).
|
|
703
|
+
#
|
|
704
|
+
# Three states:
|
|
705
|
+
# - no row → create with explicit `value: default_value`. Passing
|
|
706
|
+
# `value:` explicitly bypasses the UNSET_VALUE sentinel path on
|
|
707
|
+
# Value#initialize (backfill knows the default; no need to re-
|
|
708
|
+
# resolve via the sentinel).
|
|
709
|
+
# - row exists with nil typed column → update to default. This is the
|
|
710
|
+
# case the skip rule deliberately allows backfill to fix (a Value
|
|
711
|
+
# row created via explicit `value: nil` is still a backfill
|
|
712
|
+
# candidate per CONTEXT.md).
|
|
713
|
+
# - row exists with non-nil typed column → skip (idempotence).
|
|
714
|
+
def backfill_one(entity, column)
|
|
715
|
+
existing = TypedEAV::Value.where(entity: entity, field_id: id).first
|
|
716
|
+
|
|
717
|
+
if existing.nil?
|
|
718
|
+
TypedEAV::Value.create!(entity: entity, field: self, value: default_value)
|
|
719
|
+
elsif existing[column].nil?
|
|
720
|
+
existing.update!(value: default_value)
|
|
721
|
+
end
|
|
722
|
+
# else: row exists with non-nil typed column → skip (skip rule).
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# ── Phase 03 event dispatch ──
|
|
726
|
+
#
|
|
727
|
+
# Branches the four change_types via lifecycle predicates in the order
|
|
728
|
+
# locked by 03-CONTEXT.md §"`on_field_change` change_type set" → "Mechanism":
|
|
729
|
+
#
|
|
730
|
+
# previously_new_record? → :create
|
|
731
|
+
# destroyed? → :destroy
|
|
732
|
+
# saved_change_to_attribute?(:name) → :rename
|
|
733
|
+
# else → :update
|
|
734
|
+
#
|
|
735
|
+
# The four predicates are mutually exclusive per save
|
|
736
|
+
# (`previously_new_record?` and `destroyed?` cannot both be true;
|
|
737
|
+
# rename/update only reachable when neither create nor destroy fired),
|
|
738
|
+
# so the ordering is behaviorally equivalent to any other ordering —
|
|
739
|
+
# but matching CONTEXT.md verbatim keeps the code grep-able against
|
|
740
|
+
# the locked decision and avoids the next reader wondering whether
|
|
741
|
+
# the order was intentional or accidental.
|
|
742
|
+
#
|
|
743
|
+
# `previously_new_record?` is the documented Rails AR predicate that
|
|
744
|
+
# answers "was this row newly inserted in the just-committed save?"
|
|
745
|
+
# (true after a successful create-commit, false after update/destroy).
|
|
746
|
+
# The plan referenced `created?` as a "Rails 6.1+ alias" but that
|
|
747
|
+
# alias does NOT exist on ActiveRecord records on the pinned
|
|
748
|
+
# activerecord 8.1.3 (NoMethodError verified via dummy app probe in
|
|
749
|
+
# plan 03-02 P02 — see SUMMARY deviations). `id_previously_changed?`
|
|
750
|
+
# works incidentally (PK goes nil→assigned during INSERT) but is
|
|
751
|
+
# less intent-revealing. `previously_new_record?` is the correct
|
|
752
|
+
# documented form for this lifecycle-state question.
|
|
753
|
+
#
|
|
754
|
+
# Rename detection is structural: any save where the :name column changed
|
|
755
|
+
# counts as a rename, even if combined with other attribute changes
|
|
756
|
+
# (sort_order, options, default_value, field_dependent). This false-
|
|
757
|
+
# positive bias is intentional — Phase 07's matview must regenerate
|
|
758
|
+
# column DDL on rename; missing a rename combined with other edits would
|
|
759
|
+
# corrupt the matview's column-name → field-name map.
|
|
760
|
+
#
|
|
761
|
+
# `:name` is the only attribute name we hardcode in this callback, and
|
|
762
|
+
# it's structural (the locked rename-detection mechanism per 03-CONTEXT.md),
|
|
763
|
+
# not a data access — we don't read the value of `name`, only that it
|
|
764
|
+
# changed.
|
|
765
|
+
def _dispatch_field_change
|
|
766
|
+
change_type = if previously_new_record?
|
|
767
|
+
:create
|
|
768
|
+
elsif destroyed?
|
|
769
|
+
:destroy
|
|
770
|
+
elsif saved_change_to_attribute?(:name)
|
|
771
|
+
:rename
|
|
772
|
+
else
|
|
773
|
+
:update
|
|
774
|
+
end
|
|
775
|
+
TypedEAV::EventDispatcher.dispatch_field_change(self, change_type)
|
|
776
|
+
end
|
|
232
777
|
end
|
|
778
|
+
# rubocop:enable Metrics/ClassLength
|
|
233
779
|
end
|
|
234
780
|
end
|