typed_eav 0.4.0 → 0.6.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: 392d8c372c1b70b4b710cef0b9f67d79531fb237bbca37419906ed468ced365f
4
- data.tar.gz: e8d9fd82f5d0d3a2cd894f89090a31522882c0606f28e691be373aacccf73cce
3
+ metadata.gz: 255a937ce445032caf1880728a75a7363dda1323f8d20650bb2c73380cdb0c82
4
+ data.tar.gz: 3ff1c84f019c73902ae18812b19e8ab254d30813128f80fc7878adf0b43e21d1
5
5
  SHA512:
6
- metadata.gz: 0fa26b021b7d5223f1af10e016dad89c546102f8f807d00fa6d3ea8d07a95a3e91948fa67e09137b2d93bb4cba38bf0ed2a91887812a7777fef4821bd87db190
7
- data.tar.gz: 195ee963d09d43cb358ff2f39ee4f62bd131cb75fbb110bbe6a00e569c5b693fbd21313e27f899e492edc9ebb921a1021bd432aa754059df4f609d6fa50cd30a
6
+ metadata.gz: f0dc18a3c981530aba9c5be15acf07a1ce1a766845464aaa656876856f113d2b874e70173e9feb51bc2f88596c6bb9deea8a4b473d058e266c8ebc4e4584689c
7
+ data.tar.gz: 1d76a14b8ad44f7f17aee8e7973a6a468536711ddfd299cf376cd9d4d41e643c0e84e4b648df1b75bb961cb93764789c9ff9e3c15baffea8f7c323ff64a8f566
data/CHANGELOG.md CHANGED
@@ -5,6 +5,95 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [0.6.0] - 2026-07-13
11
+
12
+ Hardens correctness, query efficiency, installation confidence, and release
13
+ safety while defining the supported runtime window. Existing public method
14
+ signatures and valid-record behavior remain unchanged. The compatibility floor
15
+ is intentionally raised to the release lines exercised by CI.
16
+
17
+ ### Changed
18
+
19
+ - Define the supported runtime contract as Ruby 3.3–4.0, Rails 7.2–8.1, and
20
+ PostgreSQL 15–18. Runtime dependency bounds now reject unverified future
21
+ Ruby and Rails release lines; prereleases remain outside the support
22
+ guarantee. Issue #26.
23
+
24
+ ### Fixed
25
+
26
+ - Prevent single-value reads from returning a stale value from a losing
27
+ partition, and enforce the parent-scope partition invariant in PostgreSQL.
28
+ Issues #22 and #23.
29
+ - Exercise packaged migrations in a generated Rails consumer application so
30
+ missing or non-idempotent installation paths fail before release. Issue #25.
31
+
32
+ ### Performance
33
+
34
+ - Remove obsolete option-cache invalidation and batch-load fields for already
35
+ loaded typed values. Issues #28 and #29.
36
+ - Bound scaffold visibility queries and preload existing values during default
37
+ backfills. Issues #30 and #31.
38
+ - Keep all-partitions filter unions inside SQL and batch schema-import lookups
39
+ and option writes. Issues #32 and #33.
40
+
41
+ ### Documentation
42
+
43
+ - Refresh maintainer, schema, and architecture guidance for the 0.5+ behavior
44
+ and current database invariants. Issue #34.
45
+
46
+ ### Release engineering
47
+
48
+ - Gate trusted publishing on the exact dereferenced tag commit, the complete
49
+ supported compatibility matrix, lint, package-content inspection, and a
50
+ real-host migration installation using the same checksummed gem artifact
51
+ that is sent to RubyGems. Issue #27.
52
+
53
+ ### References
54
+
55
+ - Issues #22, #23, and #25–#34.
56
+
57
+ ## [0.5.0] - 2026-06-01
58
+
59
+ Adds a human display label for fields, distinct from the immutable machine
60
+ slug `name` (issue #21). Fully additive — every change defaults to the
61
+ pre-0.5.0 behavior. Existing rows (with `label` NULL) render exactly as
62
+ before, and consumers that never set a label observe no difference.
63
+
64
+ ### Added
65
+
66
+ - `label` — a nullable, free-text column on `typed_eav_fields`
67
+ (`20260507000000_add_label_to_typed_eav_fields.rb`). No index, no
68
+ default, no backfill: `label` never participates in uniqueness, lookup,
69
+ partitioning, or ordering, so an index would be dead weight. Run
70
+ `rails typed_eav:install:migrations` (or copy the migration) and migrate
71
+ to pick it up. `name` stays the immutable machine key.
72
+
73
+ - `TypedEAV::Field::Base#display_name` — the canonical human-facing
74
+ display string. Returns the free-text `label` when present, otherwise
75
+ falls back to `name.humanize`. A blank (`""`) label falls back too (via
76
+ `presence`). This is the ONE accessor all rendering should use; existing
77
+ rows render unchanged.
78
+
79
+ - `SchemaPortability` round-trips the label. `export_schema` emits the
80
+ **raw** `"label"` so `import_schema` reproduces it verbatim and
81
+ divergence detection treats a differing label as a difference; legacy
82
+ payloads with no `"label"` key import as NULL with no version gate.
83
+ `export_snapshot_schema` emits the **resolved** `"display_name"` instead
84
+ (render-oriented snapshots hand the consumer a ready-to-render string —
85
+ intentionally asymmetric to the raw-label regular export).
86
+
87
+ ### Changed
88
+
89
+ - A label-only edit on a Field dispatches the `:update` event, not
90
+ `:rename`. `:rename` remains reserved for changes to the machine `name`.
91
+ Regression-pinned in `spec/regressions/issue_21_label_no_rename_spec.rb`.
92
+
93
+ ### References
94
+
95
+ - Issue #21 — Field display label / `display_name` contract.
96
+
8
97
  ## [0.4.0] - 2026-05-26
9
98
 
10
99
  Closes four follow-up gaps (PRD #15) surfaced when a downstream Rails app
@@ -338,6 +427,7 @@ worked examples.
338
427
 
339
428
  Initial release.
340
429
 
430
+ [0.6.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.6.0
341
431
  [0.3.2]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.2
342
432
  [0.3.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.1
343
433
  [0.3.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.0
data/README.md CHANGED
@@ -27,6 +27,22 @@ WHERE integer_value = 42
27
27
 
28
28
  Standard B-tree indexes work. Range scans work. The query planner is happy. ActiveRecord handles all type casting automatically through the column's registered type.
29
29
 
30
+ ## Compatibility
31
+
32
+ The canonical support contract lives in
33
+ [`.github/compatibility.json`](.github/compatibility.json). Typed EAV supports:
34
+
35
+ | Runtime | Supported versions |
36
+ |---|---|
37
+ | Ruby | 3.3 through 4.0 (`>= 3.3`, `< 4.1`) |
38
+ | Rails | 7.2 through 8.1 (`>= 7.2`, `< 8.2`) |
39
+ | PostgreSQL | 15 through 18 |
40
+
41
+ CI proves representative floor, middle, and ceiling combinations rather than
42
+ every Cartesian product. Versions outside these ranges and prerelease versions
43
+ are outside the support guarantee. PostgreSQL compatibility claims assume the
44
+ current minor release for each supported major version.
45
+
30
46
  ## Installation
31
47
 
32
48
  Add to your Gemfile:
@@ -810,6 +826,7 @@ A few non-obvious contracts worth knowing about up front:
810
826
  - **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
827
  - **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
828
  - **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.
829
+ - **`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
830
 
814
831
  ## Event hooks
815
832
 
@@ -1232,16 +1249,19 @@ As of v0.2.0, the paired partial unique indexes cover the three-key partition tu
1232
1249
 
1233
1250
  ## Schema
1234
1251
 
1235
- The gem creates four tables:
1252
+ The gem creates five tables:
1236
1253
 
1237
1254
  - `typed_eav_fields` - field definitions (STI, one row per field per entity type)
1238
1255
  - `typed_eav_values` - values (one row per entity per field, with typed columns)
1239
1256
  - `typed_eav_options` - allowed values for select/multi-select fields
1240
1257
  - `typed_eav_sections` - optional UI grouping
1258
+ - `typed_eav_value_versions` - opt-in, append-only audit history for Value
1259
+ create, update, and destroy events; it retains durable entity identity even
1260
+ when the live Value row is later removed
1241
1261
 
1242
1262
  ## Architecture
1243
1263
 
1244
- Internal module layout as of 0.3.0. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md) through [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md).
1264
+ Internal module layout as of 0.5.0. Most consumers never reach for these directly — the public surface is the `has_typed_eav` macro and the instance/class methods it installs — but the split matters if you're extending the gem, debugging an integration, or evaluating it for production. Decisions are anchored by [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md) through [ADR-0006](docs/adr/0006-include-missing-via-set-complement.md).
1245
1265
 
1246
1266
  ### Macro entry: `HasTypedEav`
1247
1267
 
@@ -1313,6 +1333,16 @@ Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMetho
1313
1333
 
1314
1334
  Every method uses `TypedEAV::Partition.definitions_by_name` so the collision-precedence rules for ambient/explicit/parent scopes are computed in one place.
1315
1335
 
1336
+ ### Partition visibility: `Partition`
1337
+
1338
+ Host applications that need to inspect effective schema should use the
1339
+ documented-public `TypedEAV::Partition` seam rather than rebuilding tuple
1340
+ predicates. It exposes `visible_fields`, `effective_fields_by_name`,
1341
+ `definitions_by_name`, `definitions_multimap_by_name`, `visible_sections`,
1342
+ and `find_visible_section!`. These methods preserve global, scope-only, and
1343
+ full-tuple precedence; ADR-0006 additionally fixes include-missing set
1344
+ composition at the `FilterQuery` altitude.
1345
+
1316
1346
  ### Field types and storage: `Field::TypedStorage`
1317
1347
 
1318
1348
  `TypedEAV::Field::Base` is the STI parent of every field type. The shared storage surface lives in the `TypedEAV::Field::TypedStorage` concern (`lib/typed_eav/field/typed_storage.rb`, ~200 LOC), auto-included on `Field::Base`. Per [ADR-0001](docs/adr/0001-collapse-column-mapping-stack.md), it provides:
@@ -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
@@ -282,6 +297,8 @@ module TypedEAV
282
297
  end
283
298
 
284
299
  # Kept for backward compatibility but now a no-op since we don't cache.
300
+ # TypedEAV does not invoke this hook internally; overriding it is not a
301
+ # supported option-lifecycle extension point.
285
302
  def clear_option_cache!
286
303
  # no-op
287
304
  end
@@ -346,10 +363,17 @@ module TypedEAV
346
363
  # surfaces; prior batches stay committed. Caller re-runs idempotently
347
364
  # because the per-record skip rule re-checks each entity.
348
365
  ActiveRecord::Base.transaction(requires_new: true) do
349
- batch.each do |entity|
350
- next unless partition_matches?(entity)
351
-
352
- backfill_one(entity, column)
366
+ eligible = batch.select { |entity| partition_matches?(entity) }
367
+ existing_by_entity_id = TypedEAV::Value
368
+ .where(
369
+ entity_type: entity_class.polymorphic_name,
370
+ entity_id: eligible.map(&:id),
371
+ field_id: id,
372
+ )
373
+ .index_by(&:entity_id)
374
+
375
+ eligible.each do |entity|
376
+ backfill_one(entity, column, existing_by_entity_id[entity.id])
353
377
  end
354
378
  end
355
379
  end
@@ -581,9 +605,7 @@ module TypedEAV
581
605
  # row created via explicit `value: nil` is still a backfill
582
606
  # candidate per CONTEXT.md).
583
607
  # - row exists with non-nil typed column → skip (idempotence).
584
- def backfill_one(entity, column)
585
- existing = TypedEAV::Value.where(entity: entity, field_id: id).first
586
-
608
+ def backfill_one(entity, column, existing)
587
609
  if existing.nil?
588
610
  TypedEAV::Value.create!(entity: entity, field: self, value: default_value)
589
611
  elsif existing[column].nil?
@@ -12,13 +12,5 @@ module TypedEAV
12
12
  validates :value, presence: true, uniqueness: { scope: :field_id }
13
13
 
14
14
  scope :sorted, -> { order(sort_order: :asc, label: :asc, id: :asc) }
15
-
16
- after_commit :clear_field_option_cache
17
-
18
- private
19
-
20
- def clear_field_option_cache
21
- field&.clear_option_cache!
22
- end
23
15
  end
24
16
  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
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EnforceParentScopeInvariant < ActiveRecord::Migration[7.1]
4
+ CONSTRAINTS = {
5
+ typed_eav_fields: :chk_te_fields_parent_scope_requires_scope,
6
+ typed_eav_sections: :chk_te_sections_parent_scope_requires_scope,
7
+ }.freeze
8
+
9
+ INVARIANT_SQL = <<~SQL.squish.freeze
10
+ parent_scope IS NULL
11
+ OR BTRIM(parent_scope) = ''
12
+ OR (scope IS NOT NULL AND BTRIM(scope) <> '')
13
+ SQL
14
+
15
+ ORPHAN_SQL = <<~SQL.squish.freeze
16
+ parent_scope IS NOT NULL
17
+ AND BTRIM(parent_scope) <> ''
18
+ AND (scope IS NULL OR BTRIM(scope) = '')
19
+ SQL
20
+
21
+ def up
22
+ assert_no_orphan_parent_rows!
23
+
24
+ CONSTRAINTS.each do |table, name|
25
+ add_check_constraint table, INVARIANT_SQL, name: name, validate: false, if_not_exists: true
26
+
27
+ validate_check_constraint table, name: name
28
+ end
29
+ end
30
+
31
+ def down
32
+ CONSTRAINTS.each do |table, name|
33
+ remove_check_constraint table, name: name, if_exists: true
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def assert_no_orphan_parent_rows!
40
+ violations = CONSTRAINTS.keys.filter_map do |table|
41
+ count = select_value("SELECT COUNT(*) FROM #{quote_table_name(table)} WHERE #{ORPHAN_SQL}").to_i
42
+ [table, count] if count.positive?
43
+ end
44
+ return if violations.empty?
45
+
46
+ evidence = violations.map { |table, count| "#{count} #{table} row(s) have parent_scope without scope" }.join("; ")
47
+ raise ActiveRecord::MigrationError,
48
+ "Cannot enforce the typed_eav parent-scope invariant: #{evidence}. Repair these rows and retry."
49
+ end
50
+ end
@@ -133,9 +133,7 @@ class TypedEAVController < ApplicationController
133
133
  # `TypedEAV.config.scope_resolver` to set the tuple.
134
134
  def scoped_fields
135
135
  partition = current_partition!
136
- return TypedEAV::Field::Base.all if partition[:mode] == :all_partitions
137
-
138
- TypedEAV::Field::Base.where(id: visible_field_ids(partition))
136
+ TypedEAV::Partition.visible_fields(**partition)
139
137
  end
140
138
 
141
139
  # Resolve the ambient tuple for writes. Mirrors `scoped_fields` semantics:
@@ -183,12 +181,6 @@ class TypedEAVController < ApplicationController
183
181
  { scope: field.scope, parent_scope: field.parent_scope, mode: :partition }
184
182
  end
185
183
 
186
- def visible_field_ids(partition)
187
- TypedEAV::Field::Base.distinct.pluck(:entity_type).flat_map do |entity_type|
188
- TypedEAV::Partition.visible_fields(entity_type: entity_type, **partition).pluck(:id)
189
- end
190
- end
191
-
192
184
  def resolve_type_class(type_name)
193
185
  return TypedEAV::Field::Text if type_name.blank?
194
186
 
@@ -75,7 +75,15 @@ module TypedEAV
75
75
  # short: meaningful only with `:is_null` (Reading A "no non-NULL
76
76
  # value," includes no-row hosts), no-op with `:is_not_null`, silently
77
77
  # ignored otherwise.
78
- def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE, include_missing: false)
78
+ # rubocop:disable Metrics/ParameterLists -- preserves the public positional and partition keyword API.
79
+ def with_field(
80
+ name,
81
+ operator_or_value = nil,
82
+ value = nil,
83
+ scope: UNSET_SCOPE,
84
+ parent_scope: UNSET_SCOPE,
85
+ include_missing: false
86
+ )
79
87
  filter = if value.nil? && !operator_or_value.is_a?(Symbol)
80
88
  # Two-arg form: with_field("name", "value") implies :eq
81
89
  { name: name, op: :eq, value: operator_or_value }
@@ -84,6 +92,7 @@ module TypedEAV
84
92
  end
85
93
  where_typed_eav(filter, scope: scope, parent_scope: parent_scope, include_missing: include_missing)
86
94
  end
95
+ # rubocop:enable Metrics/ParameterLists
87
96
 
88
97
  # Returns field definitions for this entity type.
89
98
  #
@@ -147,9 +147,7 @@ module TypedEAV
147
147
  # field def has a non-NULL value for it. Union the non-missing
148
148
  # entity_ids across all per-tenant field defs, then complement at
149
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
150
+ non_missing_ids = union_entity_ids(fields, :is_not_null, nil)
153
151
  query.where.not(id: non_missing_ids)
154
152
  else
155
153
  union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
@@ -168,11 +166,15 @@ module TypedEAV
168
166
  end
169
167
 
170
168
  # OR-across all field_ids that share the same name (across tenants),
171
- # while preserving AND between filters via the chained `.where`. Use the
172
- # underlying Value scope (`.filter`) and `pluck(:entity_id)` to collapse
173
- # to a plain integer array we can union across tenants.
169
+ # while preserving AND between filters via the chained `.where`. Keep the
170
+ # union as a composable entity-id subquery so matching IDs never need to
171
+ # be materialized in Ruby.
174
172
  def union_entity_ids(fields, operator, value)
175
- fields.flat_map { |f| TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id) }.uniq
173
+ fields
174
+ .map { |field| TypedEAV::QueryBuilder.filter(field, operator, value) }
175
+ .reduce { |union, relation| union.or(relation) }
176
+ .distinct
177
+ .select(:entity_id)
176
178
  end
177
179
 
178
180
  def parse_filter(filter)
@@ -166,13 +166,14 @@ module TypedEAV
166
166
  typed_values.filter_map { |tv| tv.field_id || tv.field&.id }
167
167
  end
168
168
 
169
- # Selects the candidate value for `typed_eav_value`. On a collision,
170
- # prefer the row attached to the winning field_id; otherwise fall back
171
- # to the first orphan/non-collision candidate.
169
+ # Selects the candidate value for `typed_eav_value`. When a definition
170
+ # wins for the current partition, only a row attached to that field_id
171
+ # may surface. Retained rows from a host's former partition must not be
172
+ # used as a fallback when the current winner has no value yet.
172
173
  def select_winning_value(candidates, winning)
173
174
  return candidates.first unless winning
174
175
 
175
- candidates.detect { |v| (v.field_id || v.field&.id) == winning.id } || candidates.first
176
+ candidates.detect { |v| (v.field_id || v.field&.id) == winning.id }
176
177
  end
177
178
 
178
179
  # Hash-builder helper for `typed_eav_hash`. When a winner is registered
@@ -234,10 +235,12 @@ module TypedEAV
234
235
  # fast without forcing that contract.
235
236
  def loaded_typed_values_with_fields
236
237
  if typed_values.loaded?
237
- # Don't re-query if the caller already preloaded; ensure each value's
238
- # field is materialized (fall back to per-row load if the nested
239
- # `:field` was not preloaded).
240
- typed_values.to_a
238
+ values = typed_values.to_a
239
+ missing_fields = values.reject { |value| value.association(:field).loaded? }
240
+ if missing_fields.any?
241
+ ActiveRecord::Associations::Preloader.new(records: missing_fields, associations: :field).call
242
+ end
243
+ values
241
244
  else
242
245
  typed_values.includes(:field).to_a
243
246
  end
@@ -21,13 +21,18 @@ module TypedEAV
21
21
  # scope-only rows, and full-tuple rows. Passing mode: :all_partitions is
22
22
  # the deliberate admin bypass; it is distinct from `scope: nil`, which
23
23
  # means the global partition only.
24
- def visible_fields(entity_type:, scope: nil, parent_scope: nil, mode: :partition)
24
+ def visible_fields(entity_type: nil, scope: nil, parent_scope: nil, mode: :partition)
25
25
  validate_mode!(mode)
26
- return TypedEAV::Field::Base.where(entity_type: entity_type) if mode == :all_partitions
26
+ fields = TypedEAV::Field::Base.all
27
+ fields = fields.where(entity_type: entity_type) if entity_type
28
+ return fields if mode == :all_partitions
27
29
 
28
30
  raise ArgumentError, ORPHAN_PARENT_MESSAGE unless ScopeTuple.invariant_satisfied?(scope, parent_scope)
29
31
 
30
- TypedEAV::Field::Base.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
32
+ fields.where(
33
+ scope: [scope, nil].uniq,
34
+ parent_scope: [parent_scope, nil].uniq,
35
+ )
31
36
  end
32
37
 
33
38
  # One visible field per name after collision resolution. Most-specific
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module SchemaPortability
5
+ class ImportIndex
6
+ attr_reader :fields, :sections
7
+
8
+ def initialize(field_entries, section_entries)
9
+ @fields = preload_fields(field_entries)
10
+ @sections = preload_sections(section_entries)
11
+ end
12
+
13
+ def field_identity(field_or_entry)
14
+ identity(field_or_entry, :name)
15
+ end
16
+
17
+ def section_identity(section_or_entry)
18
+ identity(section_or_entry, :code)
19
+ end
20
+
21
+ private
22
+
23
+ def preload_fields(entries)
24
+ return {} if entries.empty?
25
+
26
+ identity_relation(TypedEAV::Field::Base, entries, :name)
27
+ .includes(:field_options)
28
+ .index_by { |field| field_identity(field) }
29
+ end
30
+
31
+ def preload_sections(entries)
32
+ return {} if entries.empty?
33
+
34
+ identity_relation(TypedEAV::Section, entries, :code)
35
+ .index_by { |section| section_identity(section) }
36
+ end
37
+
38
+ def identity_relation(model, entries, key)
39
+ relations = entries.uniq { |entry| identity(entry, key) }.map do |entry|
40
+ model.where(
41
+ key => entry[key.to_s],
42
+ entity_type: entry["entity_type"],
43
+ scope: entry["scope"],
44
+ parent_scope: entry["parent_scope"],
45
+ )
46
+ end
47
+ relations.reduce { |union, relation| union.or(relation) }
48
+ end
49
+
50
+ def identity(record_or_entry, key)
51
+ attributes = [key, :entity_type, :scope, :parent_scope]
52
+ return attributes.map { |attribute| record_or_entry[attribute.to_s] } if record_or_entry.is_a?(Hash)
53
+
54
+ attributes.map { |attribute| record_or_entry.public_send(attribute) }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "schema_portability/import_index"
4
+
3
5
  module TypedEAV
4
6
  # Export and import field + section definitions for an exact partition
5
7
  # tuple. Value rows are intentionally out of scope.
@@ -89,14 +91,18 @@ module TypedEAV
89
91
  validate_conflict_policy!(on_conflict)
90
92
 
91
93
  result = { "created" => 0, "updated" => 0, "skipped" => 0, "unchanged" => 0, "errors" => [] }
94
+ field_entries = Array(hash["fields"])
95
+ section_entries = Array(hash["sections"])
92
96
 
93
97
  TypedEAV::Field::Base.transaction do
94
- Array(hash["fields"]).each do |entry|
95
- import_field_entry(entry, on_conflict, result)
98
+ import_index = ImportIndex.new(field_entries, section_entries)
99
+
100
+ field_entries.each do |entry|
101
+ import_field_entry(entry, on_conflict, result, import_index)
96
102
  end
97
103
 
98
- Array(hash["sections"]).each do |entry|
99
- import_section_entry(entry, on_conflict, result)
104
+ section_entries.each do |entry|
105
+ import_section_entry(entry, on_conflict, result, import_index)
100
106
  end
101
107
  end
102
108
 
@@ -105,7 +111,7 @@ module TypedEAV
105
111
 
106
112
  private
107
113
 
108
- # rubocop:disable Metrics/AbcSize -- flat projection is the canonical field export shape.
114
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- flat projection is the canonical field export shape.
109
115
  def export_field_entry(field)
110
116
  entry = {
111
117
  "name" => field.name,
@@ -113,6 +119,12 @@ module TypedEAV
113
119
  "entity_type" => field.entity_type,
114
120
  "scope" => field.scope,
115
121
  "parent_scope" => field.parent_scope,
122
+ # Raw label (issue #21) — NOT the resolved display_name. The regular
123
+ # export round-trips the stored value verbatim so import reproduces
124
+ # it exactly and divergence detection (field_export_row_equal?) treats
125
+ # a differing label as a difference. Legacy payloads lack this key →
126
+ # entry["label"] is nil on import → label stays NULL (no version gate).
127
+ "label" => field.label,
116
128
  "required" => field.required,
117
129
  "sort_order" => field.sort_order,
118
130
  "field_dependent" => field.field_dependent,
@@ -135,7 +147,7 @@ module TypedEAV
135
147
 
136
148
  entry
137
149
  end
138
- # rubocop:enable Metrics/AbcSize
150
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
139
151
 
140
152
  # Lean per-field projection used by {.export_snapshot_schema}. Mirrors
141
153
  # the option-row ordering rule from {.export_field_entry} — sort
@@ -145,6 +157,12 @@ module TypedEAV
145
157
  entry = {
146
158
  "name" => field.name,
147
159
  "field_type_name" => field.field_type_name,
160
+ # RESOLVED display_name (issue #21), NOT the raw label — snapshots are
161
+ # render-oriented (CONTEXT decision 3). This is intentionally
162
+ # asymmetric to the regular export's raw "label": a snapshot consumer
163
+ # gets the ready-to-render string (label when present, else
164
+ # name.humanize) without re-deriving it.
165
+ "display_name" => field.display_name,
148
166
  "required" => field.required,
149
167
  "sort_order" => field.sort_order,
150
168
  "options" => field.options,
@@ -195,13 +213,9 @@ module TypedEAV
195
213
  "Supported: #{valid_policies.map { |policy| ":#{policy}" }.join(", ")}."
196
214
  end
197
215
 
198
- def import_field_entry(entry, on_conflict, result)
199
- existing = TypedEAV::Field::Base.find_by(
200
- name: entry["name"],
201
- entity_type: entry["entity_type"],
202
- scope: entry["scope"],
203
- parent_scope: entry["parent_scope"],
204
- )
216
+ def import_field_entry(entry, on_conflict, result, import_index)
217
+ identity = import_index.field_identity(entry)
218
+ existing = import_index.fields[identity]
205
219
 
206
220
  if existing
207
221
  reject_type_swap!(existing, entry)
@@ -221,7 +235,7 @@ module TypedEAV
221
235
  result["updated"] += 1
222
236
  end
223
237
  else
224
- create_field!(entry)
238
+ import_index.fields[identity] = create_field!(entry)
225
239
  result["created"] += 1
226
240
  end
227
241
  end
@@ -247,6 +261,7 @@ module TypedEAV
247
261
 
248
262
  def overwrite_field!(existing, entry)
249
263
  existing.assign_attributes(
264
+ label: entry["label"],
250
265
  required: entry["required"],
251
266
  sort_order: entry["sort_order"],
252
267
  field_dependent: entry["field_dependent"],
@@ -269,7 +284,7 @@ module TypedEAV
269
284
 
270
285
  def create_field!(entry)
271
286
  field = TypedEAV::Field::Base.create!(entry.except("options_data"))
272
- return unless field.optionable?
287
+ return field unless field.optionable?
273
288
 
274
289
  Array(entry["options_data"]).each do |option|
275
290
  field.field_options.create!(
@@ -278,16 +293,12 @@ module TypedEAV
278
293
  sort_order: option["sort_order"],
279
294
  )
280
295
  end
296
+ field
281
297
  end
282
298
 
283
- # rubocop:disable Metrics/MethodLength -- mirrors field import for section rows without option replacement.
284
- def import_section_entry(entry, on_conflict, result)
285
- existing = TypedEAV::Section.find_by(
286
- code: entry["code"],
287
- entity_type: entry["entity_type"],
288
- scope: entry["scope"],
289
- parent_scope: entry["parent_scope"],
290
- )
299
+ def import_section_entry(entry, on_conflict, result, import_index)
300
+ identity = import_index.section_identity(entry)
301
+ existing = import_index.sections[identity]
291
302
 
292
303
  if existing
293
304
  if section_export_row_equal?(existing, entry)
@@ -309,11 +320,10 @@ module TypedEAV
309
320
  result["updated"] += 1
310
321
  end
311
322
  else
312
- TypedEAV::Section.create!(entry)
323
+ import_index.sections[identity] = TypedEAV::Section.create!(entry)
313
324
  result["created"] += 1
314
325
  end
315
326
  end
316
- # rubocop:enable Metrics/MethodLength
317
327
 
318
328
  def raise_divergent_section!(entry)
319
329
  raise ArgumentError,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.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.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dchuk
@@ -15,14 +15,20 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '7.1'
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.2'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
- version: '7.1'
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.2'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: csv
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -86,6 +92,8 @@ files:
86
92
  - db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb
87
93
  - db/migrate/20260505000000_create_typed_eav_value_versions.rb
88
94
  - db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb
95
+ - db/migrate/20260507000000_add_label_to_typed_eav_fields.rb
96
+ - db/migrate/20260712000000_enforce_parent_scope_invariant.rb
89
97
  - lib/generators/typed_eav/install/install_generator.rb
90
98
  - lib/generators/typed_eav/scaffold/scaffold_generator.rb
91
99
  - lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb
@@ -151,6 +159,7 @@ files:
151
159
  - lib/typed_eav/query_builder.rb
152
160
  - lib/typed_eav/registry.rb
153
161
  - lib/typed_eav/schema_portability.rb
162
+ - lib/typed_eav/schema_portability/import_index.rb
154
163
  - lib/typed_eav/scope_tuple.rb
155
164
  - lib/typed_eav/version.rb
156
165
  - lib/typed_eav/versioned.rb
@@ -173,14 +182,17 @@ required_ruby_version: !ruby/object:Gem::Requirement
173
182
  requirements:
174
183
  - - ">="
175
184
  - !ruby/object:Gem::Version
176
- version: '3.1'
185
+ version: '3.3'
186
+ - - "<"
187
+ - !ruby/object:Gem::Version
188
+ version: '4.1'
177
189
  required_rubygems_version: !ruby/object:Gem::Requirement
178
190
  requirements:
179
191
  - - ">="
180
192
  - !ruby/object:Gem::Version
181
193
  version: '0'
182
194
  requirements: []
183
- rubygems_version: 3.7.1
195
+ rubygems_version: 3.6.9
184
196
  specification_version: 4
185
197
  summary: Typed custom fields for ActiveRecord models
186
198
  test_files: []