Packages

Builds dynamic Ecto queries based on given parameters, allowing developers to retrieve data flexibly without writing custom queries for each use case.

Current section

Files

Jump to
bind lib bind.ex
Raw

lib/bind.ex

## File: bind/lib/bind.ex
defmodule Bind do
import Ecto.Query
@doc """
Merges additional filters with existing query params.
"""
def filter(query_string, filters) when is_binary(query_string) do
query_string
|> Bind.QueryString.to_map()
|> filter(filters)
end
def filter(params, filters) when is_map(params) do
Map.merge(params, filters)
end
@doc """
Builds an Ecto query for the given schema based on the provided parameters.
"""
def query(params, schema, opts \\ [])
def query(params, schema, opts) when is_map(params) do
allowed_joins = Keyword.get(opts, :joins, [])
virtual = normalize_virtual(Keyword.get(opts, :virtual, %{}))
with {:ok, params, virtual_filters} <- extract_virtual(params, virtual),
:ok <- validate_virtual_sort(params, virtual, schema) do
case Bind.QueryBuilder.build_where_query(params, allowed_joins) do
{:error, reason} ->
{:error, reason}
where_query ->
sort_query = Bind.QueryBuilder.build_sort_query(params)
base =
schema
|> where(^where_query)
|> Bind.QueryBuilder.apply_joins(params, allowed_joins)
case apply_virtual(base, virtual_filters) do
{:error, reason} ->
{:error, reason}
scoped ->
scoped
|> order_by(^Enum.into(sort_query, []))
|> Bind.QueryBuilder.add_limit_query(params)
|> Bind.QueryBuilder.add_offset_query(params)
end
end
end
end
def query(query_string, schema, opts) when is_binary(query_string) do
query_string
|> Bind.QueryString.to_map()
|> query(schema, opts)
end
@doc """
Maps over query parameters, letting you transform values by pattern matching field names.
"""
def map(query_string, field_mappers) when is_binary(query_string) do
query_string
|> URI.decode_query()
|> map(field_mappers)
end
def map(params, field_mappers) when is_map(params) do
Enum.reduce(params, %{}, fn {key, value}, acc ->
case Bind.Parse.where_field(key) do
# Handle regular fields [field_name, constraint]
[field_name, constraint] ->
field = to_string(field_name)
new_value = map_value(field_mappers, field, constraint, value)
Map.put(acc, key, new_value)
# Handle JSONB fields [json_field, json_key, constraint]
[json_field, json_key, _constraint] when is_binary(json_key) ->
field = to_string(json_field)
new_value = find_mapper(field_mappers, field).(value)
Map.put(acc, key, new_value)
# Handle join fields [assoc, field, constraint, :join]
[_assoc, field_name, constraint, :join] ->
field = to_string(field_name)
new_value = map_value(field_mappers, field, constraint, value)
Map.put(acc, key, new_value)
# Handle join JSONB fields [assoc, field, json_key, constraint, :join_jsonb]
[_assoc, field_name, _json_key, _constraint, :join_jsonb] ->
field = to_string(field_name)
new_value = find_mapper(field_mappers, field).(value)
Map.put(acc, key, new_value)
# Handle non-where fields (like start, limit)
nil ->
{field, is_negated} =
case String.starts_with?(key, "-") do
true -> {String.trim_leading(key, "-"), true}
false -> {key, false}
end
new_value = find_mapper(field_mappers, field).(value)
final_key = if is_negated, do: "-#{field}", else: field
Map.put(acc, final_key, new_value)
end
end)
end
# `in` values are comma-joined; a custom mapper (e.g. hashed-id decode)
# must see each element, not the joined string. The mapped value stays a
# list and flows through query's `in` handling as-is. Without a custom
# mapper the value is left untouched.
defp map_value(mappers, field, "in", value) do
if has_custom_mapper?(mappers, field) do
mapper = find_mapper(mappers, field)
value
|> Bind.QueryBuilder.in_values()
|> Enum.map(mapper)
else
value
end
end
defp map_value(mappers, field, _constraint, value) do
find_mapper(mappers, field).(value)
end
defp find_mapper(mappers, field) do
case Map.get(mappers, String.to_atom(field)) do
nil ->
case Enum.find(mappers, fn
{%Regex{} = re, _} -> Regex.match?(re, field)
_ -> false
end) do
{_, mapper} -> mapper
nil -> & &1
end
mapper ->
mapper
end
end
@doc """
Maps over query parameters with error handling.
Returns {:ok, mapped_params} on success or {:error, reason} on failure.
"""
def map_safe(query_string, field_mappers) when is_binary(query_string) do
query_string
|> URI.decode_query()
|> map_safe(field_mappers)
end
def map_safe(params, field_mappers) when is_map(params) do
try do
result =
Enum.reduce_while(params, {:ok, %{}}, fn {key, value}, {:ok, acc} ->
case Bind.Parse.where_field(key) do
[field_name, constraint] ->
field = to_string(field_name)
handle_map_safe_field(key, value, field, constraint, field_mappers, acc)
[json_field, json_key, _constraint] when is_binary(json_key) ->
field = to_string(json_field)
handle_map_safe_field(key, value, field, nil, field_mappers, acc)
[_assoc, field_name, constraint, :join] ->
field = to_string(field_name)
handle_map_safe_field(key, value, field, constraint, field_mappers, acc)
[_assoc, field_name, _json_key, _constraint, :join_jsonb] ->
field = to_string(field_name)
handle_map_safe_field(key, value, field, nil, field_mappers, acc)
nil ->
{field, is_negated} =
case String.starts_with?(key, "-") do
true -> {String.trim_leading(key, "-"), true}
false -> {key, false}
end
mapper = find_mapper(field_mappers, field)
if should_skip_transformation?(value) && has_custom_mapper?(field_mappers, field) do
{:cont, {:ok, acc}}
else
case apply_mapper_safe(mapper, value) do
{:ok, new_value} ->
final_key = if is_negated, do: "-#{field}", else: field
{:cont, {:ok, Map.put(acc, final_key, new_value)}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end
end
end)
case result do
{:ok, mapped} -> {:ok, mapped}
{:error, reason} -> {:error, {:transformation_failed, reason}}
end
rescue
e -> {:error, {:transformation_failed, Exception.message(e)}}
end
end
# Helper for map_safe field handling
defp handle_map_safe_field(key, value, field, constraint, field_mappers, acc) do
mapper = find_mapper(field_mappers, field)
custom? = has_custom_mapper?(field_mappers, field)
cond do
should_skip_transformation?(value) && custom? ->
{:cont, {:ok, acc}}
constraint == "in" && custom? ->
case map_in_safe(mapper, value) do
{:ok, new_value} -> {:cont, {:ok, Map.put(acc, key, new_value)}}
{:error, reason} -> {:halt, {:error, reason}}
end
true ->
case apply_mapper_safe(mapper, value) do
{:ok, new_value} -> {:cont, {:ok, Map.put(acc, key, new_value)}}
{:error, reason} -> {:halt, {:error, reason}}
end
end
end
# Applies the mapper to each element of an `in` value, halting on the
# first error. The mapped value stays a list.
defp map_in_safe(mapper, value) do
value
|> Bind.QueryBuilder.in_values()
|> Enum.reduce_while({:ok, []}, fn element, {:ok, mapped} ->
case apply_mapper_safe(mapper, element) do
{:ok, new_value} -> {:cont, {:ok, [new_value | mapped]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, mapped} -> {:ok, Enum.reverse(mapped)}
{:error, reason} -> {:error, reason}
end
end
defp apply_mapper_safe(mapper, value) do
case mapper.(value) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
result -> {:ok, result}
end
end
defp should_skip_transformation?(value) do
value in [nil, ""]
end
# Virtual fields — semantics:
#
# - `virtual: %{field: fun}` is shorthand for `virtual: %{field: [eq: fun]}`.
# Each fun is `(query, value)` and returns the query with the filter
# applied, or `{:error, reason}` (a string, matching bind's own error
# shape) to reject the value and abort the build.
# - Undeclared constraints are rejected, never collapsed: with only `eq`
# declared, `field[neq]=1` returns {:error, "Invalid constraint: ..."}.
# - A virtual field shadows a real column of the same name and replaces its
# entire constraint surface — undeclared constraints on the column are
# rejected too. Multiple declared constraints in one request AND together.
# - Values arrive as the params map holds them; bind does not normalize
# types (the binary query-string path numeric-converts, the map path
# doesn't). Exception: `in` is normalized to a list via in_values/1.
# - Sorting by a virtual-only field is rejected up front; sorting by a
# shadowed real column stays legal.
# - Scopes apply after `where` and `joins:`, before sort/limit/pagination,
# so those (including the implicit defaults) wrap the scoped query.
# - Only the plain `field[op]` form participates; JSONB (`field.key[op]`)
# and join (`assoc:field[op]`) notations never match a virtual field.
# - Composes with map/map_safe: for `in` constraints, mappers apply per
# element and the mapped list flows through (see map_value/4).
# Normalizes both declaration forms to a string-keyed
# %{"name" => %{"constraint" => fun}} map. Matching happens on strings so
# user-controlled param names never create atoms here.
defp normalize_virtual(virtual) do
Map.new(virtual, fn
{name, fun} when is_function(fun, 2) ->
{to_string(name), %{"eq" => fun}}
{name, constraints} when is_list(constraints) ->
{to_string(name),
Map.new(constraints, fn {constraint, fun} when is_function(fun, 2) ->
{to_string(constraint), fun}
end)}
end)
end
# Splits params matching a declared virtual field out of the map, pairing
# each with its scope function. A matching field with an undeclared
# constraint is an error. Only the plain `field[op]` form participates:
# the \w+ pattern excludes JSONB (dots) and join (colons) notation.
defp extract_virtual(params, virtual) when map_size(virtual) == 0 do
{:ok, params, []}
end
defp extract_virtual(params, virtual) do
result =
Enum.reduce_while(params, {:ok, params, []}, fn {key, value}, {:ok, remaining, filters} ->
with true <- is_binary(key),
[_, name, constraint] <- Regex.run(~r/^(\w+)\[(\w+)\]$/, key),
%{} = constraints <- Map.get(virtual, name) do
case Map.get(constraints, constraint) do
nil ->
{:halt, {:error, "Invalid constraint: #{name}[#{constraint}]"}}
fun ->
filter = {fun, virtual_value(constraint, value)}
{:cont, {:ok, Map.delete(remaining, key), [filter | filters]}}
end
else
_ -> {:cont, {:ok, remaining, filters}}
end
end)
case result do
{:ok, remaining, filters} -> {:ok, remaining, Enum.reverse(filters)}
{:error, reason} -> {:error, reason}
end
end
defp virtual_value("in", value), do: Bind.QueryBuilder.in_values(value)
defp virtual_value(_constraint, value), do: value
# Sorting by a virtual-only name would order_by a nonexistent column and
# raise at Repo.all; reject it up front. A virtual name shadowing a real
# column stays sortable (the sort hits the column).
defp validate_virtual_sort(_params, virtual, _schema) when map_size(virtual) == 0, do: :ok
defp validate_virtual_sort(params, virtual, schema) do
sort = Map.get(params, "sort")
if is_binary(sort) and sort != "" do
name = String.trim_leading(sort, "-")
if Map.has_key?(virtual, name) and not schema_field?(schema, name) do
{:error, "Cannot sort by virtual field: #{name}"}
else
:ok
end
else
:ok
end
end
defp schema_field?(schema, name) when is_atom(schema) do
function_exported?(schema, :__schema__, 1) and
Enum.any?(schema.__schema__(:fields), &(to_string(&1) == name))
end
defp schema_field?(_schema, _name), do: false
defp apply_virtual(query, filters) do
Enum.reduce_while(filters, query, fn {fun, value}, q ->
case fun.(q, value) do
{:error, reason} -> {:halt, {:error, reason}}
scoped -> {:cont, scoped}
end
end)
end
defp has_custom_mapper?(mappers, field) do
case Map.get(mappers, String.to_atom(field)) do
nil ->
Enum.any?(mappers, fn
{%Regex{} = re, _} -> Regex.match?(re, field)
_ -> false
end)
_ ->
true
end
end
end