typed_eav 0.5.0 → 0.7.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +207 -0
  3. data/README.md +271 -61
  4. data/app/models/typed_eav/field/base.rb +90 -33
  5. data/app/models/typed_eav/field/currency.rb +43 -0
  6. data/app/models/typed_eav/field/file.rb +1 -1
  7. data/app/models/typed_eav/field/image.rb +1 -1
  8. data/app/models/typed_eav/field/reference.rb +11 -0
  9. data/app/models/typed_eav/option.rb +0 -8
  10. data/app/models/typed_eav/section.rb +11 -5
  11. data/app/models/typed_eav/value.rb +95 -32
  12. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
  13. data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +54 -0
  14. data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
  15. data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +1 -9
  16. data/lib/typed_eav/bulk_read.rb +41 -9
  17. data/lib/typed_eav/bulk_upsert.rb +141 -0
  18. data/lib/typed_eav/bulk_write.rb +65 -39
  19. data/lib/typed_eav/config.rb +19 -21
  20. data/lib/typed_eav/csv_mapper.rb +1 -1
  21. data/lib/typed_eav/engine.rb +16 -27
  22. data/lib/typed_eav/entity_query.rb +42 -8
  23. data/lib/typed_eav/event_dispatcher.rb +21 -30
  24. data/lib/typed_eav/field/typed_storage.rb +48 -0
  25. data/lib/typed_eav/field_deletion.rb +75 -0
  26. data/lib/typed_eav/filter_query.rb +9 -7
  27. data/lib/typed_eav/has_typed_eav/instance_methods.rb +11 -8
  28. data/lib/typed_eav/partition.rb +8 -3
  29. data/lib/typed_eav/query_builder.rb +17 -27
  30. data/lib/typed_eav/registry.rb +7 -8
  31. data/lib/typed_eav/schema_portability/import_index.rb +58 -0
  32. data/lib/typed_eav/schema_portability.rb +22 -25
  33. data/lib/typed_eav/version.rb +1 -1
  34. data/lib/typed_eav/versioning/subscriber.rb +25 -29
  35. data/lib/typed_eav/versioning.rb +59 -41
  36. data/lib/typed_eav.rb +2 -0
  37. metadata +19 -5
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EnforceParentScopeInvariant < ActiveRecord::Migration[7.1]
4
+ # Strong Migrations requires check-constraint validation outside the default
5
+ # transaction so the catalog-only add and validation can proceed safely.
6
+ disable_ddl_transaction!
7
+
8
+ CONSTRAINTS = {
9
+ typed_eav_fields: :chk_te_fields_parent_scope_requires_scope,
10
+ typed_eav_sections: :chk_te_sections_parent_scope_requires_scope,
11
+ }.freeze
12
+
13
+ INVARIANT_SQL = <<~SQL.squish.freeze
14
+ parent_scope IS NULL
15
+ OR BTRIM(parent_scope) = ''
16
+ OR (scope IS NOT NULL AND BTRIM(scope) <> '')
17
+ SQL
18
+
19
+ ORPHAN_SQL = <<~SQL.squish.freeze
20
+ parent_scope IS NOT NULL
21
+ AND BTRIM(parent_scope) <> ''
22
+ AND (scope IS NULL OR BTRIM(scope) = '')
23
+ SQL
24
+
25
+ def up
26
+ assert_no_orphan_parent_rows!
27
+
28
+ CONSTRAINTS.each do |table, name|
29
+ add_check_constraint table, INVARIANT_SQL, name: name, validate: false, if_not_exists: true
30
+
31
+ validate_check_constraint table, name: name
32
+ end
33
+ end
34
+
35
+ def down
36
+ CONSTRAINTS.each do |table, name|
37
+ remove_check_constraint table, name: name, if_exists: true
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def assert_no_orphan_parent_rows!
44
+ violations = CONSTRAINTS.keys.filter_map do |table|
45
+ count = select_value("SELECT COUNT(*) FROM #{quote_table_name(table)} WHERE #{ORPHAN_SQL}").to_i
46
+ [table, count] if count.positive?
47
+ end
48
+ return if violations.empty?
49
+
50
+ evidence = violations.map { |table, count| "#{count} #{table} row(s) have parent_scope without scope" }.join("; ")
51
+ raise ActiveRecord::MigrationError,
52
+ "Cannot enforce the typed_eav parent-scope invariant: #{evidence}. Repair these rows and retry."
53
+ end
54
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ class UsePartialCoveringScalarIndexes < ActiveRecord::Migration[7.1]
4
+ # Concurrent create-before-drop keeps the replacement indexes available
5
+ # throughout the upgrade and leaves rollback able to recreate legacy DDL.
6
+ disable_ddl_transaction!
7
+
8
+ TABLE = :typed_eav_values
9
+
10
+ INDEXES = [
11
+ {
12
+ column: :integer_value,
13
+ replacement: "idx_te_values_field_int_present",
14
+ legacy: "idx_te_values_field_int",
15
+ value_opclass: "int8_ops",
16
+ },
17
+ {
18
+ column: :decimal_value,
19
+ replacement: "idx_te_values_field_dec_present",
20
+ legacy: "idx_te_values_field_dec",
21
+ value_opclass: "numeric_ops",
22
+ },
23
+ {
24
+ column: :date_value,
25
+ replacement: "idx_te_values_field_date_present",
26
+ legacy: "idx_te_values_field_date",
27
+ value_opclass: "date_ops",
28
+ },
29
+ {
30
+ column: :datetime_value,
31
+ replacement: "idx_te_values_field_dt_present",
32
+ legacy: "idx_te_values_field_dt",
33
+ value_opclass: "timestamp_ops",
34
+ },
35
+ {
36
+ column: :boolean_value,
37
+ replacement: "idx_te_values_field_bool_present",
38
+ legacy: "idx_te_values_field_bool",
39
+ value_opclass: "bool_ops",
40
+ },
41
+ {
42
+ column: :string_value,
43
+ replacement: "idx_te_values_field_str_present",
44
+ legacy: "idx_te_values_field_str",
45
+ value_opclass: "text_pattern_ops",
46
+ },
47
+ ].freeze
48
+
49
+ def up
50
+ INDEXES.each { |definition| ensure_index!(replacement_definition(definition)) }
51
+ assert_all_valid!(INDEXES.map { |definition| replacement_definition(definition) })
52
+ INDEXES.each { |definition| remove_expected_index!(legacy_definition(definition)) }
53
+ end
54
+
55
+ def down
56
+ INDEXES.each { |definition| ensure_index!(legacy_definition(definition)) }
57
+ assert_all_valid!(INDEXES.map { |definition| legacy_definition(definition) })
58
+ INDEXES.each { |definition| remove_expected_index!(replacement_definition(definition)) }
59
+ end
60
+
61
+ private
62
+
63
+ def replacement_definition(definition)
64
+ definition.merge(
65
+ name: definition.fetch(:replacement),
66
+ include: ["entity_id"],
67
+ predicate: "#{definition.fetch(:column)} IS NOT NULL",
68
+ )
69
+ end
70
+
71
+ def legacy_definition(definition)
72
+ definition.merge(
73
+ name: definition.fetch(:legacy),
74
+ include: %w[entity_id entity_type],
75
+ predicate: nil,
76
+ )
77
+ end
78
+
79
+ def ensure_index!(expected)
80
+ actual = catalog_index(expected.fetch(:name))
81
+ return create_and_validate!(expected) unless actual
82
+ return if exact_definition?(actual, expected) && usable?(actual)
83
+
84
+ unless exact_definition?(actual, expected)
85
+ raise ActiveRecord::MigrationError,
86
+ "index #{expected.fetch(:name)} exists with unexpected definition: #{actual.inspect}"
87
+ end
88
+
89
+ say "repairing invalid interrupted index #{expected.fetch(:name)}"
90
+ remove_index TABLE, name: expected.fetch(:name), algorithm: :concurrently
91
+ create_and_validate!(expected)
92
+ end
93
+
94
+ def create_and_validate!(expected)
95
+ options = {
96
+ name: expected.fetch(:name),
97
+ include: expected.fetch(:include),
98
+ algorithm: :concurrently,
99
+ }
100
+ options[:where] = expected.fetch(:predicate) if expected.fetch(:predicate)
101
+ if expected.fetch(:value_opclass) == "text_pattern_ops"
102
+ options[:opclass] = { expected.fetch(:column) => :text_pattern_ops }
103
+ end
104
+
105
+ add_index TABLE, [:field_id, expected.fetch(:column)], **options
106
+ actual = catalog_index(expected.fetch(:name))
107
+ return if actual && exact_definition?(actual, expected) && usable?(actual)
108
+
109
+ raise ActiveRecord::MigrationError,
110
+ "concurrent index #{expected.fetch(:name)} was not created with its exact valid definition"
111
+ end
112
+
113
+ def assert_all_valid!(expected_indexes)
114
+ invalid = expected_indexes.filter_map do |expected|
115
+ actual = catalog_index(expected.fetch(:name))
116
+ expected.fetch(:name) unless actual && exact_definition?(actual, expected) && usable?(actual)
117
+ end
118
+ return if invalid.empty?
119
+
120
+ raise ActiveRecord::MigrationError,
121
+ "refusing to remove existing indexes before all replacements are valid: #{invalid.join(", ")}"
122
+ end
123
+
124
+ def remove_expected_index!(expected)
125
+ actual = catalog_index(expected.fetch(:name))
126
+ return unless actual
127
+
128
+ unless exact_definition?(actual, expected)
129
+ raise ActiveRecord::MigrationError,
130
+ "index #{expected.fetch(:name)} exists with unexpected definition: #{actual.inspect}"
131
+ end
132
+
133
+ remove_index TABLE, name: expected.fetch(:name), algorithm: :concurrently
134
+ end
135
+
136
+ def usable?(actual)
137
+ actual.fetch("valid") && actual.fetch("ready")
138
+ end
139
+
140
+ def exact_definition?(actual, expected)
141
+ actual.fetch("method") == "btree" &&
142
+ !actual.fetch("unique") &&
143
+ actual.fetch("columns") == ["field_id", expected.fetch(:column).to_s, *expected.fetch(:include)] &&
144
+ actual.fetch("key_count") == 2 &&
145
+ actual.fetch("opclasses") == ["int8_ops", expected.fetch(:value_opclass)] &&
146
+ normalized_predicate(actual.fetch("predicate")) == normalized_predicate(expected.fetch(:predicate))
147
+ end
148
+
149
+ def normalized_predicate(predicate)
150
+ predicate&.gsub(/[()\s"]/, "")&.downcase
151
+ end
152
+
153
+ def catalog_index(name)
154
+ quoted_name = connection.quote(name)
155
+ row = connection.select_one(<<~SQL.squish)
156
+ SELECT
157
+ index_state.indisvalid AS valid,
158
+ index_state.indisready AS ready,
159
+ index_state.indisunique AS unique,
160
+ access_method.amname AS method,
161
+ index_state.indnkeyatts AS key_count,
162
+ pg_get_expr(index_state.indpred, index_state.indrelid) AS predicate,
163
+ array_to_string(ARRAY(
164
+ SELECT attribute.attname
165
+ FROM unnest(index_state.indkey) WITH ORDINALITY AS index_column(attnum, position)
166
+ JOIN pg_attribute attribute
167
+ ON attribute.attrelid = index_state.indrelid
168
+ AND attribute.attnum = index_column.attnum
169
+ ORDER BY index_column.position
170
+ ), ',') AS columns,
171
+ array_to_string(ARRAY(
172
+ SELECT operator_class.opcname
173
+ FROM unnest(index_state.indclass) WITH ORDINALITY AS index_opclass(opcoid, position)
174
+ JOIN pg_opclass operator_class ON operator_class.oid = index_opclass.opcoid
175
+ WHERE index_opclass.position <= index_state.indnkeyatts
176
+ ORDER BY index_opclass.position
177
+ ), ',') AS opclasses
178
+ FROM pg_class index_relation
179
+ JOIN pg_index index_state ON index_state.indexrelid = index_relation.oid
180
+ JOIN pg_class table_relation ON table_relation.oid = index_state.indrelid
181
+ JOIN pg_namespace namespace ON namespace.oid = table_relation.relnamespace
182
+ JOIN pg_am access_method ON access_method.oid = index_relation.relam
183
+ WHERE namespace.nspname = ANY (current_schemas(false))
184
+ AND table_relation.relname = 'typed_eav_values'
185
+ AND index_relation.relname = #{quoted_name}
186
+ SQL
187
+ return unless row
188
+
189
+ row.merge(
190
+ "valid" => ActiveModel::Type::Boolean.new.cast(row.fetch("valid")),
191
+ "ready" => ActiveModel::Type::Boolean.new.cast(row.fetch("ready")),
192
+ "unique" => ActiveModel::Type::Boolean.new.cast(row.fetch("unique")),
193
+ "key_count" => row.fetch("key_count").to_i,
194
+ "columns" => row.fetch("columns").split(","),
195
+ "opclasses" => row.fetch("opclasses").split(","),
196
+ )
197
+ end
198
+ 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
 
@@ -6,13 +6,12 @@ module TypedEAV
6
6
  # `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
7
7
  # record count or field count.
8
8
  #
9
- # ## Pipeline (one query per unique partition tuple + one bulk value preload)
9
+ # ## Pipeline (one batched definition query + one bulk value preload)
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 — `typed_eav_definitions` per unique tuple via
14
- # `Partition.definitions_by_name` (shared
15
- # collision-precedence helper)
13
+ # 3. winning_ids_by_tuple — one exact-partition definition query, then
14
+ # `Partition.definitions_by_name` per tuple
16
15
  # 4. preload_values — single SELECT across ALL records
17
16
  # 5. build_result_hash — per-record inner hash; orphan-skip + winning-id
18
17
  # precedence mirrored from the instance path.
@@ -21,9 +20,9 @@ module TypedEAV
21
20
  #
22
21
  # - 1 SELECT typed_eav_values WHERE entity_type=? AND entity_id IN (?)
23
22
  # - 1 SELECT typed_eav_fields WHERE id IN (?) (via includes)
24
- # - 1 SELECT typed_eav_fields per unique partition tuple
23
+ # - 1 SELECT typed_eav_fields for the union of requested partitions
25
24
  #
26
- # Total: 2 + (unique partition tuples) queries — independent of record count.
25
+ # Total: 3 queries — independent of record count and partition cardinality.
27
26
  #
28
27
  # ## Single-class invariant
29
28
  #
@@ -74,10 +73,43 @@ module TypedEAV
74
73
  end
75
74
 
76
75
  def winning_ids_by_tuple(tuples)
77
- tuples.each_with_object({}) do |(s, ps), memo|
78
- defs = host_class.typed_eav_definitions(scope: s, parent_scope: ps)
79
- memo[[s, ps]] = TypedEAV::Partition.definitions_by_name(defs).transform_values(&:id)
76
+ definitions_by_tuple = batched_definitions(tuples).group_by do |definition|
77
+ [definition.scope, definition.parent_scope]
80
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"
81
113
  end
82
114
 
83
115
  def preload_values(records)
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Reduced-semantics, SQL-oriented bulk value writer.
5
+ #
6
+ # This API runs casting/domain/entity/partition validation and validation
7
+ # callbacks, but does not run host callbacks/validations or host saves, Value
8
+ # persistence callbacks, versioning, or per-record error isolation. Callers must
9
+ # acknowledge those semantics explicitly. It pre-casts and validates every
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.
12
+ module BulkUpsert
13
+ TYPE_COLUMNS = %i[
14
+ string_value text_value boolean_value integer_value decimal_value date_value datetime_value json_value
15
+ ].freeze
16
+
17
+ class << self
18
+ # rubocop:disable Metrics/ParameterLists -- transaction controls are explicit API contract.
19
+ def execute(
20
+ host_class:, records:, values_by_field_name:, acknowledge_reduced_semantics:, transaction: :all, chunk_size: nil
21
+ )
22
+ validate_options!(records, values_by_field_name, acknowledge_reduced_semantics, transaction, chunk_size)
23
+ records = records.to_a
24
+ return 0 if records.empty?
25
+
26
+ validate_record_classes!(host_class, records)
27
+ reject_duplicate_or_unpersisted!(records)
28
+ ensure_connection_owner!(host_class)
29
+ values_by_field_name = normalize_values!(values_by_field_name)
30
+ units = transaction == :all ? [records] : records.each_slice(chunk_size)
31
+ units.sum { |unit| write_unit(host_class, unit, values_by_field_name) }
32
+ end
33
+
34
+ private
35
+
36
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- tuple grouping, validation, and row construction are one durability unit.
37
+ def write_unit(host_class, records, values_by_field_name)
38
+ 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
52
+ rows = records.flat_map do |record|
53
+ fields = fields_by_tuple.fetch([record.typed_eav_scope, record.typed_eav_parent_scope])
54
+ values_by_field_name.filter_map do |name, raw|
55
+ field = fields[name.to_s]
56
+ raise ArgumentError, "bulk_upsert field is not visible for #{record.class.name}: #{name}" unless field
57
+
58
+ build_row(host_class, record, field, raw)
59
+ end
60
+ end
61
+ return 0 if rows.empty?
62
+
63
+ host_class.transaction do
64
+ # rubocop:disable Rails/SkipsModelValidations -- every row is prevalidated in :bulk_upsert context.
65
+ TypedEAV::Value.upsert_all(
66
+ rows,
67
+ unique_by: :idx_te_values_entity_field,
68
+ update_only: (TYPE_COLUMNS + [:updated_at]),
69
+ record_timestamps: false,
70
+ )
71
+ # rubocop:enable Rails/SkipsModelValidations
72
+ end
73
+ rows.length
74
+ end
75
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
76
+
77
+ def build_row(host_class, record, field, raw)
78
+ value = TypedEAV::Value.new(entity: record, field: field)
79
+ value.value = raw
80
+ value.valid?(:bulk_upsert)
81
+ errors = value.errors.to_hash
82
+ raise ArgumentError, "bulk_upsert validation failed for #{field.name}: #{errors.inspect}" if errors.any?
83
+
84
+ now = Time.current
85
+ row = {
86
+ entity_type: host_class.polymorphic_name,
87
+ entity_id: record.id,
88
+ field_id: field.id,
89
+ created_at: now,
90
+ updated_at: now,
91
+ }
92
+ TYPE_COLUMNS.each { |column| row[column] = value[column] }
93
+ row
94
+ end
95
+
96
+ def validate_options!(records, values, acknowledged, transaction, chunk_size)
97
+ raise ArgumentError, "bulk_upsert requires an Enumerable of records" if records.nil?
98
+ raise ArgumentError, "bulk_upsert requires a Hash of values_by_field_name" unless values.is_a?(Hash)
99
+ raise ArgumentError, "bulk_upsert requires acknowledge_reduced_semantics: true" unless acknowledged == true
100
+ return if transaction == :all
101
+ return if transaction == :chunks && chunk_size.is_a?(Integer) && chunk_size.positive?
102
+
103
+ raise ArgumentError, "transaction must be :all or :chunks with a positive chunk_size"
104
+ end
105
+
106
+ def normalize_values!(values)
107
+ normalized = {}
108
+ values.each do |name, raw|
109
+ key = name.to_s
110
+ if normalized.key?(key)
111
+ raise ArgumentError, "bulk_upsert field keys collide after string normalization: #{key}"
112
+ end
113
+
114
+ normalized[key] = raw
115
+ end
116
+ normalized
117
+ end
118
+
119
+ def validate_record_classes!(host_class, records)
120
+ return if records.all?(host_class)
121
+
122
+ raise ArgumentError, "bulk_upsert expects records of class #{host_class.name}"
123
+ end
124
+
125
+ def reject_duplicate_or_unpersisted!(records)
126
+ raise ArgumentError, "bulk_upsert requires persisted records" if records.any? { |record| !record.persisted? }
127
+ return if records.map(&:id).uniq.length == records.length
128
+
129
+ raise ArgumentError, "bulk_upsert does not accept duplicate entity records"
130
+ end
131
+
132
+ def ensure_connection_owner!(host_class)
133
+ pools = [host_class.connection_pool, TypedEAV::Value.connection_pool, TypedEAV::Field::Base.connection_pool]
134
+ return if pools.map(&:object_id).uniq.one?
135
+
136
+ raise ArgumentError, "bulk_upsert requires host, value, and field connections to share a pool"
137
+ end
138
+ end
139
+ # rubocop:enable Metrics/ParameterLists
140
+ end
141
+ end