api_query_language-active_record

ActiveRecord backend for api_query_language. Consumes the parsed AST and produces real AR/Arel queries via #apply_to(relation).

Part of the another_api family.

Installation

gem "api_query_language-active_record"

Installing this gem transitively installs api_query_language and requires activerecord >= 7.2.

Quickstart

require "api_query_language/active_record"

relation = Post.all
filter = ApiQueryLanguage::ActiveRecord::Filtering::FilterExpression.new(
  "title:Hello*",
  { title: nil } # field name → DB column (nil = same name)
)
filter.apply_to(relation)
# Emits Arel `matches` — ILIKE on PostgreSQL, LIKE on MySQL/SQLite:
# => Post.where(Post.arel_table[:title].matches("Hello%"))

sort = ApiQueryLanguage::ActiveRecord::Sorting::SortExpression.new(
  "created_at:desc",
  { created_at: nil }
)
sort.apply_to(relation)
# => Post.order(created_at: :desc)

Consumers who only need the AST (e.g. for Elasticsearch queries, or for validating filter expressions server-side before forwarding) should use the base api_query_language gem directly and skip this one.

Field mappings

The second constructor argument is a hash keyed by the field name exposed in the query string. Each value can take several shapes:

{
  # nil → use a column of the same name on the root model
  name: nil,

  # String or Symbol → rename to a different column on the root model
  email: :email_address,
  created: "created_at",

  # Single-element Array — same as Symbol, kept for readability
  state: [:status],

  # Dotted path → JOIN through associations, filter on the joined column.
  # "author.company.name" joins `author` then `company` and filters on
  # `companies.name`. Applied automatically; the resulting relation is
  # `.distinct`.
  "author.name": nil,
  "author.company.name": nil,

  # Rich mapping — any object responding to `.column` acts as a
  # QueryableMapping; `api_serializer`'s `QueryableConfig` is the canonical
  # example. Supports:
  #   .column          → column name (falls back to the field name)
  #   .transform       → lambda run on the raw value before casting/filtering
  #   .allowed_values  → array; values outside it raise DisallowedValueError
  role: UserSchema.filtering_mapped_attributes[:role]
}

transform: runs on the decoded value before it reaches the caster, letting an API consumer pass opaque tokens ("admin", "me") that you rewrite into real column values server-side.

Collection columns (PostgreSQL)

For PG array columns the visitor dispatches to attribute.contains(...) rather than =/LIKE, so roles:admin on a roles text[] column emits roles @> ARRAY['admin']. Non-array collection types raise UnsupportedCollectionFieldTypeError.

Case sensitivity

The {ieq} comparison operator forces case-insensitive matching across adapters: the column is wrapped in LOWER() and compared via Arel matches against the lower-cased value. On PostgreSQL this ends up as LOWER(col) ILIKE <value>; on MySQL/SQLite it is LOWER(col) LIKE <value>. Either way the match is case-insensitive.

Wildcard matching (* / +) uses Arel::Attribute#matches directly, which emits ILIKE on PostgreSQL and LIKE on MySQL/SQLite. Behaviour there is adapter-dependent: PostgreSQL is always case-insensitive; SQLite's LIKE is case-insensitive only for ASCII A–Z; MySQL depends on the column's collation.

Custom visitors

QueryContext carries the root relation and the field-mapping lookup as the AST is walked; each visitor receives a NodeWithContext (current AST node, current relation, optional field context). If you need to extend query behaviour beyond what ships — e.g. a new comparison operator — subclass Visitor and register it in Filtering::Visitors::AstVisitor::VISITOR_MAP.

License

MIT. See LICENSE.txt at the repository root.