typed_eav 0.5.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: 2584a7a3e9eab294e874931c8202f2dae2082be6a036bd419fce7df3fdd94f56
4
- data.tar.gz: 7d24090e4969e89bb268f89510b46ce034d8f9f045054c0d6d80828c4cd3006c
3
+ metadata.gz: 255a937ce445032caf1880728a75a7363dda1323f8d20650bb2c73380cdb0c82
4
+ data.tar.gz: 3ff1c84f019c73902ae18812b19e8ab254d30813128f80fc7878adf0b43e21d1
5
5
  SHA512:
6
- metadata.gz: 4aeaa932e2dff4d5ab22ae95c66d98953b93163fc9580445f05a365ce1489a56cfdbaf998e47548d5fe468227341eec170409eafad5191449cb96201039900d0
7
- data.tar.gz: 1b626f58647506b2d0317e382d0fd2d8ebf7daae1c11e84dc9d58b1929107af9c62854c19c39a6244e9957d1e20ccb7b3c924c7aee9a77aa19414453c356e534
6
+ metadata.gz: f0dc18a3c981530aba9c5be15acf07a1ce1a766845464aaa656876856f113d2b874e70173e9feb51bc2f88596c6bb9deea8a4b473d058e266c8ebc4e4584689c
7
+ data.tar.gz: 1d76a14b8ad44f7f17aee8e7973a6a468536711ddfd299cf376cd9d4d41e643c0e84e4b648df1b75bb961cb93764789c9ff9e3c15baffea8f7c323ff64a8f566
data/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ 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
+
8
57
  ## [0.5.0] - 2026-06-01
9
58
 
10
59
  Adds a human display label for fields, distinct from the immutable machine
@@ -378,6 +427,7 @@ worked examples.
378
427
 
379
428
  Initial release.
380
429
 
430
+ [0.6.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.6.0
381
431
  [0.3.2]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.2
382
432
  [0.3.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.3.1
383
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:
@@ -1233,16 +1249,19 @@ As of v0.2.0, the paired partial unique indexes cover the three-key partition tu
1233
1249
 
1234
1250
  ## Schema
1235
1251
 
1236
- The gem creates four tables:
1252
+ The gem creates five tables:
1237
1253
 
1238
1254
  - `typed_eav_fields` - field definitions (STI, one row per field per entity type)
1239
1255
  - `typed_eav_values` - values (one row per entity per field, with typed columns)
1240
1256
  - `typed_eav_options` - allowed values for select/multi-select fields
1241
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
1242
1261
 
1243
1262
  ## Architecture
1244
1263
 
1245
- 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).
1246
1265
 
1247
1266
  ### Macro entry: `HasTypedEav`
1248
1267
 
@@ -1314,6 +1333,16 @@ Single-record reads (`typed_eav_value`, `typed_eav_hash`) live on `InstanceMetho
1314
1333
 
1315
1334
  Every method uses `TypedEAV::Partition.definitions_by_name` so the collision-precedence rules for ambient/explicit/parent scopes are computed in one place.
1316
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
+
1317
1346
  ### Field types and storage: `Field::TypedStorage`
1318
1347
 
1319
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:
@@ -297,6 +297,8 @@ module TypedEAV
297
297
  end
298
298
 
299
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.
300
302
  def clear_option_cache!
301
303
  # no-op
302
304
  end
@@ -361,10 +363,17 @@ module TypedEAV
361
363
  # surfaces; prior batches stay committed. Caller re-runs idempotently
362
364
  # because the per-record skip rule re-checks each entity.
363
365
  ActiveRecord::Base.transaction(requires_new: true) do
364
- batch.each do |entity|
365
- next unless partition_matches?(entity)
366
-
367
- 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])
368
377
  end
369
378
  end
370
379
  end
@@ -596,9 +605,7 @@ module TypedEAV
596
605
  # row created via explicit `value: nil` is still a backfill
597
606
  # candidate per CONTEXT.md).
598
607
  # - row exists with non-nil typed column → skip (idempotence).
599
- def backfill_one(entity, column)
600
- existing = TypedEAV::Value.where(entity: entity, field_id: id).first
601
-
608
+ def backfill_one(entity, column, existing)
602
609
  if existing.nil?
603
610
  TypedEAV::Value.create!(entity: entity, field: self, value: default_value)
604
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,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,
@@ -141,7 +147,7 @@ module TypedEAV
141
147
 
142
148
  entry
143
149
  end
144
- # rubocop:enable Metrics/AbcSize
150
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
145
151
 
146
152
  # Lean per-field projection used by {.export_snapshot_schema}. Mirrors
147
153
  # the option-row ordering rule from {.export_field_entry} — sort
@@ -207,13 +213,9 @@ module TypedEAV
207
213
  "Supported: #{valid_policies.map { |policy| ":#{policy}" }.join(", ")}."
208
214
  end
209
215
 
210
- def import_field_entry(entry, on_conflict, result)
211
- existing = TypedEAV::Field::Base.find_by(
212
- name: entry["name"],
213
- entity_type: entry["entity_type"],
214
- scope: entry["scope"],
215
- parent_scope: entry["parent_scope"],
216
- )
216
+ def import_field_entry(entry, on_conflict, result, import_index)
217
+ identity = import_index.field_identity(entry)
218
+ existing = import_index.fields[identity]
217
219
 
218
220
  if existing
219
221
  reject_type_swap!(existing, entry)
@@ -233,7 +235,7 @@ module TypedEAV
233
235
  result["updated"] += 1
234
236
  end
235
237
  else
236
- create_field!(entry)
238
+ import_index.fields[identity] = create_field!(entry)
237
239
  result["created"] += 1
238
240
  end
239
241
  end
@@ -282,7 +284,7 @@ module TypedEAV
282
284
 
283
285
  def create_field!(entry)
284
286
  field = TypedEAV::Field::Base.create!(entry.except("options_data"))
285
- return unless field.optionable?
287
+ return field unless field.optionable?
286
288
 
287
289
  Array(entry["options_data"]).each do |option|
288
290
  field.field_options.create!(
@@ -291,16 +293,12 @@ module TypedEAV
291
293
  sort_order: option["sort_order"],
292
294
  )
293
295
  end
296
+ field
294
297
  end
295
298
 
296
- # rubocop:disable Metrics/MethodLength -- mirrors field import for section rows without option replacement.
297
- def import_section_entry(entry, on_conflict, result)
298
- existing = TypedEAV::Section.find_by(
299
- code: entry["code"],
300
- entity_type: entry["entity_type"],
301
- scope: entry["scope"],
302
- parent_scope: entry["parent_scope"],
303
- )
299
+ def import_section_entry(entry, on_conflict, result, import_index)
300
+ identity = import_index.section_identity(entry)
301
+ existing = import_index.sections[identity]
304
302
 
305
303
  if existing
306
304
  if section_export_row_equal?(existing, entry)
@@ -322,11 +320,10 @@ module TypedEAV
322
320
  result["updated"] += 1
323
321
  end
324
322
  else
325
- TypedEAV::Section.create!(entry)
323
+ import_index.sections[identity] = TypedEAV::Section.create!(entry)
326
324
  result["created"] += 1
327
325
  end
328
326
  end
329
- # rubocop:enable Metrics/MethodLength
330
327
 
331
328
  def raise_divergent_section!(entry)
332
329
  raise ArgumentError,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.5.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.5.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
@@ -87,6 +93,7 @@ files:
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
89
95
  - db/migrate/20260507000000_add_label_to_typed_eav_fields.rb
96
+ - db/migrate/20260712000000_enforce_parent_scope_invariant.rb
90
97
  - lib/generators/typed_eav/install/install_generator.rb
91
98
  - lib/generators/typed_eav/scaffold/scaffold_generator.rb
92
99
  - lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb
@@ -152,6 +159,7 @@ files:
152
159
  - lib/typed_eav/query_builder.rb
153
160
  - lib/typed_eav/registry.rb
154
161
  - lib/typed_eav/schema_portability.rb
162
+ - lib/typed_eav/schema_portability/import_index.rb
155
163
  - lib/typed_eav/scope_tuple.rb
156
164
  - lib/typed_eav/version.rb
157
165
  - lib/typed_eav/versioned.rb
@@ -174,14 +182,17 @@ required_ruby_version: !ruby/object:Gem::Requirement
174
182
  requirements:
175
183
  - - ">="
176
184
  - !ruby/object:Gem::Version
177
- version: '3.1'
185
+ version: '3.3'
186
+ - - "<"
187
+ - !ruby/object:Gem::Version
188
+ version: '4.1'
178
189
  required_rubygems_version: !ruby/object:Gem::Requirement
179
190
  requirements:
180
191
  - - ">="
181
192
  - !ruby/object:Gem::Version
182
193
  version: '0'
183
194
  requirements: []
184
- rubygems_version: 3.7.1
195
+ rubygems_version: 3.6.9
185
196
  specification_version: 4
186
197
  summary: Typed custom fields for ActiveRecord models
187
198
  test_files: []