A comprehensive, declarative query building and serialization system for Phoenix/Ecto applications that supports complex filtering, sorting, pagination, and deep association preloading through JSON API requests.
The system consists of several interconnected modules that work together to provide a clean, declarative API for building complex database queries:
Frontend JSON Request
↓
QueryEngine (orchestrator)
↓
QueryBuilder (parses & builds query)
↓
QueryJoiner (manages associations)
↓
QueryFilter/QueryExpressor (applies filters)
↓
QueryAssociator (preloads & serializes)
↓
QuerySerializer (transforms to JSON)
↓
Clean JSON Response
The main entry point that orchestrates the entire query pipeline. Provides two main functions:
all/7
- For index endpoints with paginationget/5
- For show endpoints
Parses JSON query parameters and builds Ecto queries using declarative filter and sort specifications. Handles a sophisticated two-phase filtering process:
- Explicit Filters: Applies filters explicitly provided in the JSON request
- Default Filters: Applies default/fallback filters for any missing filter specifications
Applies filters to queries with support for:
- Simple field filtering
- Deep association filtering (up to 21 levels deep)
- Conditional operators based on values
- Default fallback filters with multiple fallback strategies
Generates dynamic Ecto query expressions for different binding arities. Handles the complex task of building type-safe dynamic queries with proper variable binding.
Manages preloading of associations and delegates serialization to individual schemas.
Provides a macro for schemas to define their serialization behavior with hooks for custom field processing.
# In your controller or filter module
@index_filters [
BaseFilters.is_active(), # Reusable base filter with default
BaseFilters.organization_id(),
{
:custom_field,
{
QueryFilter,
:by_field,
:field_name,
:==,
_allow_nil = false,
_default_or_fallback_filter = nil
}
},
{
:association_filter,
{
QueryFilter,
:by_association_field,
[:user, :profile], # Association path
:name,
:==,
_allow_nil = false,
_default_or_fallback_filter = nil
}
}
]
@sort_specs [
BaseSorts.created_at_desc_default(), # Default sort
BaseSorts.id_asc_default(),
{
:custom_sort,
{
QuerySort,
:by_association_field,
[:user],
:name,
:asc,
_default = false
}
}
]
@index_preloads [
:simple_association,
{:nested_association, [:deep_nested]},
{
:complex_association,
[
nested_field: [:deep_field],
another_nested: [
:sound_generation_profile,
:audio_control_mode
]
]
}
]
def index(conn, params) do
QueryEngine.all(
Repo,
MySchema,
@index_preloads,
@index_filters,
@sort_specs,
params
)
|> then(fn {:ok, response} -> json(conn, response) end)
end
def show(conn, %{"id" => id}) do
QueryEngine.get(Repo, MySchema, id, @show_preloads)
|> then(fn {_status, response} -> json(conn, response) end)
end
defmodule MyApp.MySchema do
use Ecto.Schema
use ToadAudioOrchestratorWeb.Api.QuerySerializer
# Override serialization behavior
def serializer_skip_fields(_opts), do: [:internal_field, :password_hash]
def do_serialize_field(:created_at, value, _opts) do
# Custom date formatting
DateTime.to_iso8601(value)
end
def do_serialize_field(_field, value, _opts), do: value
end
const queryParams = {
query: JSON.stringify({
filters: {
is_active: true,
organization_id: 123,
"user.name": "John" // Association filtering
},
sort: ["created_at:desc", "name:asc"]
}),
page: 1,
per_page: 25
}
fetch(`/api/resources?${new URLSearchParams(queryParams)}`)
{
status: 200,
results: {
data: [
{
id: 1,
name: "Resource 1",
schema: "MyApp.MySchema",
user: {
id: 1,
name: "John",
schema: "MyApp.User"
}
}
],
page_info: {
current_page: 1,
total_pages: 5,
total_count: 123,
per_page: 25
},
schema: "MyApp.MySchema"
}
}
{
:filter_key, # Key that appears in JSON filters
{
Module, # Module containing filter function
:function_name, # Function to call
:field_or_binding_list, # Field name or association path
:operator, # :==, :!=, :>, :<, etc. or {:truthy_op, :falsy_op}
allow_nil, # boolean - whether to apply filter with nil values
default_value # Default value or {Module, :func, args} for default filter
}
}
{
:sort_key, # Key that appears in JSON sort array
{
Module, # Module containing sort function
:function_name, # Function to call
binding_list, # Association path ([] for direct field)
:field_name, # Field to sort on
:direction, # :asc or :desc
is_default # boolean - whether this is a default sort
}
}
The system automatically applies fallback filters when they're not explicitly provided in requests. This enables tenant scoping, soft deletes, security defaults, and better user experience.
The most powerful feature allows operators to change based on the filter value's truthiness:
def is_active() do
{
:is_active,
{
QueryFilter,
:by_field,
:archived_at,
{:is_nil, :not_nil}, # Conditional operators
false, # Don't filter on explicit nil values
true # Default to active (true)
}
}
end
Behavior:
is_active: true
→WHERE archived_at IS NULL
(active records)is_active: false
→WHERE archived_at IS NOT NULL
(archived records)is_active: null
→ Filter skipped (allow_nil = false)- Request without
is_active
→WHERE archived_at IS NULL
(defaults to active)
- Explicit Filters First: Processes filters provided in the JSON request
- Default Filters Second: Applies defaults for missing filters only
Override Prevention: Defaults only apply when the filter key is completely missing:
# {"filters": {"is_active": null}} → Filter skipped (explicit null)
# {"filters": {}} → Default applied (key missing)
Literal Values (most common):
{:tenant_scoped, {QueryFilter, :by_field, :tenant_id, :==, false, 123}}
# When not provided, filters by tenant_id = 123
Function Calls:
{:tenant_id, {QueryFilter, :by_field, :tenant_id, :==, false, {MyModule, :current_tenant_id, []}}}
# Calls MyModule.current_tenant_id() to get default value
Nil with allow_nil
flag:
{:optional, {QueryFilter, :by_field, :field, :==, true, nil}}
# If allow_nil = true: applies filter with nil value
# If allow_nil = false: skips filter entirely
# Operator changes based on filter value
{:is_active, {QueryFilter, :by_field, :archived_at, {:is_nil, :not_nil}, false, true}}
# When is_active: true -> uses :is_nil
# When is_active: false -> uses :not_nil
# When not provided -> defaults to true, uses :is_nil
# Applied when filter not explicitly provided
{:tenant_scoped, {QueryFilter, :by_field, :tenant_id, :==, false, current_tenant_id}}
# Filter by nested association fields
{:user_profile_type, {QueryFilter, :by_association_field, [:user, :profile], :type, :==, false, nil}}