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
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ # Multi-filter query orchestrator. Given a model + a list of filter hashes
5
+ # + an already-resolved scope tuple (or `EntityQuery::ALL_SCOPES`), composes
6
+ # an `ActiveRecord::Relation` by fanning out across filters and dispatching
7
+ # each per-filter predicate to `TypedEAV::QueryBuilder`.
8
+ #
9
+ # FilterQuery.new(
10
+ # model: Contact,
11
+ # filters: [{ name: "age", op: :gt, value: 21 }],
12
+ # scope: "t1",
13
+ # parent_scope: nil,
14
+ # ).to_relation
15
+ #
16
+ # ## Two altitudes (ADR-0002)
17
+ #
18
+ # `QueryBuilder` stays the per-field SQL primitive (knows nothing about
19
+ # multi-filter composition or scope collision). `FilterQuery` is the
20
+ # orchestrator that knows about input shape, partition lookup, collision
21
+ # precedence, and per-filter chaining.
22
+ #
23
+ # ## Scope shape
24
+ #
25
+ # `scope:` is either `EntityQuery::ALL_SCOPES` (atomic-bypass under
26
+ # `TypedEAV.unscoped { }` — the multimap branch) or a `String | nil`
27
+ # already-resolved scope value. `parent_scope:` is `String | nil`. Scope
28
+ # resolution and sentinel handling live in `EntityQuery#resolve_scope`;
29
+ # this class works on resolved tuples only.
30
+ class FilterQuery
31
+ FILTER_KEYS = %w[name n op operator value v].freeze
32
+ private_constant :FILTER_KEYS
33
+
34
+ def initialize(model:, filters:, scope:, parent_scope:)
35
+ @model = model
36
+ @raw_filters = filters
37
+ @scope = scope
38
+ @parent_scope = parent_scope
39
+ end
40
+
41
+ def to_relation
42
+ filters = normalize_filters(@raw_filters)
43
+ defs = lookup_definitions
44
+
45
+ if all_scopes?
46
+ apply_multimap_filters(filters, TypedEAV::Partition.definitions_multimap_by_name(defs))
47
+ else
48
+ apply_single_scope_filters(filters, TypedEAV::Partition.definitions_by_name(defs))
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :model
55
+
56
+ # ── Input normalization ─────────────────────────────────────────────
57
+
58
+ # Accepts splat args, a single Array, a single filter Hash, a hash-of-
59
+ # hashes (form params), or `ActionController::Parameters`. Returns an
60
+ # Array of plain Hashes (each a filter spec with :name/:n, :op/:operator,
61
+ # :value/:v keys).
62
+ def normalize_filters(filters)
63
+ flattened = filters.map { |f| coerce_to_h(f) }
64
+ flattened = expand_single_argument(flattened) if flattened.size == 1
65
+ Array(flattened)
66
+ end
67
+
68
+ def coerce_to_h(filter)
69
+ filter.respond_to?(:to_unsafe_h) ? filter.to_unsafe_h : filter
70
+ end
71
+
72
+ def expand_single_argument(filters)
73
+ inner = coerce_to_h(filters.first)
74
+ return inner if inner.is_a?(Array)
75
+ return [inner] unless inner.is_a?(Hash)
76
+ return [inner] if filter_hash?(inner)
77
+
78
+ inner.values
79
+ end
80
+
81
+ def filter_hash?(hash)
82
+ hash.keys.any? { |k| FILTER_KEYS.include?(k.to_s) }
83
+ end
84
+
85
+ # ── Partition lookup ───────────────────────────────────────────────
86
+
87
+ def all_scopes?
88
+ @scope.equal?(TypedEAV::EntityQuery::ALL_SCOPES)
89
+ end
90
+
91
+ def lookup_definitions
92
+ if all_scopes?
93
+ TypedEAV::Partition.visible_fields(entity_type: model.name, mode: :all_partitions)
94
+ else
95
+ TypedEAV::Partition.visible_fields(
96
+ entity_type: model.name,
97
+ scope: @scope,
98
+ parent_scope: @parent_scope,
99
+ )
100
+ end
101
+ end
102
+
103
+ # ── Filter dispatch ────────────────────────────────────────────────
104
+
105
+ def apply_single_scope_filters(filters, fields_by_name)
106
+ filters.inject(model.all) do |query, filter|
107
+ spec = parse_filter(filter)
108
+ field = fields_by_name[spec[:name]] || raise_unknown_field(spec[:name], fields_by_name.keys)
109
+ matching_ids = TypedEAV::QueryBuilder.entity_ids(field, spec[:operator], spec[:value])
110
+ query.where(id: matching_ids)
111
+ end
112
+ end
113
+
114
+ def apply_multimap_filters(filters, fields_multimap)
115
+ filters.inject(model.all) do |query, filter|
116
+ spec = parse_filter(filter)
117
+ fields = fields_multimap[spec[:name]]
118
+ raise_unknown_field(spec[:name], fields_multimap.keys) unless fields&.any?
119
+
120
+ union_ids = union_entity_ids(fields, spec[:operator], spec[:value])
121
+ query.where(id: union_ids)
122
+ end
123
+ end
124
+
125
+ # OR-across all field_ids that share the same name (across tenants),
126
+ # while preserving AND between filters via the chained `.where`. Use the
127
+ # underlying Value scope (`.filter`) and `pluck(:entity_id)` to collapse
128
+ # to a plain integer array we can union across tenants.
129
+ def union_entity_ids(fields, operator, value)
130
+ fields.flat_map { |f| TypedEAV::QueryBuilder.filter(f, operator, value).pluck(:entity_id) }.uniq
131
+ end
132
+
133
+ def parse_filter(filter)
134
+ h = filter.to_h.with_indifferent_access
135
+ {
136
+ name: (h[:n] || h[:name]).to_s,
137
+ operator: (h[:op] || h[:operator] || :eq).to_sym,
138
+ value: h.key?(:v) ? h[:v] : h[:value],
139
+ }
140
+ end
141
+
142
+ def raise_unknown_field(name, available)
143
+ raise ArgumentError,
144
+ "Unknown typed field '#{name}' for #{model.name}. " \
145
+ "Available fields: #{available.join(", ")}"
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module HasTypedEAV
5
+ # Per-record API mixed into host AR models by the `has_typed_eav` macro.
6
+ # Reads/writes typed values via field name, returns scope/parent_scope
7
+ # via the configured accessor methods, and builds the collision-collapsed
8
+ # per-instance definition map (delegating to `Partition.definitions_by_name`
9
+ # so the class-query path and the instance path share one source of truth).
10
+ module InstanceMethods
11
+ # The field definitions available for this record
12
+ def typed_eav_definitions
13
+ self.class.typed_eav_definitions(
14
+ scope: typed_eav_scope,
15
+ parent_scope: typed_eav_parent_scope,
16
+ )
17
+ end
18
+
19
+ # Current scope value (for multi-tenant)
20
+ def typed_eav_scope
21
+ return nil unless self.class.typed_eav_scope_method
22
+
23
+ send(self.class.typed_eav_scope_method)&.to_s
24
+ end
25
+
26
+ # Current parent_scope value (for two-level partitioning).
27
+ #
28
+ # Returns nil for models that did not declare `parent_scope_method:` —
29
+ # the method is defined unconditionally so callers (e.g. the Value-side
30
+ # cross-axis validator) can `respond_to?` and read uniformly without
31
+ # branching on `parent_scope_method` configuration. Mirrors the
32
+ # `&.to_s` normalization on `typed_eav_scope`.
33
+ def typed_eav_parent_scope
34
+ return nil unless self.class.typed_eav_parent_scope_method
35
+
36
+ send(self.class.typed_eav_parent_scope_method)&.to_s
37
+ end
38
+
39
+ # Build missing values with defaults for all available fields.
40
+ # Useful in forms to show all fields even when no value exists yet.
41
+ #
42
+ # Iterates the collision-collapsed view (`typed_eav_defs_by_name`)
43
+ # rather than the raw definitions list. Otherwise, when a record's
44
+ # scope partition has both a global (scope=NULL) and a same-name
45
+ # scoped field, `for_entity` returns BOTH rows and the form would
46
+ # render two inputs for the same name — but only the scoped one
47
+ # round-trips on save (it wins in `typed_eav_defs_by_name`).
48
+ def initialize_typed_values
49
+ existing_field_ids = typed_values.loaded? ? typed_values.map(&:field_id) : typed_values.pluck(:field_id)
50
+
51
+ typed_eav_defs_by_name.each_value do |field|
52
+ next if existing_field_ids.include?(field.id)
53
+
54
+ typed_values.build(field: field, value: field.default_value)
55
+ end
56
+
57
+ typed_values
58
+ end
59
+
60
+ # Bulk assign values by field NAME. Coexists with (rather than replaces)
61
+ # the `accepts_nested_attributes_for :typed_values` setter declared
62
+ # on the host model, which accepts entries keyed by field ID.
63
+ #
64
+ # The nested-attributes setter is the standard Rails form contract
65
+ # (forms post `field_id` as a hidden input per value row). This setter
66
+ # takes entries keyed by field *name* and translates them to field
67
+ # IDs before handing off to the nested-attributes setter. It also
68
+ # enforces the `types:` restriction declared on `has_typed_eav` and
69
+ # supports `_destroy: true` for removing a value by name.
70
+ #
71
+ # record.typed_eav_attributes = [
72
+ # { name: "age", value: 30 },
73
+ # { name: "email", value: "test@example.com" },
74
+ # { name: "old_field", _destroy: true },
75
+ # ]
76
+ #
77
+ # Pick the one that fits: forms -> typed_values_attributes=, scripting
78
+ # -> typed_eav_attributes=. They can't both run in the same save.
79
+ def typed_eav_attributes=(attributes)
80
+ fields_by_name = typed_eav_defs_by_name
81
+ values_by_field_id = typed_values.index_by(&:field_id)
82
+
83
+ nested = normalize_typed_eav_attributes(attributes).filter_map do |attrs|
84
+ build_or_update_typed_value(attrs, fields_by_name, values_by_field_id)
85
+ end
86
+
87
+ self.typed_values_attributes = nested if nested.any?
88
+ end
89
+
90
+ alias typed_eav= typed_eav_attributes=
91
+
92
+ # Get a specific field's value by name. Honors an already-loaded
93
+ # `typed_values` association so list-page callers that preloaded
94
+ # `typed_values: :field` don't trigger a fresh query per record.
95
+ #
96
+ # On a global+scoped name collision, prefer the value bound to the
97
+ # winning field_id (scoped wins). Without this guard, a stray value
98
+ # row attached to a shadowed global field would surface here even
99
+ # though writes route through the scoped winner.
100
+ def typed_eav_value(name)
101
+ winning = typed_eav_defs_by_name[name.to_s]
102
+ # Skip orphans (`v.field` nil — definition deleted out from under
103
+ # the value via raw SQL or a missing FK cascade) so a stray row
104
+ # can't crash the read path with NoMethodError.
105
+ candidates = loaded_typed_values_with_fields.select { |v| v.field && v.field.name == name.to_s }
106
+ select_winning_value(candidates, winning)&.value
107
+ end
108
+
109
+ # Set a specific field's value by name
110
+ def set_typed_eav_value(name, value)
111
+ field = typed_eav_defs_by_name[name.to_s]
112
+ return unless field
113
+
114
+ existing = typed_values.detect { |v| v.field_id == field.id }
115
+ if existing
116
+ existing.value = value
117
+ else
118
+ typed_values.build(field: field, value: value)
119
+ end
120
+ end
121
+
122
+ # Hash of all field values: { "field_name" => value, ... }. Same
123
+ # preload semantics as `typed_eav_value` — respects already-loaded
124
+ # associations instead of rebuilding the relation.
125
+ #
126
+ # Collision-safe: on a global+scoped name overlap, the value attached
127
+ # to the winning field_id wins (scoped). Without this guard, a stray
128
+ # row tied to a shadowed global field could surface here even though
129
+ # writes route through the scoped winner.
130
+ def typed_eav_hash
131
+ winning_ids_by_name = typed_eav_defs_by_name.transform_values(&:id)
132
+
133
+ loaded_typed_values_with_fields.each_with_object({}) do |tv, hash|
134
+ # Skip orphans (`tv.field` nil — definition deleted out from under
135
+ # the value) so the hash isn't crashy when stale rows linger.
136
+ next unless tv.field
137
+
138
+ assign_hash_value(hash, tv, winning_ids_by_name)
139
+ end
140
+ end
141
+
142
+ private
143
+
144
+ # Selects the candidate value for `typed_eav_value`. On a collision,
145
+ # prefer the row attached to the winning field_id; otherwise fall back
146
+ # to the first orphan/non-collision candidate.
147
+ def select_winning_value(candidates, winning)
148
+ return candidates.first unless winning
149
+
150
+ candidates.detect { |v| (v.field_id || v.field&.id) == winning.id } || candidates.first
151
+ end
152
+
153
+ # Hash-builder helper for `typed_eav_hash`. When a winner is registered
154
+ # for the name, only its row may surface (scoped-beats-global). When
155
+ # no winner is registered (definition deleted while values remain),
156
+ # fall back to first-wins so the hash isn't lossy.
157
+ def assign_hash_value(hash, value_row, winning_ids_by_name)
158
+ name = value_row.field.name
159
+ winning_id = winning_ids_by_name[name]
160
+ effective_id = value_row.field_id || value_row.field&.id
161
+
162
+ if winning_id
163
+ hash[name] = value_row.value if effective_id == winning_id
164
+ else
165
+ hash[name] = value_row.value unless hash.key?(name)
166
+ end
167
+ end
168
+
169
+ # Normalize the input to `typed_eav_attributes=` into an Array of
170
+ # plain Hashes. Accepts ActionController::Parameters, hash-of-hashes
171
+ # (form params), Array, or any Enumerable.
172
+ def normalize_typed_eav_attributes(attributes)
173
+ attributes = attributes.to_h if attributes.respond_to?(:permitted?)
174
+ attributes = attributes.values if attributes.is_a?(Hash)
175
+ Array(attributes)
176
+ end
177
+
178
+ # Translate a single name-keyed attribute hash into the corresponding
179
+ # nested-attributes entry (id-keyed), or builds a new value row in-place.
180
+ # Returns nil when the field is unknown, the field type is excluded by
181
+ # the host's `types:` restriction, or when the new-row path already
182
+ # added to the association (no nested-attributes entry needed).
183
+ def build_or_update_typed_value(attrs, fields_by_name, values_by_field_id)
184
+ attrs = attrs.to_h.with_indifferent_access
185
+ field = fields_by_name[attrs[:name]]
186
+ return nil unless field
187
+ return nil if typed_eav_type_disallowed?(field)
188
+
189
+ existing = values_by_field_id[field.id]
190
+ return { id: existing&.id, _destroy: true } if destroy_flag?(attrs)
191
+ return { id: existing.id, value: attrs[:value] } if existing
192
+
193
+ typed_values.build(field: field, value: attrs[:value])
194
+ nil
195
+ end
196
+
197
+ def typed_eav_type_disallowed?(field)
198
+ allowed = self.class.allowed_typed_eav_types
199
+ allowed&.exclude?(field.field_type_name)
200
+ end
201
+
202
+ def destroy_flag?(attrs)
203
+ ActiveRecord::Type::Boolean.new.cast(attrs[:_destroy])
204
+ end
205
+
206
+ # Returns typed_values with their fields, preferring already-loaded
207
+ # associations. Callers on list pages should preload with
208
+ # `includes(typed_values: :field)`; this method keeps the happy path
209
+ # fast without forcing that contract.
210
+ def loaded_typed_values_with_fields
211
+ if typed_values.loaded?
212
+ # Don't re-query if the caller already preloaded; ensure each value's
213
+ # field is materialized (fall back to per-row load if the nested
214
+ # `:field` was not preloaded).
215
+ typed_values.to_a
216
+ else
217
+ typed_values.includes(:field).to_a
218
+ end
219
+ end
220
+
221
+ # Field definitions indexed by name with deterministic collision
222
+ # handling: when both a global (scope=NULL) and a scoped field share
223
+ # a name, the most-specific (scoped) definition wins. Delegates to
224
+ # `TypedEAV::Partition.definitions_by_name` so the class-query path
225
+ # and the instance path share one source of truth.
226
+ #
227
+ # ## Bulk-write memoization (Phase 06 plan 05)
228
+ #
229
+ # `bulk_set_typed_eav_values` sets `Thread.current[:typed_eav_bulk_defs_memo]`
230
+ # to a Hash before its records loop. We consult it here so the per-
231
+ # record `typed_eav_attributes=` call does NOT issue a fresh
232
+ # `typed_eav_definitions` SELECT per record. AR's per-block query
233
+ # cache (`ActiveRecord::Base.cache`) is invalidated by every write —
234
+ # because each record's INSERT clears the cache — so cache-do alone
235
+ # cannot keep field-definition reads N+1-free across the bulk loop.
236
+ # The thread-local memo is the explicit fallback documented in plan
237
+ # 06-05 §T3 notes; it pre-warms once per `[host_class, scope,
238
+ # parent_scope]` tuple and reuses across every record in that tuple.
239
+ #
240
+ # Outside a bulk operation the memo is nil and we fall through to
241
+ # the standard read path — zero overhead.
242
+ def typed_eav_defs_by_name
243
+ memo = Thread.current[:typed_eav_bulk_defs_memo]
244
+ if memo
245
+ key = [self.class.name, typed_eav_scope, typed_eav_parent_scope]
246
+ memo[key] ||= TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
247
+ else
248
+ TypedEAV::Partition.definitions_by_name(typed_eav_definitions)
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end