typed_eav 0.3.2 → 0.5.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: 2584a7a3e9eab294e874931c8202f2dae2082be6a036bd419fce7df3fdd94f56
4
+ data.tar.gz: 7d24090e4969e89bb268f89510b46ce034d8f9f045054c0d6d80828c4cd3006c
5
5
  SHA512:
6
- metadata.gz: efe552ea5914d737eb82b2d6c33a6198c5d0c161d1e0c08d7037c4e52f85b8b199a0ea608b15b910004dc7e645c3b3a1a3099b07f847cefac14380103f5cbab4
7
- data.tar.gz: 283c08abec00ff9604d56ac7d5cacec43b1596e9c9ba44710a5087edf326218bd075b6b9ac7d2098b9934ea4c86dd40588cb8a7bc5e72c4b7818c0ea532f76b2
6
+ metadata.gz: 4aeaa932e2dff4d5ab22ae95c66d98953b93163fc9580445f05a365ce1489a56cfdbaf998e47548d5fe468227341eec170409eafad5191449cb96201039900d0
7
+ data.tar.gz: 1b626f58647506b2d0317e382d0fd2d8ebf7daae1c11e84dc9d58b1929107af9c62854c19c39a6244e9957d1e20ccb7b3c924c7aee9a77aa19414453c356e534
data/CHANGELOG.md CHANGED
@@ -5,6 +5,143 @@ 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.5.0] - 2026-06-01
9
+
10
+ Adds a human display label for fields, distinct from the immutable machine
11
+ slug `name` (issue #21). Fully additive — every change defaults to the
12
+ pre-0.5.0 behavior. Existing rows (with `label` NULL) render exactly as
13
+ before, and consumers that never set a label observe no difference.
14
+
15
+ ### Added
16
+
17
+ - `label` — a nullable, free-text column on `typed_eav_fields`
18
+ (`20260507000000_add_label_to_typed_eav_fields.rb`). No index, no
19
+ default, no backfill: `label` never participates in uniqueness, lookup,
20
+ partitioning, or ordering, so an index would be dead weight. Run
21
+ `rails typed_eav:install:migrations` (or copy the migration) and migrate
22
+ to pick it up. `name` stays the immutable machine key.
23
+
24
+ - `TypedEAV::Field::Base#display_name` — the canonical human-facing
25
+ display string. Returns the free-text `label` when present, otherwise
26
+ falls back to `name.humanize`. A blank (`""`) label falls back too (via
27
+ `presence`). This is the ONE accessor all rendering should use; existing
28
+ rows render unchanged.
29
+
30
+ - `SchemaPortability` round-trips the label. `export_schema` emits the
31
+ **raw** `"label"` so `import_schema` reproduces it verbatim and
32
+ divergence detection treats a differing label as a difference; legacy
33
+ payloads with no `"label"` key import as NULL with no version gate.
34
+ `export_snapshot_schema` emits the **resolved** `"display_name"` instead
35
+ (render-oriented snapshots hand the consumer a ready-to-render string —
36
+ intentionally asymmetric to the raw-label regular export).
37
+
38
+ ### Changed
39
+
40
+ - A label-only edit on a Field dispatches the `:update` event, not
41
+ `:rename`. `:rename` remains reserved for changes to the machine `name`.
42
+ Regression-pinned in `spec/regressions/issue_21_label_no_rename_spec.rb`.
43
+
44
+ ### References
45
+
46
+ - Issue #21 — Field display label / `display_name` contract.
47
+
48
+ ## [0.4.0] - 2026-05-26
49
+
50
+ Closes four follow-up gaps (PRD #15) surfaced when a downstream Rails app
51
+ consolidated onto 0.3.2 and found four places where the gem's public
52
+ surface forced workarounds: a missing per-record-varying bulk-write entry
53
+ point, a dedup defect on unsaved entities with in-memory `typed_values`
54
+ builds, an `:is_null` operator that couldn't honor the user-intuitive "is
55
+ empty" semantic, and a portable-schema shape that was wrong for in-app
56
+ snapshot stores. A fifth gap (G5) was scoped down to a documentation
57
+ promotion on the existing `Partition` module rather than a new wrapper.
58
+
59
+ All five changes are additive — no public-API breakage. New entry points
60
+ default to current shapes; existing callers of `bulk_set_typed_eav_values`,
61
+ `with_field`/`where_typed_eav` (without the new kwarg), `export_schema`,
62
+ and `initialize_typed_values` (on persisted records with no in-memory
63
+ builds) keep their behavior byte-for-byte. New ADR: ADR-0006 pins the G3
64
+ `include_missing` strategy as set-complement at the `FilterQuery` altitude
65
+ (rejects the LEFT JOIN framing the PRD originally sketched).
66
+
67
+ ### Added
68
+
69
+ - `Entity.bulk_set_typed_eav_values_per_record(values_by_record,
70
+ version_grouping: :default)` — per-record-varying sibling to
71
+ `bulk_set_typed_eav_values`. Takes a `Hash<host_record,
72
+ Hash<field_name, value>>` and routes each record's value-set through
73
+ the same outer-transaction-plus-savepoint-per-record envelope,
74
+ returning the same `{ successes: [...], errors_by_record: { record
75
+ => errors_hash } }` shape. Supports sparse-update semantics
76
+ (unlisted fields untouched), `{ _destroy: true }` value-removal
77
+ shorthand, mixed-scope records (each record honors its own
78
+ `[scope, parent_scope]` even inside `TypedEAV.unscoped { ... }`),
79
+ and `:per_field` UUID allocation across the union of field names.
80
+ Empty input short-circuits without opening a transaction. Internally,
81
+ both public executors (`BulkWrite.execute` and
82
+ `BulkWrite.execute_per_record`) now share a single
83
+ `execute_pairs(pairs, effective_grouping, field_uuids)` helper that
84
+ takes ordered `[record, vbn]` pairs — preserving `execute`'s byte-
85
+ for-byte behavior on duplicate in-memory instances of the same
86
+ persisted row (Hash-key collision is documented as a gotcha only
87
+ on the new API). G1 (issue #18).
88
+
89
+ - `Entity.with_field` and `Entity.where_typed_eav` accept an opt-in
90
+ `include_missing:` keyword (default `false`). Threaded through to
91
+ `FilterQuery#initialize`. When paired with `:is_null`, the operator
92
+ matches hosts with **no non-NULL value** for the field — including
93
+ hosts that have no `typed_eav_values` row at all (Reading A: the
94
+ user-intuitive "is empty" semantic). Implemented as a set-complement
95
+ against `:is_not_null` at the `FilterQuery` altitude; `QueryBuilder`
96
+ is not modified. With `:is_not_null` the kwarg is a no-op; with any
97
+ other operator (`:eq`, `:gt`, `:contains`, `:references`, `:between`,
98
+ `:starts_with`, etc.) it is silently ignored — filter UIs can pass
99
+ the kwarg uniformly without branching per operator. On the multimap
100
+ (`ALL_SCOPES`) branch, "no non-NULL value" reads across all matching
101
+ field definitions for the name: a host matches iff none of the
102
+ per-tenant field defs have a non-NULL value for it. G3 (issue #19).
103
+ See ADR-0006.
104
+
105
+ - `TypedEAV::SchemaPortability.export_snapshot_schema(entity_type:,
106
+ scope: nil, parent_scope: nil)` — sibling to `export_schema` that
107
+ returns a lean, restore-oriented projection in a versioned envelope:
108
+ `{ "snapshot_schema_version" => 1, "fields" => [...] }`. Per-field
109
+ entries carry only `name`, `field_type_name`, `required`,
110
+ `sort_order`, `options`, and (for optionable types) `options_data`
111
+ — `entity_type`, `scope`, `parent_scope`, `type` (AR STI class name),
112
+ `field_dependent`, and `default_value_meta` are omitted. Non-optionable
113
+ fields omit the `options_data` key entirely (absent, not nil). The
114
+ `snapshot_schema_version` integer will be bumped explicitly when the
115
+ inner shape evolves — it is not frozen forever. Fields are ordered by
116
+ `sort_order` and `options_data` mirrors the loaded/unloaded ordering
117
+ rule used by `export_schema`. G4 (PRD #15).
118
+
119
+ ### Documentation
120
+
121
+ - `TypedEAV::Partition.find_visible_section!` is **documented-public**
122
+ going forward. Apps building admin UIs that need to authorize a
123
+ section lookup before editing, rendering, or destroying it should
124
+ call this rather than `Section.find(id)`. Method shape and behavior
125
+ do not change — this is a documentation clarification that promotes
126
+ an existing, already-shipping method into the documented surface
127
+ area, alongside the sibling `Partition` methods (`visible_fields`,
128
+ `effective_fields_by_name`, `definitions_by_name`,
129
+ `definitions_multimap_by_name`, `visible_sections`). G5 (issue #20).
130
+
131
+ ### Fixed
132
+
133
+ - `InstanceMethods#initialize_typed_values` no longer builds duplicate
134
+ rows on entities that already have in-memory `typed_values` builds
135
+ (form path with `field_id`, scripting path via `typed_eav_attributes=`,
136
+ or direct `typed_values.build(...)` on a persisted record). Covers
137
+ three cases: (1) new record + nested attributes, (2) new record +
138
+ scripting setter, (3) persisted record + unloaded association + a
139
+ build that lives in `target` without flipping `@loaded`. The
140
+ persisted-no-builds fast path still uses `pluck` only — no extra
141
+ association load. Dedup also tolerates an in-memory build whose
142
+ `field_id` is nil but whose `field` association is set
143
+ (`field_id || field&.id` fallback). G2 (PRD #15).
144
+
8
145
  ## [0.3.2] - 2026-05-25
9
146
 
10
147
  Documentation-only release. No code or behavior changes.
data/README.md CHANGED
@@ -810,6 +810,7 @@ A few non-obvious contracts worth knowing about up front:
810
810
  - **Orphan-parent rows rejected**: a `Field` or `Section` row with `parent_scope` set but `scope` blank is invalid. The `Value`-side guard rejects cross-`(scope, parent_scope)` writes too.
811
811
  - **Event hooks fire from `after_commit`**: the `on_value_change` and `on_field_change` callbacks fire after the database write is durable; their exceptions never break a save. See §"Event hooks" for the full contract.
812
812
  - **Versioning is opt-in**: When enabled (`TypedEAV.config.versioning = true` on the gem; `versioned: true` per host), every `:create` / `:update` / `:destroy` event on a Value writes an append-only audit row in `typed_eav_value_versions`. See §"Versioning" for the full contract.
813
+ - **`label` is cosmetic, `name` is the machine key**: A field's optional `label` is free-text human display, independent of the slug `name`. Render via `display_name`, which returns `label` when present else `name.humanize`. `label` has no uniqueness or format constraints (only a 255-char max) and never affects ordering, lookup, partitioning, or rename detection — editing only `label` fires `on_field_change` with `:update`, never `:rename`. Existing rows (`label` NULL) render unchanged. Schema export round-trips the raw `label` (legacy payloads without a `label` key import as NULL); snapshot export carries the resolved `display_name`.
813
814
 
814
815
  ## Event hooks
815
816
 
@@ -45,6 +45,11 @@ module TypedEAV
45
45
 
46
46
  validates :name, presence: true, uniqueness: { scope: %i[entity_type scope parent_scope] }
47
47
  validates :name, exclusion: { in: RESERVED_NAMES, message: "is reserved" }
48
+ # `label` is optional free-text human display, independent of the machine
49
+ # slug `name` (issue #21). The ONLY guard is a max-length sanity bound —
50
+ # intentionally NO uniqueness and NO format/exclusion constraint. RESERVED_NAMES,
51
+ # slug-uniqueness, and rename detection all key on :name and never on :label.
52
+ validates :label, length: { maximum: 255 }, allow_nil: true
48
53
  validates :type, presence: true
49
54
  validates :entity_type, presence: true
50
55
  validate :validate_default_value
@@ -262,6 +267,16 @@ module TypedEAV
262
267
  self.class.name.demodulize.underscore
263
268
  end
264
269
 
270
+ # Canonical human-facing display string (issue #21). Returns the
271
+ # free-text `label` when present, otherwise falls back to humanizing the
272
+ # machine slug `name`. This is the ONE accessor all rendering should use;
273
+ # `name` stays the immutable machine key. A blank ("") label falls back
274
+ # too (via `presence`), so existing rows (label NULL) render exactly as
275
+ # they did before this column existed.
276
+ def display_name
277
+ label.presence || name.humanize
278
+ end
279
+
265
280
  def array_field?
266
281
  false
267
282
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddLabelToTypedEAVFields < ActiveRecord::Migration[7.1]
4
+ # Additive, nullable, free-text display label distinct from the machine
5
+ # slug `name` (issue #21). No index, no default, no backfill: `label` never
6
+ # participates in uniqueness, lookup, partitioning, or ordering, so an index
7
+ # would be dead weight. Reversible via `change` because `add_column`
8
+ # auto-inverts. Existing rows keep label NULL and render unchanged through
9
+ # the new `Field#display_name` (label.presence || name.humanize).
10
+ def change
11
+ add_column :typed_eav_fields, :label, :string, null: true
12
+ end
13
+ end
@@ -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)
@@ -56,6 +113,12 @@ module TypedEAV
56
113
  "entity_type" => field.entity_type,
57
114
  "scope" => field.scope,
58
115
  "parent_scope" => field.parent_scope,
116
+ # Raw label (issue #21) — NOT the resolved display_name. The regular
117
+ # export round-trips the stored value verbatim so import reproduces
118
+ # it exactly and divergence detection (field_export_row_equal?) treats
119
+ # a differing label as a difference. Legacy payloads lack this key →
120
+ # entry["label"] is nil on import → label stays NULL (no version gate).
121
+ "label" => field.label,
59
122
  "required" => field.required,
60
123
  "sort_order" => field.sort_order,
61
124
  "field_dependent" => field.field_dependent,
@@ -80,6 +143,41 @@ module TypedEAV
80
143
  end
81
144
  # rubocop:enable Metrics/AbcSize
82
145
 
146
+ # Lean per-field projection used by {.export_snapshot_schema}. Mirrors
147
+ # the option-row ordering rule from {.export_field_entry} — sort
148
+ # loaded-association rows by `[sort_order || 0, label, id]`, and
149
+ # delegate to the `field_options.sorted` scope on the unloaded path.
150
+ def export_snapshot_field_entry(field)
151
+ entry = {
152
+ "name" => field.name,
153
+ "field_type_name" => field.field_type_name,
154
+ # RESOLVED display_name (issue #21), NOT the raw label — snapshots are
155
+ # render-oriented (CONTEXT decision 3). This is intentionally
156
+ # asymmetric to the regular export's raw "label": a snapshot consumer
157
+ # gets the ready-to-render string (label when present, else
158
+ # name.humanize) without re-deriving it.
159
+ "display_name" => field.display_name,
160
+ "required" => field.required,
161
+ "sort_order" => field.sort_order,
162
+ "options" => field.options,
163
+ }
164
+
165
+ if field.optionable?
166
+ options_rows = if field.field_options.loaded?
167
+ field.field_options.sort_by do |option|
168
+ [option.sort_order || 0, option.label.to_s, option.id]
169
+ end
170
+ else
171
+ field.field_options.sorted
172
+ end
173
+ entry["options_data"] = options_rows.map do |option|
174
+ { "label" => option.label, "value" => option.value, "sort_order" => option.sort_order }
175
+ end
176
+ end
177
+
178
+ entry
179
+ end
180
+
83
181
  def export_section_entry(section)
84
182
  {
85
183
  "name" => section.name,
@@ -161,6 +259,7 @@ module TypedEAV
161
259
 
162
260
  def overwrite_field!(existing, entry)
163
261
  existing.assign_attributes(
262
+ label: entry["label"],
164
263
  required: entry["required"],
165
264
  sort_order: entry["sort_order"],
166
265
  field_dependent: entry["field_dependent"],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.3.2"
4
+ VERSION = "0.5.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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dchuk
@@ -86,6 +86,7 @@ files:
86
86
  - db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb
87
87
  - db/migrate/20260505000000_create_typed_eav_value_versions.rb
88
88
  - db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb
89
+ - db/migrate/20260507000000_add_label_to_typed_eav_fields.rb
89
90
  - lib/generators/typed_eav/install/install_generator.rb
90
91
  - lib/generators/typed_eav/scaffold/scaffold_generator.rb
91
92
  - lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb