typed_eav 0.3.2 → 0.4.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 +97 -0
- data/lib/typed_eav/bulk_write.rb +146 -17
- data/lib/typed_eav/entity_query.rb +92 -3
- data/lib/typed_eav/filter_query.rb +54 -9
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +26 -1
- data/lib/typed_eav/partition.rb +43 -0
- data/lib/typed_eav/schema_portability.rb +86 -0
- data/lib/typed_eav/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 392d8c372c1b70b4b710cef0b9f67d79531fb237bbca37419906ed468ced365f
|
|
4
|
+
data.tar.gz: e8d9fd82f5d0d3a2cd894f89090a31522882c0606f28e691be373aacccf73cce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0fa26b021b7d5223f1af10e016dad89c546102f8f807d00fa6d3ea8d07a95a3e91948fa67e09137b2d93bb4cba38bf0ed2a91887812a7777fef4821bd87db190
|
|
7
|
+
data.tar.gz: 195ee963d09d43cb358ff2f39ee4f62bd131cb75fbb110bbe6a00e569c5b693fbd21313e27f899e492edc9ebb921a1021bd432aa754059df4f609d6fa50cd30a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,103 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-05-26
|
|
9
|
+
|
|
10
|
+
Closes four follow-up gaps (PRD #15) surfaced when a downstream Rails app
|
|
11
|
+
consolidated onto 0.3.2 and found four places where the gem's public
|
|
12
|
+
surface forced workarounds: a missing per-record-varying bulk-write entry
|
|
13
|
+
point, a dedup defect on unsaved entities with in-memory `typed_values`
|
|
14
|
+
builds, an `:is_null` operator that couldn't honor the user-intuitive "is
|
|
15
|
+
empty" semantic, and a portable-schema shape that was wrong for in-app
|
|
16
|
+
snapshot stores. A fifth gap (G5) was scoped down to a documentation
|
|
17
|
+
promotion on the existing `Partition` module rather than a new wrapper.
|
|
18
|
+
|
|
19
|
+
All five changes are additive — no public-API breakage. New entry points
|
|
20
|
+
default to current shapes; existing callers of `bulk_set_typed_eav_values`,
|
|
21
|
+
`with_field`/`where_typed_eav` (without the new kwarg), `export_schema`,
|
|
22
|
+
and `initialize_typed_values` (on persisted records with no in-memory
|
|
23
|
+
builds) keep their behavior byte-for-byte. New ADR: ADR-0006 pins the G3
|
|
24
|
+
`include_missing` strategy as set-complement at the `FilterQuery` altitude
|
|
25
|
+
(rejects the LEFT JOIN framing the PRD originally sketched).
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `Entity.bulk_set_typed_eav_values_per_record(values_by_record,
|
|
30
|
+
version_grouping: :default)` — per-record-varying sibling to
|
|
31
|
+
`bulk_set_typed_eav_values`. Takes a `Hash<host_record,
|
|
32
|
+
Hash<field_name, value>>` and routes each record's value-set through
|
|
33
|
+
the same outer-transaction-plus-savepoint-per-record envelope,
|
|
34
|
+
returning the same `{ successes: [...], errors_by_record: { record
|
|
35
|
+
=> errors_hash } }` shape. Supports sparse-update semantics
|
|
36
|
+
(unlisted fields untouched), `{ _destroy: true }` value-removal
|
|
37
|
+
shorthand, mixed-scope records (each record honors its own
|
|
38
|
+
`[scope, parent_scope]` even inside `TypedEAV.unscoped { ... }`),
|
|
39
|
+
and `:per_field` UUID allocation across the union of field names.
|
|
40
|
+
Empty input short-circuits without opening a transaction. Internally,
|
|
41
|
+
both public executors (`BulkWrite.execute` and
|
|
42
|
+
`BulkWrite.execute_per_record`) now share a single
|
|
43
|
+
`execute_pairs(pairs, effective_grouping, field_uuids)` helper that
|
|
44
|
+
takes ordered `[record, vbn]` pairs — preserving `execute`'s byte-
|
|
45
|
+
for-byte behavior on duplicate in-memory instances of the same
|
|
46
|
+
persisted row (Hash-key collision is documented as a gotcha only
|
|
47
|
+
on the new API). G1 (issue #18).
|
|
48
|
+
|
|
49
|
+
- `Entity.with_field` and `Entity.where_typed_eav` accept an opt-in
|
|
50
|
+
`include_missing:` keyword (default `false`). Threaded through to
|
|
51
|
+
`FilterQuery#initialize`. When paired with `:is_null`, the operator
|
|
52
|
+
matches hosts with **no non-NULL value** for the field — including
|
|
53
|
+
hosts that have no `typed_eav_values` row at all (Reading A: the
|
|
54
|
+
user-intuitive "is empty" semantic). Implemented as a set-complement
|
|
55
|
+
against `:is_not_null` at the `FilterQuery` altitude; `QueryBuilder`
|
|
56
|
+
is not modified. With `:is_not_null` the kwarg is a no-op; with any
|
|
57
|
+
other operator (`:eq`, `:gt`, `:contains`, `:references`, `:between`,
|
|
58
|
+
`:starts_with`, etc.) it is silently ignored — filter UIs can pass
|
|
59
|
+
the kwarg uniformly without branching per operator. On the multimap
|
|
60
|
+
(`ALL_SCOPES`) branch, "no non-NULL value" reads across all matching
|
|
61
|
+
field definitions for the name: a host matches iff none of the
|
|
62
|
+
per-tenant field defs have a non-NULL value for it. G3 (issue #19).
|
|
63
|
+
See ADR-0006.
|
|
64
|
+
|
|
65
|
+
- `TypedEAV::SchemaPortability.export_snapshot_schema(entity_type:,
|
|
66
|
+
scope: nil, parent_scope: nil)` — sibling to `export_schema` that
|
|
67
|
+
returns a lean, restore-oriented projection in a versioned envelope:
|
|
68
|
+
`{ "snapshot_schema_version" => 1, "fields" => [...] }`. Per-field
|
|
69
|
+
entries carry only `name`, `field_type_name`, `required`,
|
|
70
|
+
`sort_order`, `options`, and (for optionable types) `options_data`
|
|
71
|
+
— `entity_type`, `scope`, `parent_scope`, `type` (AR STI class name),
|
|
72
|
+
`field_dependent`, and `default_value_meta` are omitted. Non-optionable
|
|
73
|
+
fields omit the `options_data` key entirely (absent, not nil). The
|
|
74
|
+
`snapshot_schema_version` integer will be bumped explicitly when the
|
|
75
|
+
inner shape evolves — it is not frozen forever. Fields are ordered by
|
|
76
|
+
`sort_order` and `options_data` mirrors the loaded/unloaded ordering
|
|
77
|
+
rule used by `export_schema`. G4 (PRD #15).
|
|
78
|
+
|
|
79
|
+
### Documentation
|
|
80
|
+
|
|
81
|
+
- `TypedEAV::Partition.find_visible_section!` is **documented-public**
|
|
82
|
+
going forward. Apps building admin UIs that need to authorize a
|
|
83
|
+
section lookup before editing, rendering, or destroying it should
|
|
84
|
+
call this rather than `Section.find(id)`. Method shape and behavior
|
|
85
|
+
do not change — this is a documentation clarification that promotes
|
|
86
|
+
an existing, already-shipping method into the documented surface
|
|
87
|
+
area, alongside the sibling `Partition` methods (`visible_fields`,
|
|
88
|
+
`effective_fields_by_name`, `definitions_by_name`,
|
|
89
|
+
`definitions_multimap_by_name`, `visible_sections`). G5 (issue #20).
|
|
90
|
+
|
|
91
|
+
### Fixed
|
|
92
|
+
|
|
93
|
+
- `InstanceMethods#initialize_typed_values` no longer builds duplicate
|
|
94
|
+
rows on entities that already have in-memory `typed_values` builds
|
|
95
|
+
(form path with `field_id`, scripting path via `typed_eav_attributes=`,
|
|
96
|
+
or direct `typed_values.build(...)` on a persisted record). Covers
|
|
97
|
+
three cases: (1) new record + nested attributes, (2) new record +
|
|
98
|
+
scripting setter, (3) persisted record + unloaded association + a
|
|
99
|
+
build that lives in `target` without flipping `@loaded`. The
|
|
100
|
+
persisted-no-builds fast path still uses `pluck` only — no extra
|
|
101
|
+
association load. Dedup also tolerates an in-memory build whose
|
|
102
|
+
`field_id` is nil but whose `field` association is set
|
|
103
|
+
(`field_id || field&.id` fallback). G2 (PRD #15).
|
|
104
|
+
|
|
8
105
|
## [0.3.2] - 2026-05-25
|
|
9
106
|
|
|
10
107
|
Documentation-only release. No code or behavior changes.
|
data/lib/typed_eav/bulk_write.rb
CHANGED
|
@@ -3,9 +3,25 @@
|
|
|
3
3
|
module TypedEAV
|
|
4
4
|
# Internal executor for host-class bulk typed-value writes.
|
|
5
5
|
#
|
|
6
|
-
# Host models keep the public `bulk_set_typed_eav_values`
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Host models keep the public `bulk_set_typed_eav_values` (uniform
|
|
7
|
+
# values-per-record) and `bulk_set_typed_eav_values_per_record`
|
|
8
|
+
# (per-record-varying values) APIs; this module owns the transaction
|
|
9
|
+
# shape, savepoint isolation, error aggregation, field name resolution
|
|
10
|
+
# delegation, and version-group stamping.
|
|
11
|
+
#
|
|
12
|
+
# ## Internal shape (G1, issue #18)
|
|
13
|
+
#
|
|
14
|
+
# Both public executors (`execute` and `execute_per_record`) are thin
|
|
15
|
+
# adapters: they validate their inputs, resolve the version grouping,
|
|
16
|
+
# allocate field UUIDs, and then hand off to `execute_pairs(pairs,
|
|
17
|
+
# effective_grouping, field_uuids)` — a single shared loop that takes
|
|
18
|
+
# ordered `[record, vbn]` pairs and runs the outer-transaction-plus-
|
|
19
|
+
# savepoint-per-record envelope.
|
|
20
|
+
#
|
|
21
|
+
# Pair-shaped (not Hash-shaped) so `execute`'s `[record, vbn]` list can
|
|
22
|
+
# carry duplicate in-memory instances of the same persisted row without
|
|
23
|
+
# silently collapsing them via Hash-key collision — preserving
|
|
24
|
+
# `execute`'s byte-for-byte behavior contract.
|
|
9
25
|
module BulkWrite
|
|
10
26
|
class << self
|
|
11
27
|
def execute(host_class:, records:, values_by_field_name:, version_grouping: :default)
|
|
@@ -20,7 +36,29 @@ module TypedEAV
|
|
|
20
36
|
vbn = values_by_field_name.transform_keys(&:to_s)
|
|
21
37
|
field_uuids = effective_grouping == :per_field ? vbn.keys.index_with { SecureRandom.uuid } : nil
|
|
22
38
|
|
|
23
|
-
|
|
39
|
+
execute_pairs(records.map { |r| [r, vbn] }, effective_grouping, field_uuids)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Per-record-varying sibling to `execute`. Accepts a `Hash<host_record,
|
|
43
|
+
# Hash<field_name, value>>` and routes each record's value-set through
|
|
44
|
+
# the same shared `execute_pairs` envelope.
|
|
45
|
+
#
|
|
46
|
+
# Empty `values_by_record` short-circuits to the empty result without
|
|
47
|
+
# opening a transaction (matches `execute`'s empty-records contract).
|
|
48
|
+
def execute_per_record(host_class:, values_by_record:, version_grouping: :default)
|
|
49
|
+
validate_per_record_inputs!(values_by_record, version_grouping)
|
|
50
|
+
|
|
51
|
+
return { successes: [], errors_by_record: {} } if values_by_record.empty?
|
|
52
|
+
|
|
53
|
+
validate_record_classes!(host_class, values_by_record.keys, method: :bulk_set_typed_eav_values_per_record)
|
|
54
|
+
|
|
55
|
+
effective_grouping = resolve_grouping(version_grouping)
|
|
56
|
+
pairs = values_by_record.map { |record, vbn| [record, vbn.transform_keys(&:to_s)] }
|
|
57
|
+
field_uuids = if effective_grouping == :per_field
|
|
58
|
+
pairs.flat_map { |(_record, vbn)| vbn.keys }.uniq.index_with { SecureRandom.uuid }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
execute_pairs(pairs, effective_grouping, field_uuids)
|
|
24
62
|
end
|
|
25
63
|
|
|
26
64
|
def apply_record_save(record:, vbn:, effective_grouping:, uuids:, accumulator:)
|
|
@@ -30,7 +68,7 @@ module TypedEAV
|
|
|
30
68
|
end
|
|
31
69
|
|
|
32
70
|
do_save = lambda do
|
|
33
|
-
record.typed_eav_attributes = vbn.map { |name, value|
|
|
71
|
+
record.typed_eav_attributes = vbn.map { |name, value| typed_eav_entry_for(name, value) }
|
|
34
72
|
stamp_pending_version_group_ids(record, effective_grouping, uuids)
|
|
35
73
|
|
|
36
74
|
if record.save
|
|
@@ -50,24 +88,36 @@ module TypedEAV
|
|
|
50
88
|
|
|
51
89
|
private
|
|
52
90
|
|
|
53
|
-
|
|
91
|
+
# Shared loop over ordered `[record, vbn]` pairs. Holds the outer
|
|
92
|
+
# transaction, the `ActiveRecord::Base.cache` block, the bulk-
|
|
93
|
+
# definitions memo envelope, and the per-record `requires_new: true`
|
|
94
|
+
# savepoint loop. Calls `apply_record_save` per iteration.
|
|
95
|
+
#
|
|
96
|
+
# Pair-shaped (not Hash-shaped) so duplicate in-memory instances of
|
|
97
|
+
# the same persisted row iterate each instance separately — matters
|
|
98
|
+
# for `execute`'s byte-for-byte behavior contract on
|
|
99
|
+
# `[Entity.find(1), Entity.find(1)]`-shaped input.
|
|
100
|
+
def execute_pairs(pairs, effective_grouping, field_uuids)
|
|
54
101
|
successes = []
|
|
55
102
|
errors_by_record = {}
|
|
56
103
|
|
|
57
104
|
with_bulk_definitions_memo do
|
|
58
105
|
ActiveRecord::Base.cache do
|
|
59
106
|
ActiveRecord::Base.transaction do
|
|
60
|
-
|
|
107
|
+
pairs.each do |(record, vbn)|
|
|
61
108
|
record_uuid = effective_grouping == :per_record ? SecureRandom.uuid : nil
|
|
109
|
+
record_field_uuids = record_scoped_field_uuids(field_uuids, vbn)
|
|
62
110
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
111
|
+
with_record_scope(record) do
|
|
112
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
113
|
+
apply_record_save(
|
|
114
|
+
record: record,
|
|
115
|
+
vbn: vbn,
|
|
116
|
+
effective_grouping: effective_grouping,
|
|
117
|
+
uuids: { record: record_uuid, field: record_field_uuids },
|
|
118
|
+
accumulator: { successes: successes, errors_by_record: errors_by_record },
|
|
119
|
+
)
|
|
120
|
+
end
|
|
71
121
|
end
|
|
72
122
|
end
|
|
73
123
|
end
|
|
@@ -77,6 +127,63 @@ module TypedEAV
|
|
|
77
127
|
{ successes: successes, errors_by_record: errors_by_record }
|
|
78
128
|
end
|
|
79
129
|
|
|
130
|
+
# For `:per_field`, the global `field_uuids` map covers the union of
|
|
131
|
+
# field names across all records' value hashes. The per-record save
|
|
132
|
+
# only needs the slice for the names actually being written on THIS
|
|
133
|
+
# record so `apply_record_save`'s push_uuid lookup
|
|
134
|
+
# (`uuids[:field].values.first`) stays well-defined.
|
|
135
|
+
def record_scoped_field_uuids(field_uuids, vbn)
|
|
136
|
+
return nil unless field_uuids
|
|
137
|
+
|
|
138
|
+
vbn.keys.index_with { |name| field_uuids[name] }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Honor the record's own `[scope, parent_scope]` when iterating a
|
|
142
|
+
# potentially mixed-scope batch. Inside `TypedEAV.unscoped { ... }`,
|
|
143
|
+
# `EntityQuery#resolve_scope` short-circuits to `ALL_SCOPES` and
|
|
144
|
+
# ignores the explicit `scope:` kwarg — which would surface the
|
|
145
|
+
# wrong field definitions for a record whose scope/parent_scope
|
|
146
|
+
# differs from its siblings. We restore strict scoping per record by
|
|
147
|
+
# temporarily clearing the `unscoped?` flag and pushing the record's
|
|
148
|
+
# own tuple onto the `with_scope` stack. Records on hosts without
|
|
149
|
+
# `scope_method:` (no `typed_eav_scope` to read) pass through
|
|
150
|
+
# unchanged.
|
|
151
|
+
def with_record_scope(record, &)
|
|
152
|
+
scope_method = record.class.respond_to?(:typed_eav_scope_method) ? record.class.typed_eav_scope_method : nil
|
|
153
|
+
return yield unless scope_method
|
|
154
|
+
|
|
155
|
+
s = record.typed_eav_scope
|
|
156
|
+
ps = record.typed_eav_parent_scope
|
|
157
|
+
|
|
158
|
+
# `:typed_eav_unscoped` is the same thread-local key used by
|
|
159
|
+
# `TypedEAV.unscoped` / `TypedEAV.unscoped?` (the constant is
|
|
160
|
+
# `private_constant` on `TypedEAV`; we use the literal symbol to
|
|
161
|
+
# avoid reaching into private internals).
|
|
162
|
+
prev_unscoped = Thread.current[:typed_eav_unscoped]
|
|
163
|
+
Thread.current[:typed_eav_unscoped] = nil
|
|
164
|
+
TypedEAV.with_scope([s, ps], &)
|
|
165
|
+
ensure
|
|
166
|
+
Thread.current[:typed_eav_unscoped] = prev_unscoped if scope_method
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Translates one `(name, value)` from a vbn hash into the nested-
|
|
170
|
+
# attributes entry shape that `typed_eav_attributes=` expects.
|
|
171
|
+
# `_destroy: true` shorthand (`{ "field" => { _destroy: true } }`)
|
|
172
|
+
# is detected here and emitted as `{ name:, _destroy: true }` so
|
|
173
|
+
# the value is removed rather than written as a literal Hash payload.
|
|
174
|
+
def typed_eav_entry_for(name, value)
|
|
175
|
+
return { name: name, _destroy: true } if destroy_marker?(value)
|
|
176
|
+
|
|
177
|
+
{ name: name, value: value }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def destroy_marker?(value)
|
|
181
|
+
return false unless value.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
flag = value[:_destroy] || value["_destroy"]
|
|
184
|
+
ActiveRecord::Type::Boolean.new.cast(flag)
|
|
185
|
+
end
|
|
186
|
+
|
|
80
187
|
def validate_inputs!(records, values_by_field_name, version_grouping)
|
|
81
188
|
if records.nil?
|
|
82
189
|
raise ArgumentError,
|
|
@@ -89,6 +196,28 @@ module TypedEAV
|
|
|
89
196
|
"got #{values_by_field_name.class}"
|
|
90
197
|
end
|
|
91
198
|
|
|
199
|
+
validate_grouping!(version_grouping)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate_per_record_inputs!(values_by_record, version_grouping)
|
|
203
|
+
unless values_by_record.is_a?(Hash)
|
|
204
|
+
raise ArgumentError,
|
|
205
|
+
"bulk_set_typed_eav_values_per_record requires a Hash of values_by_record, " \
|
|
206
|
+
"got #{values_by_record.class}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
values_by_record.each do |record, vbn|
|
|
210
|
+
next if vbn.is_a?(Hash)
|
|
211
|
+
|
|
212
|
+
raise ArgumentError,
|
|
213
|
+
"bulk_set_typed_eav_values_per_record: per-record value for #{record.inspect} " \
|
|
214
|
+
"must be a Hash of field-name => value, got #{vbn.class}"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
validate_grouping!(version_grouping)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_grouping!(version_grouping)
|
|
92
221
|
valid_grouping = %i[default per_record per_field none]
|
|
93
222
|
unless valid_grouping.include?(version_grouping)
|
|
94
223
|
raise ArgumentError,
|
|
@@ -104,12 +233,12 @@ module TypedEAV
|
|
|
104
233
|
"version_grouping: :none to opt out explicitly, or omit the kwarg to silently no-op."
|
|
105
234
|
end
|
|
106
235
|
|
|
107
|
-
def validate_record_classes!(host_class, records)
|
|
236
|
+
def validate_record_classes!(host_class, records, method: :bulk_set_typed_eav_values)
|
|
108
237
|
return if records.all?(host_class)
|
|
109
238
|
|
|
110
239
|
classes = records.map { |record| record.class.name }.uniq
|
|
111
240
|
raise ArgumentError,
|
|
112
|
-
"
|
|
241
|
+
"#{method} expects records of class #{host_class.name} (or its subclasses); " \
|
|
113
242
|
"got mixed classes: #{classes.join(", ")}"
|
|
114
243
|
end
|
|
115
244
|
|
|
@@ -35,7 +35,19 @@ module TypedEAV
|
|
|
35
35
|
# - omitted -> resolve from ambient (`with_scope` -> resolver -> raise/nil)
|
|
36
36
|
# - passed a value -> use verbatim (explicit override; admin/test path)
|
|
37
37
|
# - passed nil -> filter to global-only on that axis (prior behavior)
|
|
38
|
-
|
|
38
|
+
#
|
|
39
|
+
# `include_missing:` behavior (opt-in, default `false`):
|
|
40
|
+
# - Only meaningful when paired with `:is_null`. When `true`, the
|
|
41
|
+
# `:is_null` predicate broadens to the user-intuitive "is empty"
|
|
42
|
+
# semantic: matches hosts with **no non-NULL value** for the field —
|
|
43
|
+
# including hosts that have no `typed_eav_values` row at all
|
|
44
|
+
# (Reading A from ADR-0006).
|
|
45
|
+
# - With `:is_not_null`, the kwarg is a no-op (lets filter UIs pass
|
|
46
|
+
# it uniformly without branching per operator).
|
|
47
|
+
# - With any other operator (`:eq`, `:gt`, `:contains`, `:between`,
|
|
48
|
+
# `:starts_with`, `:references`, etc.), the kwarg is silently
|
|
49
|
+
# ignored.
|
|
50
|
+
def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false)
|
|
39
51
|
resolved = resolve_scope(scope, parent_scope)
|
|
40
52
|
effective_scope, effective_parent = scope_pair(resolved)
|
|
41
53
|
|
|
@@ -44,6 +56,7 @@ module TypedEAV
|
|
|
44
56
|
filters: filters,
|
|
45
57
|
scope: effective_scope,
|
|
46
58
|
parent_scope: effective_parent,
|
|
59
|
+
include_missing: include_missing,
|
|
47
60
|
).to_relation
|
|
48
61
|
end
|
|
49
62
|
|
|
@@ -56,14 +69,20 @@ module TypedEAV
|
|
|
56
69
|
# Accepts both `scope:` and `parent_scope:` kwargs with the same
|
|
57
70
|
# ambient/explicit/nil semantics as `where_typed_eav`. Single-scope
|
|
58
71
|
# callers (no `parent_scope:`) are unaffected.
|
|
59
|
-
|
|
72
|
+
#
|
|
73
|
+
# `include_missing:` (opt-in, default `false`) is forwarded to
|
|
74
|
+
# `where_typed_eav` unchanged. See its RDoc for full semantics — in
|
|
75
|
+
# short: meaningful only with `:is_null` (Reading A "no non-NULL
|
|
76
|
+
# value," includes no-row hosts), no-op with `:is_not_null`, silently
|
|
77
|
+
# ignored otherwise.
|
|
78
|
+
def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false)
|
|
60
79
|
filter = if value.nil? && !operator_or_value.is_a?(Symbol)
|
|
61
80
|
# Two-arg form: with_field("name", "value") implies :eq
|
|
62
81
|
{ name: name, op: :eq, value: operator_or_value }
|
|
63
82
|
else
|
|
64
83
|
{ name: name, op: operator_or_value, value: value }
|
|
65
84
|
end
|
|
66
|
-
where_typed_eav(filter, scope: scope, parent_scope: parent_scope)
|
|
85
|
+
where_typed_eav(filter, scope: scope, parent_scope: parent_scope, include_missing: include_missing)
|
|
67
86
|
end
|
|
68
87
|
|
|
69
88
|
# Returns field definitions for this entity type.
|
|
@@ -105,6 +124,76 @@ module TypedEAV
|
|
|
105
124
|
)
|
|
106
125
|
end
|
|
107
126
|
|
|
127
|
+
# Per-record-varying bulk write API. Sibling to `bulk_set_typed_eav_values`
|
|
128
|
+
# for callers (sync importers, per-row updaters) where each record carries
|
|
129
|
+
# its own values hash. Routes through the same outer-transaction-plus-
|
|
130
|
+
# savepoint envelope and returns the same
|
|
131
|
+
# `{ successes: [...], errors_by_record: { record => errors_hash } }`
|
|
132
|
+
# shape. See `TypedEAV::BulkWrite` for the transaction shape and the
|
|
133
|
+
# `version_grouping:` semantics.
|
|
134
|
+
#
|
|
135
|
+
# ## Input shape
|
|
136
|
+
#
|
|
137
|
+
# Contact.bulk_set_typed_eav_values_per_record(
|
|
138
|
+
# alice => { "name" => "Alice", "age" => 31 },
|
|
139
|
+
# bob => { "name" => "Bob", "city" => "Portland" },
|
|
140
|
+
# )
|
|
141
|
+
#
|
|
142
|
+
# `values_by_record` is `Hash<host_record, Hash<field_name, value>>`.
|
|
143
|
+
# Field-name keys may be strings or symbols (normalized to strings).
|
|
144
|
+
# Ruby's insertion-ordered Hash invariant determines record iteration
|
|
145
|
+
# order — callers can rely on it.
|
|
146
|
+
#
|
|
147
|
+
# ## AR persisted-record hash-key collision gotcha
|
|
148
|
+
#
|
|
149
|
+
# Two distinct in-memory instances of the **same persisted row**
|
|
150
|
+
# (e.g. `Contact.find(1)` and `Contact.find(1)`) collide as Hash keys
|
|
151
|
+
# because AR's `eql?`/`hash` is defined by `class + id`. In a Hash,
|
|
152
|
+
# the second instance silently overwrites the first's value entry
|
|
153
|
+
# and only ONE save runs. If you need to apply two updates to the
|
|
154
|
+
# same row in caller order, sequence the calls outside the Hash —
|
|
155
|
+
# this API iterates whatever the Hash holds.
|
|
156
|
+
#
|
|
157
|
+
# The sibling `bulk_set_typed_eav_values(records, vbn)` API takes an
|
|
158
|
+
# Array of records and is unaffected — duplicate in-memory instances
|
|
159
|
+
# iterate each instance separately. The internal `execute_pairs`
|
|
160
|
+
# helper preserves that contract for both surfaces.
|
|
161
|
+
#
|
|
162
|
+
# ## Sparse-update semantic
|
|
163
|
+
#
|
|
164
|
+
# Unlisted fields on a record are **not** touched. To delete a value,
|
|
165
|
+
# pass the destroy-marker hash:
|
|
166
|
+
#
|
|
167
|
+
# Contact.bulk_set_typed_eav_values_per_record(
|
|
168
|
+
# alice => { "old_field" => { _destroy: true } },
|
|
169
|
+
# )
|
|
170
|
+
#
|
|
171
|
+
# ## Mixed-scope records
|
|
172
|
+
#
|
|
173
|
+
# Records in one call may span multiple partitions: r1 in workspace 1
|
|
174
|
+
# and r2 in workspace 2 in the same call resolve their own scopes
|
|
175
|
+
# independently. Each record's `typed_eav_attributes=` consults the
|
|
176
|
+
# field definitions for its own `[scope, parent_scope]`. The thread-
|
|
177
|
+
# local definition memo keys `[host_class, scope, parent_scope]`
|
|
178
|
+
# collect one entry per distinct partition touched — no collisions.
|
|
179
|
+
# Callers spanning multiple partitions should wrap the call in
|
|
180
|
+
# `TypedEAV.unscoped { ... }` so each record can apply its own scope.
|
|
181
|
+
#
|
|
182
|
+
# ## `:per_field` union semantic
|
|
183
|
+
#
|
|
184
|
+
# When `version_grouping: :per_field`, the per-field UUIDs span the
|
|
185
|
+
# **union** of field names across all records. Records that both
|
|
186
|
+
# write `"name"` share one UUID for that cell; a record writing
|
|
187
|
+
# `"city"` (that no other record writes) gets its own UUID for
|
|
188
|
+
# `"city"`. Overlapping fields share a version group across records.
|
|
189
|
+
def bulk_set_typed_eav_values_per_record(values_by_record, version_grouping: :default)
|
|
190
|
+
TypedEAV::BulkWrite.execute_per_record(
|
|
191
|
+
host_class: self,
|
|
192
|
+
values_by_record: values_by_record,
|
|
193
|
+
version_grouping: version_grouping,
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
|
|
108
197
|
private
|
|
109
198
|
|
|
110
199
|
# Translates a resolved scope into the `(scope, parent_scope)` pair
|
|
@@ -27,15 +27,31 @@ module TypedEAV
|
|
|
27
27
|
# already-resolved scope value. `parent_scope:` is `String | nil`. Scope
|
|
28
28
|
# resolution and sentinel handling live in `EntityQuery#resolve_scope`;
|
|
29
29
|
# this class works on resolved tuples only.
|
|
30
|
+
#
|
|
31
|
+
# ## `include_missing:` (Reading A)
|
|
32
|
+
#
|
|
33
|
+
# Opt-in kwarg (default `false`). Only meaningful when
|
|
34
|
+
# `operator == :is_null`; silently ignored otherwise (`:is_not_null` becomes
|
|
35
|
+
# a no-op; other operators are unaffected). When `true`, the `:is_null`
|
|
36
|
+
# predicate is composed as a **set complement** against `:is_not_null`:
|
|
37
|
+
# matches hosts that have **no non-NULL value** for the field — including
|
|
38
|
+
# hosts with no `typed_eav_values` row at all.
|
|
39
|
+
#
|
|
40
|
+
# On the multimap (`ALL_SCOPES`) branch, "no non-NULL value" reads across
|
|
41
|
+
# ALL matching field definitions for the name: a host matches iff none of
|
|
42
|
+
# the per-tenant field defs have a non-NULL value for it (Reading A —
|
|
43
|
+
# NOT "no row anywhere"). See ADR-0006 for why composition happens at this
|
|
44
|
+
# altitude rather than via a LEFT JOIN in `QueryBuilder`.
|
|
30
45
|
class FilterQuery
|
|
31
46
|
FILTER_KEYS = %w[name n op operator value v].freeze
|
|
32
47
|
private_constant :FILTER_KEYS
|
|
33
48
|
|
|
34
|
-
def initialize(model:, filters:, scope:, parent_scope:)
|
|
35
|
-
@model
|
|
36
|
-
@raw_filters
|
|
37
|
-
@scope
|
|
38
|
-
@parent_scope
|
|
49
|
+
def initialize(model:, filters:, scope:, parent_scope:, include_missing: false)
|
|
50
|
+
@model = model
|
|
51
|
+
@raw_filters = filters
|
|
52
|
+
@scope = scope
|
|
53
|
+
@parent_scope = parent_scope
|
|
54
|
+
@include_missing = include_missing
|
|
39
55
|
end
|
|
40
56
|
|
|
41
57
|
def to_relation
|
|
@@ -106,8 +122,17 @@ module TypedEAV
|
|
|
106
122
|
filters.inject(model.all) do |query, filter|
|
|
107
123
|
spec = parse_filter(filter)
|
|
108
124
|
field = fields_by_name[spec[:name]] || raise_unknown_field(spec[:name], fields_by_name.keys)
|
|
109
|
-
|
|
110
|
-
|
|
125
|
+
|
|
126
|
+
if invert_is_null?(spec[:operator])
|
|
127
|
+
# Reading A: "no non-NULL value." Set-complement against the
|
|
128
|
+
# scope-winning field's `:is_not_null` subquery — includes both
|
|
129
|
+
# NULL-column rows and entities with no row at all. ADR-0006.
|
|
130
|
+
non_missing_ids = TypedEAV::QueryBuilder.entity_ids(field, :is_not_null, nil)
|
|
131
|
+
query.where.not(id: non_missing_ids)
|
|
132
|
+
else
|
|
133
|
+
matching_ids = TypedEAV::QueryBuilder.entity_ids(field, spec[:operator], spec[:value])
|
|
134
|
+
query.where(id: matching_ids)
|
|
135
|
+
end
|
|
111
136
|
end
|
|
112
137
|
end
|
|
113
138
|
|
|
@@ -117,11 +142,31 @@ module TypedEAV
|
|
|
117
142
|
fields = fields_multimap[spec[:name]]
|
|
118
143
|
raise_unknown_field(spec[:name], fields_multimap.keys) unless fields&.any?
|
|
119
144
|
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
if invert_is_null?(spec[:operator])
|
|
146
|
+
# Reading A across all matching field defs: a host matches iff NO
|
|
147
|
+
# field def has a non-NULL value for it. Union the non-missing
|
|
148
|
+
# entity_ids across all per-tenant field defs, then complement at
|
|
149
|
+
# the host level. ADR-0006.
|
|
150
|
+
non_missing_ids = fields.flat_map do |f|
|
|
151
|
+
TypedEAV::QueryBuilder.entity_ids(f, :is_not_null, nil).pluck(:entity_id)
|
|
152
|
+
end.uniq
|
|
153
|
+
query.where.not(id: non_missing_ids)
|
|
154
|
+
else
|
|
155
|
+
union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
|
|
156
|
+
query.where(id: union_ids)
|
|
157
|
+
end
|
|
122
158
|
end
|
|
123
159
|
end
|
|
124
160
|
|
|
161
|
+
# `include_missing: true` only modifies the `:is_null` branch (Reading A:
|
|
162
|
+
# "no non-NULL value"). For every other operator — including
|
|
163
|
+
# `:is_not_null`, which already returns the natural complement — the
|
|
164
|
+
# kwarg is silently ignored, so filter UIs can pass it uniformly without
|
|
165
|
+
# branching per operator.
|
|
166
|
+
def invert_is_null?(operator)
|
|
167
|
+
@include_missing && operator == :is_null
|
|
168
|
+
end
|
|
169
|
+
|
|
125
170
|
# OR-across all field_ids that share the same name (across tenants),
|
|
126
171
|
# while preserving AND between filters via the chained `.where`. Use the
|
|
127
172
|
# underlying Value scope (`.filter`) and `pluck(:entity_id)` to collapse
|
|
@@ -46,7 +46,7 @@ module TypedEAV
|
|
|
46
46
|
# render two inputs for the same name — but only the scoped one
|
|
47
47
|
# round-trips on save (it wins in `typed_eav_defs_by_name`).
|
|
48
48
|
def initialize_typed_values
|
|
49
|
-
existing_field_ids =
|
|
49
|
+
existing_field_ids = existing_typed_value_field_ids
|
|
50
50
|
|
|
51
51
|
typed_eav_defs_by_name.each_value do |field|
|
|
52
52
|
next if existing_field_ids.include?(field.id)
|
|
@@ -141,6 +141,31 @@ module TypedEAV
|
|
|
141
141
|
|
|
142
142
|
private
|
|
143
143
|
|
|
144
|
+
# Field ids already represented in `typed_values`, accounting for both
|
|
145
|
+
# persisted rows and in-memory builds. Three branches:
|
|
146
|
+
#
|
|
147
|
+
# - **new_record? or loaded?** — walk the in-memory collection, with a
|
|
148
|
+
# `field_id || field&.id` fallback so callers who bypass the
|
|
149
|
+
# belongs_to FK setter (e.g. assigning via `association(:field).target=`)
|
|
150
|
+
# still get dedup-correct results.
|
|
151
|
+
# - **persisted + unloaded** — combine a cheap `pluck` of persisted rows
|
|
152
|
+
# with any in-memory builds in `typed_values.target`. AR's
|
|
153
|
+
# `add_to_target` (called by `build`) does not flip `@loaded`, so
|
|
154
|
+
# target-resident builds are otherwise invisible to `pluck`. The
|
|
155
|
+
# persisted-no-builds happy path is unaffected: `target` is empty,
|
|
156
|
+
# `pluck` runs once, no extra association load.
|
|
157
|
+
def existing_typed_value_field_ids
|
|
158
|
+
return walk_in_memory_typed_value_field_ids if new_record? || typed_values.loaded?
|
|
159
|
+
|
|
160
|
+
persisted = typed_values.pluck(:field_id)
|
|
161
|
+
in_memory = typed_values.target.reject(&:persisted?).filter_map { |tv| tv.field_id || tv.field&.id }
|
|
162
|
+
(persisted + in_memory).uniq
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def walk_in_memory_typed_value_field_ids
|
|
166
|
+
typed_values.filter_map { |tv| tv.field_id || tv.field&.id }
|
|
167
|
+
end
|
|
168
|
+
|
|
144
169
|
# Selects the candidate value for `typed_eav_value`. On a collision,
|
|
145
170
|
# prefer the row attached to the winning field_id; otherwise fall back
|
|
146
171
|
# to the first orphan/non-collision candidate.
|
data/lib/typed_eav/partition.rb
CHANGED
|
@@ -88,6 +88,49 @@ module TypedEAV
|
|
|
88
88
|
TypedEAV::Section.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Looks up a single {TypedEAV::Section} by `id` constrained to the
|
|
92
|
+
# caller's partition tuple. Documented-public surface: apps building
|
|
93
|
+
# admin UIs that need to authorize a section lookup before editing,
|
|
94
|
+
# rendering, or destroying it should call this rather than
|
|
95
|
+
# `Section.find(id)`, which would happily return a section belonging
|
|
96
|
+
# to another tenant's partition.
|
|
97
|
+
#
|
|
98
|
+
# Visibility merge matches {visible_sections}: rows whose
|
|
99
|
+
# `(entity_type, scope, parent_scope)` is either the requested tuple
|
|
100
|
+
# or the global `(scope: nil, parent_scope: nil)` partition are
|
|
101
|
+
# eligible. The most-specific-wins precedence used for field
|
|
102
|
+
# collision resolution does not apply here — section lookup is by
|
|
103
|
+
# primary key, not by name.
|
|
104
|
+
#
|
|
105
|
+
# Sibling documented-public methods on this module that share the
|
|
106
|
+
# same partition-visibility surface: {visible_fields},
|
|
107
|
+
# {effective_fields_by_name}, {definitions_by_name},
|
|
108
|
+
# {definitions_multimap_by_name}, {visible_sections}.
|
|
109
|
+
#
|
|
110
|
+
# @param id [Integer, String] the section's primary key. Blank input
|
|
111
|
+
# is the caller's responsibility to guard upstream; this method
|
|
112
|
+
# does not silently swallow `nil` / `""` — it forwards to
|
|
113
|
+
# `ActiveRecord::Relation#find`, which raises
|
|
114
|
+
# `ActiveRecord::RecordNotFound` on blank.
|
|
115
|
+
# @param entity_type [String] the host AR class name the section
|
|
116
|
+
# belongs to (matches `Section#entity_type`).
|
|
117
|
+
# @param scope [Object, nil] the resolved scope value from the
|
|
118
|
+
# caller's partition. `nil` means the global partition only.
|
|
119
|
+
# @param parent_scope [Object, nil] the resolved parent_scope value.
|
|
120
|
+
# Must be `nil` when `scope` is blank (the orphan-parent invariant
|
|
121
|
+
# shared with {visible_sections}).
|
|
122
|
+
# @param mode [Symbol] `:partition` (default) restricts to the
|
|
123
|
+
# caller's tuple plus the global tuple. `:all_partitions` is the
|
|
124
|
+
# deliberate admin bypass that ignores the tuple entirely —
|
|
125
|
+
# distinct from `scope: nil`, which means "global partition only."
|
|
126
|
+
# @return [TypedEAV::Section] the section record when it belongs to
|
|
127
|
+
# the caller's partition or the global tuple.
|
|
128
|
+
# @raise [ActiveRecord::RecordNotFound] when the section's
|
|
129
|
+
# `(scope, parent_scope)` falls outside the visibility merge for
|
|
130
|
+
# the requested tuple, or when no section with `id` exists.
|
|
131
|
+
# @raise [ArgumentError] when `parent_scope` is present and `scope`
|
|
132
|
+
# is blank (orphan-parent), or when `mode` is neither
|
|
133
|
+
# `:partition` nor `:all_partitions`.
|
|
91
134
|
def find_visible_section!(id, entity_type:, scope: nil, parent_scope: nil, mode: :partition)
|
|
92
135
|
visible_sections(entity_type: entity_type, scope: scope, parent_scope: parent_scope, mode: mode).find(id)
|
|
93
136
|
end
|
|
@@ -27,6 +27,63 @@ module TypedEAV
|
|
|
27
27
|
}
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# Lean, restore-oriented projection of the field schema for a partition
|
|
31
|
+
# tuple. Sibling to {.export_schema} — same partition filter, narrower
|
|
32
|
+
# per-field surface, no sections, no partition-identity keys.
|
|
33
|
+
#
|
|
34
|
+
# The envelope is:
|
|
35
|
+
#
|
|
36
|
+
# {
|
|
37
|
+
# "snapshot_schema_version" => 1,
|
|
38
|
+
# "fields" => [ <snapshot_field_entry>, ... ] # ordered by sort_order
|
|
39
|
+
# }
|
|
40
|
+
#
|
|
41
|
+
# The `snapshot_schema_version` integer will be bumped explicitly when
|
|
42
|
+
# the inner per-field shape evolves in a non-additive way — it is NOT
|
|
43
|
+
# frozen forever. Consumers should branch on the version to handle
|
|
44
|
+
# cross-version snapshots.
|
|
45
|
+
#
|
|
46
|
+
# Each per-field entry is a strict subset of the full
|
|
47
|
+
# {.export_field_entry} shape:
|
|
48
|
+
#
|
|
49
|
+
# {
|
|
50
|
+
# "name" => field.name,
|
|
51
|
+
# "field_type_name" => field.field_type_name,
|
|
52
|
+
# "required" => field.required,
|
|
53
|
+
# "sort_order" => field.sort_order,
|
|
54
|
+
# "options" => field.options,
|
|
55
|
+
# "options_data" => [...] # ONLY present when field.optionable?
|
|
56
|
+
# }
|
|
57
|
+
#
|
|
58
|
+
# Omitted vs the full schema export: `entity_type`, `scope`,
|
|
59
|
+
# `parent_scope`, `type` (the AR STI class name), `field_dependent`,
|
|
60
|
+
# and `default_value_meta`. Non-optionable fields omit `options_data`
|
|
61
|
+
# entirely (absent, not nil, not an empty array).
|
|
62
|
+
#
|
|
63
|
+
# The `field_type_name` value is the documented field-type dispatch
|
|
64
|
+
# identifier — robust to namespace relocations of the field class
|
|
65
|
+
# because it strips the namespace via `demodulize` before
|
|
66
|
+
# `underscore`-ing. It is NOT robust to renames of the leaf class
|
|
67
|
+
# itself: `Field::Select` → `"select"`, but renaming the class to
|
|
68
|
+
# `Field::Status` would change the dispatch identifier to `"status"`.
|
|
69
|
+
#
|
|
70
|
+
# @param entity_type [String] host AR model class name (e.g. "Contact")
|
|
71
|
+
# @param scope [String, nil] first partition axis
|
|
72
|
+
# @param parent_scope [String, nil] second partition axis
|
|
73
|
+
# @return [Hash] versioned snapshot envelope
|
|
74
|
+
def export_snapshot_schema(entity_type:, scope: nil, parent_scope: nil)
|
|
75
|
+
fields = TypedEAV::Field::Base
|
|
76
|
+
.where(entity_type: entity_type, scope: scope, parent_scope: parent_scope)
|
|
77
|
+
.includes(:field_options)
|
|
78
|
+
.order(:sort_order)
|
|
79
|
+
.map { |field| export_snapshot_field_entry(field) }
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
"snapshot_schema_version" => 1,
|
|
83
|
+
"fields" => fields,
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
30
87
|
def import_schema(hash, on_conflict: :error)
|
|
31
88
|
validate_schema_version!(hash)
|
|
32
89
|
validate_conflict_policy!(on_conflict)
|
|
@@ -80,6 +137,35 @@ module TypedEAV
|
|
|
80
137
|
end
|
|
81
138
|
# rubocop:enable Metrics/AbcSize
|
|
82
139
|
|
|
140
|
+
# Lean per-field projection used by {.export_snapshot_schema}. Mirrors
|
|
141
|
+
# the option-row ordering rule from {.export_field_entry} — sort
|
|
142
|
+
# loaded-association rows by `[sort_order || 0, label, id]`, and
|
|
143
|
+
# delegate to the `field_options.sorted` scope on the unloaded path.
|
|
144
|
+
def export_snapshot_field_entry(field)
|
|
145
|
+
entry = {
|
|
146
|
+
"name" => field.name,
|
|
147
|
+
"field_type_name" => field.field_type_name,
|
|
148
|
+
"required" => field.required,
|
|
149
|
+
"sort_order" => field.sort_order,
|
|
150
|
+
"options" => field.options,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if field.optionable?
|
|
154
|
+
options_rows = if field.field_options.loaded?
|
|
155
|
+
field.field_options.sort_by do |option|
|
|
156
|
+
[option.sort_order || 0, option.label.to_s, option.id]
|
|
157
|
+
end
|
|
158
|
+
else
|
|
159
|
+
field.field_options.sorted
|
|
160
|
+
end
|
|
161
|
+
entry["options_data"] = options_rows.map do |option|
|
|
162
|
+
{ "label" => option.label, "value" => option.value, "sort_order" => option.sort_order }
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
entry
|
|
167
|
+
end
|
|
168
|
+
|
|
83
169
|
def export_section_entry(section)
|
|
84
170
|
{
|
|
85
171
|
"name" => section.name,
|
data/lib/typed_eav/version.rb
CHANGED