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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +31 -2
- data/app/models/typed_eav/field/base.rb +14 -7
- data/app/models/typed_eav/option.rb +0 -8
- data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +50 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +1 -9
- data/lib/typed_eav/entity_query.rb +10 -1
- data/lib/typed_eav/filter_query.rb +9 -7
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +11 -8
- data/lib/typed_eav/partition.rb +8 -3
- data/lib/typed_eav/schema_portability/import_index.rb +58 -0
- data/lib/typed_eav/schema_portability.rb +22 -25
- data/lib/typed_eav/version.rb +1 -1
- metadata +16 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 255a937ce445032caf1880728a75a7363dda1323f8d20650bb2c73380cdb0c82
|
|
4
|
+
data.tar.gz: 3ff1c84f019c73902ae18812b19e8ab254d30813128f80fc7878adf0b43e21d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
|
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.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`.
|
|
172
|
-
#
|
|
173
|
-
#
|
|
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
|
|
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`.
|
|
170
|
-
#
|
|
171
|
-
#
|
|
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 }
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
data/lib/typed_eav/partition.rb
CHANGED
|
@@ -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
|
|
24
|
+
def visible_fields(entity_type: nil, scope: nil, parent_scope: nil, mode: :partition)
|
|
25
25
|
validate_mode!(mode)
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
existing =
|
|
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,
|
data/lib/typed_eav/version.rb
CHANGED
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
|
+
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.
|
|
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.
|
|
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.
|
|
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.
|
|
195
|
+
rubygems_version: 3.6.9
|
|
185
196
|
specification_version: 4
|
|
186
197
|
summary: Typed custom fields for ActiveRecord models
|
|
187
198
|
test_files: []
|