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.

Field owns operand casting and operator validation before this layer builds Arel. Active Record supplies bind plumbing for the already-normalized typed value; no manual SQL CAST() calls or per-type query caster classes are needed here.

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))



120
121
122
# File 'lib/typed_eav/query_builder.rb', line 120

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.



26
27
28
29
30
31
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
# File 'lib/typed_eav/query_builder.rb', line 26

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

  # Route the operator to its physical column via the field-class
  # dispatch. Single-cell types return `value_columns.first` for every
  # operator — BC-safe. Multi-cell types (Currency) route operators
  # like `:eq` (amount) and `:currency_eq` (currency code) to
  # different columns. See `Field::TypedStorage.operator_column`.
  col = field.class.operator_column(operator)
  arel_col = values_table[col]

  base = value_scope(field)
  excluded = %i[is_null is_not_null contains not_contains starts_with ends_with]
  operand = field.cast_query_operand(operator, value) unless excluded.include?(operator)

  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, operand)
  when :not_eq
    not_eq_predicate(base, arel_col, col, operand)
  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).
    if operand.nil?
      base.none
    else
      base.where(arel_col.eq(operand))
    end
  when :gt
    base.where(arel_col.gt(operand))
  when :gteq
    base.where(arel_col.gteq(operand))
  when :lt
    base.where(arel_col.lt(operand))
  when :lteq
    base.where(arel_col.lteq(operand))
  when :between
    base.where(arel_col.between(operand))
  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} @> ?", [operand].to_json)
  when :all_eq
    # For json_value arrays: contains all given elements
    base.where("#{col} @> ?", operand.to_json)
  else
    raise ArgumentError, "Unhandled operator: #{operator}"
  end
end