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
|
@@ -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
|
-
|
|
21
|
-
|
|
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
|