typed_eav 0.1.0 → 0.2.1

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 +89 -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 +36 -2
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # Foreign-key field type. Stores the target record's integer ID in
6
+ # `integer_value`. The `:references` operator accepts AR record
7
+ # instances OR Integer IDs at query time, normalizing to the FK via
8
+ # `field.cast` before predicate emission.
9
+ #
10
+ # ## Phase 05 Gating Decision 2 (RESOLVED)
11
+ #
12
+ # Reference target-scope validation runs along a DIFFERENT axis than
13
+ # the existing `Value#validate_field_scope_matches_entity` guard
14
+ # (which checks the SOURCE entity against the FIELD's `scope`).
15
+ # Reference checks the TARGET entity against the FIELD's
16
+ # `target_scope` option:
17
+ #
18
+ # - `target_scope` NIL → references to any entity type (scoped or
19
+ # unscoped) are accepted; no cross-scope check at value save time.
20
+ # - `target_scope` SET + `target_entity_type` is unscoped (no
21
+ # `has_typed_eav scope_method:`) → field save FAILS with explicit
22
+ # error. Mirrors the `field.scope.present?` guard pattern in
23
+ # `Value#validate_field_scope_matches_entity` (value.rb:403-408)
24
+ # — fail fast at field-config time rather than letting every
25
+ # value save dead-letter.
26
+ # - `target_scope` SET + target scoped + target's `typed_eav_scope`
27
+ # does not match `target_scope` → value save FAILS.
28
+ #
29
+ # ## Operators (explicit narrowing)
30
+ #
31
+ # `:eq, :is_null, :is_not_null, :references`. Does NOT inherit
32
+ # `:integer_value`'s default operator set (which includes `:gt`,
33
+ # `:lt`, `:between`) since arithmetic comparisons on FKs don't
34
+ # carry useful semantics. The `:references` operator is registered
35
+ # ONLY on this class — QueryBuilder's operator-validation gate
36
+ # rejects it on every other field type.
37
+ #
38
+ # `:references` semantics are equivalent to `:eq` on integer_value
39
+ # but additionally accept AR record instances (normalized via
40
+ # `field.cast`). This gives ergonomic parity with Rails AR
41
+ # association queries (`Contact.where(manager: alice)`) — the EAV
42
+ # equivalent accepts a model instance directly. Allowing `:eq` to
43
+ # accept AR records would require a casting fork inside
44
+ # QueryBuilder.filter's `:eq` branch which would touch every other
45
+ # field type; adding a separate operator symbol is the minimal path.
46
+ #
47
+ # ## Options
48
+ #
49
+ # - `target_entity_type`: REQUIRED. String class name of the target
50
+ # AR model (e.g., `"Contact"`). Validated to constantize.
51
+ # - `target_scope`: OPTIONAL. The expected `typed_eav_scope` value
52
+ # for target records. Type-loose comparison (`to_s == to_s`)
53
+ # matches the Phase 1 `entity_partition_axis_matches?` pattern.
54
+ #
55
+ # ## Storage column
56
+ #
57
+ # `:integer_value`. String FK targets and UUID FK targets are out
58
+ # of scope (the dummy app and prevailing AR convention is integer
59
+ # PK; UUID support would require schema changes to typed_eav_values
60
+ # that are not Phase 5).
61
+ class Reference < Base
62
+ value_column :integer_value
63
+
64
+ operators(*%i[eq is_null is_not_null references])
65
+
66
+ store_accessor :options, :target_entity_type, :target_scope
67
+
68
+ validates :target_entity_type, presence: true
69
+ validate :target_entity_type_resolves
70
+ validate :target_scope_requires_scoped_target
71
+
72
+ # Cast contract:
73
+ # - nil / blank → [nil, false]
74
+ # - Integer → [int, false]
75
+ # - numeric String (e.g., "42") → [int, false]
76
+ # - non-numeric String → [nil, true]
77
+ # - AR record matching target_entity_type → [record.id, false]
78
+ # - AR record of a different class → [nil, true] (configured for a
79
+ # specific target type — rejecting other types catches typos at
80
+ # write time)
81
+ # - any other shape → [nil, true]
82
+ #
83
+ # Accepts both Integer FKs AND model instances for ergonomic
84
+ # parity with Rails AR association API (`belongs_to :manager;
85
+ # contact.manager = alice` works whether `alice` is a Contact or
86
+ # an id).
87
+ def cast(raw)
88
+ return [nil, false] if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
89
+
90
+ # CRITICAL: top-level `::Integer` (and `::String` below). Inside
91
+ # `module TypedEAV; module Field`, the bare `Integer` constant
92
+ # resolves to TypedEAV::Field::Integer (a Field subclass), not
93
+ # the Ruby Integer class — so `raw.is_a?(Integer)` would always
94
+ # be false. Same hazard with String. The leading `::` anchors
95
+ # constant lookup to ::Object and avoids the namespace shadow.
96
+ return [raw, false] if raw.is_a?(::Integer)
97
+
98
+ if raw.is_a?(::String)
99
+ # Integer(...) with exception: false returns nil for non-numeric
100
+ # input (including decimals like "1.5" — fractional FKs are
101
+ # nonsense). Same rejection pattern as Field::Integer#cast.
102
+ # `Integer(...)` is the Kernel method (not the constant —
103
+ # method-call syntax routes through Kernel#Integer rather
104
+ # than constant lookup, so the TypedEAV::Field::Integer
105
+ # constant shadow that bites `is_a?(::Integer)` above does
106
+ # NOT bite this call form).
107
+ int = Integer(raw, exception: false)
108
+ return [int, false] if int
109
+
110
+ return [nil, true]
111
+ end
112
+
113
+ # AR record path: must match target_entity_type. Class-mismatch
114
+ # is treated as :invalid at cast time so the error surface lines
115
+ # up with other type-mismatch failures (cast-tuple invalid bit
116
+ # → Value#validate_value → errors.add(:value, :invalid)).
117
+ if raw.respond_to?(:id) && raw.class.respond_to?(:name)
118
+ target_class = resolve_target_class
119
+ return [nil, true] if target_class.nil?
120
+ return [raw.id, false] if raw.is_a?(target_class)
121
+
122
+ return [nil, true]
123
+ end
124
+
125
+ [nil, true]
126
+ end
127
+
128
+ # Value-time validation: when target_scope is set on the field,
129
+ # the target record's typed_eav_scope must match. When target_scope
130
+ # is nil, no cross-scope check fires (the field author is declaring
131
+ # "this reference is to a global/unscoped entity"). When target
132
+ # lookup fails (record was deleted or never existed), errors.add
133
+ # (:value, :invalid) — reuses the existing :invalid symbol from
134
+ # cast-time invalidation for UX consistency.
135
+ def validate_typed_value(record, val)
136
+ return if val.nil?
137
+ return if target_scope.blank?
138
+
139
+ target_class = resolve_target_class
140
+ # Field-save validators above already reject the (target_scope
141
+ # set + unscoped target) combination. These guards are defense
142
+ # in depth: if a Reference field somehow exists with a stale
143
+ # target_class reference, fail soft at value save rather than
144
+ # raise NoMethodError on `target_class.nil?` chains.
145
+ return unless target_class
146
+ return unless target_class.respond_to?(:typed_eav_scope_method)
147
+ return unless target_class.typed_eav_scope_method
148
+
149
+ target_record = target_class.find_by(id: val)
150
+ if target_record.nil?
151
+ record.errors.add(:value, :invalid)
152
+ return
153
+ end
154
+
155
+ return if target_partition_matches?(target_record, target_scope)
156
+
157
+ record.errors.add(:value, "target's scope does not match target_scope")
158
+ end
159
+
160
+ private
161
+
162
+ # Constantizes target_entity_type via safe_constantize so a typo
163
+ # in the option (or a class that's been removed) returns nil
164
+ # rather than raising NameError. The target_entity_type_resolves
165
+ # validator surfaces the nil → invalid path with a specific
166
+ # error message at field save time.
167
+ def resolve_target_class
168
+ return nil if target_entity_type.blank?
169
+
170
+ target_entity_type.safe_constantize
171
+ end
172
+
173
+ # Structurally parallel to Field::Base#entity_partition_axis_matches?
174
+ # (field/base.rb:654-665) but reads from the field's `target_scope`
175
+ # option rather than a `scope`/`parent_scope` class-attribute axis.
176
+ # Refactoring to a shared base helper is OUT OF SCOPE for Phase 5
177
+ # (existing helper signature is `(entity, axis)` reading via
178
+ # `public_send(axis)` — this helper reads from a different option
179
+ # surface). Future cleanup is a Phase 7 ergonomics pass at most.
180
+ #
181
+ # Type-loose comparison via `.to_s == .to_s` mirrors the Phase 1
182
+ # entity_partition_axis_matches? pattern (field/base.rb:664) — a
183
+ # numeric tenant_id and a String "1" must match correctly,
184
+ # especially after jsonb deserialization which can return either.
185
+ def target_partition_matches?(target_record, expected_scope)
186
+ return true if expected_scope.blank?
187
+ return false unless target_record.respond_to?(:typed_eav_scope)
188
+
189
+ actual = target_record.typed_eav_scope
190
+ return false if actual.nil?
191
+
192
+ expected_scope.to_s == actual.to_s
193
+ end
194
+
195
+ # Field-save validation: target_entity_type must constantize.
196
+ # presence is handled separately via `validates :target_entity_type,
197
+ # presence: true` so this validator only fires when a value is
198
+ # given — avoids "blank AND unresolvable" double error.
199
+ def target_entity_type_resolves
200
+ return if target_entity_type.blank?
201
+ return if resolve_target_class
202
+
203
+ errors.add(:target_entity_type,
204
+ "must be a valid class name (got #{target_entity_type.inspect})")
205
+ end
206
+
207
+ # Field-save validation (Gating Decision 2): target_scope SET +
208
+ # target_entity_type unscoped → fail. target_scope nil → no check
209
+ # (the field author is declaring "no cross-scope filtering" — any
210
+ # entity type is acceptable target). The two earlier validators
211
+ # cover the (blank target_entity_type) and (unresolvable
212
+ # target_entity_type) cases; this validator focuses on the
213
+ # scope-vs-no-scope-method axis.
214
+ def target_scope_requires_scoped_target
215
+ return if target_scope.blank?
216
+ return if target_entity_type.blank? # other validators catch this
217
+
218
+ target_class = resolve_target_class
219
+ return if target_class.nil? # other validators catch this
220
+
221
+ return if target_class.respond_to?(:typed_eav_scope_method) &&
222
+ target_class.typed_eav_scope_method
223
+
224
+ errors.add(:target_scope,
225
+ "cannot be set when target_entity_type (#{target_entity_type}) " \
226
+ "is not registered with `has_typed_eav scope_method:`")
227
+ end
228
+ end
229
+ end
230
+ end
@@ -10,16 +10,126 @@ module TypedEAV
10
10
  dependent: :nullify
11
11
 
12
12
  validates :name, presence: true
13
- validates :code, presence: true, uniqueness: { scope: %i[entity_type scope] }
13
+ validates :code, presence: true, uniqueness: { scope: %i[entity_type scope parent_scope] }
14
14
  validates :entity_type, presence: true
15
+ validate :validate_parent_scope_invariant
15
16
 
16
17
  scope :active, -> { where(active: true) }
17
18
  # Mirror Field::Base.for_entity: scoped rows plus global (scope=NULL) rows
18
19
  # so global sections are visible across partitions while scoped sections
19
- # stay isolated. Pass the section's scope key as a string.
20
- scope :for_entity, lambda { |entity_type, scope: nil|
21
- where(entity_type: entity_type, scope: [scope, nil].uniq)
20
+ # stay isolated. Pass the section's scope key as a string. The
21
+ # `parent_scope` axis expands the same way: callers passing
22
+ # `parent_scope:` get rows matching that parent_scope plus parent-scope
23
+ # globals (parent_scope IS NULL), keeping the symmetric set semantics
24
+ # across both partition keys.
25
+ scope :for_entity, lambda { |entity_type, scope: nil, parent_scope: nil|
26
+ where(
27
+ entity_type: entity_type,
28
+ scope: [scope, nil].uniq,
29
+ parent_scope: [parent_scope, nil].uniq,
30
+ )
22
31
  }
23
32
  scope :sorted, -> { order(sort_order: :asc, name: :asc) }
33
+
34
+ # ── Display ordering ──
35
+ #
36
+ # Mirrors Field::Base ordering helpers byte-for-byte (per CONTEXT.md
37
+ # inline-duplication decision; see Phase 01 validate_parent_scope_invariant
38
+ # precedent). Keep the two implementations symmetric — when one changes,
39
+ # the other should change in the same commit. See field/base.rb for
40
+ # rationale comments on the partition-level FOR UPDATE locking strategy.
41
+
42
+ def move_higher
43
+ reorder_within_partition do |siblings|
44
+ idx = siblings.index { |r| r.id == id }
45
+ next siblings if idx.nil? || idx.zero?
46
+
47
+ siblings[idx], siblings[idx - 1] = siblings[idx - 1], siblings[idx]
48
+ siblings
49
+ end
50
+ end
51
+
52
+ def move_lower
53
+ reorder_within_partition do |siblings|
54
+ idx = siblings.index { |r| r.id == id }
55
+ next siblings if idx.nil? || idx == siblings.size - 1
56
+
57
+ siblings[idx], siblings[idx + 1] = siblings[idx + 1], siblings[idx]
58
+ siblings
59
+ end
60
+ end
61
+
62
+ def move_to_top
63
+ reorder_within_partition do |siblings|
64
+ idx = siblings.index { |r| r.id == id }
65
+ next siblings if idx.nil? || idx.zero?
66
+
67
+ moving = siblings.delete_at(idx)
68
+ siblings.unshift(moving)
69
+ siblings
70
+ end
71
+ end
72
+
73
+ def move_to_bottom
74
+ reorder_within_partition do |siblings|
75
+ idx = siblings.index { |r| r.id == id }
76
+ next siblings if idx.nil? || idx == siblings.size - 1
77
+
78
+ moving = siblings.delete_at(idx)
79
+ siblings.push(moving)
80
+ siblings
81
+ end
82
+ end
83
+
84
+ def insert_at(position)
85
+ reorder_within_partition do |siblings|
86
+ idx = siblings.index { |r| r.id == id }
87
+ next siblings if idx.nil?
88
+
89
+ target = position.clamp(1, siblings.size) - 1
90
+ next siblings if idx == target
91
+
92
+ moving = siblings.delete_at(idx)
93
+ siblings.insert(target, moving)
94
+ siblings
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ # Orphan-parent invariant: a section with `scope` blank cannot have a
101
+ # `parent_scope` set. Mirrors Field::Base#validate_parent_scope_invariant —
102
+ # CONTEXT.md decision: inline-duplicate across the two files (not a shared
103
+ # concern this phase) so each file is self-contained.
104
+ def validate_parent_scope_invariant
105
+ return if parent_scope.blank?
106
+ return if scope.present?
107
+
108
+ errors.add(:parent_scope, "cannot be set when scope is blank")
109
+ end
110
+
111
+ def reorder_within_partition
112
+ self.class.transaction do
113
+ locked = self.class
114
+ .for_entity(entity_type, scope: scope, parent_scope: parent_scope)
115
+ .order(:id)
116
+ .lock("FOR UPDATE")
117
+ .to_a
118
+
119
+ siblings = locked.sort_by { |r| [r.sort_order.nil? ? 1 : 0, r.sort_order || 0, r.name.to_s] }
120
+
121
+ siblings = yield(siblings)
122
+ normalize_partition_sort_order(siblings)
123
+ end
124
+ end
125
+
126
+ def normalize_partition_sort_order(siblings)
127
+ siblings.each_with_index do |record, index|
128
+ desired = index + 1
129
+ next if record.sort_order == desired
130
+
131
+ 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.
132
+ end
133
+ end
24
134
  end
25
135
  end