Packages

A client for interacting with the Sanity API and constructing GROQ queries from Elixir applications.

Current section

Files

Jump to
sanity_ex lib query.ex
Raw

lib/query.ex

defmodule SanityEx.Query do
@moduledoc """
`SanityEx.Query` is used for constructing GROQ queries from Elixir syntax.
## Example
iex> Query.new()
|> Query.filter(%{"_type" => "'post'"})
|> Query.project([
"title",
"body",
%{
"'author'" => [
["'_id'", ["author", "_id", :follow]],
["'_type'", ["author", "_type", :follow]],
["'name'", ["author", "name", :follow]],
["'slug'", ["author", "slug", :follow]],
["'image'", ["author", "image", :follow]]
]
}
])
|> Query.slice(0)
|> Query.build()
"*[!(_id in path('drafts.**')) && _type == 'post']{title, body, 'author':{'_id':author->_id, '_type':author->_type, 'name':author->name, 'slug':author->slug, 'image':author->image}}[0]"
"""
alias __MODULE__
defstruct [
:base_query,
:filters,
:projections,
:order,
:slice
]
@type t :: %__MODULE__{
base_query: String.t(),
filters: list(),
projections: list(),
order: list(),
slice: nil | integer() | String.t() | {integer(), integer()}
}
@doc """
Initialize the building of a GROQ query pipeline. Returns a SanityEx.Query struct representing a GROQ query.
## Options
- `:include_drafts` - If set, the query will include draft documents.
- `:base_query` - If set, the query will use this as its basis instead of `*`.
"""
@doc since: "0.1.0"
@spec new(Keyword.t()) :: t()
def new(opts \\ []) do
include_drafts = Keyword.get(opts, :include_drafts, false)
base_query = Keyword.get(opts, :base_query, "*")
filters =
if include_drafts or base_query == "" do
[]
else
[%{"_id" => ["in", "path('drafts.**')", :negate]}]
end
%Query{
base_query: base_query,
filters: filters,
projections: [],
order: [],
slice: nil
}
end
@doc """
Add a filter to the query. The filter retains documents for which the expression evaluates to true.
In a GROQ query, the Filter expression is the first expression in the query, and is wrapped in square brackets.
## Example
```elixir
filter(query, [ %{ "_type" => "'post'" }, [{ "_id" => "'id'" }, { "slug.current" => "'slug'" }, { :join => "||" }] ])
# => *[_type == 'post' && (_id == 'id' || slug.current == 'slug')]
```
Read more about [filtering queries](https://www.sanity.io/docs/query-cheat-sheet#3949cadc7524)
"""
@doc since: "0.1.0"
@spec filter(Query.t(), maybe_improper_list() | map()) ::
{:error, String.t(), Query.t()} | Query.t()
def filter(%Query{} = template, [key, val]) when not is_map(key) and not is_map(val) do
filter(template, [%{key => val}])
end
def filter(%Query{} = template, filter) when is_map(filter) do
filter(template, [filter])
end
def filter(%Query{} = template, filters) when is_list(filters) do
if filters_valid?(filters) do
%{template | filters: template.filters ++ filters}
else
{:error, "Filters must be a list of maps or nested lists of maps", template}
end
end
def filter({:error, reason, template}, _filter) do
{:error, reason, template}
end
defp filters_valid?(filters) do
Enum.all?(filters, fn
filter when is_map(filter) -> true
filter when is_list(filter) -> filters_valid?(filter)
_ -> false
end)
end
@doc """
Adds object projection(s) to the query. This determines how the results of the query should be formatted.
## Example
```elixir
project(query, ["_id", ["'objectID'", "_id"], "_rev", "_type", "title", ... ])
# => *[_type == 'post']{_id, 'objectID':_id, _rev, _type, title, ... }
```
In order to Join certain attributes inside a projection, you can use a nested map with the `:follow` atom present:
```elixir
project(
query,
[
"_id",
%{
"'author'" => [
["'_id'", ["author", "_id", :follow]]
]
}
]
)
# => *[_type == 'post']{_id, 'author':{'_id':author->_id}}
```
Read more about [object projections](https://www.sanity.io/docs/query-cheat-sheet#55d30f6804cc)
"""
@doc since: "0.1.0"
@spec project(Query.t(), any) ::
{:error, String.t(), Query.t()} | Query.t()
def project(%Query{} = template, projections) do
case projections do
projections when is_list(projections) ->
%{template | projections: template.projections ++ projections}
projection when is_map(projection) or is_binary(projection) ->
%{template | projections: template.projections ++ [projection]}
_ ->
{:error, "Projections must be a string, list of strings, or nested maps", template}
end
end
def project({:error, reason, template}, _projection) do
{:error, reason, template}
end
@doc """
Specify the ordering of the results of the query.
## Example
```elixir
order(query, "_createdAt desc")
# or, equivalently:
order(query, ["_createdAt", "desc"])
# => *[_type == 'post'] | order(_createdAt desc)
```
The examples above order the query results by the `_createdAt` attribute in descending order.
Read more about [ordering results](https://www.sanity.io/docs/query-cheat-sheet#b5aec96cf56c)
"""
@doc since: "0.1.0"
@spec order(Query.t(), integer() | String.t() | maybe_improper_list()) ::
{:error, String.t(), Query.t()} | Query.t()
def order(%Query{} = template, order) do
case order do
order when is_binary(order) ->
%{template | order: [order]}
order when is_list(order) ->
%{template | order: order}
_ ->
{:error, "Order must be a string or a list of strings", template}
end
end
def order({:error, reason, template}, _order) do
{:error, reason, template}
end
@doc """
Slice the results returned by the query, or access a specific index.
## Example
```elixir
slice(query, {0, 5})
# => *[_type == 'post'][0...5]
slice(query, 0)
# => *[_type == 'post'][0]
```
The first example above limits the query results to a maximum of 5 documents, starting at index 0. The second returns only the first index of the query results.
Read more about [slicing query results](https://www.sanity.io/docs/query-cheat-sheet#aa94b64a5bf5)
"""
@doc since: "0.1.0"
@spec slice(Query.t(), integer() | String.t() | {integer(), integer()}) ::
{:error, String.t(), Query.t()} | Query.t()
def slice(%Query{} = template, slice) do
case slice do
{offset, limit}
when is_integer(offset) and is_integer(limit) and offset >= 0 and limit > 0 ->
%{template | slice: {offset, limit}}
{offset, limit}
when is_integer(offset) and is_integer(limit) and offset == 0 and limit == 0 ->
template
slice when (is_integer(slice) and slice >= 0) or is_binary(slice) ->
%{template | slice: slice}
_ ->
{:error,
"Slice must be a positive integer, a binary, or a tuple of {offset, limit} where both are positive integers",
template}
end
end
def slice({:error, reason, template}, _slice) do
{:error, reason, template}
end
@doc """
Builds a GROQ query from the given structure.
The final query is returned as a string.
"""
@doc since: "0.1.0"
@spec build(Query.t()) :: {:error, String.t(), Query.t()} | String.t()
def build(%Query{
base_query: base_query,
filters: filters,
projections: projections,
order: order,
slice: slice
}) do
base_query
|> build_filters(filters)
|> build_projections(projections)
|> build_order(order)
|> build_slice(slice)
end
def build({:error, reason, template}) do
{:error, reason, template}
end
@doc """
Builds a GROQ query from the given structure.
The final query is returned as a string, or an error is raised if the query could not be built.
"""
@doc since: "0.1.0"
@spec build!(Query.t()) :: String.t()
def build!(query) do
case build(query) do
{:error, reason, _} -> raise reason
query_str -> query_str
end
end
defp build_filters(query_str, filters) do
filters_string =
filters
|> Enum.map(&format_filter!/1)
|> Enum.join(" && ")
case filters_string do
"" -> query_str
_ -> query_str <> "[#{filters_string}]"
end
end
defp format_filter!(filter) do
case filter do
%{} ->
Enum.map(Enum.filter(filter, fn {key, _} -> is_binary(key) end), fn {key, value} ->
nest = Map.get(filter, :nest, false)
case nest do
true ->
key <> "(" <> format_filter!(value <> ")")
_ ->
format_filter_pair(key, value)
end
end)
{key, value} when is_binary(key) and is_binary(value) ->
format_filter_pair(key, value)
filters when is_list(filters) ->
join =
Enum.find(filters, fn x -> is_map(x) && Map.get(x, :join) end)
|> (fn x ->
if x do
Map.get(x, :join)
else
"||"
end
end).()
negate =
Enum.find(filters, fn x -> is_map(x) && Map.get(x, :negate) end)
|> (fn x ->
if x do
"!"
else
""
end
end).()
filters =
Enum.reject(filters, fn x -> is_map(x) && (Map.get(x, :join) || Map.get(x, :negate)) end)
negate <> "(#{Enum.map(filters, &format_filter!/1) |> Enum.join(" " <> join <> " ")})"
filter when is_binary(filter) ->
filter
filter ->
raise "Invalid filter format: #{inspect(filter)}"
end
end
defp format_filter_pair(key, value) do
key =
case key do
key when is_atom(key) -> Atom.to_string(key)
_ -> key
end
case value do
[operator, value | opts] when is_binary(operator) and is_binary(value) ->
negate = Enum.any?(opts, fn opt -> opt == :negate end)
case negate do
true ->
"!(#{key} #{operator} #{value})"
false ->
"#{key} #{operator} #{value}"
end
[operator, value] when is_binary(operator) and is_binary(value) ->
"#{key} #{operator} #{value}"
_ ->
"#{key} == #{value}"
end
end
defp build_projections(query, projections) do
query_length = String.length(query)
case projections do
nil ->
query
[] ->
query
# If only one projection is provided, don't wrap it in curly braces, as it can be a direct property access of the filter
# However, only do this if filters are present, as otherwise it's a direct query (e.g. query length > 1)
# This should also not occur if the projection contains nested projections - in that case, it should always be joined and wrapped
[projection] when query_length > 1 and is_binary(projection) ->
query <> ".#{projection}"
_ ->
query <> "{#{join_projections(projections)}}"
end
end
defp join_projections(projections) do
Enum.join(
projections
|> Enum.map(&format_projection/1),
", "
)
end
defp format_projection(projection) when is_map(projection) do
joiner = if Map.get(projection, :join, false), do: Map.get(projection, :join), else: ":"
Enum.join(
Enum.map(Enum.filter(projection, fn {key, _} -> is_binary(key) end), fn {key, value} ->
"#{key}#{joiner}{#{join_projections(value)}}"
end),
", "
)
end
defp format_projection([key, value]) do
case value do
value when is_list(value) ->
"#{key}:#{format_projection(value)}"
_ ->
"#{key}:#{value}"
end
end
defp format_projection([key, value, opt]) do
joiner = if opt == :follow, do: "->", else: ":"
case value do
value when is_list(value) ->
"#{key}#{joiner}{#{join_projections(value)}}"
_ ->
"#{key}#{joiner}#{value}"
end
end
defp format_projection(projection) do
projection
end
defp build_order(query_str, order) do
case order do
[] ->
query_str
_ ->
query_str <> " | order(#{Enum.join(order, ", ")})"
end
end
defp build_slice(query_str, slice) do
case slice do
nil ->
query_str
{offset, limit} ->
query_str <> " [#{offset}...#{offset + limit}]"
slice when is_binary(slice) or is_integer(slice) ->
query_str <> "[#{slice}]"
_ ->
query_str
end
end
end