Current section
Files
Jump to
Current section
Files
lib/graphql/relay/connection/ecto.ex
if Code.ensure_loaded?(Ecto) do
defmodule GraphQL.Relay.Connection.Ecto do
@moduledoc """
Interface between Relay Connections and Ecto. This module allows you to
back a Relay Connection with an Ecto query.
## Example
A Relay connection starts with a connection definition. For example, a
`user` can have many `todos` (see code comments):
```elixir
%ObjectType{
name: "User",
description: "The user",
fields: %{
id: Node.global_id_field("user"),
name: %{type: %String{}},
email: %{type: %String{}},
todos: %{
type: Todo.connection[:connection_type],
description: "The todos this user owns",
args: Map.merge(
%{status: %{type: %String{}, defaultValue: "any"}},
Connection.args
),
resolve: fn(user, args, _ctx) ->
# Here we prepare our Ecto query
query = Ecto.assoc(user, :todos)
query = case args do
%{status: "active"} -> from things in query, where: things.complete == false
%{status: "completed"} -> from things in query, where: things.complete == true
_ -> query
end
# Here we resolve the connection using our Ecto Connection module,
# passing it the Ecto Repo we want to query. The `resolve/3` function
# will execute the query and return the results in the form Relay
# requires.
Connection.Ecto.resolve(Repo, query, args)
end
},
},
interfaces: [Root.node_interface]
}
```
For a full example see https://github.com/graphql-elixir/graphql_relay/blob/master/examples/todo/web/graphql/user.ex
"""
import Ecto.Query
@prefix "ectoconnection:"
@doc """
WARNING: this function signature is deprecated. Use `resolve/3`.
"""
def resolve(query, %{repo: repo} = args) do
# Emit deprecation warning once we hit >= v0.6 per RELEASE.md#Deprecations
# :elixir_errors.warn __ENV__.line, __ENV__.file, "Use of `GraphQL.Relay.Connection.Ecto.resolve/2` is deprecated! Use `GraphQL.Relay.Connection.Ecto.resolve/3`."
resolve(repo, query, args)
end
@doc """
Pass in an Ecto.Repo, an Ecto.Query, and Relay args to have the query
executed and results returned in the format Relay requires.
If you wish to have the results ordered by some field other than `id`,
use `ordered_by` and `ordered_by_direction`.
Example for returning data in reverse chronological order (newest first)
like you would do for a page listing blog posts:
args = %{
first: 10,
ordered_by: :inserted_at,
ordered_by_direction: :desc
}
Your GraphQL resolve function for the connection will look something like:
resolve: fn(blog, args, _ctx) ->
# Order by desc inserted_at
args = Map.merge(args, %{ordered_by: :inserted_at, ordered_by_direction: :desc})
query = Ecto.assoc(blog, :posts)
GraphQL.Relay.Connection.Ecto.resolve(Repo, query, args)
end
"""
def resolve(repo, query, args \\ %{}) do
before = cursor_to_offset(args[:before])
# `after` is a keyword http://elixir-lang.org/docs/master/elixir/Kernel.SpecialForms.html#try/1
a_after = cursor_to_offset(args[:after])
first = args[:first]
last = args[:last]
ordered_by_property = args[:ordered_by] || :id
ordered_by_direction = get_ordered_by_direction(args[:ordered_by_direction] || :asc)
opposite_ordered_by_direction = if ordered_by_direction == :asc, do: :desc, else: :asc
limit = Enum.min([first, last, connection_count(repo, query)])
query = if a_after do
case ordered_by_direction do
:asc ->
query |> where([a], field(a, ^ordered_by_property) > ^a_after)
:desc ->
query |> where([a], field(a, ^ordered_by_property) < ^a_after)
end
else
query
end
query = if before do
case ordered_by_direction do
:asc ->
query |> where([a], field(a, ^ordered_by_property) < ^before)
:desc ->
query |> where([a], field(a, ^ordered_by_property) > ^before)
end
else
query
end
# Calculate has_next_page/has_prev_page before order_by to avoid group_by requirement
has_next_page = case first do
nil -> false
_ ->
first_limit = first + 1
has_more_records_query = make_query_countable(from things in query, limit: ^first_limit)
has_more_records_query = from things in has_more_records_query, select: count(things.id)
repo.one(has_more_records_query) > first
end
has_prev_page = case last do
nil -> false
_ ->
last_limit = last + 1
has_prev_records_query = make_query_countable(from things in query, limit: ^last_limit)
has_prev_records_query = from things in has_prev_records_query, select: count(things.id)
repo.one(has_prev_records_query) > last
end
query = if first do
sort_values = Keyword.new([{ordered_by_direction, ordered_by_property}])
query |> order_by(^sort_values) |> limit(^limit)
else
query
end
query = if last do
sort_values = Keyword.new([{opposite_ordered_by_direction, ordered_by_property}])
query |> order_by(^sort_values) |> limit(^limit)
else
query
end
records = repo.all(query)
edges = Enum.map(records, fn(record) ->
%{
cursor: cursor_for_object_in_connection(record, ordered_by_property),
node: record
}
end)
edges = case last do
nil -> edges
_ -> Enum.reverse(edges)
end
first_edge = List.first(edges)
last_edge = List.last(edges)
%{
edges: edges,
pageInfo: %{
startCursor: first_edge && Map.get(first_edge, :cursor),
endCursor: last_edge && Map.get(last_edge, :cursor),
hasPreviousPage: has_prev_page,
hasNextPage: has_next_page
}
}
end
def cursor_to_offset(nil), do: nil
def cursor_to_offset(cursor) do
case Base.decode64(cursor) do
{:ok, decoded_cursor} ->
string = String.slice(decoded_cursor, String.length(@prefix)..String.length(decoded_cursor))
case Ecto.DateTime.cast(string) do
{:ok, date} -> date
:error -> string
end
:error ->
nil
end
end
def cursor_for_object_in_connection(object, property \\ :id) do
prop = case Map.get(object, property) do
%Ecto.DateTime{} = date_time -> Ecto.DateTime.to_iso8601(date_time)
prop -> to_string(prop)
end
Base.encode64("#{@prefix}#{prop}")
end
def connection_count(repo, query) do
query = make_query_countable(query)
count_query = from things in query, select: count(things.id)
repo.one(count_query)
end
defp get_ordered_by_direction(value) when value in [:asc, :desc] do
value
end
defp make_query_countable(query) do
query
|> remove_order
|> remove_preload
|> remove_select
end
# Remove order by if it exists so that we avoid `field X in "order_by"
# does not exist in the model source in query`
defp remove_order(query) do
Ecto.Query.exclude(query, :order_by)
end
# Remove preload if it exists so that we avoid "the binding used in `from`
# must be selected in `select` when using `preload` in query`"
defp remove_preload(query) do
Ecto.Query.exclude(query, :preload)
end
# Remove select if it exists so that we avoid `only one select
# expression is allowed in query` Ecto exception
defp remove_select(query) do
Ecto.Query.exclude(query, :select)
end
end
else
defmodule GraphQL.Relay.Connection.Ecto do
def resolve(_, _) do
raise RuntimeError, message: """
Ecto not available. Make sure Ecto is installed as a dependency of your
application.
"""
end
end
end