Querying typed fields
Filtering
Queries use native typed columns and the indexes shipped for each type. The Field remains the owner of operand casting and validation; the query builder receives a field-normalized value rather than applying a generic Active Record cast.
# Short form - single field filter
Contact.with_field("age", :gt, 21)
Contact.with_field("status", "active") # :eq is the default operator
Contact.with_field("nickname", :contains, "smith")
# Chain them
Contact.with_field("age", :gteq, 18)
.with_field("status", "active")
.with_field("tags", :any_eq, "vip")
# Multi-filter form (good for search UIs)
Contact.where_typed_eav(
{ name: "age", op: :gt, value: 21 },
{ name: "status", op: :eq, value: "active" },
{ name: "city", op: :contains, value: "port" },
)
# Compact keys (for URL params / form submissions)
Contact.where_typed_eav(
{ n: "age", op: :gt, v: 21 },
{ n: "status", v: "active" },
)
# With scoping
Contact.where_typed_eav(
{ name: "priority", op: :eq, value: "high" },
scope: current_tenant.id
)
# Combine with standard ActiveRecord
Contact.where(company_id: 42)
.with_field("status", "active")
.with_field("age", :gteq, 21)
.order(:name)
.limit(25)
Missing values and explicit NULL
By default, :is_null finds existing Value rows whose typed value is NULL.
It does not find hosts with no Value row. For an “is empty” search that covers
both cases, opt into include_missing::
contacts = Contact.where(tenant_id: "t1")
contacts.with_field("age", :is_null, scope: "t1")
contacts.with_field("age", :is_null, scope: "t1", include_missing: true)
# The same option applies to every :is_null filter in a multi-filter query:
contacts.where_typed_eav(
{ name: "age", op: :is_null },
{ name: "status", value: "active" },
scope: "t1", include_missing: true
)
include_missing: true means the host has no non-NULL value for the
selected definition. It has no effect on :is_not_null or any other operator;
it does not make an unknown field name valid. In administrative
TypedEAV.unscoped queries, no matching same-name definition may hold a
non-NULL value for that host. Other filters remain ANDed, and host authorization
still belongs on the caller relation.
Sorting by a typed field
Contact.where(tenant_id: "t1")
.order_typed_eav("age", direction: :desc, nulls: :last, scope: "t1")
.limit(25)
order_typed_eav returns an Active Record relation and orders in PostgreSQL,
without loading typed values into Ruby. It replaces prior ordering while
preserving host filters, STI restrictions, limits, and offsets. direction:
accepts :asc (default) or :desc; nulls: accepts :first or :last
(default in either direction). Missing rows and explicit NULLs share that
placement. Equal values use the host primary key ascending as a stable tie-break.
Scope arguments (or the ambient scope) select the winning field definition;
they do not filter host records by tenant. Keep authorization/tenant filters
on the caller relation. All-partitions TypedEAV.unscoped is rejected for this
API: choose one effective definition instead. Single native scalar cells are
supported, using their stored values (for example, reference IDs and attachment
signed IDs, not display labels). JSON/array and multi-cell fields such as
Currency are rejected rather than assigned an implicit ordering.
Distinct values and grouped counts
contacts = Contact.where(tenant_id: "t1")
contacts.distinct_typed_eav_values("status", scope: "t1", limit: 100)
# => ["active", "paused", nil]
contacts.typed_eav_value_counts("status", scope: "t1", limit: 100)
# => {"active" => 24, "paused" => 3, nil => 2}
contacts.count_distinct_typed_eav_values("status", scope: "t1")
# => 3
These scalar queries run in SQL without hydrating hosts or Values. Results
use native ascending value order, with explicit NULL (nil) last. Missing
value rows contribute nothing; false and empty strings remain real values.
Grouped counts count host identities, not duplicate rows introduced by joins.
The caller’s filters, STI restrictions, distinctness, and pagination determine
the host set before summarization. Scope arguments choose field-definition
visibility, not host authorization, just as with typed sorting.
Lists and grouped-count hashes default to 100 values and accept a positive
Integer limit: up to 1,000. They are truncated in value order, not ranked by
frequency. Compare their length with count_distinct_typed_eav_values to
detect truncation; that exact SQL count includes one NULL category and returns
only an Integer, regardless of cardinality. It still requires database work
over the matching set. Collection/multi-cell fields and all-partitions mode
are unsupported, matching scalar sorting.
Numeric aggregates
Contact.where(tenant_id: "t1").aggregate_typed_eav(
"score", operation: :sum, scope: "t1"
)
aggregate_typed_eav requires operation: :min, :max, or :sum and returns
one SQL-calculated scalar over the caller’s host set. Integer fields return
Integers; Decimal and Percentage fields preserve BigDecimal precision, with
no Float conversion. Percentage values remain stored fractions, not formatted
percent strings. Missing rows and explicit NULLs are ignored. With no non-NULL
values, min/max return nil and sum returns the field’s typed zero.
The same host filtering, partition visibility, STI, and pagination rules as distinct queries apply. Only Integer/Decimal families (including Percentage) with a single numeric cell are supported. Reference IDs, text, collections, and multi-cell Currency are rejected; the gem does not silently sum identifiers or combine currencies. No values or host records are loaded to compute the result.
Available Operators
| Operator | Works On | Description |
|---|---|---|
:eq |
all | Equal (default) |
:not_eq |
all | Not equal (NULL-safe) |
:gt |
numeric, date, datetime | Greater than |
:gteq |
numeric, date, datetime | Greater than or equal |
:lt |
numeric, date, datetime | Less than |
:lteq |
numeric, date, datetime | Less than or equal |
:between |
numeric, date, datetime | Between (pass Range or Array) |
:contains |
text, long_text | ILIKE %value% |
:not_contains |
text, long_text | NOT ILIKE %value% |
:starts_with |
text, long_text | ILIKE value% |
:ends_with |
text, long_text | ILIKE %value |
:any_eq |
json arrays | Array contains element |
:all_eq |
json arrays | Array contains all elements |
:is_null |
all | Value is NULL |
:is_not_null |
all | Value is not NULL |
How Type Inference Works
The owning Field casts and validates query operands before SQL generation; Active Record supplies the SQL bind plumbing:
# The Integer Field casts and validates the operand before SQL generation
Contact.with_field("age", :gt, "21")
# SQL: WHERE integer_value > 21 (not '21')
# The Date Field owns date parsing and validation
Contact.with_field("birthday", :lt, "2000-01-01")
# SQL: WHERE date_value < '2000-01-01'::date
# The Boolean Field owns truthy/falsy casting
Contact.with_field("active", "true")
# SQL: WHERE boolean_value = TRUE
Field-owned casting keeps query operands aligned with write semantics, including strict range/array shapes and specialized fields such as Currency and Reference. The resulting normalized operand is bound against the Field’s typed column; Active Record supplies SQL bind plumbing, not the field’s domain semantics.