typed_eav 0.2.1 → 0.3.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 +110 -0
- data/README.md +165 -47
- data/app/models/typed_eav/field/base.rb +28 -159
- data/app/models/typed_eav/field/currency.rb +54 -20
- data/app/models/typed_eav/field/date.rb +16 -1
- data/app/models/typed_eav/field/date_time.rb +16 -1
- data/app/models/typed_eav/field/decimal.rb +9 -1
- data/app/models/typed_eav/field/email.rb +13 -16
- data/app/models/typed_eav/field/integer.rb +6 -1
- data/app/models/typed_eav/field/long_text.rb +17 -1
- data/app/models/typed_eav/field/multi_select.rb +12 -9
- data/app/models/typed_eav/field/optionable.rb +59 -0
- data/app/models/typed_eav/field/percentage.rb +6 -6
- data/app/models/typed_eav/field/range_bounded.rb +71 -0
- data/app/models/typed_eav/field/select.rb +9 -10
- data/app/models/typed_eav/field/text.rb +11 -29
- data/app/models/typed_eav/field/url.rb +14 -16
- data/app/models/typed_eav/field/validated_string.rb +87 -0
- data/app/models/typed_eav/value.rb +9 -9
- data/lib/typed_eav/bulk_read.rb +124 -0
- data/lib/typed_eav/engine.rb +1 -1
- data/lib/typed_eav/entity_query.rb +186 -0
- data/lib/typed_eav/field/typed_storage.rb +205 -0
- data/lib/typed_eav/filter_query.rb +148 -0
- data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
- data/lib/typed_eav/has_typed_eav.rb +29 -793
- data/lib/typed_eav/partition.rb +51 -11
- data/lib/typed_eav/query_builder.rb +6 -7
- data/lib/typed_eav/scope_tuple.rb +116 -0
- data/lib/typed_eav/version.rb +1 -1
- data/lib/typed_eav/versioning/subscriber.rb +7 -6
- data/lib/typed_eav.rb +23 -64
- metadata +10 -4
- data/lib/typed_eav/column_mapping.rb +0 -110
- data/lib/typed_eav/currency_storage_contract.rb +0 -46
- data/lib/typed_eav/field_storage_contract.rb +0 -68
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
5
|
+
module TypedEAV
|
|
6
|
+
module Field
|
|
7
|
+
# Intermediate STI base for string-valued field families that share a
|
|
8
|
+
# `min_length` / `max_length` / `pattern` validation surface.
|
|
9
|
+
#
|
|
10
|
+
# Leaves: `Field::Text`, `Field::Email`, `Field::Url`.
|
|
11
|
+
#
|
|
12
|
+
# Hoists the `string_value` storage declaration, the
|
|
13
|
+
# `store_accessor :options, :min_length, :max_length, :pattern` line,
|
|
14
|
+
# the `min_length` / `max_length` numericality validators, the
|
|
15
|
+
# `max_gte_min_length` guard, the `validate_pattern_syntax` guard, and
|
|
16
|
+
# the protected `validate_length` / `validate_pattern` helpers — all
|
|
17
|
+
# previously duplicated across Text/Email/Url with one drift gap
|
|
18
|
+
# (`max_gte_min_length` was only on Text).
|
|
19
|
+
#
|
|
20
|
+
# Default `validate_typed_value` runs `validate_length` plus
|
|
21
|
+
# `validate_pattern if pattern.present?`. Leaves override and call
|
|
22
|
+
# `super` to layer on their format-specific check (Email's
|
|
23
|
+
# `EMAIL_FORMAT`, Url's `URL_FORMAT`).
|
|
24
|
+
#
|
|
25
|
+
# Public extension point: external authors can subclass this directly
|
|
26
|
+
# to inherit the full min/max/pattern surface (see README §"Custom field
|
|
27
|
+
# types"). STI dispatch is unaffected — leaves still store their own
|
|
28
|
+
# class names in the `type` column.
|
|
29
|
+
class ValidatedString < Base
|
|
30
|
+
value_column :string_value
|
|
31
|
+
|
|
32
|
+
store_accessor :options, :min_length, :max_length, :pattern
|
|
33
|
+
|
|
34
|
+
validates :min_length, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
|
|
35
|
+
validates :max_length, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
|
36
|
+
validate :max_gte_min_length
|
|
37
|
+
validate :validate_pattern_syntax
|
|
38
|
+
|
|
39
|
+
def validate_typed_value(record, val)
|
|
40
|
+
validate_length(record, val)
|
|
41
|
+
validate_pattern(record, val) if pattern.present?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
protected
|
|
45
|
+
|
|
46
|
+
def validate_length(record, val)
|
|
47
|
+
opts = options_hash
|
|
48
|
+
str = val.to_s
|
|
49
|
+
if opts[:min_length] && str.length < opts[:min_length].to_i
|
|
50
|
+
record.errors.add(:value, :too_short, count: opts[:min_length])
|
|
51
|
+
end
|
|
52
|
+
return unless opts[:max_length] && str.length > opts[:max_length].to_i
|
|
53
|
+
|
|
54
|
+
record.errors.add(:value, :too_long, count: opts[:max_length])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_pattern(record, val)
|
|
58
|
+
opts = options_hash
|
|
59
|
+
pattern = opts[:pattern]
|
|
60
|
+
return if pattern.blank?
|
|
61
|
+
|
|
62
|
+
matched = Timeout.timeout(1) { Regexp.new(pattern).match?(val.to_s) }
|
|
63
|
+
record.errors.add(:value, :invalid) unless matched
|
|
64
|
+
rescue RegexpError
|
|
65
|
+
record.errors.add(:value, "has an invalid pattern configured")
|
|
66
|
+
rescue Timeout::Error
|
|
67
|
+
record.errors.add(:value, "pattern validation timed out")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def max_gte_min_length
|
|
73
|
+
return unless min_length && max_length
|
|
74
|
+
|
|
75
|
+
errors.add(:max_length, "must be >= min_length") if max_length < min_length
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_pattern_syntax
|
|
79
|
+
return if pattern.blank?
|
|
80
|
+
|
|
81
|
+
Regexp.new(pattern)
|
|
82
|
+
rescue RegexpError => e
|
|
83
|
+
errors.add(:pattern, "is invalid: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -86,7 +86,7 @@ module TypedEAV
|
|
|
86
86
|
def value
|
|
87
87
|
return nil unless field
|
|
88
88
|
|
|
89
|
-
field.
|
|
89
|
+
field.read_value(self)
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
def value=(val)
|
|
@@ -114,7 +114,7 @@ module TypedEAV
|
|
|
114
114
|
# decimal_value, raising TypeMismatch at save time.
|
|
115
115
|
# Rails will further cast each column on save via its column type.
|
|
116
116
|
casted, invalid = field.cast(val)
|
|
117
|
-
field.
|
|
117
|
+
field.write_value(self, casted)
|
|
118
118
|
@cast_was_invalid = invalid
|
|
119
119
|
else
|
|
120
120
|
# Field not yet assigned - stash for later
|
|
@@ -276,7 +276,7 @@ module TypedEAV
|
|
|
276
276
|
# Currency in Phase 05). Reconstructing that shape from
|
|
277
277
|
# before_value's per-column hash adds complexity for zero benefit
|
|
278
278
|
# since the per-column values are exactly what we need.
|
|
279
|
-
field.
|
|
279
|
+
field.class.value_columns.each do |col|
|
|
280
280
|
self[col] = version.before_value[col.to_s]
|
|
281
281
|
end
|
|
282
282
|
|
|
@@ -369,20 +369,20 @@ module TypedEAV
|
|
|
369
369
|
end
|
|
370
370
|
|
|
371
371
|
# Writes the field's configured default to the typed column(s) via the
|
|
372
|
-
# `field.
|
|
372
|
+
# `field.apply_default(self)` dispatch. Does NOT route through value=
|
|
373
373
|
# because field.default_value is already cast via
|
|
374
374
|
# cast(default_value_meta["v"]).first — re-casting would be redundant.
|
|
375
375
|
# Field-side validate_default_value (field/base.rb) catches invalid raw
|
|
376
|
-
# defaults at field save time, so what
|
|
376
|
+
# defaults at field save time, so what apply_default writes is always
|
|
377
377
|
# either a castable value or nil.
|
|
378
378
|
#
|
|
379
379
|
# Multi-cell forward-compat: single-cell types fall through to
|
|
380
|
-
# `self[
|
|
381
|
-
# Currency / future multi-cell types override `
|
|
380
|
+
# `self[value_columns.first] = field.default_value` (TypedStorage default).
|
|
381
|
+
# Currency / future multi-cell types override `apply_default` to
|
|
382
382
|
# populate multiple columns from a composite default. The dispatch
|
|
383
383
|
# preserves the bypass-Value#value= contract end-to-end.
|
|
384
384
|
def apply_field_default
|
|
385
|
-
field.
|
|
385
|
+
field.apply_default(self)
|
|
386
386
|
end
|
|
387
387
|
|
|
388
388
|
def validate_value
|
|
@@ -540,7 +540,7 @@ module TypedEAV
|
|
|
540
540
|
# code) cell would silently be missed by the dispatch gate, and
|
|
541
541
|
# Phase 04 versioning would never see it (Scout §3 / Discrepancy D3
|
|
542
542
|
# from plan 04-01).
|
|
543
|
-
return unless field.
|
|
543
|
+
return unless field.value_changed?(self)
|
|
544
544
|
|
|
545
545
|
TypedEAV::EventDispatcher.dispatch_value_change(self, :update)
|
|
546
546
|
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# Bulk-read query object. Returns `{ record_id => { field_name => value } }`
|
|
5
|
+
# for an Enumerable of host records — the class-method bulk variant of
|
|
6
|
+
# `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
|
|
7
|
+
# record count or field count.
|
|
8
|
+
#
|
|
9
|
+
# ## Pipeline (one query per unique partition tuple + one bulk value preload)
|
|
10
|
+
#
|
|
11
|
+
# 1. validate_records! — nil -> ArgumentError; single-class invariant
|
|
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)
|
|
16
|
+
# 4. preload_values — single SELECT across ALL records
|
|
17
|
+
# 5. build_result_hash — per-record inner hash; orphan-skip + winning-id
|
|
18
|
+
# precedence mirrored from the instance path.
|
|
19
|
+
#
|
|
20
|
+
# ## Query bound
|
|
21
|
+
#
|
|
22
|
+
# - 1 SELECT typed_eav_values WHERE entity_type=? AND entity_id IN (?)
|
|
23
|
+
# - 1 SELECT typed_eav_fields WHERE id IN (?) (via includes)
|
|
24
|
+
# - 1 SELECT typed_eav_fields per unique partition tuple
|
|
25
|
+
#
|
|
26
|
+
# Total: 2 + (unique partition tuples) queries — independent of record count.
|
|
27
|
+
#
|
|
28
|
+
# ## Single-class invariant
|
|
29
|
+
#
|
|
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)`.
|
|
33
|
+
class BulkRead
|
|
34
|
+
def initialize(host_class:, records:)
|
|
35
|
+
@host_class = host_class
|
|
36
|
+
@records = records
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_hash
|
|
40
|
+
records = coerce_records
|
|
41
|
+
return {} if records.empty?
|
|
42
|
+
|
|
43
|
+
validate_record_classes!(records)
|
|
44
|
+
|
|
45
|
+
tuples_by_record = group_by_tuple(records)
|
|
46
|
+
winning_ids_by_tuple = winning_ids_by_tuple(tuples_by_record.values.uniq)
|
|
47
|
+
values_by_record_id = preload_values(records)
|
|
48
|
+
|
|
49
|
+
build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :host_class
|
|
55
|
+
|
|
56
|
+
def coerce_records
|
|
57
|
+
raise ArgumentError, "typed_eav_hash_for requires an Enumerable of records, got nil" if @records.nil?
|
|
58
|
+
|
|
59
|
+
@records.to_a
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_record_classes!(records)
|
|
63
|
+
return if records.all?(host_class)
|
|
64
|
+
|
|
65
|
+
classes = records.map { |r| r.class.name }.uniq
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"typed_eav_hash_for expects records of class #{host_class.name} (or its subclasses); " \
|
|
68
|
+
"got mixed classes: #{classes.join(", ")}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def group_by_tuple(records)
|
|
72
|
+
# Memo of record -> tuple key so each record only computes its tuple once.
|
|
73
|
+
records.index_with { |r| [r.typed_eav_scope, r.typed_eav_parent_scope] }
|
|
74
|
+
end
|
|
75
|
+
|
|
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
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def preload_values(records)
|
|
84
|
+
rows = TypedEAV::Value
|
|
85
|
+
.includes(:field)
|
|
86
|
+
.where(entity_type: host_class.name, entity_id: records.map(&:id))
|
|
87
|
+
.to_a
|
|
88
|
+
rows.group_by(&:entity_id)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_result(records, tuples_by_record, winning_ids_by_tuple, values_by_record_id)
|
|
92
|
+
records.each_with_object({}) do |record, result|
|
|
93
|
+
tuple_key = tuples_by_record[record]
|
|
94
|
+
winning_ids_by_name = winning_ids_by_tuple.fetch(tuple_key, {})
|
|
95
|
+
rows = values_by_record_id.fetch(record.id, [])
|
|
96
|
+
result[record.id] = inner_hash_for(rows, winning_ids_by_name)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Builds the inner `{ field_name => value }` hash for a single record.
|
|
101
|
+
#
|
|
102
|
+
# Skips orphans (`tv.field` nil — definition deleted via raw SQL or a
|
|
103
|
+
# Phase 02 `:nullify` cascade). When a winning field_id is registered
|
|
104
|
+
# for the name, only its row may surface (scoped-beats-global collision
|
|
105
|
+
# precedence). When no winner is registered (definition deleted while
|
|
106
|
+
# values remain), fall back to first-wins so the hash isn't lossy.
|
|
107
|
+
def inner_hash_for(value_rows, winning_ids_by_name)
|
|
108
|
+
value_rows.each_with_object({}) do |tv, inner|
|
|
109
|
+
next unless tv.field
|
|
110
|
+
|
|
111
|
+
name = tv.field.name
|
|
112
|
+
winning_id = winning_ids_by_name[name]
|
|
113
|
+
next assign_with_precedence(inner, name, tv, winning_id) if winning_id
|
|
114
|
+
|
|
115
|
+
inner[name] = tv.value unless inner.key?(name)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def assign_with_precedence(inner, name, value_row, winning_id)
|
|
120
|
+
effective_id = value_row.field_id || value_row.field&.id
|
|
121
|
+
inner[name] = value_row.value if effective_id == winning_id
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/typed_eav/engine.rb
CHANGED
|
@@ -5,7 +5,7 @@ module TypedEAV
|
|
|
5
5
|
isolate_namespace TypedEAV
|
|
6
6
|
|
|
7
7
|
initializer "typed_eav.autoload" do
|
|
8
|
-
require_relative "
|
|
8
|
+
require_relative "field/typed_storage"
|
|
9
9
|
require_relative "config"
|
|
10
10
|
require_relative "registry"
|
|
11
11
|
# Eager-loaded (not autoloaded) — Phase 04 versioning will register on
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# Class-level query orchestration extended onto host AR models by the
|
|
5
|
+
# `has_typed_eav` macro. Owns the `UNSET_SCOPE` / `ALL_SCOPES` sentinels
|
|
6
|
+
# and the `resolve_scope` chain; delegates the heavy lifting to
|
|
7
|
+
# `FilterQuery` (multi-filter SQL composition) and `BulkRead` (bulk
|
|
8
|
+
# per-record reads). `bulk_set_typed_eav_values` stays as a 3-line wrapper
|
|
9
|
+
# around the existing `BulkWrite` executor.
|
|
10
|
+
module EntityQuery
|
|
11
|
+
# Sentinel for the `scope:` kwarg default. Distinguishes "kwarg not
|
|
12
|
+
# passed -> resolve from ambient" (UNSET_SCOPE) from "explicitly nil ->
|
|
13
|
+
# filter to global-only fields" (preserves prior behavior).
|
|
14
|
+
UNSET_SCOPE = Object.new.freeze
|
|
15
|
+
|
|
16
|
+
# Sentinel returned by `resolve_scope` inside an `unscoped { }` block.
|
|
17
|
+
# Signals the caller to skip the scope filter entirely (return fields
|
|
18
|
+
# across all partitions, not just global).
|
|
19
|
+
ALL_SCOPES = Object.new.freeze
|
|
20
|
+
|
|
21
|
+
# Query by custom field values. Accepts an array of filter hashes
|
|
22
|
+
# or a hash of hashes (from form params).
|
|
23
|
+
#
|
|
24
|
+
# Each filter needs:
|
|
25
|
+
# :name or :n - the field name
|
|
26
|
+
# :op or :operator - the operator (default: :eq)
|
|
27
|
+
# :value or :v - the comparison value
|
|
28
|
+
#
|
|
29
|
+
# Contact.where_typed_eav(
|
|
30
|
+
# { name: "age", op: :gt, value: 21 },
|
|
31
|
+
# { name: "city", value: "Portland" } # op defaults to :eq
|
|
32
|
+
# )
|
|
33
|
+
#
|
|
34
|
+
# `scope:` and `parent_scope:` behavior:
|
|
35
|
+
# - omitted -> resolve from ambient (`with_scope` -> resolver -> raise/nil)
|
|
36
|
+
# - passed a value -> use verbatim (explicit override; admin/test path)
|
|
37
|
+
# - passed nil -> filter to global-only on that axis (prior behavior)
|
|
38
|
+
def where_typed_eav(*filters, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
39
|
+
resolved = resolve_scope(scope, parent_scope)
|
|
40
|
+
effective_scope, effective_parent = scope_pair(resolved)
|
|
41
|
+
|
|
42
|
+
TypedEAV::FilterQuery.new(
|
|
43
|
+
model: self,
|
|
44
|
+
filters: filters,
|
|
45
|
+
scope: effective_scope,
|
|
46
|
+
parent_scope: effective_parent,
|
|
47
|
+
).to_relation
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Shorthand for single-field queries.
|
|
51
|
+
#
|
|
52
|
+
# Contact.with_field("age", :gt, 21)
|
|
53
|
+
# Contact.with_field("active", true) # op defaults to :eq
|
|
54
|
+
# Contact.with_field("name", :contains, "smith")
|
|
55
|
+
#
|
|
56
|
+
# Accepts both `scope:` and `parent_scope:` kwargs with the same
|
|
57
|
+
# ambient/explicit/nil semantics as `where_typed_eav`. Single-scope
|
|
58
|
+
# callers (no `parent_scope:`) are unaffected.
|
|
59
|
+
def with_field(name, operator_or_value = nil, value = nil, scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
60
|
+
filter = if value.nil? && !operator_or_value.is_a?(Symbol)
|
|
61
|
+
# Two-arg form: with_field("name", "value") implies :eq
|
|
62
|
+
{ name: name, op: :eq, value: operator_or_value }
|
|
63
|
+
else
|
|
64
|
+
{ name: name, op: operator_or_value, value: value }
|
|
65
|
+
end
|
|
66
|
+
where_typed_eav(filter, scope: scope, parent_scope: parent_scope)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Returns field definitions for this entity type.
|
|
70
|
+
#
|
|
71
|
+
# `scope:` and `parent_scope:` behavior:
|
|
72
|
+
# - omitted -> resolve from ambient (`with_scope` -> resolver -> raise/nil)
|
|
73
|
+
# - passed a value -> use verbatim (explicit override; admin/test path)
|
|
74
|
+
# - passed nil -> filter to global-only on that axis (prior behavior preserved)
|
|
75
|
+
def typed_eav_definitions(scope: UNSET_SCOPE, parent_scope: UNSET_SCOPE)
|
|
76
|
+
resolved = resolve_scope(scope, parent_scope)
|
|
77
|
+
if resolved.equal?(ALL_SCOPES)
|
|
78
|
+
TypedEAV::Partition.visible_fields(entity_type: name, mode: :all_partitions)
|
|
79
|
+
else
|
|
80
|
+
s, ps = resolved
|
|
81
|
+
TypedEAV::Partition.visible_fields(entity_type: name, scope: s, parent_scope: ps)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Bulk read API. Returns `{ record_id => { field_name => value } }` for
|
|
86
|
+
# an Enumerable of host records — the class-method bulk variant of
|
|
87
|
+
# `HasTypedEAV::InstanceMethods#typed_eav_hash`. N+1-free regardless of
|
|
88
|
+
# record count or field count. See `TypedEAV::BulkRead` for the pipeline
|
|
89
|
+
# and query bound.
|
|
90
|
+
def typed_eav_hash_for(records)
|
|
91
|
+
TypedEAV::BulkRead.new(host_class: self, records: records).to_hash
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Bulk write API. Sets the same `values_by_field_name` Hash on every
|
|
95
|
+
# record in `records` inside ONE outer ActiveRecord transaction with a
|
|
96
|
+
# SAVEPOINT-PER-RECORD failure-isolation envelope. See `TypedEAV::BulkWrite`
|
|
97
|
+
# for the transaction shape, error-aggregation contract, and the
|
|
98
|
+
# `version_grouping:` semantics.
|
|
99
|
+
def bulk_set_typed_eav_values(records, values_by_field_name, version_grouping: :default)
|
|
100
|
+
TypedEAV::BulkWrite.execute(
|
|
101
|
+
host_class: self,
|
|
102
|
+
records: records,
|
|
103
|
+
values_by_field_name: values_by_field_name,
|
|
104
|
+
version_grouping: version_grouping,
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Translates a resolved scope into the `(scope, parent_scope)` pair
|
|
111
|
+
# passed to `FilterQuery`. Preserves the `ALL_SCOPES` sentinel through
|
|
112
|
+
# to `FilterQuery` (it routes to the multimap branch); for resolved
|
|
113
|
+
# tuples, returns the pair verbatim.
|
|
114
|
+
def scope_pair(resolved)
|
|
115
|
+
return [ALL_SCOPES, nil] if resolved.equal?(ALL_SCOPES)
|
|
116
|
+
|
|
117
|
+
resolved
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Resolves the scope and parent_scope kwargs into a concrete tuple for
|
|
121
|
+
# field-definition lookup. Returns one of:
|
|
122
|
+
# - `ALL_SCOPES` — inside `TypedEAV.unscoped { }`, atomic bypass.
|
|
123
|
+
# - `[scope, parent_scope]` — both elements are String or nil.
|
|
124
|
+
# Raises:
|
|
125
|
+
# - `TypedEAV::ScopeRequired` when the model declares `scope_method:`
|
|
126
|
+
# but ambient scope can't be resolved and `require_scope` is true.
|
|
127
|
+
def resolve_scope(explicit_scope, explicit_parent_scope)
|
|
128
|
+
return ALL_SCOPES if TypedEAV.unscoped?
|
|
129
|
+
|
|
130
|
+
if explicit_given?(explicit_scope, explicit_parent_scope)
|
|
131
|
+
return resolve_explicit_scope(explicit_scope, explicit_parent_scope)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
return [nil, nil] unless typed_eav_scope_method
|
|
135
|
+
|
|
136
|
+
resolve_ambient_scope
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def explicit_given?(explicit_scope, explicit_parent_scope)
|
|
140
|
+
!explicit_scope.equal?(UNSET_SCOPE) || !explicit_parent_scope.equal?(UNSET_SCOPE)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Per-slot normalize via `ScopeTuple.normalize_permissive` to coerce
|
|
144
|
+
# scalars/AR-records to strings, with UNSET_SCOPE collapsing to nil for
|
|
145
|
+
# the corresponding slot. Orphan-parent invariant: a request for
|
|
146
|
+
# parent_scope without scope is dead-letter — silently narrow ps to nil.
|
|
147
|
+
def resolve_explicit_scope(explicit_scope, explicit_parent_scope)
|
|
148
|
+
s = normalize_explicit_or_nil(explicit_scope, slot: :scope)
|
|
149
|
+
ps = normalize_explicit_or_nil(explicit_parent_scope, slot: :parent)
|
|
150
|
+
ps = nil unless TypedEAV::ScopeTuple.invariant_satisfied?(s, ps)
|
|
151
|
+
[s, ps]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def normalize_explicit_or_nil(value, slot:)
|
|
155
|
+
return nil if value.equal?(UNSET_SCOPE)
|
|
156
|
+
|
|
157
|
+
normalize_explicit_slot(value, slot: slot)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def normalize_explicit_slot(value, slot:)
|
|
161
|
+
tuple = slot == :scope ? [value, nil] : [nil, value]
|
|
162
|
+
index = slot == :scope ? :first : :last
|
|
163
|
+
TypedEAV::ScopeTuple.normalize_permissive(tuple)&.public_send(index)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Ambient resolver path (via `with_scope` stack or configured lambda).
|
|
167
|
+
# `TypedEAV.current_scope` already validates the return shape to
|
|
168
|
+
# `nil | [a, b]` — no shape check is duplicated here.
|
|
169
|
+
def resolve_ambient_scope
|
|
170
|
+
resolved = TypedEAV.current_scope
|
|
171
|
+
return resolved if resolved
|
|
172
|
+
|
|
173
|
+
raise_scope_required if TypedEAV.config.require_scope
|
|
174
|
+
|
|
175
|
+
[nil, nil]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def raise_scope_required
|
|
179
|
+
raise TypedEAV::ScopeRequired,
|
|
180
|
+
"No ambient scope resolvable for #{name}. " \
|
|
181
|
+
"Wrap the call in `TypedEAV.with_scope(value) { ... }`, " \
|
|
182
|
+
"configure `TypedEAV.config.scope_resolver`, or use " \
|
|
183
|
+
"`TypedEAV.unscoped { ... }` to deliberately bypass."
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
module Field
|
|
5
|
+
# One concern owns the entire native-typed-column storage seam.
|
|
6
|
+
#
|
|
7
|
+
# Field types declare WHICH typed column(s) hold their value via the
|
|
8
|
+
# class-level DSL (`value_column`, `value_columns`, `operators`,
|
|
9
|
+
# `operator_column`), and override three instance methods to compose
|
|
10
|
+
# multi-cell value shapes (`read_value`, `write_value`, `apply_default`).
|
|
11
|
+
# Snapshot/change-detection helpers (`value_changed?`, `before_snapshot`,
|
|
12
|
+
# `after_snapshot`) are concrete and derive from `value_columns`; they
|
|
13
|
+
# are NOT overridable — the snapshot shape is a versioning-coupled
|
|
14
|
+
# invariant.
|
|
15
|
+
#
|
|
16
|
+
# ## Class-level DSL
|
|
17
|
+
#
|
|
18
|
+
# value_column :integer_value # single-cell sugar; primary cell
|
|
19
|
+
# value_columns :decimal_value, :string_value # plural form
|
|
20
|
+
# operators :eq, :gt, :is_null # restrict supported operators
|
|
21
|
+
# operator_column(:currency_eq) # override to route ops to cells
|
|
22
|
+
#
|
|
23
|
+
# Both `value_column` and `value_columns` share the same `@value_columns`
|
|
24
|
+
# class instance variable. `value_column` returns the first element of
|
|
25
|
+
# `value_columns`, preserving the single-cell sugar shape.
|
|
26
|
+
#
|
|
27
|
+
# ## Override-point instance methods (the entire extension surface)
|
|
28
|
+
#
|
|
29
|
+
# - `read_value(record)` — compose the logical value from the cells.
|
|
30
|
+
# - `write_value(record, casted)` — unpack the casted value across cells.
|
|
31
|
+
# - `apply_default(record)` — populate cells from the field's default.
|
|
32
|
+
#
|
|
33
|
+
# The default implementations target `value_columns.first` (single-cell
|
|
34
|
+
# behavior). Multi-cell types override ALL THREE — overriding just one
|
|
35
|
+
# creates an asymmetry where reads see the multi-cell shape but writes
|
|
36
|
+
# / defaults populate only one column (or vice versa).
|
|
37
|
+
#
|
|
38
|
+
# ## Concrete (non-overridable) snapshot helpers
|
|
39
|
+
#
|
|
40
|
+
# - `value_changed?(record)` — true iff ANY value_columns column has a
|
|
41
|
+
# saved_change_to_attribute? — used by the Value :update dispatch gate
|
|
42
|
+
# so multi-cell types fire the event when only the second cell changed.
|
|
43
|
+
# - `before_snapshot(record, change_type)` — per-column hash keyed by
|
|
44
|
+
# string column names. `:create` returns `{}`.
|
|
45
|
+
# - `after_snapshot(record, change_type)` — per-column hash keyed by
|
|
46
|
+
# string column names. `:destroy` returns `{}`.
|
|
47
|
+
#
|
|
48
|
+
# Snapshot keys are stringified so query patterns like
|
|
49
|
+
# `WHERE before_value->>'integer_value' = '42'` work uniformly.
|
|
50
|
+
module TypedStorage
|
|
51
|
+
extend ActiveSupport::Concern
|
|
52
|
+
|
|
53
|
+
DEFAULT_OPERATORS_BY_COLUMN = {
|
|
54
|
+
boolean_value: %i[eq not_eq is_null is_not_null],
|
|
55
|
+
string_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
|
|
56
|
+
text_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
|
|
57
|
+
integer_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
58
|
+
decimal_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
59
|
+
date_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
60
|
+
datetime_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
61
|
+
json_value: %i[contains is_null is_not_null],
|
|
62
|
+
}.freeze
|
|
63
|
+
FALLBACK_OPERATORS = %i[eq not_eq is_null is_not_null].freeze
|
|
64
|
+
|
|
65
|
+
class_methods do
|
|
66
|
+
# Declare the typed column(s) this field type stores its value in.
|
|
67
|
+
#
|
|
68
|
+
# `value_column :col` — single-cell sugar; equivalent to
|
|
69
|
+
# `value_columns :col`. Returns `:col` when called without arguments.
|
|
70
|
+
# Raises NotImplementedError when called without arguments AND no
|
|
71
|
+
# column has been declared (the "subclass must declare" enforcement).
|
|
72
|
+
#
|
|
73
|
+
# Both `value_column` and `value_columns` write to the same
|
|
74
|
+
# `@value_columns` class instance variable on the declaring class
|
|
75
|
+
# (Ruby class ivars are NOT inherited, so each subclass that calls
|
|
76
|
+
# the setter installs its own). `Field::Percentage` re-declares
|
|
77
|
+
# `value_column :decimal_value` to work around this non-inheritance.
|
|
78
|
+
def value_column(column_name = nil)
|
|
79
|
+
if column_name
|
|
80
|
+
@value_columns = [column_name.to_sym]
|
|
81
|
+
else
|
|
82
|
+
cols = value_columns
|
|
83
|
+
cols.first
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Declare the typed columns this field type stores across (multi-cell
|
|
88
|
+
# form). Returns the configured Array when called without arguments.
|
|
89
|
+
# Raises NotImplementedError when no column(s) have been declared
|
|
90
|
+
# on this class — the same enforcement as `value_column`.
|
|
91
|
+
#
|
|
92
|
+
# The primary cell is `value_columns.first`; defaults for
|
|
93
|
+
# `read_value` / `write_value` / `apply_default` target it.
|
|
94
|
+
def value_columns(*cols)
|
|
95
|
+
if cols.any?
|
|
96
|
+
@value_columns = cols.map(&:to_sym)
|
|
97
|
+
else
|
|
98
|
+
@value_columns || raise(NotImplementedError,
|
|
99
|
+
"#{name} must declare `value_column :column_name`")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The physical column this operator acts on. Defaults to
|
|
104
|
+
# `value_columns.first` for single-cell field types. Multi-cell
|
|
105
|
+
# types (Currency: `:currency_eq` → `:string_value`; everything
|
|
106
|
+
# else → `:decimal_value`) override this to route operators to
|
|
107
|
+
# different cells.
|
|
108
|
+
#
|
|
109
|
+
# Called by `QueryBuilder.filter` AFTER the
|
|
110
|
+
# `supported_operators.include?(operator)` validation gate, so
|
|
111
|
+
# `_operator` is always one the field explicitly supports.
|
|
112
|
+
def operator_column(_operator)
|
|
113
|
+
value_columns.first
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Operators this field type supports for querying. Defaults to the
|
|
117
|
+
# column-aware default set. Override via `.operators(*ops)`.
|
|
118
|
+
def supported_operators
|
|
119
|
+
@supported_operators || default_operators_for(value_columns.first)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def operators(*ops)
|
|
123
|
+
@supported_operators = ops.map(&:to_sym)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def default_operators_for(col)
|
|
129
|
+
DEFAULT_OPERATORS_BY_COLUMN.fetch(col, FALLBACK_OPERATORS)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# ── Override-point instance methods (the entire multi-cell surface) ──
|
|
134
|
+
|
|
135
|
+
# Returns the logical value for this field as stored on `value_record`.
|
|
136
|
+
# Default reads the primary cell. Override in multi-cell types to
|
|
137
|
+
# compose a hash (e.g., `Field::Currency` returns
|
|
138
|
+
# `{amount: r[:decimal_value], currency: r[:string_value]}`).
|
|
139
|
+
def read_value(value_record)
|
|
140
|
+
value_record[self.class.value_columns.first]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Writes a casted value to `value_record`. Default writes the primary
|
|
144
|
+
# cell. Override in multi-cell types to unpack the casted value
|
|
145
|
+
# across multiple cells.
|
|
146
|
+
def write_value(value_record, casted)
|
|
147
|
+
value_record[self.class.value_columns.first] = casted
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Writes this field's configured default to `value_record`. Default
|
|
151
|
+
# writes `default_value` to the primary cell, bypassing Value#value=
|
|
152
|
+
# to avoid re-casting an already-cast default. Override in multi-cell
|
|
153
|
+
# types to populate multiple cells from a composite default.
|
|
154
|
+
def apply_default(value_record)
|
|
155
|
+
value_record[self.class.value_columns.first] = default_value
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ── Concrete snapshot helpers (NOT overridable) ──
|
|
159
|
+
|
|
160
|
+
# True iff ANY of the field's value_columns had a saved change in the
|
|
161
|
+
# just-committed save. Used by Value's :update dispatch gate so
|
|
162
|
+
# multi-cell types correctly fire the event when only the second cell
|
|
163
|
+
# changed (regression case Phase 5 D3).
|
|
164
|
+
def value_changed?(value_record)
|
|
165
|
+
self.class.value_columns.any? do |column|
|
|
166
|
+
value_record.saved_change_to_attribute?(column)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Pre-change snapshot keyed by string column names.
|
|
171
|
+
# - :create → {} (no before state)
|
|
172
|
+
# - :update → {col => attribute_before_last_save(col)}
|
|
173
|
+
# - :destroy → {col => value_record[col]} (in-memory on the destroyed
|
|
174
|
+
# AR record per Phase 03 P04 live-validation)
|
|
175
|
+
def before_snapshot(value_record, change_type)
|
|
176
|
+
case change_type.to_sym
|
|
177
|
+
when :create
|
|
178
|
+
{}
|
|
179
|
+
when :update
|
|
180
|
+
self.class.value_columns.to_h do |column|
|
|
181
|
+
[column.to_s, value_record.attribute_before_last_save(column.to_s)]
|
|
182
|
+
end
|
|
183
|
+
when :destroy
|
|
184
|
+
self.class.value_columns.to_h { |column| [column.to_s, value_record[column]] }
|
|
185
|
+
else
|
|
186
|
+
raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Post-change snapshot keyed by string column names.
|
|
191
|
+
# - :create / :update → {col => value_record[col]}
|
|
192
|
+
# - :destroy → {} (no after state)
|
|
193
|
+
def after_snapshot(value_record, change_type)
|
|
194
|
+
case change_type.to_sym
|
|
195
|
+
when :create, :update
|
|
196
|
+
self.class.value_columns.to_h { |column| [column.to_s, value_record[column]] }
|
|
197
|
+
when :destroy
|
|
198
|
+
{}
|
|
199
|
+
else
|
|
200
|
+
raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|