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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '09c84a25e8888fd675c1225a2a20cf0dfcfcbc26f6ccc5fe025d70fe8ba8269b'
4
- data.tar.gz: 2f5308382740430050acbefc7e857f6b2f4d17ea906991def3c43316ee017820
3
+ metadata.gz: 392d8c372c1b70b4b710cef0b9f67d79531fb237bbca37419906ed468ced365f
4
+ data.tar.gz: e8d9fd82f5d0d3a2cd894f89090a31522882c0606f28e691be373aacccf73cce
5
5
  SHA512:
6
- metadata.gz: efe552ea5914d737eb82b2d6c33a6198c5d0c161d1e0c08d7037c4e52f85b8b199a0ea608b15b910004dc7e645c3b3a1a3099b07f847cefac14380103f5cbab4
7
- data.tar.gz: 283c08abec00ff9604d56ac7d5cacec43b1596e9c9ba44710a5087edf326218bd075b6b9ac7d2098b9934ea4c86dd40588cb8a7bc5e72c4b7818c0ea532f76b2
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.
@@ -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` API; this module
7
- # owns the transaction shape, savepoint isolation, error aggregation, field
8
- # name resolution delegation, and version-group stamping.
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
- execute_records(records, vbn, effective_grouping, field_uuids)
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| { name: name, value: 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
- def execute_records(records, vbn, effective_grouping, field_uuids)
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
- records.each do |record|
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
- ActiveRecord::Base.transaction(requires_new: true) do
64
- apply_record_save(
65
- record: record,
66
- vbn: vbn,
67
- effective_grouping: effective_grouping,
68
- uuids: { record: record_uuid, field: field_uuids },
69
- accumulator: { successes: successes, errors_by_record: errors_by_record },
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
- "bulk_set_typed_eav_values expects records of class #{host_class.name} (or its subclasses); " \
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
- def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
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
- def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
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 = model
36
- @raw_filters = filters
37
- @scope = scope
38
- @parent_scope = 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
- matching_ids = TypedEAV::QueryBuilder.entity_ids(field, spec[:operator], spec[:value])
110
- query.where(id: matching_ids)
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
- union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
121
- query.where(id: union_ids)
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 = typed_values.loaded? ? typed_values.map(&:field_id) : typed_values.pluck(:field_id)
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.
@@ -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,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typed_eav
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dchuk