typed_eav 0.2.1 → 0.3.1

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 +134 -0
  3. data/README.md +287 -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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEAV
4
+ module Field
5
+ # Intermediate STI base for field families that constrain a single
6
+ # comparable value by `min`/`max` bounds.
7
+ #
8
+ # Leaves: `Field::Integer`, `Field::Decimal`, `Field::Date`,
9
+ # `Field::DateTime`. `Field::Percentage` keeps its `< Decimal` chain
10
+ # (so the new full chain is
11
+ # `Percentage < Decimal < RangeBounded < Base`).
12
+ #
13
+ # Does NOT declare a `value_column` — each leaf still owns its typed
14
+ # column (`integer_value`, `decimal_value`, `date_value`,
15
+ # `datetime_value`). The family is identified by "has min/max bounds",
16
+ # not by storage shape.
17
+ #
18
+ # Hoists the protected `validate_range` / `validate_date_range` /
19
+ # `validate_datetime_range` helpers previously kept on `Field::Base`.
20
+ # Each leaf declares its own `store_accessor` (`:min`/`:max` for
21
+ # Integer/Decimal; `:min_date`/`:max_date` for Date;
22
+ # `:min_datetime`/`:max_datetime` for DateTime) and its own
23
+ # `validates :max, comparison: { greater_than_or_equal_to: :min }`
24
+ # macro using the appropriate key names. Adding the macro to
25
+ # Date/DateTime in-slice closes a latent-bug gap previously only
26
+ # caught on Integer/Decimal.
27
+ #
28
+ # Public extension point: external authors can subclass this directly
29
+ # if they want a typed numeric/temporal column with min/max guards
30
+ # (see README §"Custom field types"). STI dispatch is unaffected.
31
+ class RangeBounded < Base
32
+ protected
33
+
34
+ def validate_range(record, val)
35
+ opts = options_hash
36
+ record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min]) if opts[:min] && val < opts[:min].to_d
37
+ return unless opts[:max] && val > opts[:max].to_d
38
+
39
+ record.errors.add(:value, :less_than_or_equal_to, count: opts[:max])
40
+ end
41
+
42
+ def validate_date_range(record, val)
43
+ opts = options_hash
44
+ if opts[:min_date]
45
+ min = ::Date.parse(opts[:min_date])
46
+ record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_date]) if val < min
47
+ end
48
+ if opts[:max_date]
49
+ max = ::Date.parse(opts[:max_date])
50
+ record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_date]) if val > max
51
+ end
52
+ rescue ::Date::Error
53
+ record.errors.add(:base, "field has invalid date configuration")
54
+ end
55
+
56
+ def validate_datetime_range(record, val)
57
+ opts = options_hash
58
+ if opts[:min_datetime]
59
+ min = ::Time.zone.parse(opts[:min_datetime])
60
+ record.errors.add(:value, :greater_than_or_equal_to, count: opts[:min_datetime]) if val < min
61
+ end
62
+ if opts[:max_datetime]
63
+ max = ::Time.zone.parse(opts[:max_datetime])
64
+ record.errors.add(:value, :less_than_or_equal_to, count: opts[:max_datetime]) if val > max
65
+ end
66
+ rescue ArgumentError
67
+ record.errors.add(:base, "field has invalid datetime configuration")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -2,20 +2,19 @@
2
2
 
3
3
  module TypedEAV
4
4
  module Field
5
+ # Single-choice option-set field. Stores the chosen value in
6
+ # `string_value`. Inherits `optionable? = true`, the public-facing
7
+ # sorted `allowed_values` helper, and the protected
8
+ # `validate_option_inclusion` helper from `Field::Optionable`. Stays a
9
+ # direct child of `Field::Base` — Optionable is a concern (mixin), not
10
+ # an intermediate STI class, because Select and MultiSelect don't
11
+ # share a `value_column`.
5
12
  class Select < Base
13
+ include Optionable
14
+
6
15
  value_column :string_value
7
16
  operators :eq, :not_eq, :is_null, :is_not_null
8
17
 
9
- def optionable? = true
10
-
11
- def allowed_values
12
- if field_options.loaded?
13
- field_options.sort_by { |o| [o.sort_order || 0, o.label.to_s] }.map(&:value)
14
- else
15
- field_options.sorted.pluck(:value)
16
- end
17
- end
18
-
19
18
  def cast(raw)
20
19
  [raw&.to_s, false]
21
20
  end
@@ -2,40 +2,22 @@
2
2
 
3
3
  module TypedEAV
4
4
  module Field
5
- class Text < Base
5
+ # String-typed field with optional length / pattern guards. Storage,
6
+ # `store_accessor`, numericality validators, `max_gte_min_length`,
7
+ # `validate_pattern_syntax`, and the default `validate_typed_value`
8
+ # (length + pattern) all come from `Field::ValidatedString`. Text adds
9
+ # only `cast` (raw → String).
10
+ class Text < ValidatedString
11
+ # Re-declare value_column to populate Text's own @value_columns class
12
+ # instance variable — Ruby class ivars are NOT inherited through
13
+ # subclass lookup (the same workaround `Field::Percentage` uses
14
+ # against `Field::Decimal`). BC-safe and explicit; STI dispatch is
15
+ # unaffected (the `type` column still stores "TypedEAV::Field::Text").
6
16
  value_column :string_value
7
17
 
8
- store_accessor :options, :min_length, :max_length, :pattern
9
-
10
- validates :min_length, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
11
- validates :max_length, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
12
- validate :max_gte_min_length
13
- validate :validate_pattern_syntax
14
-
15
18
  def cast(raw)
16
19
  [raw&.to_s, false]
17
20
  end
18
-
19
- def validate_typed_value(record, val)
20
- validate_length(record, val)
21
- validate_pattern(record, val) if pattern.present?
22
- end
23
-
24
- private
25
-
26
- def max_gte_min_length
27
- return unless min_length && max_length
28
-
29
- errors.add(:max_length, "must be >= min_length") if max_length < min_length
30
- end
31
-
32
- def validate_pattern_syntax
33
- return if pattern.blank?
34
-
35
- Regexp.new(pattern)
36
- rescue RegexpError => e
37
- errors.add(:pattern, "is invalid: #{e.message}")
38
- end
39
21
  end
40
22
  end
41
23
  end
@@ -4,12 +4,21 @@ require "uri"
4
4
 
5
5
  module TypedEAV
6
6
  module Field
7
- class Url < Base
7
+ # URL-typed field. Inherits `string_value` storage shape, the
8
+ # `min_length` / `max_length` / `pattern` `store_accessor`, the
9
+ # `max_gte_min_length` guard, and the `validate_pattern_syntax` guard
10
+ # from `Field::ValidatedString`. Adds the `URL_FORMAT` regex
11
+ # (http/https only) and layers the format check onto
12
+ # `validate_typed_value` via `super`.
13
+ #
14
+ # Latent-bug fix (per ADR-0004): `max_gte_min_length` (previously
15
+ # only on Text) now applies here — a Url field configured with
16
+ # `max_length < min_length` fails at field-save.
17
+ class Url < ValidatedString
18
+ # Re-declare value_column for Ruby class-ivar non-inheritance — see
19
+ # comment on Field::Text. STI dispatch is unaffected.
8
20
  value_column :string_value
9
21
 
10
- store_accessor :options, :min_length, :max_length, :pattern
11
- validate :validate_pattern_syntax
12
-
13
22
  URL_FORMAT = /\A#{URI::DEFAULT_PARSER.make_regexp(%w[http https])}\z/
14
23
 
15
24
  def cast(raw)
@@ -21,20 +30,9 @@ module TypedEAV
21
30
  end
22
31
 
23
32
  def validate_typed_value(record, val)
24
- validate_length(record, val)
25
- validate_pattern(record, val) if pattern.present?
33
+ super
26
34
  record.errors.add(:value, "is not a valid URL") unless url_format_valid?(val)
27
35
  end
28
-
29
- private
30
-
31
- def validate_pattern_syntax
32
- return if pattern.blank?
33
-
34
- Regexp.new(pattern)
35
- rescue RegexpError => e
36
- errors.add(:pattern, "is invalid: #{e.message}")
37
- end
38
36
  end
39
37
  end
40
38
  end
@@ -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.storage_contract.read(self)
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.storage_contract.write(self, casted)
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.storage_contract.value_columns.each do |col|
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.apply_default_to(self)` dispatch. Does NOT route through value=
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 apply_default_to writes is always
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[value_column] = field.default_value` (Field::Base default).
381
- # Currency / future multi-cell types override `apply_default_to` to
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.storage_contract.apply_default(self)
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.storage_contract.changed?(self)
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
@@ -5,7 +5,7 @@ module TypedEAV
5
5
  isolate_namespace TypedEAV
6
6
 
7
7
  initializer "typed_eav.autoload" do
8
- require_relative "column_mapping"
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