Skip to content

Commit

Permalink
improvement: add error handling tooling for custom queries
Browse files Browse the repository at this point in the history
docs: add guide for generic actions
  • Loading branch information
zachdaniel committed Oct 9, 2024
1 parent 6fedbd7 commit faff318
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 30 deletions.
36 changes: 33 additions & 3 deletions documentation/topics/custom-queries-and-mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,51 @@
You can define your own queries and mutations in your schema,
using Absinthe's tooling. See their docs for more.

> ### You probably don't need this! {: .info}
>
> You can define generic actions in your resources which can return any
> type that you want, and those generic actions will automatically get
> all of the goodness of AshGraphql, with automatic data loading and
> type derivation, etc. See the [generic actions guide](/documentation/topics/generic-actions.md) for more.
## Using AshGraphql's types

If you want to return resource types defined by AshGraphql, however,
you will need to use `AshGraphql.load_fields/4` to ensure that any
you will need to use `AshGraphql.load_fields_on_query/2` to ensure that any
requested fields are loaded.

For example:

```elixir
require Ash.Query

query do
field :custom_get_post, :post do
arg(:id, non_null(:id))

resolve(fn %{id: post_id}, resolution ->
MyApp.Blog.Post
|> Ash.Query.filter(id == ^post_id)
|> AshGraphql.load_fields_on_query(resolution)
|> Ash.read_one(not_found_error?: true)
|> AshGraphql.handle_errors(MyApp.Blog.Post, resolution)
end)
end
end
```

Alternatively, if you have records already that you need to load data on, use `AshGraphql.load_fields/3`:

```elixir
query do
field :custom_get_post, :post do
arg(:id, non_null(:id))

resolve(fn %{id: post_id}, resolution ->
with {:ok, post} when not is_nil(post) <- Ash.get(AshGraphql.Test.Post, post_id) do
AshGraphql.load_fields(post, AshGraphql.Test.Post, resolution)
with {:ok, post} when not is_nil(post) <- Ash.get(MyApp.Blog.Post, post_id) do
AshGraphql.load_fields(post, MyApp.Blog.Post, resolution)
end
|> AshGraphql.handle_errors(MyApp.Blog.Post, resolution)
end)
end
end
Expand Down
56 changes: 56 additions & 0 deletions documentation/topics/generic-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generic Actions

Generic actions allow us to build any interface we want in Ash. AshGraphql
has full support for generic actions, from type generation to data loading.

This means that you can write actions that return records or lists of records
and those will have all of their fields appropriately loadable, or you can have
generic actions that return simple scalars, like integers or strings.

## Examples

Here we have a simple generic action returning a scalar value.

```elixir
graphql do
queries do
action :say_hello, :say_hello
end
end

actions do
action :say_hello, :string do
argument :to, :string, allow_nil?: false

run fn input, _ ->
{:ok, "Hello, #{input.arguments.to}"}
end
end
end
```

And here we have a generic action returning a list of records.

```elixir
graphql do
type :post

queries do
action :random_ten, :random_ten
end
end

actions do
action :random_ten, {:array, :struct} do
constraints items: [instance_of: __MODULE__]

run fn input, context ->
# This is just an example, not an efficient way to get
# ten random records
with {:ok, records} <- Ash.read(__MODULE__) do
{:ok, Enum.take_random(records, 10)}
end
end
end
end
```
98 changes: 74 additions & 24 deletions lib/ash_graphql.ex
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,27 @@ defmodule AshGraphql do
{:ok, input} | {:error, term()}
when input: Ash.Resource.record() | list(Ash.Resource.record()) | Ash.Page.page()
def load_fields(data, resource, resolution, opts \\ []) do
Ash.load(data, load_fields_on_query(resource, resolution, opts), resource: resource)
end

@doc """
The same as `load_fields/4`, but modifies the provided query to load the required fields.
This allows doing the loading in a single query rather than two separate queries.
"""
@spec load_fields_on_query(
query :: Ash.Query.t() | Ash.Resource.t(),
Absinthe.Resolution.t(),
Keyword.t()
) ::
Ash.Query.t()
def load_fields_on_query(query, resolution, opts \\ []) do
query =
query
|> Ash.Query.new()

resource = query.resource

domain =
opts[:domain] || Ash.Resource.Info.domain(resource) ||
raise ArgumentError,
Expand All @@ -630,33 +651,62 @@ defmodule AshGraphql do
authorize? = Keyword.get(opts, :authorize?, AshGraphql.Domain.Info.authorize?(domain))
actor = Keyword.get(opts, :actor, Map.get(resolution.context, :actor))

query =
resource
|> Ash.Query.new()
|> Ash.Query.set_tenant(tenant)
|> Ash.Query.set_context(AshGraphql.ContextHelpers.get_context(resolution.context))
|> AshGraphql.Graphql.Resolver.select_fields(
resource,
resolution,
nil,
opts[:path] || []
)
|> AshGraphql.Graphql.Resolver.load_fields(
[
domain: domain,
tenant: tenant,
authorize?: authorize?,
actor: actor
],
resource,
resolution,
resolution.path,
resolution.context
)
query
|> Ash.Query.set_tenant(tenant)
|> Ash.Query.set_context(AshGraphql.ContextHelpers.get_context(resolution.context))
|> AshGraphql.Graphql.Resolver.select_fields(
resource,
resolution,
nil,
opts[:path] || []
)
|> AshGraphql.Graphql.Resolver.load_fields(
[
domain: domain,
tenant: tenant,
authorize?: authorize?,
actor: actor
],
resource,
resolution,
resolution.path,
resolution.context
)
end

@doc """
Applies AshGraphql's error handling logic if the value is an `{:error, error}` tuple, otherwise returns the value
Useful for automatically handling errors in custom queries
## Options
Ash.load(data, query, resource: resource)
- `domain`: The domain to use when loading the fields. Determined from the resource by default.
"""
@spec handle_errors(
result :: term,
resource :: Ash.Resource.t(),
resolution :: Absinthe.Resolution.t(),
opts :: Keyword.t()
) ::
term()
def handle_errors(result, resource, resolution, opts \\ [])

def handle_errors({:error, error}, resource, resolution, opts) do
domain =
Ash.Resource.Info.domain(resource) || opts[:domain] ||
raise ArgumentError,
"Could not determine domain for #{inspect(resource)}. Please specify the `domain` option."

AshGraphql.Graphql.Resolver.to_resolution(
{:error, List.wrap(error)},
resolution.context,
domain
)
end

def handle_errors(result, _, _, _), do: result

@doc false
def only_union_types(attributes) do
Enum.flat_map(attributes, fn attribute ->
Expand Down
5 changes: 3 additions & 2 deletions lib/graphql/resolver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3082,9 +3082,10 @@ defmodule AshGraphql.Graphql.Resolver do
end)
end

defp to_resolution({:ok, value}, _context, _domain), do: {:ok, value}
@doc false
def to_resolution({:ok, value}, _context, _domain), do: {:ok, value}

defp to_resolution({:error, error}, context, domain) do
def to_resolution({:error, error}, context, domain) do
{:error,
error
|> unwrap_errors()
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ defmodule AshGraphql.MixProject do
"documentation/tutorials/getting-started-with-graphql.md",
"documentation/topics/authorize-with-graphql.md",
"documentation/topics/handle-errors.md",
"documentation/topics/generic-actions.md",
"documentation/topics/sdl-file.md",
"documentation/topics/use-enums-with-graphql.md",
"documentation/topics/use-json-with-graphql.md",
Expand Down
64 changes: 64 additions & 0 deletions test/read_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,70 @@ defmodule AshGraphql.ReadTest do
assert result[:data]["customGetPost"]["latestCommentAt"]
end

test "custom queries can load fields with `load_fields_on_query`" do
AshGraphql.Test.Post
|> Ash.Changeset.for_create(:create,
text: "foo",
published: true,
score: 9.8
)
|> Ash.create!()

post =
AshGraphql.Test.Post
|> Ash.Changeset.for_create(:create, text: "foo", published: true)
|> Ash.create!()

AshGraphql.Test.Comment
|> Ash.Changeset.for_create(:create, %{text: "stuff"})
|> Ash.Changeset.force_change_attribute(:post_id, post.id)
|> Ash.create!()

resp =
"""
query Post($id: ID!) {
customGetPostQuery(id: $id) {
latestCommentAt
}
}
"""
|> Absinthe.run(AshGraphql.Test.Schema,
variables: %{
"id" => post.id
}
)

assert {:ok, result} = resp
assert result[:data]["customGetPostQuery"]["latestCommentAt"]
end

test "custom queries can handle errors" do
resp =
"""
query Post($id: ID!) {
customGetPost(id: $id) {
id
}
}
"""
|> Absinthe.run(AshGraphql.Test.Schema,
variables: %{
"id" => Ash.UUID.generate()
}
)

assert {:ok, result} = resp

assert [
%{
code: "not_found",
message: "could not be found",
path: ["customGetPost"],
short_message: "could not be found"
}
] = result[:errors]
end

test "a read with custom types works" do
AshGraphql.Test.Post
|> Ash.Changeset.for_create(:create,
Expand Down
16 changes: 15 additions & 1 deletion test/support/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,22 @@ defmodule AshGraphql.Test.Schema do

resolve(fn %{id: post_id}, resolution ->
with {:ok, post} when not is_nil(post) <- Ash.get(AshGraphql.Test.Post, post_id) do
AshGraphql.load_fields(post, AshGraphql.Test.Post, resolution)
post
|> AshGraphql.load_fields(AshGraphql.Test.Post, resolution)
end
|> AshGraphql.handle_errors(AshGraphql.Test.Post, resolution)
end)
end

field :custom_get_post_query, :post do
arg(:id, non_null(:id))

resolve(fn %{id: post_id}, resolution ->
AshGraphql.Test.Post
|> Ash.Query.do_filter(id: post_id)
|> AshGraphql.load_fields_on_query(resolution)
|> Ash.read_one(not_found_error?: true)
|> AshGraphql.handle_errors(AshGraphql.Test.Post, resolution)
end)
end
end
Expand Down

0 comments on commit faff318

Please sign in to comment.