Class: TypedEAV::QueryBuilder
- Inherits:
-
Object
- Object
- TypedEAV::QueryBuilder
- 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
-
.entity_ids(field, operator, value) ⇒ Object
Convenience: returns entity IDs matching the filter.
-
.filter(field, operator, value) ⇒ Object
Returns an ActiveRecord::Relation of TypedEAV::Value records matching the given field, operator, and comparison value.
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 |