Packages

A resource declaration and interaction library. Built with pluggable data layers, and designed to be used by multiple front ends.

Retired package: Renamed - renamed to ash

Current section

Files

Jump to
ash_core lib ash query.ex
Raw

lib/ash/query.ex

defmodule Ash.Query do
@moduledoc """
Utilties around constructing/manipulating ash queries.
Ash queries are used for read actions and side loads, and ultimately
map to queries to a resource's data layer.
"""
defstruct [
:api,
:resource,
:filter,
:data_layer_query,
side_load: [],
sort: [],
limit: nil,
offset: 0,
errors: [],
valid?: true
]
@type t :: %__MODULE__{}
defimpl Inspect do
import Inspect.Algebra
def inspect(query, opts) do
opts = %{
opts
| syntax_colors: [
atom: :yellow,
binary: :green,
boolean: :magenta,
list: :cyan,
map: :magenta,
number: :red,
regex: :violet,
tuple: :white
]
}
error_doc =
if Enum.empty?(query.errors) do
empty()
else
concat("errors: ", to_doc(query.errors, opts))
end
container_doc(
"#Ash.Query<",
[
concat("resource: ", inspect(query.resource)),
concat("filter: ", to_doc(query.filter, opts)),
concat("sort: ", to_doc(query.sort, opts)),
concat("limit: ", to_doc(query.limit, opts)),
concat("offset: ", to_doc(query.offset, opts)),
concat("side_load: ", to_doc(query.side_load, opts)),
error_doc
],
">",
opts,
fn str, _ -> str end
)
end
end
alias Ash.Actions.Sort
alias Ash.Error.Query.{InvalidLimit, InvalidOffset}
alias Ash.Error.SideLoad.{InvalidQuery, NoSuchRelationship}
@doc "Create a new query."
def new(resource, api \\ nil) when is_atom(resource) do
%__MODULE__{
api: api,
filter: nil,
resource: resource
}
|> set_data_layer_query()
end
@doc "Set the query's api, and any side loaded query's api"
def set_api(query, api) do
query = to_query(query)
%{query | api: api, side_load: set_side_load_api(query.side_load, api)}
end
@doc "Limit the results returned from the query"
def limit(query, nil), do: query
def limit(query, limit) when is_integer(limit) do
query
|> to_query()
|> Map.put(:limit, max(0, limit))
|> set_data_layer_query()
end
def limit(query, limit) do
add_error(query, :offset, InvalidLimit.exception(limit: limit))
end
@doc "Skip the first n records"
def offset(query, nil), do: query
def offset(query, offset) when is_integer(offset) do
query
|> to_query()
|> Map.put(:offset, max(0, offset))
|> set_data_layer_query()
end
def offset(query, offset) do
query
|> to_query()
|> add_error(:offset, InvalidOffset.exception(offset: offset))
end
@doc "Side loads related entities"
def side_load(query, statement) do
query = to_query(query)
with sanitized_statement <- List.wrap(sanitize_side_loads(statement)),
:ok <- validate_side_load(query.resource, sanitized_statement),
new_side_loads <- merge_side_load(query.side_load, sanitized_statement) do
%{query | side_load: new_side_loads}
else
{:error, errors} ->
Enum.reduce(errors, query, &add_error(&2, :side_load, &1))
end
end
def validate_side_load(resource, side_loads, path \\ []) do
case do_validate_side_load(resource, side_loads, path) do
[] -> :ok
errors -> {:error, errors}
end
end
def do_validate_side_load(_resource, %Ash.Query{} = query, path) do
if query.limit || (query.offset && query.offset != 0) do
[{:error, InvalidQuery.exception(query: query, side_load_path: Enum.reverse(path))}]
else
case query.errors do
[] ->
[]
_errors ->
[
{:error,
InvalidQuery.exception(
query: query,
side_load_path: Enum.reverse(path)
)}
]
end
end
end
def do_validate_side_load(resource, {atom, _} = tuple, path) when is_atom(atom) do
do_validate_side_load(resource, [tuple], path)
end
def do_validate_side_load(resource, side_loads, path) when is_list(side_loads) do
side_loads
|> List.wrap()
|> Enum.flat_map(fn
{_key, %Ash.Query{}} ->
[]
{key, value} ->
case Ash.Resource.relationship(resource, key) do
nil ->
[
{:error,
NoSuchRelationship.exception(
resource: resource,
relationship: key,
side_load_path: Enum.reverse(path)
)}
]
relationship ->
validate_matching_query_and_continue(value, resource, key, path, relationship)
end
end)
end
def filter(query, nil), do: query
def filter(query, %Ash.Filter{} = filter) do
query = to_query(query)
new_filter =
case query.filter do
nil ->
{:ok, filter}
existing_filter ->
Ash.Filter.add_to_filter(existing_filter, filter)
end
case new_filter do
{:ok, filter} ->
set_data_layer_query(%{query | filter: filter})
{:error, error} ->
add_error(query, :filter, error)
end
end
def filter(query, statement) do
query = to_query(query)
filter =
if query.filter do
Ash.Filter.add_to_filter(query.filter, statement)
else
Ash.Filter.parse(query.resource, statement)
end
case filter do
{:ok, filter} ->
query
|> Map.put(:filter, filter)
|> set_data_layer_query()
{:error, error} ->
add_error(query, :filter, error)
end
end
def sort(query, sorts) when is_list(sorts) do
query = to_query(query)
sorts
|> Enum.reduce(query, fn
{sort, direction}, query ->
%{query | sort: query.sort ++ [{sort, direction}]}
sort, query ->
%{query | sort: query.sort ++ [{sort, :asc}]}
end)
|> validate_sort()
|> set_data_layer_query()
end
def unset(query, keys) when is_list(keys) do
Enum.reduce(keys, query, &unset(&2, &1))
end
def unset(query, key) do
if key in [:api, :resource] do
query
else
query
|> to_query()
|> struct([{key, Map.get(%__MODULE__{}, key)}])
end
end
@doc false
def data_layer_query(%{resource: resource} = ash_query, opts \\ []) do
query = Ash.DataLayer.resource_to_query(resource)
with {:ok, query} <- Ash.DataLayer.sort(query, ash_query.sort, resource),
{:ok, query} <- maybe_filter(query, ash_query, opts) do
{:ok, query}
else
{:error, error} -> {:error, error}
end
end
defp validate_sort(%{resource: resource, sort: sort} = query) do
case Sort.process(resource, sort) do
{:ok, new_sort} -> %{query | sort: new_sort}
{:error, error} -> add_error(query, :sort, error)
end
end
defp add_error(query, key, message) do
query = to_query(query)
message =
if is_binary(message) do
"#{key}: #{message}"
else
message
end
%{
query
| errors: [Map.put(Ash.Error.to_ash_error(message), :path, key) | query.errors],
valid?: false
}
end
defp set_data_layer_query(query) do
case data_layer_query(query) do
{:ok, data_layer_query} -> %{query | data_layer_query: data_layer_query}
{:error, error} -> add_error(query, :data_layer_query, error)
end
end
defp validate_matching_query_and_continue(value, resource, key, path, relationship) do
%{destination: relationship_resource} = relationship
case value do
%__MODULE__{resource: query_resource} = destination_query
when query_resource != relationship_resource ->
[
InvalidQuery.exception(
resource: resource,
relationship: key,
query: destination_query,
side_load_path: Enum.reverse(path)
)
]
other ->
do_validate_side_load(relationship.destination, other, [key | path])
end
end
defp maybe_filter(query, %{filter: nil}, _) do
{:ok, query}
end
defp maybe_filter(query, ash_query, opts) do
case Ash.DataLayer.filter(query, ash_query.filter, ash_query.resource) do
{:ok, filtered} ->
if Keyword.get(opts, :only_validate_filter?, true) do
{:ok, query}
else
{:ok, filtered}
end
{:error, error} ->
{:error, error}
end
end
defp set_side_load_api(nil, _), do: nil
defp set_side_load_api([], _), do: []
defp set_side_load_api(%__MODULE__{} = query, api) do
set_api(query, api)
end
defp set_side_load_api(side_loads, api) do
Enum.map(side_loads, fn {key, further} ->
{key, set_side_load_api(further, api)}
end)
end
defp to_query(%__MODULE__{} = query), do: query
defp to_query(resource), do: new(resource)
defp merge_side_load([], right), do: sanitize_side_loads(right)
defp merge_side_load(left, []), do: sanitize_side_loads(left)
defp merge_side_load(
%__MODULE__{side_load: left_side_loads},
%__MODULE__{side_load: right_side_loads} = query
) do
%{query | side_load: merge_side_load(left_side_loads, right_side_loads)}
end
defp merge_side_load(%__MODULE__{} = query, right) when is_list(right) do
Ash.Query.side_load(query, right)
end
defp merge_side_load(left, %Ash.Query{} = query) when is_list(left) do
Ash.Query.side_load(query, left)
end
defp merge_side_load(left, right) when is_atom(left), do: merge_side_load([{left, []}], right)
defp merge_side_load(left, right) when is_atom(right), do: merge_side_load(left, [{right, []}])
defp merge_side_load(left, right) when is_list(left) and is_list(right) do
right
|> sanitize_side_loads()
|> Enum.reduce(sanitize_side_loads(left), fn {rel, rest}, acc ->
Keyword.update(acc, rel, rest, &merge_side_load(&1, rest))
end)
end
defp sanitize_side_loads(side_load) when is_atom(side_load), do: {side_load, []}
defp sanitize_side_loads(%Ash.Query{} = query) do
Map.update!(query, :side_load, &sanitize_side_loads/1)
end
defp sanitize_side_loads(side_loads) do
side_loads
|> List.wrap()
|> Enum.map(fn
{key, value} ->
{key, sanitize_side_loads(value)}
side_load_part ->
cond do
is_atom(side_load_part) -> {side_load_part, []}
is_list(side_load_part) -> sanitize_side_loads(side_load_part)
true -> side_load_part
end
end)
end
end