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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +110 -0
  3. data/README.md +165 -47
  4. data/app/models/typed_eav/field/base.rb +28 -159
  5. data/app/models/typed_eav/field/currency.rb +54 -20
  6. data/app/models/typed_eav/field/date.rb +16 -1
  7. data/app/models/typed_eav/field/date_time.rb +16 -1
  8. data/app/models/typed_eav/field/decimal.rb +9 -1
  9. data/app/models/typed_eav/field/email.rb +13 -16
  10. data/app/models/typed_eav/field/integer.rb +6 -1
  11. data/app/models/typed_eav/field/long_text.rb +17 -1
  12. data/app/models/typed_eav/field/multi_select.rb +12 -9
  13. data/app/models/typed_eav/field/optionable.rb +59 -0
  14. data/app/models/typed_eav/field/percentage.rb +6 -6
  15. data/app/models/typed_eav/field/range_bounded.rb +71 -0
  16. data/app/models/typed_eav/field/select.rb +9 -10
  17. data/app/models/typed_eav/field/text.rb +11 -29
  18. data/app/models/typed_eav/field/url.rb +14 -16
  19. data/app/models/typed_eav/field/validated_string.rb +87 -0
  20. data/app/models/typed_eav/value.rb +9 -9
  21. data/lib/typed_eav/bulk_read.rb +124 -0
  22. data/lib/typed_eav/engine.rb +1 -1
  23. data/lib/typed_eav/entity_query.rb +186 -0
  24. data/lib/typed_eav/field/typed_storage.rb +205 -0
  25. data/lib/typed_eav/filter_query.rb +148 -0
  26. data/lib/typed_eav/has_typed_eav/instance_methods.rb +253 -0
  27. data/lib/typed_eav/has_typed_eav.rb +29 -793
  28. data/lib/typed_eav/partition.rb +51 -11
  29. data/lib/typed_eav/query_builder.rb +6 -7
  30. data/lib/typed_eav/scope_tuple.rb +116 -0
  31. data/lib/typed_eav/version.rb +1 -1
  32. data/lib/typed_eav/versioning/subscriber.rb +7 -6
  33. data/lib/typed_eav.rb +23 -64
  34. metadata +10 -4
  35. data/lib/typed_eav/column_mapping.rb +0 -110
  36. data/lib/typed_eav/currency_storage_contract.rb +0 -46
  37. data/lib/typed_eav/field_storage_contract.rb +0 -68
@@ -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
- validate_tuple!(scope, parent_scope)
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
- TypedEAV::HasTypedEAV.definitions_multimap_by_name(fields)
38
+ definitions_multimap_by_name(fields)
30
39
  else
31
- TypedEAV::HasTypedEAV.definitions_by_name(fields)
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
- validate_tuple!(scope, parent_scope)
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
- # Phase 05: route the operator to its physical column via field-side
48
- # dispatch. Single-cell types (every built-in as of Phase 04) return
49
- # `value_column` for every operator — BC-safe. Multi-cell types
50
- # (Phase 05 Currency) route operators like `:eq` (amount) and
51
- # `:currency_eq` (currency code) to different columns. See
52
- # ColumnMapping#operator_column.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TypedEAV
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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 through the field
81
- # storage contract. Single-cell types produce one-key snapshots;
82
- # multi-cell types like Currency produce one key per typed cell.
83
- storage = value.field.storage_contract
84
- before_value = storage.before_snapshot(value, change_type)
85
- after_value = storage.after_snapshot(value, change_type)
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 normalize_scope entirely — no risk of double-coercion.
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 deliberately do NOT pass
99
- # the raw return value through `normalize_scope`, because that helper
100
- # is permissive (`scalar` `[scalar, nil]`) for `with_scope` block BC.
101
- # Routing the resolver through it would silently swallow a contract
102
- # violation by a custom resolver returning a bare scalar.
103
- raw = Config.scope_resolver&.call
104
- return nil if raw.nil?
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(normalize_scope(value))
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. Always returns either `nil` or a 2-element tuple
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
- # value through this helper, because the bare-scalar passthrough above
232
- # would silently coerce a contract violation. Resolver shape is checked
233
- # in `current_scope` BEFORE this helper is called. This split strict
234
- # on the resolver-callable surface, permissive on the with_scope block
235
- # surface — is the Phase 1 design.
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.2.1
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/field_storage_contract.rb
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