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
data/lib/typed_eav/partition.rb
CHANGED
|
@@ -8,6 +8,14 @@ module TypedEAV
|
|
|
8
8
|
# values. Ambient resolution (`TypedEAV.current_scope`, `with_scope`,
|
|
9
9
|
# `unscoped`) stays with the adapters that know their calling context.
|
|
10
10
|
module Partition
|
|
11
|
+
# Frozen orphan-parent ArgumentError message. Kept as a module constant
|
|
12
|
+
# so both `visible_fields` and `visible_sections` raise the same string
|
|
13
|
+
# without re-allocating per call. The string is the wire-stable BC error
|
|
14
|
+
# message that `partition_spec` matches against; do not change it
|
|
15
|
+
# without coordinating with downstream rescue clauses.
|
|
16
|
+
ORPHAN_PARENT_MESSAGE = "parent_scope cannot be set when scope is blank"
|
|
17
|
+
private_constant :ORPHAN_PARENT_MESSAGE
|
|
18
|
+
|
|
11
19
|
class << self
|
|
12
20
|
# All field definitions visible from a tuple: pure global rows,
|
|
13
21
|
# scope-only rows, and full-tuple rows. Passing mode: :all_partitions is
|
|
@@ -17,7 +25,8 @@ module TypedEAV
|
|
|
17
25
|
validate_mode!(mode)
|
|
18
26
|
return TypedEAV::Field::Base.where(entity_type: entity_type) if mode == :all_partitions
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
raise ArgumentError, ORPHAN_PARENT_MESSAGE unless ScopeTuple.invariant_satisfied?(scope, parent_scope)
|
|
29
|
+
|
|
21
30
|
TypedEAV::Field::Base.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
|
|
22
31
|
end
|
|
23
32
|
|
|
@@ -26,18 +35,56 @@ module TypedEAV
|
|
|
26
35
|
def effective_fields_by_name(entity_type:, scope: nil, parent_scope: nil, mode: :partition)
|
|
27
36
|
fields = visible_fields(entity_type: entity_type, scope: scope, parent_scope: parent_scope, mode: mode)
|
|
28
37
|
if mode == :all_partitions
|
|
29
|
-
|
|
38
|
+
definitions_multimap_by_name(fields)
|
|
30
39
|
else
|
|
31
|
-
|
|
40
|
+
definitions_by_name(fields)
|
|
32
41
|
end
|
|
33
42
|
end
|
|
34
43
|
|
|
44
|
+
# Indexes field definitions by name with deterministic three-way
|
|
45
|
+
# collision resolution: when global (scope=NULL, parent_scope=NULL),
|
|
46
|
+
# scope-only (scope set, parent_scope=NULL), and full-triple (both set)
|
|
47
|
+
# fields share a name, the most-specific row wins.
|
|
48
|
+
#
|
|
49
|
+
# Sort key `[scope.nil? ? 0 : 1, parent_scope.nil? ? 0 : 1]` orders rows:
|
|
50
|
+
# [0, 0] global (least specific) -> comes first
|
|
51
|
+
# [1, 0] scope-only (middle)
|
|
52
|
+
# [1, 1] full triple (most specific) -> comes last
|
|
53
|
+
#
|
|
54
|
+
# `index_by(&:name)` keeps the LAST entry on duplicate keys (Rails
|
|
55
|
+
# convention via `Array#to_h`), so most-specific wins. The two-key sort
|
|
56
|
+
# extends the prior "scoped beats global" rule into "two-key beats
|
|
57
|
+
# one-key beats global" without changing the index_by-last-wins
|
|
58
|
+
# mechanism. The `(scope=NULL, parent_scope=NOT NULL)` slot is
|
|
59
|
+
# unreachable by construction (orphan-parent invariant in Field::Base),
|
|
60
|
+
# so the ordering is exhaustive across the three valid shapes.
|
|
61
|
+
#
|
|
62
|
+
# Shared by the class-query path (FilterQuery / BulkRead / EntityQuery)
|
|
63
|
+
# and the instance path (HasTypedEAV::InstanceMethods#typed_eav_defs_by_name)
|
|
64
|
+
# so the two cannot drift. Lives on Partition because partition-tuple
|
|
65
|
+
# precedence is a partition concept.
|
|
66
|
+
def definitions_by_name(defs)
|
|
67
|
+
defs.to_a
|
|
68
|
+
.sort_by { |d| [d.scope.nil? ? 0 : 1, d.parent_scope.nil? ? 0 : 1] }
|
|
69
|
+
.index_by(&:name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Indexes field definitions by name into a multi-map (one name ->
|
|
73
|
+
# array of fields). Used by the class-query path under
|
|
74
|
+
# `TypedEAV.unscoped { }`, where the same field name may legitimately
|
|
75
|
+
# exist across multiple tenant partitions and we must OR-across all
|
|
76
|
+
# matching field_ids per filter rather than collapse to a single row.
|
|
77
|
+
def definitions_multimap_by_name(defs)
|
|
78
|
+
defs.to_a.group_by(&:name)
|
|
79
|
+
end
|
|
80
|
+
|
|
35
81
|
# All sections visible from the same tuple as field definitions.
|
|
36
82
|
def visible_sections(entity_type:, scope: nil, parent_scope: nil, mode: :partition)
|
|
37
83
|
validate_mode!(mode)
|
|
38
84
|
return TypedEAV::Section.where(entity_type: entity_type) if mode == :all_partitions
|
|
39
85
|
|
|
40
|
-
|
|
86
|
+
raise ArgumentError, ORPHAN_PARENT_MESSAGE unless ScopeTuple.invariant_satisfied?(scope, parent_scope)
|
|
87
|
+
|
|
41
88
|
TypedEAV::Section.for_entity(entity_type, scope: scope, parent_scope: parent_scope)
|
|
42
89
|
end
|
|
43
90
|
|
|
@@ -52,13 +99,6 @@ module TypedEAV
|
|
|
52
99
|
|
|
53
100
|
raise ArgumentError, "Unknown partition mode: #{mode.inspect}. Expected :partition or :all_partitions."
|
|
54
101
|
end
|
|
55
|
-
|
|
56
|
-
def validate_tuple!(scope, parent_scope)
|
|
57
|
-
return if parent_scope.blank?
|
|
58
|
-
return if scope.present?
|
|
59
|
-
|
|
60
|
-
raise ArgumentError, "parent_scope cannot be set when scope is blank"
|
|
61
|
-
end
|
|
62
102
|
end
|
|
63
103
|
end
|
|
64
104
|
end
|
|
@@ -44,13 +44,12 @@ module TypedEAV
|
|
|
44
44
|
"Supported operators: #{supported.map { |o| ":#{o}" }.join(", ")}"
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
#
|
|
48
|
-
# dispatch. Single-cell types
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
col = field.storage_contract.query_column(operator)
|
|
47
|
+
# Route the operator to its physical column via the field-class
|
|
48
|
+
# dispatch. Single-cell types return `value_columns.first` for every
|
|
49
|
+
# operator — BC-safe. Multi-cell types (Currency) route operators
|
|
50
|
+
# like `:eq` (amount) and `:currency_eq` (currency code) to
|
|
51
|
+
# different columns. See `Field::TypedStorage.operator_column`.
|
|
52
|
+
col = field.class.operator_column(operator)
|
|
54
53
|
arel_col = values_table[col]
|
|
55
54
|
|
|
56
55
|
base = value_scope(field)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TypedEAV
|
|
4
|
+
# Localized semantics for the `(scope, parent_scope)` tuple.
|
|
5
|
+
#
|
|
6
|
+
# Five files used to smear tuple shape/coercion/invariant logic across
|
|
7
|
+
# themselves (`lib/typed_eav.rb`, `Config`, `Partition`,
|
|
8
|
+
# `HasTypedEav#resolve_scope`, `Field::Base#for_entity`). This module
|
|
9
|
+
# gathers them in one place:
|
|
10
|
+
#
|
|
11
|
+
# - `normalize_permissive(value)` — the v0.1.x-BC scalar-friendly coercer
|
|
12
|
+
# used by the `with_scope` block surface and by callers that already
|
|
13
|
+
# know they may receive a loose value (single scalar, AR record, or
|
|
14
|
+
# 2-element Array). Mirrors what `TypedEAV.normalize_scope` did before
|
|
15
|
+
# this refactor; the public method is now a 1-line BC alias to this.
|
|
16
|
+
#
|
|
17
|
+
# - `normalize_strict(value)` — the contract-surface coercer used by
|
|
18
|
+
# `TypedEAV.current_scope` when consuming a configured
|
|
19
|
+
# `Config.scope_resolver` return. Bare scalars / 1-element / 3-element
|
|
20
|
+
# Arrays raise `ArgumentError` — this is the Phase-1 strict-contract
|
|
21
|
+
# chokepoint that makes a misshaped resolver fail loudly instead of
|
|
22
|
+
# silently coercing.
|
|
23
|
+
#
|
|
24
|
+
# - `invariant_satisfied?(scope, parent_scope)` — Boolean orphan-parent
|
|
25
|
+
# check. Returns `false` only when `parent_scope` is present and
|
|
26
|
+
# `scope` is blank (the dead-letter shape: a parent-scope predicate
|
|
27
|
+
# with no scope predicate). Each caller picks its own response policy
|
|
28
|
+
# (raise / AR error / silent narrow) on a `false` result; this helper
|
|
29
|
+
# never raises.
|
|
30
|
+
#
|
|
31
|
+
# The split between permissive and strict normalization is the Phase-1
|
|
32
|
+
# asymmetric contract preserved verbatim: `with_scope` block input is
|
|
33
|
+
# BC-permissive (scalars are sugar for `[scalar, nil]`), `scope_resolver`
|
|
34
|
+
# callable return is strict (scalars are a contract violation).
|
|
35
|
+
module ScopeTuple
|
|
36
|
+
class << self
|
|
37
|
+
# BC-permissive normalizer for `with_scope` block input and explicit
|
|
38
|
+
# tuple inputs. Always returns either `nil` or a 2-element tuple
|
|
39
|
+
# `[scope, parent_scope]` where each element is a `String` or `nil`.
|
|
40
|
+
#
|
|
41
|
+
# Accepted inputs:
|
|
42
|
+
#
|
|
43
|
+
# - `nil` → `nil` (sentinel: nothing resolved).
|
|
44
|
+
# - `[a, b]` (2-element Array) → `[normalize_one(a), normalize_one(b)]`.
|
|
45
|
+
# Canonical Phase-1 input shape. `[scope, nil]` is the "scope-only"
|
|
46
|
+
# tuple; `[nil, "ps1"]` (orphan-parent) is intentionally accepted at
|
|
47
|
+
# this layer — orphan-parent rejection happens at the calling site
|
|
48
|
+
# (Field validator → AR error, Partition query → ArgumentError,
|
|
49
|
+
# resolve_scope → silent narrow). Keeping normalize permissive lets
|
|
50
|
+
# tests construct invalid states intentionally.
|
|
51
|
+
# - any other value (scalar / AR record) → `[normalize_one(value), nil]`.
|
|
52
|
+
# BC path for `with_scope(scalar)`: single-arg block usage continues
|
|
53
|
+
# to mean "scope=scalar, parent_scope=nil".
|
|
54
|
+
def normalize_permissive(value)
|
|
55
|
+
return nil if value.nil?
|
|
56
|
+
return [normalize_one(value[0]), normalize_one(value[1])] if value.is_a?(Array) && value.size == 2
|
|
57
|
+
|
|
58
|
+
[normalize_one(value), nil]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Strict-contract normalizer for `Config.scope_resolver` return values.
|
|
62
|
+
# Accepts ONLY `nil` or a 2-element Array. Bare scalars, 1-element
|
|
63
|
+
# Arrays, and 3-element Arrays raise `ArgumentError` quoting the bad
|
|
64
|
+
# input and pointing at the migration note — this is the chokepoint
|
|
65
|
+
# that makes a v0.1.x bare-scalar resolver fail loudly under Phase 1.
|
|
66
|
+
def normalize_strict(value)
|
|
67
|
+
return nil if value.nil?
|
|
68
|
+
|
|
69
|
+
unless value.is_a?(Array) && value.size == 2
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"TypedEAV.config.scope_resolver must return a 2-element " \
|
|
72
|
+
"[scope, parent_scope] Array (or nil). Got: #{value.inspect}. " \
|
|
73
|
+
"v0.1.x resolvers returning a bare scalar must be updated — " \
|
|
74
|
+
"see CHANGELOG and the README migration note."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
[normalize_one(value[0]), normalize_one(value[1])]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Orphan-parent invariant predicate. Returns `true` when the tuple is
|
|
81
|
+
# internally coherent, `false` ONLY when `parent_scope` is present
|
|
82
|
+
# while `scope` is blank — the dead-letter shape that cannot match
|
|
83
|
+
# any row under the partition predicates.
|
|
84
|
+
#
|
|
85
|
+
# Truth table:
|
|
86
|
+
# (nil, nil) → true
|
|
87
|
+
# ("t1", nil) → true
|
|
88
|
+
# ("t1", "w1") → true
|
|
89
|
+
# (nil, "w1") → false
|
|
90
|
+
# ("", "w1") → false (empty string treated as blank on scope axis)
|
|
91
|
+
# (nil, "") → true (empty string treated as blank on parent axis)
|
|
92
|
+
#
|
|
93
|
+
# Callers each pick their own response policy on a false result:
|
|
94
|
+
# `Partition.visible_fields` raises `ArgumentError`; `Field`'s model
|
|
95
|
+
# validator adds an AR error; `HasTypedEAV#resolve_scope` silently
|
|
96
|
+
# narrows the query. Returning a Boolean (not raising) keeps the
|
|
97
|
+
# per-caller decision local.
|
|
98
|
+
def invariant_satisfied?(scope, parent_scope)
|
|
99
|
+
return true if parent_scope.blank?
|
|
100
|
+
|
|
101
|
+
scope.present?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Coerce a single scope slot (scalar or AR record) into a String or nil.
|
|
107
|
+
# The pre-refactor v0.1.x scalar-coercion preserved verbatim — applied
|
|
108
|
+
# per-slot now that scope is a tuple.
|
|
109
|
+
def normalize_one(value)
|
|
110
|
+
return nil if value.nil?
|
|
111
|
+
|
|
112
|
+
value.respond_to?(:id) ? value.id.to_s : value.to_s
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/typed_eav/version.rb
CHANGED
|
@@ -77,12 +77,13 @@ module TypedEAV
|
|
|
77
77
|
private
|
|
78
78
|
|
|
79
79
|
def write_version_row(value, change_type, context)
|
|
80
|
-
# Build before_value / after_value snapshots
|
|
81
|
-
#
|
|
82
|
-
# multi-cell types like Currency produce one
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
# Build before_value / after_value snapshots directly off the
|
|
81
|
+
# field's `Field::TypedStorage` helpers. Single-cell types produce
|
|
82
|
+
# one-key snapshots; multi-cell types like Currency produce one
|
|
83
|
+
# key per typed cell.
|
|
84
|
+
field = value.field
|
|
85
|
+
before_value = field.before_snapshot(value, change_type)
|
|
86
|
+
after_value = field.after_snapshot(value, change_type)
|
|
86
87
|
|
|
87
88
|
# CRITICAL: for :destroy events, write `value_id: nil`.
|
|
88
89
|
# By the time `after_commit on: :destroy` fires, the parent row
|
data/lib/typed_eav.rb
CHANGED
|
@@ -14,14 +14,16 @@ module TypedEAV
|
|
|
14
14
|
autoload :Config
|
|
15
15
|
autoload :Registry
|
|
16
16
|
autoload :HasTypedEAV
|
|
17
|
+
autoload :EntityQuery
|
|
18
|
+
autoload :FilterQuery
|
|
19
|
+
autoload :BulkRead
|
|
17
20
|
autoload :BulkWrite
|
|
18
21
|
autoload :Partition
|
|
19
22
|
autoload :QueryBuilder
|
|
20
23
|
autoload :SchemaPortability
|
|
21
24
|
autoload :EventDispatcher
|
|
22
|
-
autoload :FieldStorageContract
|
|
23
|
-
autoload :CurrencyStorageContract
|
|
24
25
|
autoload :CSVMapper
|
|
26
|
+
autoload :ScopeTuple
|
|
25
27
|
autoload :ValueVersion
|
|
26
28
|
autoload :Versioned
|
|
27
29
|
autoload :Versioning
|
|
@@ -92,31 +94,16 @@ module TypedEAV
|
|
|
92
94
|
|
|
93
95
|
stack = Thread.current[THREAD_SCOPE_STACK]
|
|
94
96
|
# The stack stores tuples already (with_scope normalized on push), so
|
|
95
|
-
# reads bypass
|
|
97
|
+
# reads bypass coercion entirely — no risk of double-normalization.
|
|
96
98
|
return stack.last if stack.present?
|
|
97
99
|
|
|
98
|
-
# Resolver-callable strict-contract path. We
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
unless raw.is_a?(Array) && raw.size == 2
|
|
107
|
-
raise ArgumentError,
|
|
108
|
-
"TypedEAV.config.scope_resolver must return a 2-element " \
|
|
109
|
-
"[scope, parent_scope] Array (or nil). Got: #{raw.inspect}. " \
|
|
110
|
-
"v0.1.x resolvers returning a bare scalar must be updated — " \
|
|
111
|
-
"see CHANGELOG and the README migration note."
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Tuple shape verified — normalize each slot through the same scalar
|
|
115
|
-
# coercion that `normalize_scope` uses on the with_scope path. We pass
|
|
116
|
-
# the verified 2-element Array through normalize_scope (which is one
|
|
117
|
-
# of its accepted input shapes) to produce the canonical
|
|
118
|
-
# `[String|nil, String|nil]` tuple.
|
|
119
|
-
normalize_scope(raw)
|
|
100
|
+
# Resolver-callable strict-contract path. We route through
|
|
101
|
+
# `ScopeTuple.normalize_strict`, which raises `ArgumentError` on any
|
|
102
|
+
# shape other than `nil` or a 2-element Array — most importantly on
|
|
103
|
+
# the v0.1.x bare-scalar shape. The strict raise is the chokepoint
|
|
104
|
+
# that makes a misshaped resolver fail loudly; the BC-shim
|
|
105
|
+
# (silent coercion) alternative was rejected during Phase 1 design.
|
|
106
|
+
ScopeTuple.normalize_strict(Config.scope_resolver&.call)
|
|
120
107
|
end
|
|
121
108
|
|
|
122
109
|
# Run the block with `value` as the ambient scope, restoring the prior
|
|
@@ -143,7 +130,7 @@ module TypedEAV
|
|
|
143
130
|
# the resolver-callable contract rejects bare scalars.
|
|
144
131
|
def with_scope(value)
|
|
145
132
|
stack = (Thread.current[THREAD_SCOPE_STACK] ||= [])
|
|
146
|
-
stack.push(
|
|
133
|
+
stack.push(ScopeTuple.normalize_permissive(value))
|
|
147
134
|
yield
|
|
148
135
|
ensure
|
|
149
136
|
stack&.pop
|
|
@@ -207,48 +194,20 @@ module TypedEAV
|
|
|
207
194
|
end
|
|
208
195
|
|
|
209
196
|
# BC-permissive normalizer for `with_scope` block input and explicit
|
|
210
|
-
# tuple inputs.
|
|
197
|
+
# tuple inputs. 1-line alias to `ScopeTuple.normalize_permissive` —
|
|
198
|
+
# the implementation moved during the 0.3.0 ScopeTuple extraction
|
|
199
|
+
# (issue #10), but the public method/return-shape stays as a v0.2.x BC
|
|
200
|
+
# surface. Always returns either `nil` or a 2-element tuple
|
|
211
201
|
# `[scope, parent_scope]` where each element is a `String` or `nil`.
|
|
212
202
|
#
|
|
213
|
-
# Accepted inputs:
|
|
214
|
-
#
|
|
215
|
-
# - `nil` → `nil` (sentinel: nothing resolved).
|
|
216
|
-
# - `[a, b]` (2-element Array) → `[normalize_one(a), normalize_one(b)]`.
|
|
217
|
-
# This is the canonical Phase-1 input shape; callers that already have
|
|
218
|
-
# a tuple (a custom resolver, a future `with_scope([s, ps])`) pass it
|
|
219
|
-
# through unchanged. `[scope, nil]` is the canonical "scope-only" tuple.
|
|
220
|
-
# `[nil, "ps1"]` (orphan-parent) is intentionally accepted at this
|
|
221
|
-
# layer — orphan-parent rejection happens in the model validator
|
|
222
|
-
# added by plans 03/04, NOT here. Keeping normalize permissive lets
|
|
223
|
-
# tests construct invalid states intentionally.
|
|
224
|
-
# - any other value (scalar / AR record) → `[normalize_one(value), nil]`.
|
|
225
|
-
# This is the BC path for `with_scope(scalar)` — single-arg block usage
|
|
226
|
-
# continues to mean "scope=scalar, parent_scope=nil".
|
|
227
|
-
#
|
|
228
203
|
# ## NOT a contract chokepoint for resolver returns
|
|
229
204
|
#
|
|
230
205
|
# `current_scope` deliberately does NOT route a custom-resolver return
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
def normalize_scope(value)
|
|
237
|
-
return nil if value.nil?
|
|
238
|
-
return [normalize_one(value[0]), normalize_one(value[1])] if value.is_a?(Array) && value.size == 2
|
|
239
|
-
|
|
240
|
-
[normalize_one(value), nil]
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
private
|
|
244
|
-
|
|
245
|
-
# Coerce a single scope slot (scalar or AR record) into a String or nil.
|
|
246
|
-
# The previous v0.1.x scalar-coercion lives here unchanged — we just
|
|
247
|
-
# apply it per-slot now that scope is a tuple.
|
|
248
|
-
def normalize_one(value)
|
|
249
|
-
return nil if value.nil?
|
|
250
|
-
|
|
251
|
-
value.respond_to?(:id) ? value.id.to_s : value.to_s
|
|
252
|
-
end
|
|
206
|
+
# through this helper — it routes through `ScopeTuple.normalize_strict`
|
|
207
|
+
# instead — because the bare-scalar passthrough here would silently
|
|
208
|
+
# coerce a contract violation. This split (strict on the resolver-
|
|
209
|
+
# callable surface, permissive on the with_scope block surface) is the
|
|
210
|
+
# Phase-1 asymmetric contract.
|
|
211
|
+
def normalize_scope(value) = ScopeTuple.normalize_permissive(value)
|
|
253
212
|
end
|
|
254
213
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typed_eav
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dchuk
|
|
@@ -68,12 +68,15 @@ files:
|
|
|
68
68
|
- app/models/typed_eav/field/json.rb
|
|
69
69
|
- app/models/typed_eav/field/long_text.rb
|
|
70
70
|
- app/models/typed_eav/field/multi_select.rb
|
|
71
|
+
- app/models/typed_eav/field/optionable.rb
|
|
71
72
|
- app/models/typed_eav/field/percentage.rb
|
|
73
|
+
- app/models/typed_eav/field/range_bounded.rb
|
|
72
74
|
- app/models/typed_eav/field/reference.rb
|
|
73
75
|
- app/models/typed_eav/field/select.rb
|
|
74
76
|
- app/models/typed_eav/field/text.rb
|
|
75
77
|
- app/models/typed_eav/field/text_array.rb
|
|
76
78
|
- app/models/typed_eav/field/url.rb
|
|
79
|
+
- app/models/typed_eav/field/validated_string.rb
|
|
77
80
|
- app/models/typed_eav/option.rb
|
|
78
81
|
- app/models/typed_eav/section.rb
|
|
79
82
|
- app/models/typed_eav/value.rb
|
|
@@ -133,19 +136,22 @@ files:
|
|
|
133
136
|
- lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_text_array.html.erb
|
|
134
137
|
- lib/generators/typed_eav/scaffold/templates/views/typed_eav/values/inputs/_url.html.erb
|
|
135
138
|
- lib/typed_eav.rb
|
|
139
|
+
- lib/typed_eav/bulk_read.rb
|
|
136
140
|
- lib/typed_eav/bulk_write.rb
|
|
137
|
-
- lib/typed_eav/column_mapping.rb
|
|
138
141
|
- lib/typed_eav/config.rb
|
|
139
142
|
- lib/typed_eav/csv_mapper.rb
|
|
140
|
-
- lib/typed_eav/currency_storage_contract.rb
|
|
141
143
|
- lib/typed_eav/engine.rb
|
|
144
|
+
- lib/typed_eav/entity_query.rb
|
|
142
145
|
- lib/typed_eav/event_dispatcher.rb
|
|
143
|
-
- lib/typed_eav/
|
|
146
|
+
- lib/typed_eav/field/typed_storage.rb
|
|
147
|
+
- lib/typed_eav/filter_query.rb
|
|
144
148
|
- lib/typed_eav/has_typed_eav.rb
|
|
149
|
+
- lib/typed_eav/has_typed_eav/instance_methods.rb
|
|
145
150
|
- lib/typed_eav/partition.rb
|
|
146
151
|
- lib/typed_eav/query_builder.rb
|
|
147
152
|
- lib/typed_eav/registry.rb
|
|
148
153
|
- lib/typed_eav/schema_portability.rb
|
|
154
|
+
- lib/typed_eav/scope_tuple.rb
|
|
149
155
|
- lib/typed_eav/version.rb
|
|
150
156
|
- lib/typed_eav/versioned.rb
|
|
151
157
|
- lib/typed_eav/versioning.rb
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module TypedEAV
|
|
4
|
-
# Maps field types to their native database column on the values table.
|
|
5
|
-
#
|
|
6
|
-
# This is the core concept borrowed from Relaticle's hybrid EAV:
|
|
7
|
-
# instead of serializing everything into a jsonb blob, each value
|
|
8
|
-
# type gets its own column so the database can natively index,
|
|
9
|
-
# sort, and enforce constraints.
|
|
10
|
-
#
|
|
11
|
-
# Usage in field type classes:
|
|
12
|
-
#
|
|
13
|
-
# class TypedEAV::Field::Integer < TypedEAV::Field::Base
|
|
14
|
-
# value_column :integer_value
|
|
15
|
-
# end
|
|
16
|
-
#
|
|
17
|
-
# The value model reads this to know which column to read/write.
|
|
18
|
-
# The query builder reads this to know which column to filter on.
|
|
19
|
-
# ActiveRecord handles all type casting automatically via the
|
|
20
|
-
# column's registered ActiveRecord::Type.
|
|
21
|
-
module ColumnMapping
|
|
22
|
-
extend ActiveSupport::Concern
|
|
23
|
-
|
|
24
|
-
DEFAULT_OPERATORS_BY_COLUMN = {
|
|
25
|
-
boolean_value: %i[eq not_eq is_null is_not_null],
|
|
26
|
-
string_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
|
|
27
|
-
text_value: %i[eq not_eq contains not_contains starts_with ends_with is_null is_not_null],
|
|
28
|
-
integer_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
29
|
-
decimal_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
30
|
-
date_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
31
|
-
datetime_value: %i[eq not_eq gt gteq lt lteq between is_null is_not_null],
|
|
32
|
-
json_value: %i[contains is_null is_not_null],
|
|
33
|
-
}.freeze
|
|
34
|
-
FALLBACK_OPERATORS = %i[eq not_eq is_null is_not_null].freeze
|
|
35
|
-
|
|
36
|
-
class_methods do
|
|
37
|
-
# Declare which typed column this field type stores its value in.
|
|
38
|
-
def value_column(column_name = nil)
|
|
39
|
-
unless column_name
|
|
40
|
-
return @value_column || raise(NotImplementedError,
|
|
41
|
-
"#{name} must declare `value_column :column_name`")
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
@value_column = column_name.to_sym
|
|
45
|
-
end
|
|
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
|
-
|
|
93
|
-
# All operators this field type supports for querying.
|
|
94
|
-
# Subclasses can override to restrict or extend.
|
|
95
|
-
def supported_operators
|
|
96
|
-
@supported_operators || default_operators_for(value_column)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def operators(*ops)
|
|
100
|
-
@supported_operators = ops.map(&:to_sym)
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
private
|
|
104
|
-
|
|
105
|
-
def default_operators_for(col)
|
|
106
|
-
DEFAULT_OPERATORS_BY_COLUMN.fetch(col, FALLBACK_OPERATORS)
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module TypedEAV
|
|
4
|
-
# Storage contract for Field::Currency's two-cell value shape.
|
|
5
|
-
class CurrencyStorageContract < FieldStorageContract
|
|
6
|
-
VALUE_COLUMNS = %i[decimal_value string_value].freeze
|
|
7
|
-
AMOUNT_COLUMN = :decimal_value
|
|
8
|
-
CURRENCY_COLUMN = :string_value
|
|
9
|
-
|
|
10
|
-
def self.value_columns
|
|
11
|
-
VALUE_COLUMNS
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def self.query_column(operator)
|
|
15
|
-
operator == :currency_eq ? CURRENCY_COLUMN : AMOUNT_COLUMN
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
delegate :value_columns, :query_column, to: :class
|
|
19
|
-
|
|
20
|
-
def read(value_record)
|
|
21
|
-
amount = value_record[AMOUNT_COLUMN]
|
|
22
|
-
currency = value_record[CURRENCY_COLUMN]
|
|
23
|
-
return nil if amount.nil? && currency.nil?
|
|
24
|
-
|
|
25
|
-
{ amount: amount, currency: currency }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def write(value_record, casted)
|
|
29
|
-
if casted.nil?
|
|
30
|
-
value_record[AMOUNT_COLUMN] = nil
|
|
31
|
-
value_record[CURRENCY_COLUMN] = nil
|
|
32
|
-
else
|
|
33
|
-
value_record[AMOUNT_COLUMN] = casted[:amount]
|
|
34
|
-
value_record[CURRENCY_COLUMN] = casted[:currency]
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def apply_default(value_record)
|
|
39
|
-
default = field.default_value
|
|
40
|
-
return unless default.is_a?(Hash)
|
|
41
|
-
|
|
42
|
-
value_record[AMOUNT_COLUMN] = default[:amount] || default["amount"]
|
|
43
|
-
value_record[CURRENCY_COLUMN] = default[:currency] || default["currency"]
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module TypedEAV
|
|
4
|
-
# One field-owned seam for native typed-column storage behavior.
|
|
5
|
-
#
|
|
6
|
-
# The contract intentionally delegates to the existing field type hooks so
|
|
7
|
-
# custom field authors keep the same public extension points while callers
|
|
8
|
-
# stop knowing which pieces belong together.
|
|
9
|
-
class FieldStorageContract
|
|
10
|
-
def initialize(field)
|
|
11
|
-
@field = field
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def value_columns
|
|
15
|
-
field.class.value_columns
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def query_column(operator)
|
|
19
|
-
field.class.operator_column(operator)
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def read(value_record)
|
|
23
|
-
field.read_value(value_record)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def write(value_record, casted)
|
|
27
|
-
field.write_value(value_record, casted)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def apply_default(value_record)
|
|
31
|
-
field.apply_default_to(value_record)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def changed?(value_record)
|
|
35
|
-
value_columns.any? { |column| value_record.saved_change_to_attribute?(column) }
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def before_snapshot(value_record, change_type)
|
|
39
|
-
case change_type.to_sym
|
|
40
|
-
when :create
|
|
41
|
-
{}
|
|
42
|
-
when :update
|
|
43
|
-
value_columns.to_h do |column|
|
|
44
|
-
[column.to_s, value_record.attribute_before_last_save(column.to_s)]
|
|
45
|
-
end
|
|
46
|
-
when :destroy
|
|
47
|
-
value_columns.to_h { |column| [column.to_s, value_record[column]] }
|
|
48
|
-
else
|
|
49
|
-
raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def after_snapshot(value_record, change_type)
|
|
54
|
-
case change_type.to_sym
|
|
55
|
-
when :create, :update
|
|
56
|
-
value_columns.to_h { |column| [column.to_s, value_record[column]] }
|
|
57
|
-
when :destroy
|
|
58
|
-
{}
|
|
59
|
-
else
|
|
60
|
-
raise ArgumentError, "Unsupported change_type: #{change_type.inspect}"
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
attr_reader :field
|
|
67
|
-
end
|
|
68
|
-
end
|