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 +4 -4
- data/CHANGELOG.md +90 -0
- data/README.md +32 -2
- data/app/models/typed_eav/field/base.rb +29 -7
- data/app/models/typed_eav/option.rb +0 -8
- data/db/migrate/20260507000000_add_label_to_typed_eav_fields.rb +13 -0
- 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 +35 -25
- data/lib/typed_eav/version.rb +1 -1
- metadata +17 -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,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
|
|
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.
|
|
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.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
existing =
|
|
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,
|
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
|
|
@@ -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.
|
|
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.
|
|
195
|
+
rubygems_version: 3.6.9
|
|
184
196
|
specification_version: 4
|
|
185
197
|
summary: Typed custom fields for ActiveRecord models
|
|
186
198
|
test_files: []
|