typed_eav 0.1.0 → 0.2.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 +80 -0
- data/README.md +634 -2
- data/app/models/typed_eav/field/base.rb +552 -6
- data/app/models/typed_eav/field/currency.rb +125 -0
- data/app/models/typed_eav/field/file.rb +98 -0
- data/app/models/typed_eav/field/image.rb +152 -0
- data/app/models/typed_eav/field/percentage.rb +100 -0
- data/app/models/typed_eav/field/reference.rb +230 -0
- data/app/models/typed_eav/section.rb +114 -4
- data/app/models/typed_eav/value.rb +461 -11
- data/app/models/typed_eav/value_version.rb +96 -0
- data/db/migrate/20260430000000_add_parent_scope_to_typed_eav_partitions.rb +188 -0
- data/db/migrate/20260501000000_add_cascade_policy_to_typed_eav_fields.rb +41 -0
- data/db/migrate/20260505000000_create_typed_eav_value_versions.rb +120 -0
- data/db/migrate/20260506000001_add_version_group_id_to_typed_eav_value_versions.rb +76 -0
- data/lib/generators/typed_eav/scaffold/templates/config/initializers/typed_eav.rb +7 -5
- data/lib/generators/typed_eav/scaffold/templates/controllers/typed_eav_controller.rb +72 -65
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb +13 -3
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/index.html.erb +2 -0
- data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/show.html.erb +3 -0
- data/lib/typed_eav/bulk_write.rb +147 -0
- data/lib/typed_eav/column_mapping.rb +46 -0
- data/lib/typed_eav/config.rb +215 -19
- data/lib/typed_eav/csv_mapper.rb +158 -0
- data/lib/typed_eav/currency_storage_contract.rb +46 -0
- data/lib/typed_eav/engine.rb +117 -0
- data/lib/typed_eav/event_dispatcher.rb +151 -0
- data/lib/typed_eav/field_storage_contract.rb +68 -0
- data/lib/typed_eav/has_typed_eav.rb +455 -58
- data/lib/typed_eav/partition.rb +64 -0
- data/lib/typed_eav/query_builder.rb +39 -3
- data/lib/typed_eav/registry.rb +48 -9
- data/lib/typed_eav/schema_portability.rb +250 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioned.rb +73 -0
- data/lib/typed_eav/versioning/subscriber.rb +161 -0
- data/lib/typed_eav/versioning.rb +94 -0
- data/lib/typed_eav.rb +180 -12
- metadata +35 -1
|
@@ -31,7 +31,7 @@ class TypedEAVController < ApplicationController
|
|
|
31
31
|
before_action :set_field, only: %i[show edit update destroy]
|
|
32
32
|
|
|
33
33
|
def index
|
|
34
|
-
@fields = scoped_fields.order(:entity_type, :scope, :sort_order, :name)
|
|
34
|
+
@fields = scoped_fields.order(:entity_type, :scope, :parent_scope, :sort_order, :name)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def show; end
|
|
@@ -45,19 +45,20 @@ class TypedEAVController < ApplicationController
|
|
|
45
45
|
|
|
46
46
|
def create
|
|
47
47
|
type_class = resolve_type_class(params.dig(:typed_eav_field, :field_type) || params[:type])
|
|
48
|
-
|
|
48
|
+
partition = ensure_partition!
|
|
49
49
|
attrs = field_params(type_class, creating: true)
|
|
50
|
-
attrs[:scope] =
|
|
50
|
+
attrs[:scope] = partition[:scope]
|
|
51
|
+
attrs[:parent_scope] = partition[:parent_scope]
|
|
51
52
|
attrs[:section_id] = verified_section_id(
|
|
52
53
|
params.dig(:typed_eav_field, :section_id),
|
|
53
54
|
attrs[:entity_type],
|
|
54
|
-
|
|
55
|
+
partition,
|
|
55
56
|
)
|
|
56
57
|
@field = type_class.new(attrs)
|
|
57
58
|
|
|
58
59
|
if @field.save
|
|
59
60
|
redirect_to edit_typed_eav_field_path(@field), status: :see_other,
|
|
60
|
-
|
|
61
|
+
notice: "Field created."
|
|
61
62
|
else
|
|
62
63
|
render :new, status: :unprocessable_content
|
|
63
64
|
end
|
|
@@ -68,12 +69,12 @@ class TypedEAVController < ApplicationController
|
|
|
68
69
|
attrs[:section_id] = verified_section_id(
|
|
69
70
|
params.dig(:typed_eav_field, :section_id),
|
|
70
71
|
@field.entity_type,
|
|
71
|
-
@field
|
|
72
|
+
field_partition(@field),
|
|
72
73
|
)
|
|
73
74
|
|
|
74
75
|
if @field.update(attrs)
|
|
75
76
|
redirect_to edit_typed_eav_field_path(@field), status: :see_other,
|
|
76
|
-
|
|
77
|
+
notice: "Field updated."
|
|
77
78
|
else
|
|
78
79
|
render :edit, status: :unprocessable_content
|
|
79
80
|
end
|
|
@@ -94,7 +95,7 @@ class TypedEAVController < ApplicationController
|
|
|
94
95
|
@field.field_options.create!(
|
|
95
96
|
label: params[:option_label],
|
|
96
97
|
value: params[:option_value],
|
|
97
|
-
sort_order: next_order
|
|
98
|
+
sort_order: next_order,
|
|
98
99
|
)
|
|
99
100
|
end
|
|
100
101
|
redirect_to edit_typed_eav_field_path(@field), status: :see_other
|
|
@@ -120,73 +121,77 @@ class TypedEAVController < ApplicationController
|
|
|
120
121
|
@field = scoped_fields.find(params[:id])
|
|
121
122
|
end
|
|
122
123
|
|
|
123
|
-
# Base relation filtered
|
|
124
|
-
# scope=NULL
|
|
125
|
-
#
|
|
126
|
-
# - unscoped?
|
|
127
|
-
# - scope present
|
|
128
|
-
# -
|
|
129
|
-
# -
|
|
130
|
-
#
|
|
124
|
+
# Base relation filtered through TypedEAV's partition seam. Fields with the
|
|
125
|
+
# global tuple `(scope=NULL, parent_scope=NULL)` are visible to every
|
|
126
|
+
# partition. Fail-closed semantics:
|
|
127
|
+
# - unscoped? -> ALL fields across every partition
|
|
128
|
+
# - [scope, parent_scope] present -> global + scope + full-tuple fields
|
|
129
|
+
# - no tuple, require_scope=true -> raise TypedEAV::ScopeRequired
|
|
130
|
+
# - no tuple, require_scope=false -> global fields only; never leaks
|
|
131
|
+
# other tenants' rows.
|
|
131
132
|
# Use `TypedEAV.with_scope(value) { }` or configure
|
|
132
|
-
# `TypedEAV.config.scope_resolver` to set the
|
|
133
|
+
# `TypedEAV.config.scope_resolver` to set the tuple.
|
|
133
134
|
def scoped_fields
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# entirely so the scaffold remains usable instead of raising under
|
|
137
|
-
# require_scope=true.
|
|
138
|
-
return TypedEAV::Field::Base.all if TypedEAV.unscoped?
|
|
135
|
+
partition = current_partition!
|
|
136
|
+
return TypedEAV::Field::Base.all if partition[:mode] == :all_partitions
|
|
139
137
|
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
TypedEAV::Field::Base.where(id: visible_field_ids(partition))
|
|
139
|
+
end
|
|
142
140
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
# Resolve the ambient tuple for writes. Mirrors `scoped_fields` semantics:
|
|
142
|
+
# - unscoped? -> returns the global tuple
|
|
143
|
+
# - [scope, parent_scope] present -> returns that tuple
|
|
144
|
+
# - no tuple, require_scope=true -> raises TypedEAV::ScopeRequired
|
|
145
|
+
# - no tuple, require_scope=false -> returns the global tuple
|
|
146
|
+
def ensure_partition!
|
|
147
|
+
current_partition!.slice(:scope, :parent_scope)
|
|
148
|
+
end
|
|
149
149
|
|
|
150
|
-
|
|
150
|
+
# Server-side verification that the requested section exists within the same
|
|
151
|
+
# partition tuple as the field being created or updated, including global
|
|
152
|
+
# sections visible to that tuple.
|
|
153
|
+
# Returns nil if `id` is blank. Raises ActiveRecord::RecordNotFound (Rails
|
|
154
|
+
# renders 404) if the id does not belong to a section the caller can see,
|
|
155
|
+
# blocking cross-tenant assignment via a forged section_id.
|
|
156
|
+
def verified_section_id(id, entity_type, partition)
|
|
157
|
+
return nil if id.blank?
|
|
158
|
+
|
|
159
|
+
TypedEAV::Partition.find_visible_section!(
|
|
160
|
+
id,
|
|
161
|
+
entity_type: entity_type,
|
|
162
|
+
**partition,
|
|
163
|
+
).id
|
|
151
164
|
end
|
|
152
165
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
# - scope present -> returns the scope value
|
|
156
|
-
# - scope nil, require_scope=true -> raises TypedEAV::ScopeRequired
|
|
157
|
-
# - scope nil, require_scope=false -> returns nil (global-field creation)
|
|
158
|
-
def ensure_scope!
|
|
159
|
-
# Inside `TypedEAV.unscoped { }` we deliberately bypass the require_scope
|
|
160
|
-
# guard so admin tools can create global fields without first declaring an
|
|
161
|
-
# ambient scope. Wrap in `with_scope` to write into a specific tenant.
|
|
162
|
-
return nil if TypedEAV.unscoped?
|
|
166
|
+
def current_partition!
|
|
167
|
+
return { scope: nil, parent_scope: nil, mode: :all_partitions } if TypedEAV.unscoped?
|
|
163
168
|
|
|
164
|
-
|
|
165
|
-
return scope if
|
|
169
|
+
tuple = TypedEAV.current_scope
|
|
170
|
+
return { scope: tuple.first, parent_scope: tuple.last, mode: :partition } if tuple
|
|
166
171
|
|
|
167
172
|
if TypedEAV.config.require_scope
|
|
168
173
|
raise TypedEAV::ScopeRequired,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
"TypedEAV.current_scope is nil and require_scope is enabled; " \
|
|
175
|
+
"wrap the request in TypedEAV.with_scope(value) { } or configure " \
|
|
176
|
+
"TypedEAV.config.scope_resolver."
|
|
172
177
|
end
|
|
173
178
|
|
|
174
|
-
nil
|
|
179
|
+
{ scope: nil, parent_scope: nil, mode: :partition }
|
|
175
180
|
end
|
|
176
181
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
TypedEAV::Section.for_entity(entity_type, scope: scope).find(id).id
|
|
182
|
+
def field_partition(field)
|
|
183
|
+
{ scope: field.scope, parent_scope: field.parent_scope, mode: :partition }
|
|
184
|
+
end
|
|
185
|
+
|
|
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
|
|
186
190
|
end
|
|
187
191
|
|
|
188
192
|
def resolve_type_class(type_name)
|
|
189
193
|
return TypedEAV::Field::Text if type_name.blank?
|
|
194
|
+
|
|
190
195
|
TypedEAV.config.field_class_for(type_name)
|
|
191
196
|
rescue ArgumentError
|
|
192
197
|
TypedEAV::Field::Text
|
|
@@ -195,11 +200,12 @@ class TypedEAVController < ApplicationController
|
|
|
195
200
|
# Data-driven permitted params based on what the field type exposes via
|
|
196
201
|
# store_accessor. Much cleaner than a massive case statement per type.
|
|
197
202
|
#
|
|
198
|
-
# NOTE: `scope` and `section_id` are intentionally NOT in
|
|
199
|
-
# `scope`
|
|
203
|
+
# NOTE: `scope`, `parent_scope`, and `section_id` are intentionally NOT in
|
|
204
|
+
# the permit list. `scope` and `parent_scope` are derived server-side from
|
|
205
|
+
# the ambient partition tuple in `create`; a
|
|
200
206
|
# client-supplied value would let any authenticated user write into another
|
|
201
|
-
# tenant's partition. `section_id` is verified against
|
|
202
|
-
#
|
|
207
|
+
# tenant's partition. `section_id` is verified against the partition seam via
|
|
208
|
+
# `verified_section_id` on both create and update.
|
|
203
209
|
def field_params(type_class, creating:)
|
|
204
210
|
base = %i[name required sort_order]
|
|
205
211
|
base += %i[entity_type] if creating
|
|
@@ -208,11 +214,11 @@ class TypedEAVController < ApplicationController
|
|
|
208
214
|
option_keys = option_keys_for(type_class)
|
|
209
215
|
|
|
210
216
|
# Default value is scalar for most types, array for array types
|
|
211
|
-
if type_class.method_defined?(:array_field?) && type_class.allocate.array_field?
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
217
|
+
permitted = if type_class.method_defined?(:array_field?) && type_class.allocate.array_field?
|
|
218
|
+
base + option_keys + [{ default_value: [] }]
|
|
219
|
+
else
|
|
220
|
+
base + option_keys + %i[default_value]
|
|
221
|
+
end
|
|
216
222
|
|
|
217
223
|
params.require(:typed_eav_field).permit(*permitted).tap do |attrs|
|
|
218
224
|
attrs.transform_values! do |value|
|
|
@@ -224,6 +230,7 @@ class TypedEAVController < ApplicationController
|
|
|
224
230
|
# Introspect which option keys the field type exposes
|
|
225
231
|
def option_keys_for(type_class)
|
|
226
232
|
return [] unless type_class.respond_to?(:stored_attributes)
|
|
233
|
+
|
|
227
234
|
(type_class.stored_attributes[:options] || []).map(&:to_sym)
|
|
228
235
|
rescue StandardError
|
|
229
236
|
[]
|
data/lib/generators/typed_eav/scaffold/templates/views/typed_eav/forms/_common_fields.html.erb
CHANGED
|
@@ -38,11 +38,21 @@
|
|
|
38
38
|
|
|
39
39
|
<div>
|
|
40
40
|
<label>Scope</label>
|
|
41
|
-
<%# The controller derives scope from the ambient
|
|
42
|
-
|
|
41
|
+
<%# The controller derives scope from the ambient partition tuple and never
|
|
42
|
+
permits it from params. Display it read-only so users can see which
|
|
43
43
|
partition this field belongs to without implying they can change it. %>
|
|
44
|
+
<% current_partition = field.new_record? ? TypedEAV.current_scope : nil %>
|
|
45
|
+
<% current_partition_scope = current_partition&.first %>
|
|
46
|
+
<% current_partition_parent_scope = current_partition&.last %>
|
|
44
47
|
<input type="text"
|
|
45
|
-
value="<%= field.scope.presence || (
|
|
48
|
+
value="<%= field.scope.presence || (current_partition_scope.presence || "Global (all scopes)") %>"
|
|
49
|
+
disabled readonly />
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div>
|
|
53
|
+
<label>Parent Scope</label>
|
|
54
|
+
<input type="text"
|
|
55
|
+
value="<%= field.parent_scope.presence || (current_partition_parent_scope.presence || "Global (all parent scopes)") %>"
|
|
46
56
|
disabled readonly />
|
|
47
57
|
</div>
|
|
48
58
|
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
<th>Name</th>
|
|
18
18
|
<th>Required</th>
|
|
19
19
|
<th>Scope</th>
|
|
20
|
+
<th>Parent Scope</th>
|
|
20
21
|
<th>Options</th>
|
|
21
22
|
<th>Default</th>
|
|
22
23
|
<th></th>
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
<td><%= field.name %></td>
|
|
33
34
|
<td><%= field.required? ? "Yes" : "" %></td>
|
|
34
35
|
<td><code><%= field.scope.inspect %></code></td>
|
|
36
|
+
<td><code><%= field.parent_scope.inspect %></code></td>
|
|
35
37
|
<td><code><%= field.options %></code></td>
|
|
36
38
|
<td><code><%= field.default_value.inspect %></code></td>
|
|
37
39
|
<td><%= button_to "Delete", typed_eav_field_path(field), method: :delete,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# Internal executor for host-class bulk typed-value writes.
|
|
5
|
+
#
|
|
6
|
+
# Host models keep the public `bulk_set_typed_eav_values` API; this module
|
|
7
|
+
# owns the transaction shape, savepoint isolation, error aggregation, field
|
|
8
|
+
# name resolution delegation, and version-group stamping.
|
|
9
|
+
module BulkWrite
|
|
10
|
+
class << self
|
|
11
|
+
def execute(host_class:, records:, values_by_field_name:, version_grouping: :default)
|
|
12
|
+
validate_inputs!(records, values_by_field_name, version_grouping)
|
|
13
|
+
|
|
14
|
+
records = records.to_a
|
|
15
|
+
return { successes: [], errors_by_record: {} } if records.empty?
|
|
16
|
+
|
|
17
|
+
validate_record_classes!(host_class, records)
|
|
18
|
+
|
|
19
|
+
effective_grouping = resolve_grouping(version_grouping)
|
|
20
|
+
vbn = values_by_field_name.transform_keys(&:to_s)
|
|
21
|
+
field_uuids = effective_grouping == :per_field ? vbn.keys.index_with { SecureRandom.uuid } : nil
|
|
22
|
+
|
|
23
|
+
execute_records(records, vbn, effective_grouping, field_uuids)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def apply_record_save(record:, vbn:, effective_grouping:, uuids:, accumulator:)
|
|
27
|
+
push_uuid = case effective_grouping
|
|
28
|
+
when :per_record then uuids[:record]
|
|
29
|
+
when :per_field then uuids[:field].values.first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
do_save = lambda do
|
|
33
|
+
record.typed_eav_attributes = vbn.map { |name, value| { name: name, value: value } }
|
|
34
|
+
stamp_pending_version_group_ids(record, effective_grouping, uuids)
|
|
35
|
+
|
|
36
|
+
if record.save
|
|
37
|
+
accumulator[:successes] << record
|
|
38
|
+
else
|
|
39
|
+
accumulator[:errors_by_record][record] = record.errors.messages.transform_keys(&:to_s)
|
|
40
|
+
raise ActiveRecord::Rollback
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if push_uuid
|
|
45
|
+
TypedEAV.with_context(version_group_id: push_uuid, &do_save)
|
|
46
|
+
else
|
|
47
|
+
do_save.call
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def execute_records(records, vbn, effective_grouping, field_uuids)
|
|
54
|
+
successes = []
|
|
55
|
+
errors_by_record = {}
|
|
56
|
+
|
|
57
|
+
with_bulk_definitions_memo do
|
|
58
|
+
ActiveRecord::Base.cache do
|
|
59
|
+
ActiveRecord::Base.transaction do
|
|
60
|
+
records.each do |record|
|
|
61
|
+
record_uuid = effective_grouping == :per_record ? SecureRandom.uuid : nil
|
|
62
|
+
|
|
63
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
64
|
+
apply_record_save(
|
|
65
|
+
record: record,
|
|
66
|
+
vbn: vbn,
|
|
67
|
+
effective_grouping: effective_grouping,
|
|
68
|
+
uuids: { record: record_uuid, field: field_uuids },
|
|
69
|
+
accumulator: { successes: successes, errors_by_record: errors_by_record },
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
{ successes: successes, errors_by_record: errors_by_record }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_inputs!(records, values_by_field_name, version_grouping)
|
|
81
|
+
if records.nil?
|
|
82
|
+
raise ArgumentError,
|
|
83
|
+
"bulk_set_typed_eav_values requires an Enumerable of records, got nil"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
unless values_by_field_name.is_a?(Hash)
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"bulk_set_typed_eav_values requires a Hash of values_by_field_name, " \
|
|
89
|
+
"got #{values_by_field_name.class}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
valid_grouping = %i[default per_record per_field none]
|
|
93
|
+
unless valid_grouping.include?(version_grouping)
|
|
94
|
+
raise ArgumentError,
|
|
95
|
+
"version_grouping: #{version_grouping.inspect} is not supported. " \
|
|
96
|
+
"Supported values: #{valid_grouping.map(&:inspect).join(", ")}."
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return unless %i[per_record per_field].include?(version_grouping) && !TypedEAV.config.versioning
|
|
100
|
+
|
|
101
|
+
raise ArgumentError,
|
|
102
|
+
"version_grouping: #{version_grouping.inspect} was passed but versioning is disabled. " \
|
|
103
|
+
"Set TypedEAV.config.versioning = true in your initializer, or pass " \
|
|
104
|
+
"version_grouping: :none to opt out explicitly, or omit the kwarg to silently no-op."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_record_classes!(host_class, records)
|
|
108
|
+
return if records.all?(host_class)
|
|
109
|
+
|
|
110
|
+
classes = records.map { |record| record.class.name }.uniq
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"bulk_set_typed_eav_values expects records of class #{host_class.name} (or its subclasses); " \
|
|
113
|
+
"got mixed classes: #{classes.join(", ")}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def resolve_grouping(version_grouping)
|
|
117
|
+
if version_grouping == :default
|
|
118
|
+
TypedEAV.config.versioning ? :per_record : :none
|
|
119
|
+
else
|
|
120
|
+
version_grouping
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def with_bulk_definitions_memo
|
|
125
|
+
prior_memo = Thread.current[:typed_eav_bulk_defs_memo]
|
|
126
|
+
Thread.current[:typed_eav_bulk_defs_memo] = {}
|
|
127
|
+
yield
|
|
128
|
+
ensure
|
|
129
|
+
Thread.current[:typed_eav_bulk_defs_memo] = prior_memo
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def stamp_pending_version_group_ids(record, effective_grouping, uuids)
|
|
133
|
+
return if effective_grouping == :none
|
|
134
|
+
|
|
135
|
+
record.typed_values.each do |value|
|
|
136
|
+
next unless value.new_record? || value.changed?
|
|
137
|
+
|
|
138
|
+
uuid = case effective_grouping
|
|
139
|
+
when :per_record then uuids[:record]
|
|
140
|
+
when :per_field then uuids[:field][value.field&.name]
|
|
141
|
+
end
|
|
142
|
+
value.pending_version_group_id = uuid if uuid
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -44,6 +44,52 @@ module TypedEAV
|
|
|
44
44
|
@value_column = column_name.to_sym
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# All typed columns this field type stores its value across. Defaults to
|
|
48
|
+
# `[value_column]` for single-cell types — every built-in field type as
|
|
49
|
+
# of Phase 04 returns a one-element Array via this default. Phase 05
|
|
50
|
+
# Currency overrides this to return `[:decimal_value, :string_value]`
|
|
51
|
+
# (two-cell type: amount + currency code).
|
|
52
|
+
#
|
|
53
|
+
# Phase 04 versioning's snapshot logic iterates `value_columns` to build
|
|
54
|
+
# the jsonb {col_name => value} hash. Phase 04 also fixes the
|
|
55
|
+
# `Value#_dispatch_value_change_update` filter to use `value_columns.any?`
|
|
56
|
+
# instead of the singular `value_column` — forward-compatible with
|
|
57
|
+
# multi-cell types so a Currency change on the second cell alone still
|
|
58
|
+
# fires the :update event (Scout §3 / Discrepancy D3).
|
|
59
|
+
#
|
|
60
|
+
# The default delegates to `value_column` (the singular), so subclasses
|
|
61
|
+
# that haven't declared `value_column :col_name` raise the same
|
|
62
|
+
# NotImplementedError they always did when `value_columns` is invoked.
|
|
63
|
+
# This matches the existing contract — `value_column`'s raise is the
|
|
64
|
+
# "subclass must declare its column" enforcement; `value_columns`
|
|
65
|
+
# inherits that enforcement transparently.
|
|
66
|
+
def value_columns
|
|
67
|
+
[value_column]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Which physical column this operator acts on. Defaults to `value_column`
|
|
71
|
+
# for single-cell field types — every built-in type as of Phase 04
|
|
72
|
+
# inherits the default and returns the same column for every supported
|
|
73
|
+
# operator.
|
|
74
|
+
#
|
|
75
|
+
# Multi-cell field types (Phase 05: Currency) override this to route
|
|
76
|
+
# different operators to different columns. For example, Currency stores
|
|
77
|
+
# `{amount, currency}` across `decimal_value` + `string_value`; the
|
|
78
|
+
# `:eq` operator targets amount (`decimal_value`); the `:currency_eq`
|
|
79
|
+
# operator targets currency code (`string_value`).
|
|
80
|
+
#
|
|
81
|
+
# Called from `QueryBuilder.filter` AFTER the
|
|
82
|
+
# `supported_operators.include?(operator)` validation gate, so this
|
|
83
|
+
# method is only invoked with operators the field explicitly supports.
|
|
84
|
+
# Subclasses overriding for unsupported operators is a programming
|
|
85
|
+
# error caught by the gate, not by this method.
|
|
86
|
+
#
|
|
87
|
+
# The unused `operator` parameter is intentional in the default —
|
|
88
|
+
# subclasses use it to dispatch.
|
|
89
|
+
def operator_column(_operator)
|
|
90
|
+
value_column
|
|
91
|
+
end
|
|
92
|
+
|
|
47
93
|
# All operators this field type supports for querying.
|
|
48
94
|
# Subclasses can override to restrict or extend.
|
|
49
95
|
def supported_operators
|