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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/README.md +634 -2
  4. data/app/models/typed_eav/field/base.rb +552 -6
  5. data/app/models/typed_eav/field/currency.rb +125 -0
  6. data/app/models/typed_eav/field/file.rb +98 -0
  7. data/app/models/typed_eav/field/image.rb +152 -0
  8. data/app/models/typed_eav/field/percentage.rb +100 -0
  9. data/app/models/typed_eav/field/reference.rb +230 -0
  10. data/app/models/typed_eav/section.rb +114 -4
  11. data/app/models/typed_eav/value.rb +461 -11
  12. data/app/models/typed_eav/value_version.rb +96 -0
  13. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
  14. data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
  15. data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
  16. data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
  17. data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
  18. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
  19. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
  20. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
  21. data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
  22. data/lib/typed_eav/bulk_write.rb +147 -0
  23. data/lib/typed_eav/column_mapping.rb +46 -0
  24. data/lib/typed_eav/config.rb +215 -19
  25. data/lib/typed_eav/csv_mapper.rb +158 -0
  26. data/lib/typed_eav/currency_storage_contract.rb +46 -0
  27. data/lib/typed_eav/engine.rb +117 -0
  28. data/lib/typed_eav/event_dispatcher.rb +151 -0
  29. data/lib/typed_eav/field_storage_contract.rb +68 -0
  30. data/lib/typed_eav/has_typed_eav.rb +455 -58
  31. data/lib/typed_eav/partition.rb +64 -0
  32. data/lib/typed_eav/query_builder.rb +39 -3
  33. data/lib/typed_eav/registry.rb +48 -9
  34. data/lib/typed_eav/schema_portability.rb +250 -0
  35. data/lib/typed_eav/version.rb +1 -1
  36. data/lib/typed_eav/versioned.rb +73 -0
  37. data/lib/typed_eav/versioning/subscriber.rb +161 -0
  38. data/lib/typed_eav/versioning.rb +94 -0
  39. data/lib/typed_eav.rb +180 -12
  40. 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
- scope :for_entity, lambda { |entity_type, scope: nil|
45
- scopes = [scope, nil].uniq
46
- where(entity_type: entity_type, scope: scopes)
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