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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +207 -0
- data/README.md +271 -61
- data/app/models/typed_eav/field/base.rb +90 -33
- data/app/models/typed_eav/field/currency.rb +43 -0
- data/app/models/typed_eav/field/file.rb +1 -1
- data/app/models/typed_eav/field/image.rb +1 -1
- data/app/models/typed_eav/field/reference.rb +11 -0
- data/app/models/typed_eav/option.rb +0 -8
- data/app/models/typed_eav/section.rb +11 -5
- data/app/models/typed_eav/value.rb +95 -32
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +1 -1
- data/db/migrate/20260712000000_enforce_parent_scope_invariant.rb +54 -0
- data/db/migrate/20260816000000_use_partial_covering_scalar_indexes.rb +198 -0
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +1 -9
- data/lib/typed_eav/bulk_read.rb +41 -9
- data/lib/typed_eav/bulk_upsert.rb +141 -0
- data/lib/typed_eav/bulk_write.rb +65 -39
- data/lib/typed_eav/config.rb +19 -21
- data/lib/typed_eav/csv_mapper.rb +1 -1
- data/lib/typed_eav/engine.rb +16 -27
- data/lib/typed_eav/entity_query.rb +42 -8
- data/lib/typed_eav/event_dispatcher.rb +21 -30
- data/lib/typed_eav/field/typed_storage.rb +48 -0
- data/lib/typed_eav/field_deletion.rb +75 -0
- 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/query_builder.rb +17 -27
- data/lib/typed_eav/registry.rb +7 -8
- data/lib/typed_eav/schema_portability/import_index.rb +58 -0
- data/lib/typed_eav/schema_portability.rb +22 -25
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +25 -29
- data/lib/typed_eav/versioning.rb +59 -41
- data/lib/typed_eav.rb +2 -0
- 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
|
-
|
|
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
|
|
data/lib/typed_eav/bulk_read.rb
CHANGED
|
@@ -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
|
|
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 —
|
|
14
|
-
# `Partition.definitions_by_name`
|
|
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
|
|
23
|
+
# - 1 SELECT typed_eav_fields for the union of requested partitions
|
|
25
24
|
#
|
|
26
|
-
# Total:
|
|
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.
|
|
78
|
-
|
|
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
|