Class: TypedEAV::QueryBuilder

Inherits:
Object
  • Object
show all
Defined in:
lib/typed_eav/query_builder.rb

Overview

Replaces the per-type Finder class hierarchy from active_fields.

Because values live in native typed columns, ActiveRecord already knows the column types from the schema. Arel predicates (eq, gt, lt, matches, etc.) automatically go through the column’s ActiveRecord::Type for casting.

This means:

where(integer_value: "42")  ->  Rails casts "42" to 42 automatically
arel[:date_value].gt(value) ->  Rails casts string dates to Date objects

No manual CAST() calls. No per-type caster classes for queries. One module handles all field types.

Usage:

QueryBuilder.filter(field, :gt, 42)
# => ActiveRecord::Relation scoped to matching values

QueryBuilder.filter(field, :contains, "hello")
# => ILIKE query against the field's string_value column

Class Method Summary collapse

Class Method Details

.entity_ids(field, operator, value) ⇒ Object

Convenience: returns entity IDs matching the filter. Useful for subqueries: Model.where(id: QueryBuilder.entity_ids(field, :gt, 5))



131
132
133
# File 'lib/typed_eav/query_builder.rb', line 131

def entity_ids(field, operator, value)
  filter(field, operator, value).distinct.select(:entity_id)
end

.filter(field, operator, value) ⇒ Object

Returns an ActiveRecord::Relation of TypedEAV::Value records matching the given field, operator, and comparison value.

The relation is suitable for subquery use:

Model.where(id: QueryBuilder.filter(field, :gt, 5).select(:entity_id))

rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength – one operator-dispatch case statement; flattening keeps the supported-operators list scannable in one place.



32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/typed_eav/query_builder.rb', line 32

def filter(field, operator, value)
  operator = operator.to_sym

  # Validate operator is supported by this field type. The gate runs
  # BEFORE column resolution so an unsupported operator raises a
  # descriptive ArgumentError instead of silently dispatching to
  # `operator_column`'s default (which would point at the wrong
  # column for multi-cell types).
  supported = field.class.supported_operators
  unless supported.include?(operator)
    raise ArgumentError,
          "Operator :#{operator} is not supported for #{field.class.name}. " \
          "Supported operators: #{supported.map { |o| ":#{o}" }.join(", ")}"
  end

  # Phase 05: route the operator to its physical column via field-side
  # dispatch. Single-cell types (every built-in as of Phase 04) return
  # `value_column` for every operator — BC-safe. Multi-cell types
  # (Phase 05 Currency) route operators like `:eq` (amount) and
  # `:currency_eq` (currency code) to different columns. See
  # ColumnMapping#operator_column.
  col = field.storage_contract.query_column(operator)
  arel_col = values_table[col]

  base = value_scope(field)

  case operator
  when :eq, :currency_eq
    # :currency_eq (Phase 5 Currency) is semantically equality on the
    # routed column — Currency's operator_column override has already
    # routed `col` to :string_value, so reusing the eq_predicate is
    # the canonical implementation. Without this branch, the case
    # falls through to the `else` raise even though the column
    # dispatch resolved correctly. The operator-validation gate at
    # the top of #filter still narrows :currency_eq to Field::Currency
    # only — no other field type accepts it.
    eq_predicate(base, arel_col, col, value)
  when :not_eq
    not_eq_predicate(base, arel_col, col, value)
  when :references
    # Phase 5 Reference field. `value` may be an Integer FK OR an
    # AR record instance — `field.cast` normalizes both to an
    # integer FK (a class-mismatched record marks the cast invalid
    # via the second tuple element). Empty-relation semantics on
    # invalid cast: returning `base.where(col => nil)` would
    # collapse to :is_null which has different semantics ("rows
    # without an FK at all" rather than "rows referencing this
    # missing target"); `base.none` is the unambiguous "no match".
    # The :references operator is registered ONLY on Field::Reference
    # (the operator-validation gate above keeps it from leaking to
    # other types).
    fk, invalid = field.cast(value)
    if invalid || fk.nil?
      base.none
    else
      base.where(arel_col.eq(fk))
    end
  when :gt
    base.where(arel_col.gt(value))
  when :gteq
    base.where(arel_col.gteq(value))
  when :lt
    base.where(arel_col.lt(value))
  when :lteq
    base.where(arel_col.lteq(value))
  when :between
    unless value.respond_to?(:first) && value.respond_to?(:last)
      raise ArgumentError,
            ":between expects a Range or two-element Array"
    end

    base.where(arel_col.between(value.first..value.last))
  when :contains
    base.where(arel_col.matches("%#{sanitize_like(value)}%"))
  when :not_contains
    base.where(arel_col.does_not_match("%#{sanitize_like(value)}%"))
  when :starts_with
    base.where(arel_col.matches("#{sanitize_like(value)}%"))
  when :ends_with
    base.where(arel_col.matches("%#{sanitize_like(value)}"))
  when :is_null
    base.where(col => nil)
  when :is_not_null
    base.where.not(col => nil)
  when :any_eq
    # For json_value arrays: contains the given element
    base.where("#{col} @> ?", [value].to_json)
  when :all_eq
    # For json_value arrays: contains all given elements
    base.where("#{col} @> ?", Array(value).to_json)
  else
    raise ArgumentError, "Unhandled operator: #{operator}"
  end
end