typed_eav 0.7.0 → 0.7.1

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: 37b913418c998e4620c90efb72a0be8c93b838a2977db0347c9198b743ced2f3
4
- data.tar.gz: 0a8a7f3a622712cc29b5738b8b0f4a54e3df3ffc56657277a69ebb956d3c46bf
3
+ metadata.gz: 312b444b0c7b29eb298af1ef5bba02d3ba56ad811399b3ffcd2dfd2afd88267b
4
+ data.tar.gz: cfa3ca85088eaff46dff6657d098f9fcf38fec9129b53792cfb37cc19e4358ad
5
5
  SHA512:
6
- metadata.gz: c5bdc06300d8bb92538c93cfe23ac3afb6bed9719a29f6f7590740e6d4f56ac14998e21b4230c58fa2eed10e89dfe053af86144f3cbcf9637049c3d5b811d90b
7
- data.tar.gz: 4760251616a62f91b7ed931dafc8b92a25653047cab8fcbd21dd027fe6353fdd2e7209a0629233306c69f77ec8eec08459854e8cc81443595d0b68e0503f4e8b
6
+ metadata.gz: abcb3cde1dc9d09da1ea014165c245d74aa2ef2f5fd566ecc9f86343b8e30b1ca32bcc10eb9735a06a23641bb0ccef7c1078d2be288a724a20b6d7e44468e6d9
7
+ data.tar.gz: b259edd0ece6e756569a151194368fc0adda83063d56683f9113d6373c0eede174cb545f9d1ca0ccc70e2dd512239494299f3da8608567aa5e47989e3b780eb3
data/CHANGELOG.md CHANGED
@@ -7,7 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- No unreleased changes.
10
+ ## [0.7.1] - 2026-09-04
11
+
12
+ A focused patch release for inherited Active Record hosts and multi-partition
13
+ bulk upserts. No new database migrations or public API changes are required.
14
+
15
+ ### Fixed
16
+
17
+ - Resolve inherited and namespaced Active Record host definitions, filters,
18
+ bulk reads, writes, and registry/versioning opt-ins through Rails'
19
+ canonical polymorphic name. STI subclasses now consistently share their
20
+ base class's EAV schema and stored values while queries retain their host
21
+ class restriction. Partition isolation and most-specific field precedence
22
+ are preserved. (#42)
23
+
24
+ ### Performance
25
+
26
+ - Batch BulkUpsert field-definition resolution into one SELECT per transaction
27
+ unit while preserving exact tuple isolation and definition precedence. A
28
+ 20-partition regression case now issues one definition SELECT instead of 20;
29
+ chunked transactions intentionally issue one per chunk. BulkRead and
30
+ BulkUpsert share the internal batched resolver. This is a query-count
31
+ improvement, not a universal throughput claim. (#43)
32
+
33
+ ### Compatibility and verification
34
+
35
+ - BulkUpsert's reduced-semantics acknowledgement, validation, transaction
36
+ boundaries, and callback/versioning behavior are unchanged.
37
+ - Regression coverage includes true STI and namespaced hosts, public
38
+ read/filter/write paths, partition isolation, definition precedence, and
39
+ all/chunk transaction behavior.
11
40
 
12
41
  ## [0.7.0] - 2026-08-18
13
42
 
@@ -580,7 +609,8 @@ worked examples.
580
609
 
581
610
  Initial release.
582
611
 
583
- [Unreleased]: https://github.com/dchuk/typed_eav/compare/v0.7.0...HEAD
612
+ [Unreleased]: https://github.com/dchuk/typed_eav/compare/v0.7.1...HEAD
613
+ [0.7.1]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.1
584
614
  [0.7.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.7.0
585
615
  [0.6.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.6.0
586
616
  [0.5.0]: https://github.com/dchuk/typed_eav/releases/tag/v0.5.0
data/README.md CHANGED
@@ -119,6 +119,11 @@ tags.field_options.create!([
119
119
  ])
120
120
  ```
121
121
 
122
+ When deriving `entity_type` from a model class, use
123
+ `Contact.polymorphic_name`. Rails stores polymorphic associations under that
124
+ canonical name, which is the base-class type for STI hosts and respects the
125
+ application's namespaced-polymorphism setting.
126
+
122
127
  ### 3. Set values on records
123
128
 
124
129
  ```ruby
@@ -1157,7 +1162,7 @@ directly:
1157
1162
 
1158
1163
  ```ruby
1159
1164
  TypedEAV::ValueVersion
1160
- .where(entity_type: contact.class.name, entity_id: contact.id, field_id: age_field.id)
1165
+ .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id, field_id: age_field.id)
1161
1166
  .order(changed_at: :desc, id: :desc)
1162
1167
  # => [<ValueVersion change_type: "destroy" before: {"integer_value" => 42} after: {} value_id: nil>,
1163
1168
  # <ValueVersion change_type: "update" before: {"integer_value" => 41} after: {"integer_value" => 42} value_id: nil>,
@@ -1176,7 +1181,7 @@ exports) — drop the `field_id` filter:
1176
1181
 
1177
1182
  ```ruby
1178
1183
  TypedEAV::ValueVersion
1179
- .where(entity_type: contact.class.name, entity_id: contact.id)
1184
+ .where(entity_type: contact.class.polymorphic_name, entity_id: contact.id)
1180
1185
  .order(changed_at: :desc, id: :desc)
1181
1186
  # => all version rows for every typed field on this contact, most-recent-first.
1182
1187
  # Includes :create, :update, and :destroy events across every field the
@@ -1416,6 +1421,10 @@ TypedEAV::QueryBuilder ← low altitude: per-field SQL primitive
1416
1421
  3. Returns a `{record_id => {field_name => value}}` map while skipping orphaned
1417
1422
  values and preserving logical missingness.
1418
1423
 
1424
+ Definitions, filters, reads, registry entries, and writes all use the host's
1425
+ Rails `polymorphic_name`, so an STI leaf class reads and queries the same rows
1426
+ written under its base-class polymorphic type.
1427
+
1419
1428
  The final production characterization reduced the 1,002 SQL statements observed
1420
1429
  across 1,000 scopes to three for the same BulkRead shape. This is a statement-
1421
1430
  count result, not a representative throughput claim; applications should still
@@ -1459,6 +1468,12 @@ checks, and Value validation callbacks remain; host callbacks and validations,
1459
1468
  Value persistence callbacks, versioning, delete shorthand, and per-record
1460
1469
  savepoint isolation are skipped.
1461
1470
 
1471
+ Within each `transaction: :all` unit—or each requested chunk—the upsert path
1472
+ resolves every record partition through one batched field-definition SELECT.
1473
+ It shares BulkRead's internal tuple resolver, retaining global, scope-only, and
1474
+ full-tuple precedence independently for each record without broadening tenant
1475
+ visibility.
1476
+
1462
1477
  `BulkWrite` and `BulkRead` are siblings — one read path, one write path — but they don't share a base class. Per [ADR-0005](docs/adr/0005-keep-phase-six-modules-independent.md), keeping them independent preserves the option to evolve each on its own schedule.
1463
1478
 
1464
1479
  ### Per-record reads/writes: `InstanceMethods`
@@ -10,8 +10,8 @@ module TypedEAV
10
10
  #
11
11
  # 1. validate_records! — nil -> ArgumentError; single-class invariant
12
12
  # 2. group_by_tuple — `[typed_eav_scope, typed_eav_parent_scope]`
13
- # 3. winning_ids_by_tuple — one exact-partition definition query, then
14
- # `Partition.definitions_by_name` per tuple
13
+ # 3. winning_ids_by_tuple — one `Partition::DefinitionBatch` query, then
14
+ # extract the winning field ids per tuple
15
15
  # 4. preload_values — single SELECT across ALL records
16
16
  # 5. build_result_hash — per-record inner hash; orphan-skip + winning-id
17
17
  # precedence mirrored from the instance path.
@@ -26,9 +26,10 @@ module TypedEAV
26
26
  #
27
27
  # ## Single-class invariant
28
28
  #
29
- # The polymorphic value query (`entity_type: host_class.name`) targets ONE
30
- # class; mixed-class input would silently miss rows of the other class. STI
31
- # subclasses pass via `records.all?(host_class)`.
29
+ # The polymorphic value query targets the host's canonical Rails
30
+ # `polymorphic_name`, so an STI subclass reads rows stored for its base
31
+ # class. Mixed, unrelated input would still be invalid; STI subclasses pass
32
+ # via `records.all?(host_class)`.
32
33
  class BulkRead
33
34
  def initialize(host_class:, records:)
34
35
  @host_class = host_class
@@ -73,49 +74,15 @@ module TypedEAV
73
74
  end
74
75
 
75
76
  def winning_ids_by_tuple(tuples)
76
- definitions_by_tuple = batched_definitions(tuples).group_by do |definition|
77
- [definition.scope, definition.parent_scope]
78
- end
79
-
80
- tuples.to_h do |tuple|
81
- s, ps = tuple
82
- candidate_tuples = [[nil, nil], [s, nil], [s, ps]].uniq
83
- visible = candidate_tuples.flat_map { |candidate| definitions_by_tuple.fetch(candidate, []) }
84
- [tuple, TypedEAV::Partition.definitions_by_name(visible).transform_values(&:id)]
85
- end
86
- end
87
-
88
- def batched_definitions(tuples)
89
- tuples.each { |scope, parent_scope| validate_tuple!(scope, parent_scope) }
90
- relation = TypedEAV::Field::Base.where(entity_type: host_class.name)
91
- requested = tuples.map { |scope, parent_scope| { "scope" => scope, "parent_scope" => parent_scope } }
92
- relation.where(<<~SQL.squish, requested.to_json).to_a
93
- typed_eav_fields.scope IS NULL AND typed_eav_fields.parent_scope IS NULL
94
- OR EXISTS (
95
- SELECT 1
96
- FROM jsonb_to_recordset(?::jsonb) AS requested(scope text, parent_scope text)
97
- WHERE (
98
- typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
99
- AND typed_eav_fields.parent_scope IS NOT DISTINCT FROM requested.parent_scope
100
- )
101
- OR (
102
- typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
103
- AND typed_eav_fields.parent_scope IS NULL
104
- )
105
- )
106
- SQL
107
- end
108
-
109
- def validate_tuple!(scope, parent_scope)
110
- return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
111
-
112
- raise ArgumentError, "parent_scope cannot be set when scope is blank"
77
+ TypedEAV::Partition::DefinitionBatch
78
+ .resolve(entity_type: host_class.polymorphic_name, tuples: tuples)
79
+ .transform_values { |fields_by_name| fields_by_name.transform_values(&:id) }
113
80
  end
114
81
 
115
82
  def preload_values(records)
116
83
  rows = TypedEAV::Value
117
84
  .includes(:field)
118
- .where(entity_type: host_class.name, entity_id: records.map(&:id))
85
+ .where(entity_type: host_class.polymorphic_name, entity_id: records.map(&:id))
119
86
  .to_a
120
87
  rows.group_by(&:entity_id)
121
88
  end
@@ -8,7 +8,8 @@ module TypedEAV
8
8
  # persistence callbacks, versioning, or per-record error isolation. Callers must
9
9
  # acknowledge those semantics explicitly. It pre-casts and validates every
10
10
  # row in a transaction unit before issuing one upsert batch on the host
11
- # model's connection. Scope resolution and field casting remain authoritative.
11
+ # model's connection. Field definitions for every tuple in that unit resolve
12
+ # in one batch; scope precedence and field casting remain authoritative.
12
13
  module BulkUpsert
13
14
  TYPE_COLUMNS = %i[
14
15
  string_value text_value boolean_value integer_value decimal_value date_value datetime_value json_value
@@ -36,19 +37,14 @@ module TypedEAV
36
37
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- tuple grouping, validation, and row construction are one durability unit.
37
38
  def write_unit(host_class, records, values_by_field_name)
38
39
  tuples = records.map { |record| [record.typed_eav_scope, record.typed_eav_parent_scope] }.uniq
39
- fields_by_tuple = tuples.to_h do |tuple|
40
- # Use the exact tuple at the partition layer. EntityQuery treats
41
- # explicit scope kwargs as ALL_SCOPES inside TypedEAV.unscoped;
42
- # that administrative bypass must not cross-contaminate tenants.
43
- fields = TypedEAV::Partition.definitions_by_name(
44
- TypedEAV::Partition.visible_fields(
45
- entity_type: host_class.polymorphic_name,
46
- scope: tuple[0],
47
- parent_scope: tuple[1],
48
- ),
49
- )
50
- [tuple, fields]
51
- end
40
+ # Resolve every exact tuple in this transaction unit with one query.
41
+ # The shared resolver preserves global/scope/full-tuple precedence per
42
+ # tuple and never observes EntityQuery's administrative ALL_SCOPES
43
+ # bypass, so tenants remain isolated inside an unscoped block.
44
+ fields_by_tuple = TypedEAV::Partition::DefinitionBatch.resolve(
45
+ entity_type: host_class.polymorphic_name,
46
+ tuples: tuples,
47
+ )
52
48
  rows = records.flat_map do |record|
53
49
  fields = fields_by_tuple.fetch([record.typed_eav_scope, record.typed_eav_parent_scope])
54
50
  values_by_field_name.filter_map do |name, raw|
@@ -103,10 +103,10 @@ module TypedEAV
103
103
  def typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
104
104
  resolved = resolve_scope(scope, parent_scope)
105
105
  if resolved.equal?(ALL_SCOPES)
106
- TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
106
+ TypedEAV::Partition.visible_fields(entity_type: polymorphic_name, mode: :all_partitions)
107
107
  else
108
108
  s, ps = resolved
109
- TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
109
+ TypedEAV::Partition.visible_fields(entity_type: polymorphic_name, scope: s, parent_scope: ps)
110
110
  end
111
111
  end
112
112
 
@@ -106,10 +106,10 @@ module TypedEAV
106
106
 
107
107
  def lookup_definitions
108
108
  if all_scopes?
109
- TypedEAV::Partition.visible_fields(entity_type: model.name, mode: :all_partitions)
109
+ TypedEAV::Partition.visible_fields(entity_type: model.polymorphic_name, mode: :all_partitions)
110
110
  else
111
111
  TypedEAV::Partition.visible_fields(
112
- entity_type: model.name,
112
+ entity_type: model.polymorphic_name,
113
113
  scope: @scope,
114
114
  parent_scope: @parent_scope,
115
115
  )
@@ -262,7 +262,7 @@ module TypedEAV
262
262
  # because each record's INSERT clears the cache — so cache-do alone
263
263
  # cannot keep field-definition reads N+1-free across the bulk loop.
264
264
  # The thread-local memo is the explicit fallback documented in plan
265
- # 06-05 §T3 notes; it pre-warms once per `[host_class, scope,
265
+ # 06-05 §T3 notes; it pre-warms once per `[polymorphic_name, scope,
266
266
  # parent_scope]` tuple and reuses across every record in that tuple.
267
267
  #
268
268
  # Outside a bulk operation the memo is nil and we fall through to
@@ -270,7 +270,7 @@ module TypedEAV
270
270
  def typed_eav_defs_by_name
271
271
  memo = Thread.current[:typed_eav_bulk_defs_memo]
272
272
  if memo
273
- key = [self.class.name, typed_eav_scope, typed_eav_parent_scope]
273
+ key = [self.class.polymorphic_name, typed_eav_scope, typed_eav_parent_scope]
274
274
  memo[key] ||= TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
275
275
  else
276
276
  TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
@@ -93,7 +93,7 @@ module TypedEAV
93
93
 
94
94
  accepts_nested_attributes_for :typed_values, allow_destroy: true
95
95
 
96
- TypedEAV.registry.register(name, types: types, versioned: versioned)
96
+ TypedEAV.registry.register(polymorphic_name, types: types, versioned: versioned)
97
97
  end
98
98
 
99
99
  private
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Partition
5
+ # Internal batched resolver for field definitions visible to many exact
6
+ # partition tuples. It performs one definition SELECT, then applies the
7
+ # same global -> scope -> full-tuple precedence as `definitions_by_name`
8
+ # independently for every requested tuple.
9
+ class DefinitionBatch
10
+ class << self
11
+ def resolve(entity_type:, tuples:)
12
+ new(entity_type: entity_type, tuples: tuples).resolve
13
+ end
14
+ end
15
+
16
+ def initialize(entity_type:, tuples:)
17
+ @entity_type = entity_type
18
+ @tuples = tuples.uniq
19
+ end
20
+
21
+ def resolve
22
+ tuples.each { |scope, parent_scope| validate_tuple!(scope, parent_scope) }
23
+ return {} if tuples.empty?
24
+
25
+ definitions_by_tuple = load_definitions.group_by do |definition|
26
+ [definition.scope, definition.parent_scope]
27
+ end
28
+
29
+ tuples.to_h do |tuple|
30
+ visible = candidate_tuples(tuple).flat_map { |candidate| definitions_by_tuple.fetch(candidate, []) }
31
+ [tuple, TypedEAV::Partition.definitions_by_name(visible)]
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :entity_type, :tuples
38
+
39
+ def load_definitions
40
+ requested = tuples.map { |scope, parent_scope| { "scope" => scope, "parent_scope" => parent_scope } }
41
+ TypedEAV::Field::Base.where(entity_type: entity_type).where(<<~SQL.squish, requested.to_json).to_a
42
+ typed_eav_fields.scope IS NULL AND typed_eav_fields.parent_scope IS NULL
43
+ OR EXISTS (
44
+ SELECT 1
45
+ FROM jsonb_to_recordset(?::jsonb) AS requested(scope text, parent_scope text)
46
+ WHERE (
47
+ typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
48
+ AND typed_eav_fields.parent_scope IS NOT DISTINCT FROM requested.parent_scope
49
+ )
50
+ OR (
51
+ typed_eav_fields.scope IS NOT DISTINCT FROM requested.scope
52
+ AND typed_eav_fields.parent_scope IS NULL
53
+ )
54
+ )
55
+ SQL
56
+ end
57
+
58
+ def candidate_tuples(tuple)
59
+ scope, parent_scope = tuple
60
+ [[nil, nil], [scope, nil], [scope, parent_scope]].uniq
61
+ end
62
+
63
+ def validate_tuple!(scope, parent_scope)
64
+ return if TypedEAV::ScopeTuple.invariant_satisfied?(scope, parent_scope)
65
+
66
+ raise ArgumentError, "parent_scope cannot be set when scope is blank"
67
+ end
68
+ end
69
+ end
70
+ end
@@ -8,6 +8,8 @@ module TypedEAV
8
8
  # values. Ambient resolution (`TypedEAV.current_scope`, `with_scope`,
9
9
  # `unscoped`) stays with the adapters that know their calling context.
10
10
  module Partition
11
+ autoload :DefinitionBatch, "typed_eav/partition/definition_batch"
12
+
11
13
  # Frozen orphan-parent ArgumentError message. Kept as a module constant
12
14
  # so both `visible_fields` and `visible_sections` raise the same string
13
15
  # without re-allocating per call. The string is the wire-stable BC error
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.1"
5
5
  end
@@ -62,12 +62,14 @@ module TypedEAV
62
62
 
63
63
  # Re-register with versioned: true. Preserve the existing types:
64
64
  # restriction by reading the current Registry entry.
65
- # has_typed_eav already called register(name, types: types,
66
- # versioned: false) we overwrite with versioned: true while
67
- # keeping the same types. If the entry doesn't exist (defensive
68
- # — shouldn't happen post-has_typed_eav), default types to nil.
69
- existing = TypedEAV.registry.entities[name] || {}
70
- TypedEAV.registry.register(name, types: existing[:types], versioned: true)
65
+ # has_typed_eav already registered the canonical Rails polymorphic
66
+ # name. Reuse it here so including this concern on an STI subclass
67
+ # enables versioning for the base entity_type actually stored on Value.
68
+ # If the entry doesn't exist (defensive — shouldn't happen post-
69
+ # has_typed_eav), default types to nil.
70
+ entity_type = polymorphic_name
71
+ existing = TypedEAV.registry.entities[entity_type] || {}
72
+ TypedEAV.registry.register(entity_type, types: existing[:types], versioned: true)
71
73
  end
72
74
  end
73
75
  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.7.0
4
+ version: 0.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - dchuk
@@ -159,6 +159,7 @@ files:
159
159
  - lib/typed_eav/has_typed_eav.rb
160
160
  - lib/typed_eav/has_typed_eav/instance_methods.rb
161
161
  - lib/typed_eav/partition.rb
162
+ - lib/typed_eav/partition/definition_batch.rb
162
163
  - lib/typed_eav/query_builder.rb
163
164
  - lib/typed_eav/registry.rb
164
165
  - lib/typed_eav/schema_portability.rb