typed_eav 0.6.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +187 -0
  3. data/README.md +258 -62
  4. data/app/models/typed_eav/field/base.rb +77 -27
  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/section.rb +11 -5
  10. data/app/models/typed_eav/value.rb +95 -32
  11. data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
  12. data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +4 -0
  13. data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
  14. data/lib/typed_eav/bulk_read.rb +13 -14
  15. data/lib/typed_eav/bulk_upsert.rb +137 -0
  16. data/lib/typed_eav/bulk_write.rb +65 -39
  17. data/lib/typed_eav/config.rb +19 -21
  18. data/lib/typed_eav/csv_mapper.rb +1 -1
  19. data/lib/typed_eav/engine.rb +16 -27
  20. data/lib/typed_eav/entity_query.rb +34 -9
  21. data/lib/typed_eav/event_dispatcher.rb +21 -30
  22. data/lib/typed_eav/field/typed_storage.rb +48 -0
  23. data/lib/typed_eav/field_deletion.rb +75 -0
  24. data/lib/typed_eav/filter_query.rb +2 -2
  25. data/lib/typed_eav/has_typed_eav/instance_methods.rb +2 -2
  26. data/lib/typed_eav/has_typed_eav.rb +1 -1
  27. data/lib/typed_eav/partition/definition_batch.rb +70 -0
  28. data/lib/typed_eav/partition.rb +2 -0
  29. data/lib/typed_eav/query_builder.rb +17 -27
  30. data/lib/typed_eav/registry.rb +7 -8
  31. data/lib/typed_eav/version.rb +1 -1
  32. data/lib/typed_eav/versioned.rb +8 -6
  33. data/lib/typed_eav/versioning/subscriber.rb +25 -29
  34. data/lib/typed_eav/versioning.rb +59 -41
  35. data/lib/typed_eav.rb +2 -0
  36. metadata +5 -1
@@ -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
@@ -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 `Partition::DefinitionBatch` query, then
14
+ # extract the winning field ids 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,15 +20,16 @@ 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
  #
30
- # The polymorphic value query (`entity_type: host_class.name`) targets ONE
31
- # class; mixed-class input would silently miss rows of the other class. STI
32
- # 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)`.
33
33
  class BulkRead
34
34
  def initialize(host_class:, records:)
35
35
  @host_class = host_class
@@ -74,16 +74,15 @@ module TypedEAV
74
74
  end
75
75
 
76
76
  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)
80
- end
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) }
81
80
  end
82
81
 
83
82
  def preload_values(records)
84
83
  rows = TypedEAV::Value
85
84
  .includes(:field)
86
- .where(entity_type: host_class.name, entity_id: records.map(&:id))
85
+ .where(entity_type: host_class.polymorphic_name, entity_id: records.map(&:id))
87
86
  .to_a
88
87
  rows.group_by(&:entity_id)
89
88
  end
@@ -0,0 +1,137 @@
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. Field definitions for every tuple in that unit resolve
12
+ # in one batch; scope precedence and field casting remain authoritative.
13
+ module BulkUpsert
14
+ TYPE_COLUMNS = %i[
15
+ string_value text_value boolean_value integer_value decimal_value date_value datetime_value json_value
16
+ ].freeze
17
+
18
+ class << self
19
+ # rubocop:disable Metrics/ParameterLists -- transaction controls are explicit API contract.
20
+ def execute(
21
+ host_class:, records:, values_by_field_name:, acknowledge_reduced_semantics:, transaction: :all, chunk_size: nil
22
+ )
23
+ validate_options!(records, values_by_field_name, acknowledge_reduced_semantics, transaction, chunk_size)
24
+ records = records.to_a
25
+ return 0 if records.empty?
26
+
27
+ validate_record_classes!(host_class, records)
28
+ reject_duplicate_or_unpersisted!(records)
29
+ ensure_connection_owner!(host_class)
30
+ values_by_field_name = normalize_values!(values_by_field_name)
31
+ units = transaction == :all ? [records] : records.each_slice(chunk_size)
32
+ units.sum { |unit| write_unit(host_class, unit, values_by_field_name) }
33
+ end
34
+
35
+ private
36
+
37
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- tuple grouping, validation, and row construction are one durability unit.
38
+ def write_unit(host_class, records, values_by_field_name)
39
+ tuples = records.map { |record| [record.typed_eav_scope, record.typed_eav_parent_scope] }.uniq
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
+ )
48
+ rows = records.flat_map do |record|
49
+ fields = fields_by_tuple.fetch([record.typed_eav_scope, record.typed_eav_parent_scope])
50
+ values_by_field_name.filter_map do |name, raw|
51
+ field = fields[name.to_s]
52
+ raise ArgumentError, "bulk_upsert field is not visible for #{record.class.name}: #{name}" unless field
53
+
54
+ build_row(host_class, record, field, raw)
55
+ end
56
+ end
57
+ return 0 if rows.empty?
58
+
59
+ host_class.transaction do
60
+ # rubocop:disable Rails/SkipsModelValidations -- every row is prevalidated in :bulk_upsert context.
61
+ TypedEAV::Value.upsert_all(
62
+ rows,
63
+ unique_by: :idx_te_values_entity_field,
64
+ update_only: (TYPE_COLUMNS + [:updated_at]),
65
+ record_timestamps: false,
66
+ )
67
+ # rubocop:enable Rails/SkipsModelValidations
68
+ end
69
+ rows.length
70
+ end
71
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
72
+
73
+ def build_row(host_class, record, field, raw)
74
+ value = TypedEAV::Value.new(entity: record, field: field)
75
+ value.value = raw
76
+ value.valid?(:bulk_upsert)
77
+ errors = value.errors.to_hash
78
+ raise ArgumentError, "bulk_upsert validation failed for #{field.name}: #{errors.inspect}" if errors.any?
79
+
80
+ now = Time.current
81
+ row = {
82
+ entity_type: host_class.polymorphic_name,
83
+ entity_id: record.id,
84
+ field_id: field.id,
85
+ created_at: now,
86
+ updated_at: now,
87
+ }
88
+ TYPE_COLUMNS.each { |column| row[column] = value[column] }
89
+ row
90
+ end
91
+
92
+ def validate_options!(records, values, acknowledged, transaction, chunk_size)
93
+ raise ArgumentError, "bulk_upsert requires an Enumerable of records" if records.nil?
94
+ raise ArgumentError, "bulk_upsert requires a Hash of values_by_field_name" unless values.is_a?(Hash)
95
+ raise ArgumentError, "bulk_upsert requires acknowledge_reduced_semantics: true" unless acknowledged == true
96
+ return if transaction == :all
97
+ return if transaction == :chunks && chunk_size.is_a?(Integer) && chunk_size.positive?
98
+
99
+ raise ArgumentError, "transaction must be :all or :chunks with a positive chunk_size"
100
+ end
101
+
102
+ def normalize_values!(values)
103
+ normalized = {}
104
+ values.each do |name, raw|
105
+ key = name.to_s
106
+ if normalized.key?(key)
107
+ raise ArgumentError, "bulk_upsert field keys collide after string normalization: #{key}"
108
+ end
109
+
110
+ normalized[key] = raw
111
+ end
112
+ normalized
113
+ end
114
+
115
+ def validate_record_classes!(host_class, records)
116
+ return if records.all?(host_class)
117
+
118
+ raise ArgumentError, "bulk_upsert expects records of class #{host_class.name}"
119
+ end
120
+
121
+ def reject_duplicate_or_unpersisted!(records)
122
+ raise ArgumentError, "bulk_upsert requires persisted records" if records.any? { |record| !record.persisted? }
123
+ return if records.map(&:id).uniq.length == records.length
124
+
125
+ raise ArgumentError, "bulk_upsert does not accept duplicate entity records"
126
+ end
127
+
128
+ def ensure_connection_owner!(host_class)
129
+ pools = [host_class.connection_pool, TypedEAV::Value.connection_pool, TypedEAV::Field::Base.connection_pool]
130
+ return if pools.map(&:object_id).uniq.one?
131
+
132
+ raise ArgumentError, "bulk_upsert requires host, value, and field connections to share a pool"
133
+ end
134
+ end
135
+ # rubocop:enable Metrics/ParameterLists
136
+ end
137
+ end
@@ -16,7 +16,8 @@ module TypedEAV
16
16
  # allocate field UUIDs, and then hand off to `execute_pairs(pairs,
17
17
  # effective_grouping, field_uuids)` — a single shared loop that takes
18
18
  # ordered `[record, vbn]` pairs and runs the outer-transaction-plus-
19
- # savepoint-per-record envelope.
19
+ # savepoint-per-record envelope; `transaction: :chunks` repeats that
20
+ # envelope per chunk so completed chunks can commit independently.
20
21
  #
21
22
  # Pair-shaped (not Hash-shaped) so `execute`'s `[record, vbn]` list can
22
23
  # carry duplicate in-memory instances of the same persisted row without
@@ -24,19 +25,25 @@ module TypedEAV
24
25
  # `execute`'s byte-for-byte behavior contract.
25
26
  module BulkWrite
26
27
  class << self
27
- def execute(host_class:, records:, values_by_field_name:, version_grouping: :default)
28
- validate_inputs!(records, values_by_field_name, version_grouping)
28
+ # rubocop:disable Metrics/ParameterLists -- transaction controls are explicit API contract.
29
+ def execute(
30
+ host_class:, records:, values_by_field_name:, version_grouping: :default, transaction: :all, chunk_size: nil
31
+ )
32
+ validate_inputs!(records, values_by_field_name, version_grouping, transaction, chunk_size)
29
33
 
30
34
  records = records.to_a
31
35
  return { successes: [], errors_by_record: {} } if records.empty?
32
36
 
33
37
  validate_record_classes!(host_class, records)
38
+ ensure_connection_owner!(host_class)
34
39
 
35
40
  effective_grouping = resolve_grouping(version_grouping)
36
41
  vbn = values_by_field_name.transform_keys(&:to_s)
37
42
  field_uuids = effective_grouping == :per_field ? vbn.keys.index_with { SecureRandom.uuid } : nil
38
43
 
39
- execute_pairs(records.map { |r| [r, vbn] }, effective_grouping, field_uuids)
44
+ execute_pairs(
45
+ host_class, records.map { |r| [r, vbn] }, effective_grouping, field_uuids, transaction, chunk_size
46
+ )
40
47
  end
41
48
 
42
49
  # Per-record-varying sibling to `execute`. Accepts a `Hash<host_record,
@@ -45,12 +52,15 @@ module TypedEAV
45
52
  #
46
53
  # Empty `values_by_record` short-circuits to the empty result without
47
54
  # opening a transaction (matches `execute`'s empty-records contract).
48
- def execute_per_record(host_class:, values_by_record:, version_grouping: :default)
49
- validate_per_record_inputs!(values_by_record, version_grouping)
55
+ def execute_per_record(
56
+ host_class:, values_by_record:, version_grouping: :default, transaction: :all, chunk_size: nil
57
+ )
58
+ validate_per_record_inputs!(values_by_record, version_grouping, transaction, chunk_size)
50
59
 
51
60
  return { successes: [], errors_by_record: {} } if values_by_record.empty?
52
61
 
53
62
  validate_record_classes!(host_class, values_by_record.keys, method: :bulk_set_typed_eav_values_per_record)
63
+ ensure_connection_owner!(host_class)
54
64
 
55
65
  effective_grouping = resolve_grouping(version_grouping)
56
66
  pairs = values_by_record.map { |record, vbn| [record, vbn.transform_keys(&:to_s)] }
@@ -58,15 +68,11 @@ module TypedEAV
58
68
  pairs.flat_map { |(_record, vbn)| vbn.keys }.uniq.index_with { SecureRandom.uuid }
59
69
  end
60
70
 
61
- execute_pairs(pairs, effective_grouping, field_uuids)
71
+ execute_pairs(host_class, pairs, effective_grouping, field_uuids, transaction, chunk_size)
62
72
  end
73
+ # rubocop:enable Metrics/ParameterLists
63
74
 
64
75
  def apply_record_save(record:, vbn:, effective_grouping:, uuids:, accumulator:)
65
- push_uuid = case effective_grouping
66
- when :per_record then uuids[:record]
67
- when :per_field then uuids[:field].values.first
68
- end
69
-
70
76
  do_save = lambda do
71
77
  record.typed_eav_attributes = vbn.map { |name, value| typed_eav_entry_for(name, value) }
72
78
  stamp_pending_version_group_ids(record, effective_grouping, uuids)
@@ -79,11 +85,7 @@ module TypedEAV
79
85
  end
80
86
  end
81
87
 
82
- if push_uuid
83
- TypedEAV.with_context(version_group_id: push_uuid, &do_save)
84
- else
85
- do_save.call
86
- end
88
+ do_save.call
87
89
  end
88
90
 
89
91
  private
@@ -97,26 +99,31 @@ module TypedEAV
97
99
  # the same persisted row iterate each instance separately — matters
98
100
  # for `execute`'s byte-for-byte behavior contract on
99
101
  # `[Entity.find(1), Entity.find(1)]`-shaped input.
100
- def execute_pairs(pairs, effective_grouping, field_uuids)
102
+ # rubocop:disable Metrics/MethodLength -- transaction wrapper retains one readable semantic loop.
103
+ # rubocop:disable Metrics/ParameterLists -- internal transaction controls stay explicit.
104
+ def execute_pairs(host_class, pairs, effective_grouping, field_uuids, transaction, chunk_size)
101
105
  successes = []
102
106
  errors_by_record = {}
103
107
 
104
108
  with_bulk_definitions_memo do
105
- ActiveRecord::Base.cache do
106
- ActiveRecord::Base.transaction do
107
- pairs.each do |(record, vbn)|
108
- record_uuid = effective_grouping == :per_record ? SecureRandom.uuid : nil
109
- record_field_uuids = record_scoped_field_uuids(field_uuids, vbn)
110
-
111
- with_record_scope(record) do
112
- ActiveRecord::Base.transaction(requires_new: true) do
113
- apply_record_save(
114
- record: record,
115
- vbn: vbn,
116
- effective_grouping: effective_grouping,
117
- uuids: { record: record_uuid, field: record_field_uuids },
118
- accumulator: { successes: successes, errors_by_record: errors_by_record },
119
- )
109
+ host_class.cache do
110
+ units = transaction == :all ? [pairs] : pairs.each_slice(chunk_size)
111
+ units.each do |unit|
112
+ host_class.transaction do
113
+ unit.each do |(record, vbn)|
114
+ record_uuid = effective_grouping == :per_record ? SecureRandom.uuid : nil
115
+ record_field_uuids = record_scoped_field_uuids(field_uuids, vbn)
116
+
117
+ with_record_scope(record) do
118
+ host_class.transaction(requires_new: true) do
119
+ apply_record_save(
120
+ record: record,
121
+ vbn: vbn,
122
+ effective_grouping: effective_grouping,
123
+ uuids: { record: record_uuid, field: record_field_uuids },
124
+ accumulator: { successes: successes, errors_by_record: errors_by_record },
125
+ )
126
+ end
120
127
  end
121
128
  end
122
129
  end
@@ -126,6 +133,8 @@ module TypedEAV
126
133
 
127
134
  { successes: successes, errors_by_record: errors_by_record }
128
135
  end
136
+ # rubocop:enable Metrics/ParameterLists
137
+ # rubocop:enable Metrics/MethodLength
129
138
 
130
139
  # For `:per_field`, the global `field_uuids` map covers the union of
131
140
  # field names across all records' value hashes. The per-record save
@@ -184,7 +193,7 @@ module TypedEAV
184
193
  ActiveRecord::Type::Boolean.new.cast(flag)
185
194
  end
186
195
 
187
- def validate_inputs!(records, values_by_field_name, version_grouping)
196
+ def validate_inputs!(records, values_by_field_name, version_grouping, transaction, chunk_size)
188
197
  if records.nil?
189
198
  raise ArgumentError,
190
199
  "bulk_set_typed_eav_values requires an Enumerable of records, got nil"
@@ -197,9 +206,17 @@ module TypedEAV
197
206
  end
198
207
 
199
208
  validate_grouping!(version_grouping)
209
+ validate_transaction!(transaction, chunk_size)
200
210
  end
201
211
 
202
- def validate_per_record_inputs!(values_by_record, version_grouping)
212
+ def ensure_connection_owner!(host_class)
213
+ pools = [host_class.connection_pool, TypedEAV::Value.connection_pool, TypedEAV::Field::Base.connection_pool]
214
+ return if pools.map(&:object_id).uniq.one?
215
+
216
+ raise ArgumentError, "bulk_write requires host, value, and field connections to share a pool"
217
+ end
218
+
219
+ def validate_per_record_inputs!(values_by_record, version_grouping, transaction, chunk_size)
203
220
  unless values_by_record.is_a?(Hash)
204
221
  raise ArgumentError,
205
222
  "bulk_set_typed_eav_values_per_record requires a Hash of values_by_record, " \
@@ -215,6 +232,14 @@ module TypedEAV
215
232
  end
216
233
 
217
234
  validate_grouping!(version_grouping)
235
+ validate_transaction!(transaction, chunk_size)
236
+ end
237
+
238
+ def validate_transaction!(transaction, chunk_size)
239
+ return if transaction == :all
240
+ return if transaction == :chunks && chunk_size.is_a?(Integer) && chunk_size.positive?
241
+
242
+ raise ArgumentError, "transaction must be :all or :chunks with a positive chunk_size"
218
243
  end
219
244
 
220
245
  def validate_grouping!(version_grouping)
@@ -225,11 +250,12 @@ module TypedEAV
225
250
  "Supported values: #{valid_grouping.map(&:inspect).join(", ")}."
226
251
  end
227
252
 
228
- return unless %i[per_record per_field].include?(version_grouping) && !TypedEAV.config.versioning
253
+ return unless %i[per_record per_field].include?(version_grouping) &&
254
+ !TypedEAV::Versioning.atomic_callbacks_installed?
229
255
 
230
256
  raise ArgumentError,
231
- "version_grouping: #{version_grouping.inspect} was passed but versioning is disabled. " \
232
- "Set TypedEAV.config.versioning = true in your initializer, or pass " \
257
+ "version_grouping: #{version_grouping.inspect} was passed but versioning is disabled or not installed. " \
258
+ "Enable versioning at boot, or pass " \
233
259
  "version_grouping: :none to opt out explicitly, or omit the kwarg to silently no-op."
234
260
  end
235
261
 
@@ -244,7 +270,7 @@ module TypedEAV
244
270
 
245
271
  def resolve_grouping(version_grouping)
246
272
  if version_grouping == :default
247
- TypedEAV.config.versioning ? :per_record : :none
273
+ TypedEAV::Versioning.atomic_callbacks_installed? ? :per_record : :none
248
274
  else
249
275
  version_grouping
250
276
  end