├── .tool-versions ├── logos ├── logo-only.png ├── small-logo.png ├── logo-black-text.png ├── logo-white-text.png ├── cropped-for-header.png ├── logo-only.png.license ├── small-logo.png.license ├── cropped-for-header.png.license ├── logo-black-text.png.license └── logo-white-text.png.license ├── mix.lock.license ├── .tool-versions.license ├── priv ├── schema.graphql.license ├── relay_ids.graphql.license └── root_level_errors.graphql.license ├── documentation ├── dsls │ ├── DSL-AshGraphql.Domain.md.license │ └── DSL-AshGraphql.Resource.md.license └── topics │ ├── use-json-with-graphql.md │ ├── modifying-the-resolution.md │ ├── use-maps-with-graphql.md │ ├── generic-actions.md │ ├── sdl-file.md │ ├── custom-queries-and-mutations.md │ ├── use-unions-with-graphql.md │ ├── monitoring.md │ ├── relay.md │ └── use-enums-with-graphql.md ├── test ├── ash_graphql_test.exs ├── test_helper.exs ├── support │ ├── types │ │ ├── string_new_type.ex │ │ ├── enum_new_type.ex │ │ ├── status_enum.ex │ │ ├── enum_with_ash_description.ex │ │ ├── simple_union.ex │ │ ├── person_typed_struct.ex │ │ ├── embed_union_new_type.ex │ │ ├── enum_with_ash_graphql_description.ex │ │ ├── map_with_embedded_resource.ex │ │ ├── common_map.ex │ │ ├── embed_union_new_type_unnested.ex │ │ ├── type_with_type_inside.ex │ │ ├── common_map_struct.ex │ │ ├── person_map.ex │ │ ├── person_regular_struct.ex │ │ ├── foo.ex │ │ ├── type_within_type_unreferenced_submap.ex │ │ ├── array_inner_type.ex │ │ ├── union_relation.ex │ │ └── status.ex │ ├── resources │ │ ├── nested_enum.ex │ │ ├── double_rel_type.ex │ │ ├── embed.ex │ │ ├── double_rel_embed.ex │ │ ├── nested_embed.ex │ │ ├── no_graphql.ex │ │ ├── non_id_primary_key.ex │ │ ├── post_tag.ex │ │ ├── actor_agent.ex │ │ ├── movie_actor.ex │ │ ├── relay_post_tag.ex │ │ ├── multitenant_post_tag.ex │ │ ├── award.ex │ │ ├── review.ex │ │ ├── composite_primary_key.ex │ │ ├── channel │ │ │ ├── changes │ │ │ │ └── create_message_user.ex │ │ │ ├── types │ │ │ │ ├── page_of_messages.ex │ │ │ │ ├── page_of_filter_by_actor_messages.ex │ │ │ │ └── messages_union.ex │ │ │ ├── message_viewable_user.ex │ │ │ ├── message.ex │ │ │ ├── text_message.ex │ │ │ ├── image_message.ex │ │ │ ├── calculations │ │ │ │ ├── messages_calculation.ex │ │ │ │ └── filter_by_actor_messages_calculation.ex │ │ │ ├── channel_simple.ex │ │ │ └── channel.ex │ │ ├── composite_primary_key_not_encoded.ex │ │ ├── no_object.ex │ │ ├── constrained_map.ex │ │ ├── agent.ex │ │ ├── double_rel_recursive_to_embed.ex │ │ ├── map_types.ex │ │ ├── actor.ex │ │ ├── product.ex │ │ ├── resource_with_type_inside_type.ex │ │ ├── tag.ex │ │ ├── relay_tag.ex │ │ ├── other_resource.ex │ │ ├── multitenant_tag.ex │ │ ├── error_handling.ex │ │ ├── sponsored_comment.ex │ │ ├── movie.ex │ │ ├── double_rel_recursive.ex │ │ ├── resource_level_pubsub_resource.ex │ │ ├── domain_level_pubsub_resource.ex │ │ ├── resource_with_union.ex │ │ ├── comment.ex │ │ ├── resource_with_typed_struct.ex │ │ ├── subscribable.ex │ │ └── relay_subscribable.ex │ ├── gf │ │ ├── domain.ex │ │ ├── types │ │ │ └── member_status.ex │ │ ├── ash_graphql_schema.ex │ │ ├── active_member_policy.ex │ │ ├── group.ex │ │ ├── attendee.ex │ │ └── event.ex │ ├── static_calculation.ex │ ├── simple_domain │ │ └── simple_resource.ex │ ├── relay_domain.ex │ ├── force_change_id.ex │ ├── meta_middleware.ex │ ├── other_domain.ex │ ├── relay_schema.ex │ ├── simple_domain.ex │ ├── relay_ids │ │ ├── domain.ex │ │ ├── schema.ex │ │ └── resources │ │ │ ├── resource_with_no_primary_key_get.ex │ │ │ ├── user.ex │ │ │ ├── comment.ex │ │ │ └── post.ex │ ├── test_helpers.ex │ ├── lazyinit_reproduce │ │ └── example.ex │ ├── root_level_errors_schema.ex │ ├── pub_sub.ex │ ├── simple_schema.ex │ ├── root_level_errors_domain.ex │ ├── embeds.ex │ ├── schema.ex │ └── domain.ex ├── filter_sort_test.exs ├── lazyinit_post_search_test.exs ├── gf_test.exs ├── meta_test.exs └── enum_test.exs ├── lib ├── graphql │ ├── domain_middleware.ex │ ├── metadata_middleware.ex │ ├── errors.ex │ └── id_translator.ex ├── subscription │ ├── endpoint.ex │ ├── actor_function.ex │ ├── actor.ex │ ├── runner.ex │ ├── notifier.ex │ └── config.ex ├── resource │ ├── helpers.ex │ ├── transformers │ │ ├── subscription.ex │ │ ├── require_keyset_for_relay_queries.ex │ │ └── validate_actions.ex │ ├── verifiers │ │ ├── require_pkey_delimiter.ex │ │ ├── verify_query_metadata.ex │ │ ├── verify_domain_query_metadata.ex │ │ ├── verify_paginate_relationship_with.ex │ │ ├── verify_subscription_actions.ex │ │ └── verify_argument_input_types.ex │ ├── managed_relationship.ex │ └── subscription.ex ├── context_helpers.ex ├── types │ ├── json.ex │ └── json_string.ex ├── domain │ ├── transformers │ │ ├── require_keyset_for_relay_queries.ex │ │ ├── validate_actions.ex │ │ └── validate_compatible_names.ex │ ├── info.ex │ └── verifiers │ │ └── verify_subscription_pubsub.ex ├── plug.ex ├── trace_helpers.ex ├── default_error_handler.ex ├── subscriptions.ex └── mix │ └── tasks │ └── ash_graphql.install.ex ├── .github ├── workflows │ └── elixir.yml └── dependabot.yml ├── .gitignore ├── .check.exs ├── LICENSES └── MIT.txt ├── config └── config.exs ├── .formatter.exs └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.0.1 2 | elixir 1.18.4 3 | pipx 1.8.0 4 | -------------------------------------------------------------------------------- /logos/logo-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_graphql/main/logos/logo-only.png -------------------------------------------------------------------------------- /logos/small-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_graphql/main/logos/small-logo.png -------------------------------------------------------------------------------- /logos/logo-black-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_graphql/main/logos/logo-black-text.png -------------------------------------------------------------------------------- /logos/logo-white-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_graphql/main/logos/logo-white-text.png -------------------------------------------------------------------------------- /logos/cropped-for-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_graphql/main/logos/cropped-for-header.png -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.tool-versions.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/logo-only.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/schema.graphql.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/small-logo.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/relay_ids.graphql.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/cropped-for-header.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/logo-black-text.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/logo-white-text.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /priv/root_level_errors.graphql.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /documentation/dsls/DSL-AshGraphql.Domain.md.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /documentation/dsls/DSL-AshGraphql.Resource.md.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/ash_graphql_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphqlTest do 6 | use ExUnit.Case 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | 7 | Code.ensure_compiled(AshGraphql.Test.Domain) 8 | -------------------------------------------------------------------------------- /test/support/types/string_new_type.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Types.StringNewType do 6 | @moduledoc false 7 | use Ash.Type.NewType, subtype_of: :string, constraints: [match: "hello"] 8 | end 9 | -------------------------------------------------------------------------------- /test/support/types/enum_new_type.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Types.EnumNewType do 6 | @moduledoc false 7 | use Ash.Type.Enum, values: [:biz, :buz] 8 | 9 | def graphql_type(_), do: :biz_buz 10 | end 11 | -------------------------------------------------------------------------------- /test/support/resources/nested_enum.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.NestedEnum do 6 | @moduledoc false 7 | use Ash.Type.Enum, values: [:foo, :bar] 8 | 9 | def graphql_type(_), do: :nested_enum 10 | end 11 | -------------------------------------------------------------------------------- /test/support/types/status_enum.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.StatusEnum do 6 | @moduledoc false 7 | use Ash.Type.Enum, values: [:open, :closed] 8 | 9 | def graphql_type(_), do: :status_enum 10 | end 11 | -------------------------------------------------------------------------------- /lib/graphql/domain_middleware.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Graphql.DomainMiddleware do 6 | @moduledoc false 7 | def set_domain(resolution, domain) do 8 | Map.update!(resolution, :context, &Map.put(&1, :domain, domain)) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/support/gf/domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.Domain do 6 | @moduledoc false 7 | use Ash.Domain 8 | 9 | resources do 10 | resource(GF.Event) 11 | resource(GF.Attendee) 12 | resource(GF.Group) 13 | resource(GF.Member) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/graphql/metadata_middleware.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Graphql.MetadataMiddleware do 6 | @moduledoc false 7 | def set_metadata(resolution, metadata) do 8 | Map.update!(resolution, :context, &Map.put(&1, :meta, metadata || [])) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/support/resources/double_rel_type.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.DoubleRelType do 6 | @moduledoc false 7 | use Ash.Type.Enum, 8 | values: [ 9 | :first, 10 | :second 11 | ] 12 | 13 | def graphql_type(_), do: :double_rel_type 14 | end 15 | -------------------------------------------------------------------------------- /test/support/static_calculation.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.StaticCalculation do 6 | @moduledoc false 7 | use Ash.Resource.Calculation, type: :string 8 | 9 | def calculate(records, _, _) do 10 | Enum.map(records, fn _ -> "static" end) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/support/simple_domain/simple_resource.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.SimpleResource do 6 | @moduledoc false 7 | # Used for simple one-off manual tests 8 | use Ash.Resource, 9 | extensions: [AshGraphql.Resource], 10 | domain: AshGraphql.Test.SimpleDomain 11 | end 12 | -------------------------------------------------------------------------------- /test/support/gf/types/member_status.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.Types.MemberStatus do 6 | @moduledoc false 7 | use Ash.Type.Enum, values: [:non_member, :inactive, :active, :banned] 8 | 9 | def graphql_type(_), do: :sample_member_status 10 | def graphql_input_type(_), do: :sample_member_status 11 | end 12 | -------------------------------------------------------------------------------- /test/support/types/enum_with_ash_description.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.EnumWithAshDescription do 6 | @moduledoc false 7 | use Ash.Type.Enum, 8 | values: [ 9 | fizz: "A fizz", 10 | buzz: "A buzz" 11 | ] 12 | 13 | def graphql_type(_), do: :enum_with_ash_description 14 | end 15 | -------------------------------------------------------------------------------- /lib/subscription/endpoint.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Subscription.Endpoint do 6 | defmacro __using__(_opts) do 7 | quote do 8 | use Absinthe.Phoenix.Endpoint 9 | 10 | defdelegate run_docset(pubsub, docs_and_topics, notification), 11 | to: AshGraphql.Subscription.Runner 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/support/gf/ash_graphql_schema.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.AshGraphqlSchema do 6 | @moduledoc false 7 | 8 | use Absinthe.Schema 9 | 10 | @domains [GF.Domain] 11 | 12 | use AshGraphql, domains: @domains, generate_sdl_file: "priv/gf_schema.graphql" 13 | 14 | query do 15 | end 16 | 17 | mutation do 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/relay_domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayDomain do 6 | @moduledoc false 7 | 8 | use Ash.Domain, 9 | extensions: [ 10 | AshGraphql.Domain 11 | ], 12 | otp_app: :ash_grapqhl 13 | 14 | graphql do 15 | end 16 | 17 | resources do 18 | resource(AshGraphql.Test.RelaySubscribable) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/resources/embed.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Embed do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | data_layer: :embedded, 10 | extensions: [AshGraphql.Resource] 11 | 12 | graphql do 13 | type :embed 14 | end 15 | 16 | attributes do 17 | attribute(:nested_embed, AshGraphql.Test.NestedEmbed, public?: true) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: CI 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | workflow_call: 14 | jobs: 15 | ash-ci: 16 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 17 | with: 18 | reuse: true 19 | secrets: 20 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 21 | -------------------------------------------------------------------------------- /lib/subscription/actor_function.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Subscription.ActorFunction do 6 | @moduledoc false 7 | 8 | @behaviour AshGraphql.Subscription.Actor 9 | 10 | @impl true 11 | def actor(actor, [{:fun, {m, f, a}}]) do 12 | apply(m, f, [actor | a]) 13 | end 14 | 15 | @impl true 16 | def actor(actor, [{:fun, fun}]) do 17 | fun.(actor) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | --- 6 | updates: 7 | - directory: / 8 | groups: 9 | dev-dependencies: 10 | dependency-type: development 11 | production-dependencies: 12 | dependency-type: production 13 | package-ecosystem: mix 14 | schedule: 15 | day: thursday 16 | interval: monthly 17 | versioning-strategy: lockfile-only 18 | version: 2 19 | -------------------------------------------------------------------------------- /test/support/force_change_id.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ForceChangeId do 6 | @moduledoc false 7 | use Ash.Resource.Change 8 | 9 | def change(changeset, _, _) do 10 | case Ash.Changeset.fetch_argument(changeset, :id) do 11 | {:ok, id} -> 12 | Ash.Changeset.force_change_attribute(changeset, :id, id) 13 | 14 | :error -> 15 | changeset 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/meta_middleware.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.MetaMiddleware do 6 | @moduledoc false 7 | @behaviour Absinthe.Middleware 8 | 9 | def call(resolution, _config) do 10 | context = resolution.context 11 | meta = Map.get(context, :meta, []) 12 | 13 | Enum.each(meta, fn {key, value} -> 14 | send(self(), {:test_meta, key, value}) 15 | end) 16 | 17 | resolution 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/resources/double_rel_embed.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.DoubleRelEmbed do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | data_layer: :embedded, 10 | extensions: [AshGraphql.Resource] 11 | 12 | graphql do 13 | type :double_rel_embed 14 | end 15 | 16 | attributes do 17 | attribute(:recursive, :string, default: "No, not I, but me dad be!", public?: true) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/other_domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.OtherDomain do 6 | @moduledoc false 7 | 8 | # This domain and its resource serves the purpose of testing deduplication of 9 | # common map types 10 | 11 | use Ash.Domain, 12 | extensions: [ 13 | AshGraphql.Domain 14 | ], 15 | otp_app: :ash_graphql 16 | 17 | resources do 18 | resource(AshGraphql.Test.OtherResource) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/relay_schema.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelaySchema do 6 | @moduledoc false 7 | 8 | use Absinthe.Schema 9 | 10 | @domains [AshGraphql.Test.RelayDomain] 11 | 12 | use AshGraphql, 13 | domains: @domains, 14 | relay_ids?: true, 15 | generate_sdl_file: "priv/schema-relay.graphql" 16 | 17 | query do 18 | end 19 | 20 | mutation do 21 | end 22 | 23 | subscription do 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/resources/nested_embed.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.NestedEmbed do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | data_layer: :embedded, 10 | extensions: [AshGraphql.Resource] 11 | 12 | graphql do 13 | type :nested_embed 14 | end 15 | 16 | attributes do 17 | attribute(:name, :string, public?: true) 18 | attribute(:enum, AshGraphql.Test.NestedEnum, public?: true) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/resources/no_graphql.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.NoGraphql do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | attributes do 13 | uuid_primary_key(:id) 14 | 15 | attribute(:name, :string) 16 | end 17 | 18 | relationships do 19 | belongs_to(:post, AshGraphql.Test.Post, allow_nil?: false) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/subscription/actor.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Subscription.Actor do 6 | @moduledoc """ 7 | Allows the user to substitue an actor for another more generic actor, 8 | this can be used to deduplicate subscription execution 9 | """ 10 | 11 | # I'd like to have the typespec say that actor can be anything 12 | # but that the input and output must be the same 13 | @callback actor(actor :: any, opts :: Keyword.t()) :: actor :: any 14 | end 15 | -------------------------------------------------------------------------------- /test/support/simple_domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.SimpleDomain do 6 | @moduledoc false 7 | # Used for simple one-off manual tests 8 | 9 | use Ash.Domain, 10 | extensions: [ 11 | AshGraphql.Domain 12 | ], 13 | otp_app: :ash_graphql 14 | 15 | graphql do 16 | queries do 17 | end 18 | 19 | subscriptions do 20 | end 21 | end 22 | 23 | resources do 24 | resource(AshGraphql.Test.SimpleResource) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/support/types/simple_union.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Types.SimpleUnion do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :union, 9 | constraints: [ 10 | types: [ 11 | int: [ 12 | type: :integer 13 | ], 14 | string: [ 15 | type: :string 16 | ] 17 | ] 18 | ] 19 | 20 | use AshGraphql.Type 21 | 22 | @impl AshGraphql.Type 23 | def graphql_type(_), do: :simple_union 24 | end 25 | -------------------------------------------------------------------------------- /test/support/relay_ids/domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayIds.Domain do 6 | @moduledoc false 7 | 8 | use Ash.Domain, 9 | extensions: [ 10 | AshGraphql.Domain 11 | ], 12 | otp_app: :ash_graphql 13 | 14 | resources do 15 | resource(AshGraphql.Test.RelayIds.Comment) 16 | resource(AshGraphql.Test.RelayIds.Post) 17 | resource(AshGraphql.Test.RelayIds.ResourceWithNoPrimaryKeyGet) 18 | resource(AshGraphql.Test.RelayIds.User) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /documentation/topics/use-json-with-graphql.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Use JSON with GraphQL 8 | 9 | AshGraphql provides two JSON types that may be used. They are the same except for how the type is serialized in responses. 10 | 11 | - `:json_string` - serializes the json to a string, e.g `"{\"foo\":1}"` 12 | - `:json` - leaves the json as an object, e.g `{foo: 1}` 13 | 14 | By default, `:json_string` is used. The configuration for this is (uncharacteristically) placed in application config, for example: 15 | 16 | ```elixir 17 | config :ash_graphql, :json_type, :json 18 | ``` 19 | -------------------------------------------------------------------------------- /test/support/types/person_typed_struct.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PersonTypedStructData do 6 | @moduledoc false 7 | 8 | use Ash.TypedStruct 9 | 10 | typed_struct do 11 | field(:name, :string, allow_nil?: false) 12 | field(:age, :integer, allow_nil?: true) 13 | field(:email, :string, allow_nil?: true) 14 | end 15 | 16 | use AshGraphql.Type 17 | 18 | @impl true 19 | def graphql_type(_), do: :person_type 20 | 21 | @impl true 22 | def graphql_input_type(_), do: :person_input_type 23 | end 24 | -------------------------------------------------------------------------------- /test/support/test_helpers.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.TestHelpers do 6 | @moduledoc false 7 | require Logger 8 | 9 | def stop_ets do 10 | for resource <- Ash.Domain.Info.resources(AshGraphql.Test.Domain) do 11 | try do 12 | Ash.DataLayer.Ets.stop(resource) 13 | rescue 14 | error -> 15 | Logger.warning( 16 | "Error while stopping storage for #{inspect(resource)}: #{inspect(error)}" 17 | ) 18 | 19 | :ok 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/types/embed_union_new_type.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Types.EmbedUnionNewType do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :union, 9 | constraints: [ 10 | types: [ 11 | foo: [ 12 | type: Foo, 13 | tag: :type, 14 | tag_value: :foo 15 | ], 16 | bar: [ 17 | type: Bar, 18 | tag: :type, 19 | tag_value: :bar 20 | ] 21 | ] 22 | ] 23 | 24 | def graphql_type(_), do: :embed_union_new_type 25 | end 26 | -------------------------------------------------------------------------------- /test/support/types/enum_with_ash_graphql_description.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.EnumWithAshGraphqlDescription do 6 | @moduledoc false 7 | use Ash.Type.Enum, 8 | values: [ 9 | foo: "This should get ignored by AshGraphQL", 10 | bar: "This too", 11 | no_description: "And also this" 12 | ] 13 | 14 | def graphql_type(_), do: :enum_with_ash_graphql_description 15 | 16 | def graphql_describe_enum_value(:foo), do: "A foo" 17 | def graphql_describe_enum_value(:bar), do: "A bar" 18 | def graphql_describe_enum_value(_), do: nil 19 | end 20 | -------------------------------------------------------------------------------- /test/support/resources/non_id_primary_key.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.NonIdPrimaryKey do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :non_id_primary_key 15 | 16 | queries do 17 | get :get_non_id_primary_key, :read 18 | end 19 | end 20 | 21 | actions do 22 | defaults([:create, :read, :update, :destroy]) 23 | end 24 | 25 | attributes do 26 | uuid_primary_key(:other) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/resources/post_tag.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PostTag do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | actions do 13 | defaults([:create, :update, :destroy, :read]) 14 | end 15 | 16 | relationships do 17 | belongs_to :post, AshGraphql.Test.Post do 18 | primary_key?(true) 19 | allow_nil?(false) 20 | end 21 | 22 | belongs_to :tag, AshGraphql.Test.Tag do 23 | primary_key?(true) 24 | allow_nil?(false) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/types/map_with_embedded_resource.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule RankedComment do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :map, 9 | constraints: [ 10 | fields: [ 11 | rank: [ 12 | type: :float, 13 | allow_nil?: false 14 | ], 15 | comment: [ 16 | type: :struct, 17 | allow_nil?: false, 18 | constraints: [ 19 | instance_of: AshGraphql.Test.Comment 20 | ] 21 | ] 22 | ] 23 | ] 24 | 25 | def graphql_type(_), do: :ranked_comment_result_item 26 | end 27 | -------------------------------------------------------------------------------- /lib/resource/helpers.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Helpers do 6 | @moduledoc "Imported helpers for the graphql DSL section" 7 | 8 | @doc """ 9 | A list of a given type, idiomatic for those used to `absinthe` notation. 10 | """ 11 | @spec list_of(v) :: {:array, v} when v: term() 12 | def list_of(value) do 13 | {:array, value} 14 | end 15 | 16 | @doc """ 17 | A non nullable type, idiomatic for those used to `absinthe` notation. 18 | """ 19 | @spec non_null(v) :: {:non_null, v} when v: term() 20 | def non_null(value) do 21 | {:non_null, value} 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/support/resources/actor_agent.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ActorAgent do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | actions do 13 | defaults([:create, :update, :destroy, :read]) 14 | end 15 | 16 | relationships do 17 | belongs_to :actor, AshGraphql.Test.Actor do 18 | primary_key?(true) 19 | allow_nil?(false) 20 | end 21 | 22 | belongs_to :agent, AshGraphql.Test.Agent do 23 | primary_key?(true) 24 | allow_nil?(false) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/resources/movie_actor.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.MovieActor do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | actions do 13 | defaults([:create, :update, :destroy, :read]) 14 | end 15 | 16 | relationships do 17 | belongs_to :movie, AshGraphql.Test.Movie do 18 | primary_key?(true) 19 | allow_nil?(false) 20 | end 21 | 22 | belongs_to :actor, AshGraphql.Test.Actor do 23 | primary_key?(true) 24 | allow_nil?(false) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/resources/relay_post_tag.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayPostTag do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | actions do 13 | defaults([:create, :update, :destroy, :read]) 14 | end 15 | 16 | relationships do 17 | belongs_to :post, AshGraphql.Test.Post do 18 | primary_key?(true) 19 | allow_nil?(false) 20 | end 21 | 22 | belongs_to :tag, AshGraphql.Test.RelayTag do 23 | primary_key?(true) 24 | allow_nil?(false) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/types/common_map.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.CommonMap do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :map, 9 | constraints: [ 10 | fields: [ 11 | some: [ 12 | type: :string, 13 | allow_nil?: false 14 | ], 15 | stuff: [ 16 | type: :string, 17 | allow_nil?: false 18 | ] 19 | ] 20 | ] 21 | 22 | use AshGraphql.Type 23 | 24 | @impl true 25 | def graphql_type(_), do: :common_map 26 | 27 | @impl true 28 | def graphql_input_type(_), do: :common_map_input 29 | end 30 | -------------------------------------------------------------------------------- /test/support/types/embed_union_new_type_unnested.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Types.EmbedUnionNewTypeUnnested do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :union, 9 | constraints: [ 10 | types: [ 11 | foo: [ 12 | type: Foo, 13 | tag: :type, 14 | tag_value: :foo 15 | ], 16 | bar: [ 17 | type: Bar, 18 | tag: :type, 19 | tag_value: :bar 20 | ] 21 | ] 22 | ] 23 | 24 | def graphql_type(_), do: :foo_bar_unnested 25 | 26 | def graphql_unnested_unions(_), do: [:foo, :bar] 27 | end 28 | -------------------------------------------------------------------------------- /test/support/resources/multitenant_post_tag.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.MultitenantPostTag do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | actions do 13 | defaults([:create, :read, :update, :destroy]) 14 | end 15 | 16 | relationships do 17 | belongs_to :post, AshGraphql.Test.Post do 18 | primary_key?(true) 19 | allow_nil?(false) 20 | end 21 | 22 | belongs_to :tag, AshGraphql.Test.MultitenantTag do 23 | primary_key?(true) 24 | allow_nil?(false) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/context_helpers.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.ContextHelpers do 6 | @moduledoc "Helper to extract context from its various locations" 7 | 8 | def get_context(context) do 9 | case Map.get(context, :context) do 10 | nil -> 11 | case Map.get(context, :ash_context) do 12 | nil -> 13 | %{} 14 | 15 | context -> 16 | IO.warn( 17 | "Using `:ash_context` is deprecated, use `Ash.PlugHelpers.set_context/2` instead." 18 | ) 19 | 20 | context 21 | end 22 | 23 | context -> 24 | context 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/resources/award.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Award do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:award) 15 | end 16 | 17 | actions do 18 | default_accept(:*) 19 | defaults([:create, :read, :update, :destroy]) 20 | end 21 | 22 | attributes do 23 | uuid_primary_key(:id) 24 | 25 | attribute(:name, :string, public?: true) 26 | end 27 | 28 | relationships do 29 | belongs_to(:movie, AshGraphql.Test.Movie, public?: true) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/resources/review.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Review do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:review) 15 | end 16 | 17 | actions do 18 | default_accept(:*) 19 | defaults([:create, :read, :update, :destroy]) 20 | end 21 | 22 | attributes do 23 | uuid_primary_key(:id) 24 | 25 | attribute(:text, :string, public?: true) 26 | end 27 | 28 | relationships do 29 | belongs_to(:movie, AshGraphql.Test.Movie, public?: true) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/resources/composite_primary_key.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.CompositePrimaryKey do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :composite_primary_key 15 | primary_key_delimiter "~" 16 | 17 | queries do 18 | get :get_composite_primary_key, :read 19 | end 20 | end 21 | 22 | actions do 23 | defaults([:create, :update, :destroy, :read]) 24 | end 25 | 26 | attributes do 27 | uuid_primary_key(:first) 28 | uuid_primary_key(:second) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/types/type_with_type_inside.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.TypeWithTypeInside do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :map, 9 | constraints: [ 10 | fields: [ 11 | inner_type: [ 12 | type: AshGraphql.Test.TypeWithinTypeUnreferencedSubmap, 13 | allow_nil?: false 14 | ], 15 | another_field: [ 16 | type: :string, 17 | allow_nil?: false 18 | ] 19 | ] 20 | ] 21 | 22 | use AshGraphql.Type 23 | 24 | @impl true 25 | def graphql_type(_), do: :type_with_type_inside 26 | 27 | @impl true 28 | def graphql_input_type(_), do: :type_with_type_inside_input 29 | end 30 | -------------------------------------------------------------------------------- /test/support/types/common_map_struct.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.CommonMapStruct do 6 | defstruct [:some, :stuff] 7 | @moduledoc false 8 | use Ash.Type.NewType, 9 | subtype_of: :struct, 10 | constraints: [ 11 | instance_of: __MODULE__, 12 | fields: [ 13 | some: [ 14 | type: :string, 15 | allow_nil?: false 16 | ], 17 | stuff: [ 18 | type: :string, 19 | allow_nil?: false 20 | ] 21 | ] 22 | ] 23 | 24 | use AshGraphql.Type 25 | 26 | @impl true 27 | def graphql_type(_), do: :common_map_struct 28 | 29 | @impl true 30 | def graphql_input_type(_), do: :common_map_struct_input 31 | end 32 | -------------------------------------------------------------------------------- /test/support/resources/channel/changes/create_message_user.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Changes.CreateMessageUser do 6 | @moduledoc false 7 | use Ash.Resource.Change 8 | 9 | def change(changeset, _, context) do 10 | changeset 11 | |> Ash.Changeset.after_action(fn _, result -> 12 | case AshGraphql.Test.MessageUser 13 | |> Ash.Changeset.for_create(:create, %{ 14 | message_id: changeset.data.id, 15 | user_id: context.actor.id 16 | }) 17 | |> Ash.create() do 18 | {:ok, _} -> 19 | {:ok, result} 20 | 21 | {:error, error} -> 22 | {:error, error} 23 | end 24 | end) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/support/resources/composite_primary_key_not_encoded.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.CompositePrimaryKeyNotEncoded do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :composite_primary_key_not_encoded 15 | encode_primary_key? false 16 | 17 | queries do 18 | get :get_composite_primary_key_not_encoded, :read 19 | end 20 | end 21 | 22 | actions do 23 | defaults([:create, :update, :destroy, :read]) 24 | end 25 | 26 | attributes do 27 | uuid_primary_key(:first) 28 | uuid_primary_key(:second) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/types/person_map.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PersonMap do 6 | @moduledoc false 7 | 8 | use Ash.Type.NewType, 9 | subtype_of: :map, 10 | constraints: [ 11 | fields: [ 12 | name: [ 13 | type: :string, 14 | allow_nil?: false 15 | ], 16 | age: [ 17 | type: :integer, 18 | allow_nil?: true 19 | ], 20 | email: [ 21 | type: :string, 22 | allow_nil?: true 23 | ] 24 | ] 25 | ] 26 | 27 | use AshGraphql.Type 28 | 29 | @impl true 30 | def graphql_type(_), do: :person_map_type 31 | 32 | @impl true 33 | def graphql_input_type(_), do: :person_map_input_type 34 | end 35 | -------------------------------------------------------------------------------- /test/support/lazyinit_reproduce/example.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Type.LazyInitTest.Example do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :map, 9 | lazy_init?: true, 10 | constraints: [ 11 | fields: [ 12 | condition: [ 13 | type: :string 14 | ], 15 | field: [ 16 | type: :string 17 | ], 18 | operator: [ 19 | type: :string 20 | ], 21 | value: [ 22 | type: :string 23 | ], 24 | predicates: [ 25 | type: {:array, __MODULE__} 26 | ] 27 | ] 28 | ] 29 | 30 | def graphql_type(_), do: :predicate 31 | def graphql_input_type(_), do: :predicate_input 32 | end 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | ash_graphql-*.tar 28 | 29 | .vscode/ 30 | 31 | notes 32 | -------------------------------------------------------------------------------- /lib/subscription/runner.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Subscription.Runner do 6 | @moduledoc """ 7 | Custom implementation if the run_docset function for the PubSub module used for Subscriptions 8 | 9 | Mostly a copy of https://github.com/absinthe-graphql/absinthe/blob/3d0823bd71c2ebb94357a5588c723e053de8c66a/lib/absinthe/subscription/local.ex#L40 10 | but this lets us decide if we want to send the data to the client or not in certain error cases 11 | """ 12 | require Logger 13 | 14 | def run_docset(pubsub, docs_and_topics, notification) do 15 | for {topic, key_strategy, doc} <- docs_and_topics do 16 | AshGraphql.Subscription.Batcher.publish(topic, notification, pubsub, key_strategy, doc) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/types/person_regular_struct.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PersonRegularStruct do 6 | @moduledoc false 7 | 8 | use Ash.Type.NewType, 9 | subtype_of: :struct, 10 | constraints: [ 11 | fields: [ 12 | name: [ 13 | type: :string, 14 | allow_nil?: false 15 | ], 16 | age: [ 17 | type: :integer, 18 | allow_nil?: true 19 | ], 20 | email: [ 21 | type: :string, 22 | allow_nil?: true 23 | ] 24 | ] 25 | ] 26 | 27 | use AshGraphql.Type 28 | 29 | @impl true 30 | def graphql_type(_), do: :person_regular_type 31 | 32 | @impl true 33 | def graphql_input_type(_), do: :person_regular_input_type 34 | end 35 | -------------------------------------------------------------------------------- /test/support/relay_ids/schema.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayIds.Schema do 6 | @moduledoc false 7 | 8 | use Absinthe.Schema 9 | 10 | @domains [AshGraphql.Test.RelayIds.Domain] 11 | 12 | use AshGraphql, domains: @domains, relay_ids?: true, generate_sdl_file: "priv/relay_ids.graphql" 13 | 14 | query do 15 | end 16 | 17 | mutation do 18 | end 19 | 20 | object :foo do 21 | field(:foo, :string) 22 | field(:bar, :string) 23 | end 24 | 25 | input_object :foo_input do 26 | field(:foo, non_null(:string)) 27 | field(:bar, non_null(:string)) 28 | end 29 | 30 | enum :status do 31 | value(:open, description: "The post is open") 32 | value(:closed, description: "The post is closed") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/resources/no_object.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.NoObject do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | generate_object? false 15 | 16 | queries do 17 | action :no_object_count, :count 18 | end 19 | end 20 | 21 | actions do 22 | defaults([:read, :create]) 23 | 24 | action :count, {:array, :integer} do 25 | run(fn _input, _context -> 26 | {:ok, [1, 2, 3, 4, 5]} 27 | end) 28 | end 29 | end 30 | 31 | attributes do 32 | uuid_primary_key(:id) 33 | 34 | attribute(:name, :string, public?: true) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/resources/constrained_map.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ConstrainedMap do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :map, 9 | constraints: [ 10 | fields: [ 11 | foo_bar: [ 12 | type: :string, 13 | allow_nil?: false 14 | ], 15 | baz: [ 16 | type: :integer 17 | ], 18 | bam: [ 19 | type: :map, 20 | constraints: [ 21 | fields: [ 22 | qux: [ 23 | type: :string 24 | ] 25 | ] 26 | ] 27 | ] 28 | ] 29 | ] 30 | 31 | def graphql_type(_), do: :constrained_map 32 | def graphql_input_type(_), do: :constrained_map_input 33 | end 34 | -------------------------------------------------------------------------------- /documentation/topics/modifying-the-resolution.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Modifying the Resolution 8 | 9 | Using the `modify_resolution` option, you can alter the `Absinthe.Resolution`. 10 | 11 | `modify_resolution` is an MFA that will be called with the resolution, the query, and the result of the action as the first three arguments. Must return a new `Absinthe.Resolution`. 12 | 13 | This can be used to implement things like setting cookies based on resource actions. A method of using resolution context for that is documented [in Absinthe.Plug](https://hexdocs.pm/absinthe_plug/Absinthe.Plug.html#module-before-send) 14 | 15 | > ### as_mutation? {: .warning} 16 | > 17 | > If you are modifying the context in a query, then you should also set `as_mutation?` to true and represent this in your graphql as a mutation. See `as_mutation?` for more. 18 | -------------------------------------------------------------------------------- /lib/resource/transformers/subscription.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Transformers.Subscription do 6 | @moduledoc """ 7 | Adds the notifier for Subscriptions to the Resource 8 | """ 9 | 10 | use Spark.Dsl.Transformer 11 | 12 | alias Spark.Dsl.Transformer 13 | 14 | def transform(dsl) do 15 | case dsl |> Transformer.get_entities([:graphql, :subscriptions]) do 16 | [] -> 17 | {:ok, dsl} 18 | 19 | _ -> 20 | {:ok, 21 | dsl 22 | |> Transformer.persist( 23 | :simple_notifiers, 24 | [ 25 | AshGraphql.Subscription.Notifier 26 | ] ++ 27 | Transformer.get_persisted(dsl, :simple_notifiers, []) 28 | )} 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/gf/active_member_policy.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.ActiveMemberPolicy do 6 | @moduledoc false 7 | use Ash.Policy.SimpleCheck 8 | 9 | # This is used when logging a breakdown of how a policy is applied - see Logging below. 10 | def describe(_) do 11 | "Member is active and has given role" 12 | end 13 | 14 | def match?(%_{} = member, %{resource: _resource} = _context, opts) do 15 | active? = 16 | case member do 17 | %{status: :active} -> true 18 | _other -> false 19 | end 20 | 21 | if opts[:role] do 22 | GF.Member.can_take_role_action?(member, opts[:role]) 23 | else 24 | active? 25 | end 26 | end 27 | 28 | def match?(_actor, _context, _opts) do 29 | false 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/root_level_errors_schema.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RootLevelErrorsSchema do 6 | @moduledoc false 7 | 8 | use Absinthe.Schema 9 | 10 | @domains [AshGraphql.Test.RootLevelErrorsDomain] 11 | 12 | use AshGraphql, domains: @domains, generate_sdl_file: "priv/root_level_errors.graphql" 13 | 14 | query do 15 | end 16 | 17 | mutation do 18 | end 19 | 20 | object :foo do 21 | field(:foo, :string) 22 | field(:bar, :string) 23 | end 24 | 25 | input_object :foo_input do 26 | field(:foo, non_null(:string)) 27 | field(:bar, non_null(:string)) 28 | end 29 | 30 | enum :status do 31 | value(:open, description: "The post is open") 32 | value(:closed, description: "The post is closed") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/resources/agent.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Agent do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:agent) 15 | 16 | paginate_relationship_with(actors: :relay) 17 | end 18 | 19 | actions do 20 | default_accept(:*) 21 | defaults([:create, :read, :update, :destroy]) 22 | end 23 | 24 | attributes do 25 | uuid_primary_key(:id) 26 | 27 | attribute(:name, :string, public?: true) 28 | end 29 | 30 | relationships do 31 | many_to_many(:actors, AshGraphql.Test.Actor, 32 | through: AshGraphql.Test.ActorAgent, 33 | public?: true 34 | ) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/resources/double_rel_recursive_to_embed.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.DoubleRelToRecursiveParentOfEmbed do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | alias AshGraphql.Test.DoubleRelRecursive 14 | 15 | actions do 16 | default_accept(:*) 17 | defaults([:read, :create, :update, :destroy]) 18 | end 19 | 20 | attributes do 21 | uuid_primary_key(:id) 22 | 23 | attribute(:dummy, :string, default: "Dummy") 24 | end 25 | 26 | graphql do 27 | type :double_rel 28 | end 29 | 30 | relationships do 31 | has_many :all, DoubleRelRecursive do 32 | destination_attribute(:double_rel_id) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/resources/channel/types/page_of_messages.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PageOfChannelMessages do 6 | @moduledoc false 7 | 8 | use AshGraphql.Type 9 | 10 | use Ash.Type.NewType, 11 | subtype_of: :map, 12 | constraints: [ 13 | fields: [ 14 | count: [ 15 | type: :integer, 16 | allow_nil?: false 17 | ], 18 | has_next_page: [ 19 | type: :boolean, 20 | allow_nil?: false 21 | ], 22 | results: [ 23 | type: {:array, AshGraphql.Test.MessageUnion}, 24 | allow_nil?: false 25 | ] 26 | ] 27 | ] 28 | 29 | @impl true 30 | def graphql_type(_), do: :indirect_channel_messages 31 | 32 | @impl true 33 | def graphql_input_type(_), do: :indirect_channel_messages 34 | end 35 | -------------------------------------------------------------------------------- /test/support/types/foo.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Foo do 6 | @moduledoc false 7 | 8 | use Ash.Type 9 | 10 | def graphql_type(_), do: :foo 11 | def graphql_input_type(_), do: :foo_input 12 | 13 | @impl true 14 | def storage_type, do: :map 15 | 16 | @impl true 17 | def cast_input(nil, _), do: {:ok, nil} 18 | def cast_input(value, _) when is_map(value), do: {:ok, value} 19 | def cast_input(_, _), do: :error 20 | 21 | @impl true 22 | def cast_stored(nil, _), do: {:ok, nil} 23 | def cast_stored(value, _) when is_map(value), do: {:ok, value} 24 | def cast_stored(_, _), do: :error 25 | 26 | @impl true 27 | def dump_to_native(nil, _), do: {:ok, nil} 28 | def dump_to_native(value, _) when is_map(value), do: {:ok, value} 29 | def dump_to_native(_, _), do: :error 30 | end 31 | -------------------------------------------------------------------------------- /test/support/resources/channel/types/page_of_filter_by_actor_messages.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PageOfFilterByActorChannelMessages do 6 | @moduledoc false 7 | 8 | use AshGraphql.Type 9 | 10 | use Ash.Type.NewType, 11 | subtype_of: :map, 12 | constraints: [ 13 | fields: [ 14 | count: [ 15 | type: :integer, 16 | allow_nil?: false 17 | ], 18 | has_next_page: [ 19 | type: :boolean, 20 | allow_nil?: false 21 | ], 22 | results: [ 23 | type: {:array, AshGraphql.Test.MessageUnion}, 24 | allow_nil?: false 25 | ] 26 | ] 27 | ] 28 | 29 | @impl true 30 | def graphql_type(_), do: :filter_by_actor_channel_messages 31 | 32 | @impl true 33 | def graphql_input_type(_), do: :filter_by_actor_channel_messages 34 | end 35 | -------------------------------------------------------------------------------- /test/support/types/type_within_type_unreferenced_submap.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.TypeWithinTypeUnreferencedSubmap do 6 | @moduledoc """ 7 | This type exists due to a previous bug that types were not traversed 8 | properly if used as subtypes and not exposed anywhere directly. 9 | """ 10 | use Ash.Type.NewType, 11 | subtype_of: :map, 12 | constraints: [ 13 | fields: [ 14 | some: [ 15 | type: :string, 16 | allow_nil?: false 17 | ], 18 | stuff: [ 19 | type: :string, 20 | allow_nil?: false 21 | ] 22 | ] 23 | ] 24 | 25 | use AshGraphql.Type 26 | 27 | @impl true 28 | def graphql_type(_), do: :type_within_type_unreferenced_submap 29 | 30 | @impl true 31 | def graphql_input_type(_), do: :type_within_type_unreferenced_submap_input 32 | end 33 | -------------------------------------------------------------------------------- /test/support/types/array_inner_type.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Category do 6 | @moduledoc false 7 | use Ash.Type.NewType, 8 | subtype_of: :map, 9 | constraints: [ 10 | fields: [ 11 | name: [ 12 | type: :string, 13 | allow_nil?: false 14 | ] 15 | ] 16 | ] 17 | 18 | def graphql_type(_), do: :category 19 | end 20 | 21 | defmodule AshGraphql.Test.CategoryHierarchy do 22 | @moduledoc false 23 | use Ash.Type.NewType, 24 | subtype_of: :map, 25 | constraints: [ 26 | fields: [ 27 | categories: [ 28 | type: {:array, AshGraphql.Test.Category}, 29 | allow_nil?: false, 30 | constraints: [ 31 | nil_items?: false 32 | ] 33 | ] 34 | ] 35 | ] 36 | 37 | def graphql_type(_), do: :category_hierarchy 38 | end 39 | -------------------------------------------------------------------------------- /test/support/pub_sub.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PubSub do 6 | @moduledoc """ 7 | PubSub mock implementation for subscription tests 8 | """ 9 | @behaviour Absinthe.Subscription.Pubsub 10 | 11 | def start_link do 12 | Registry.start_link(keys: :duplicate, name: __MODULE__) 13 | end 14 | 15 | def node_name do 16 | Atom.to_string(node()) 17 | end 18 | 19 | def subscribe(_topic) do 20 | :ok 21 | end 22 | 23 | defdelegate run_docset(pubsub, docs_and_topics, mutation_result), 24 | to: AshGraphql.Subscription.Runner 25 | 26 | def publish_subscription(topic, data) do 27 | send( 28 | Application.get_env(__MODULE__, :notifier_test_pid), 29 | {topic, data} 30 | ) 31 | 32 | :ok 33 | end 34 | 35 | def publish_mutation(_proxy_topic, _mutation_result, _subscribed_fields) do 36 | :ok 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/simple_schema.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule SimpleSchema do 6 | @moduledoc false 7 | # Used for simple one-off manual tests 8 | 9 | use Absinthe.Schema 10 | 11 | # This is used situationally to define single actions 12 | # and types in, to avoid the complexity of the other 13 | # schemas that define a lot of types and actions etc. 14 | # This should be cleared out and assertions should be done 15 | # against other schemas 16 | @domains [AshGraphql.Test.SimpleDomain] 17 | 18 | use AshGraphql, 19 | domains: @domains, 20 | relay_ids?: true, 21 | generate_sdl_file: "priv/schema-relay.graphql" 22 | 23 | query do 24 | field :say_hello, :string do 25 | resolve(fn _, _, _ -> 26 | {:ok, "Hello from AshGraphql!"} 27 | end) 28 | end 29 | end 30 | 31 | mutation do 32 | end 33 | 34 | subscription do 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/resources/channel/message_viewable_user.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.MessageViewableUser do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | ets do 13 | table(:message_user) 14 | end 15 | 16 | actions do 17 | default_accept(:*) 18 | defaults([:read, :update, :destroy]) 19 | 20 | create :create do 21 | primary?(true) 22 | end 23 | end 24 | 25 | relationships do 26 | belongs_to(:message, AshGraphql.Test.Message, 27 | primary_key?: true, 28 | allow_nil?: false, 29 | attribute_writable?: true, 30 | public?: true 31 | ) 32 | 33 | belongs_to(:user, AshGraphql.Test.User, 34 | primary_key?: true, 35 | allow_nil?: false, 36 | attribute_writable?: true, 37 | public?: true 38 | ) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /documentation/topics/use-maps-with-graphql.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Use Maps with GraphQL 8 | 9 | If you define an `Ash.Type.NewType` that is a subtype of `:map`, _and_ you add the `fields` constraint which specifies field names and their types, `AshGraphql` will automatically derive an appropriate GraphQL type for it. 10 | 11 | For example: 12 | 13 | ```elixir 14 | defmodule MyApp.Types.Metadata do 15 | @moduledoc false 16 | use Ash.Type.NewType, subtype_of: :map, constraints: [ 17 | fields: [ 18 | title: [ 19 | type: :string 20 | ], 21 | description: [ 22 | type: :string 23 | ] 24 | ] 25 | ] 26 | 27 | def graphql_type(_), do: :metadata 28 | end 29 | 30 | ``` 31 | 32 | ## Bypassing type generation for an map 33 | 34 | Add the `graphql_define_type?/1` callback, like so, to skip Ash's generation (i.e if you're defining it yourself) 35 | 36 | ```elixir 37 | @impl true 38 | def graphql_define_type?(_), do: false 39 | ``` 40 | -------------------------------------------------------------------------------- /test/support/resources/channel/message.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Message do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets 11 | 12 | ets do 13 | table(:message) 14 | end 15 | 16 | actions do 17 | default_accept(:*) 18 | defaults([:read, :update, :destroy]) 19 | 20 | create :create do 21 | primary?(true) 22 | end 23 | end 24 | 25 | attributes do 26 | uuid_primary_key(:id) 27 | 28 | attribute(:text, :string, public?: true) 29 | 30 | attribute(:type, :atom, default: :text, constraints: [one_of: [:text, :image]], public?: true) 31 | end 32 | 33 | relationships do 34 | belongs_to(:channel, AshGraphql.Test.Channel, public?: true) 35 | 36 | has_many(:message_users, AshGraphql.Test.MessageViewableUser, 37 | destination_attribute: :message_id 38 | ) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/resources/map_types.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.MapTypes do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshGraphql.Test.Domain, 9 | data_layer: Ash.DataLayer.Ets, 10 | extensions: [AshGraphql.Resource] 11 | 12 | attributes do 13 | uuid_primary_key(:id) 14 | 15 | attribute(:json_map, :map, public?: true) 16 | 17 | attribute :values, AshGraphql.Test.ConstrainedMap do 18 | public?(true) 19 | end 20 | end 21 | 22 | actions do 23 | default_accept(:*) 24 | 25 | defaults([:create, :read, :update, :destroy]) 26 | 27 | update :module do 28 | argument(:module_values, AshGraphql.Test.ConstrainedMap) 29 | end 30 | end 31 | 32 | graphql do 33 | type :map_types 34 | 35 | queries do 36 | list :list_map_types, :read 37 | end 38 | 39 | mutations do 40 | update :module_update_map_types, :module 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/types/json.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Types.JSON do 6 | @moduledoc """ 7 | The Json scalar type allows arbitrary JSON values to be passed in and out. 8 | """ 9 | use Absinthe.Schema.Notation 10 | 11 | scalar :json, name: "Json" do 12 | description(""" 13 | The `Json` scalar type represents arbitrary json string data, represented as UTF-8 14 | character sequences. The Json type is most often used to represent a free-form 15 | human-readable json string. 16 | """) 17 | 18 | serialize(&encode/1) 19 | parse(&decode/1) 20 | end 21 | 22 | def decode(%Absinthe.Blueprint.Input.String{value: value}) do 23 | case Jason.decode(value) do 24 | {:ok, result} -> {:ok, result} 25 | _ -> :error 26 | end 27 | end 28 | 29 | def decode(%Absinthe.Blueprint.Input.Null{}) do 30 | {:ok, nil} 31 | end 32 | 33 | def decode(_) do 34 | :error 35 | end 36 | 37 | def encode(value), do: value 38 | end 39 | -------------------------------------------------------------------------------- /lib/resource/transformers/require_keyset_for_relay_queries.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Transformers.RequireKeysetForRelayQueries do 6 | # Ensures that all relay queries configure keyset pagination 7 | @moduledoc false 8 | 9 | use Spark.Dsl.Transformer 10 | alias Spark.Dsl.Transformer 11 | 12 | def after_compile?, do: true 13 | 14 | def transform(dsl) do 15 | dsl 16 | |> AshGraphql.Resource.Info.queries() 17 | |> Enum.each(fn query -> 18 | if Map.get(query, :relay?) do 19 | action = Ash.Resource.Info.action(dsl, query.action) 20 | 21 | unless action.pagination && action.pagination.keyset? do 22 | raise Spark.Error.DslError, 23 | module: Transformer.get_persisted(dsl, :module), 24 | message: "Relay queries must support keyset pagination", 25 | path: [:graphql, :queries, query.name] 26 | end 27 | end 28 | end) 29 | 30 | {:ok, dsl} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/support/resources/actor.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Actor do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:actor) 15 | 16 | paginate_relationship_with(agents: :relay) 17 | end 18 | 19 | actions do 20 | default_accept(:*) 21 | defaults([:create, :read, :update, :destroy]) 22 | end 23 | 24 | attributes do 25 | uuid_primary_key(:id) 26 | 27 | attribute(:name, :string, public?: true) 28 | attribute(:role, :atom, public?: true) 29 | end 30 | 31 | relationships do 32 | many_to_many(:movies, AshGraphql.Test.Movie, 33 | through: AshGraphql.Test.MovieActor, 34 | public?: true 35 | ) 36 | 37 | many_to_many(:agents, AshGraphql.Test.Agent, 38 | through: AshGraphql.Test.ActorAgent, 39 | public?: true 40 | ) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/domain/transformers/require_keyset_for_relay_queries.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Domain.Transformers.RequireKeysetForRelayQueries do 6 | # Ensures that all relay queries configure keyset pagination 7 | @moduledoc false 8 | 9 | use Spark.Dsl.Transformer 10 | alias Spark.Dsl.Transformer 11 | 12 | def after_compile?, do: true 13 | 14 | def transform(dsl) do 15 | dsl 16 | |> AshGraphql.Domain.Info.queries() 17 | |> Enum.each(fn query -> 18 | if Map.get(query, :relay?) do 19 | action = Ash.Resource.Info.action(query.resource, query.action) 20 | 21 | unless action.pagination && action.pagination.keyset? do 22 | raise Spark.Error.DslError, 23 | module: Transformer.get_persisted(dsl, :module), 24 | message: "Relay queries must support keyset pagination", 25 | path: [:graphql, :queries, query.name] 26 | end 27 | end 28 | end) 29 | 30 | {:ok, dsl} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/support/resources/channel/text_message.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.TextMessage do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | ets do 14 | table(:message) 15 | end 16 | 17 | resource do 18 | base_filter(type: :text) 19 | end 20 | 21 | graphql do 22 | type(:text_message) 23 | end 24 | 25 | actions do 26 | default_accept(:*) 27 | defaults([:read, :update, :destroy]) 28 | 29 | create :create do 30 | primary?(true) 31 | end 32 | end 33 | 34 | attributes do 35 | uuid_primary_key(:id) 36 | 37 | attribute(:text, :string, public?: true) 38 | 39 | attribute(:type, :atom, default: :text, constraints: [one_of: [:text, :image]], public?: true) 40 | end 41 | 42 | relationships do 43 | belongs_to(:channel, AshGraphql.Test.Channel, public?: true) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/plug.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Plug do 6 | @moduledoc """ 7 | Automatically set up the GraphQL `actor` and `tenant`. 8 | 9 | Adding this plug to your pipeline will automatically set the `actor` and 10 | `tenant` if they were previously put there by `Ash.PlugHelpers.set_actor/2` or 11 | `Ash.PlugHelpers.set_tenant/2`. 12 | """ 13 | 14 | @behaviour Plug 15 | alias Ash.PlugHelpers 16 | alias Plug.Conn 17 | 18 | def init(opts), do: opts 19 | 20 | def call(conn, _opts) do 21 | actor = PlugHelpers.get_actor(conn) 22 | tenant = PlugHelpers.get_tenant(conn) 23 | context = PlugHelpers.get_context(conn) 24 | 25 | absinthe = Map.get(conn.private, :absinthe, %{}) 26 | 27 | context = 28 | absinthe 29 | |> Map.get(:context, %{}) 30 | |> Map.merge(%{actor: actor, tenant: tenant, context: context}) 31 | 32 | absinthe = Map.put(absinthe, :context, context) 33 | Conn.put_private(conn, :absinthe, absinthe) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/support/gf/group.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.Group do 6 | @moduledoc "An Ash-managed GroupFlow Group (customer)" 7 | 8 | use Ash.Resource, 9 | domain: GF.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | @type t :: %__MODULE__{} 14 | 15 | # Attributes are the simple pieces of data that exist on your resource 16 | attributes do 17 | uuid_primary_key(:id) 18 | 19 | attribute(:abbreviation, :string, public?: true) 20 | attribute(:name, :string, public?: true) 21 | 22 | create_timestamp(:inserted_at, public?: true) 23 | update_timestamp(:updated_at, public?: true) 24 | end 25 | 26 | actions do 27 | default_accept(:*) 28 | # Add a set of simple actions. You'll customize these later. 29 | defaults([:create, :read, :update, :destroy]) 30 | end 31 | 32 | graphql do 33 | type :group2 34 | end 35 | 36 | code_interface do 37 | define(:create, action: :create) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/support/resources/channel/types/messages_union.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.MessageUnion do 6 | @moduledoc false 7 | 8 | @types [ 9 | text: [ 10 | type: :struct, 11 | constraints: [instance_of: AshGraphql.Test.TextMessage], 12 | tag: :type, 13 | tag_value: :text_message 14 | ], 15 | image: [ 16 | type: :struct, 17 | constraints: [instance_of: AshGraphql.Test.ImageMessage], 18 | tag: :type, 19 | tag_value: :image_message 20 | ] 21 | ] 22 | 23 | @structs_to_names Keyword.new(@types, fn {key, _value} -> {key, key} end) 24 | 25 | use AshGraphql.Type 26 | 27 | use Ash.Type.NewType, 28 | subtype_of: :union, 29 | constraints: [ 30 | types: @types 31 | ] 32 | 33 | def struct_to_name(%_struct{} = s), do: @structs_to_names[s.type] 34 | 35 | @impl true 36 | def graphql_type(_), do: :message 37 | 38 | @impl true 39 | def graphql_unnested_unions(_), do: Keyword.keys(@types) 40 | end 41 | -------------------------------------------------------------------------------- /.check.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | ## all available options with default values (see `mix check` docs for description) 7 | # parallel: true, 8 | # skipped: true, 9 | 10 | ## list of tools (see `mix check` docs for defaults) 11 | tools: [ 12 | ## curated tools may be disabled (e.g. the check for compilation warnings) 13 | # {:compiler, false}, 14 | 15 | ## ...or adjusted (e.g. use one-line formatter for more compact credo output) 16 | # {:credo, "mix credo --format oneline"}, 17 | 18 | {:check_formatter, command: "mix spark.formatter --check"}, 19 | {:doctor, false}, 20 | {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]} 21 | 22 | ## custom new tools may be added (mix tasks or arbitrary commands) 23 | # {:my_mix_task, command: "mix release", env: %{"MIX_ENV" => "prod"}}, 24 | # {:my_arbitrary_tool, command: "npm test", cd: "assets"}, 25 | # {:my_arbitrary_script, command: ["my_script", "argument with spaces"], cd: "scripts"} 26 | ] 27 | ] 28 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /test/support/types/union_relation.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule UnionRelation do 6 | @moduledoc false 7 | alias AshGraphql.Test.{Comment, SponsoredComment} 8 | 9 | @types [ 10 | comment: [ 11 | type: :struct, 12 | constraints: [instance_of: Comment], 13 | tag: :type, 14 | tag_value: :comment 15 | ], 16 | sponsored_comment: [ 17 | type: :struct, 18 | constraints: [instance_of: SponsoredComment], 19 | tag: :type, 20 | tag_value: :sponsored 21 | ] 22 | ] 23 | 24 | @structs_to_names Keyword.new(@types, fn {key, value} -> 25 | {value[:constraints][:instance_of], key} 26 | end) 27 | 28 | use Ash.Type.NewType, 29 | subtype_of: :union, 30 | constraints: [ 31 | types: @types 32 | ] 33 | 34 | def struct_to_name(%struct{}), do: @structs_to_names[struct] 35 | 36 | def graphql_type(_), do: :post_comments 37 | 38 | def graphql_unnested_unions(_), do: Keyword.keys(@types) 39 | end 40 | -------------------------------------------------------------------------------- /test/support/resources/channel/image_message.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ImageMessage do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | ets do 14 | table(:message) 15 | end 16 | 17 | resource do 18 | base_filter(type: :image) 19 | end 20 | 21 | graphql do 22 | type(:image_message) 23 | end 24 | 25 | actions do 26 | default_accept(:*) 27 | defaults([:read, :update, :destroy]) 28 | 29 | create :create do 30 | primary?(true) 31 | end 32 | end 33 | 34 | attributes do 35 | uuid_primary_key(:id) 36 | 37 | attribute(:text, :string, public?: true) 38 | 39 | attribute(:type, :atom, 40 | default: :image, 41 | constraints: [one_of: [:text, :image]], 42 | public?: true 43 | ) 44 | end 45 | 46 | relationships do 47 | belongs_to(:channel, AshGraphql.Test.Channel, public?: true) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/types/json_string.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Types.JSONString do 6 | @moduledoc """ 7 | The Json scalar type allows arbitrary JSON values to be passed in and out. 8 | """ 9 | use Absinthe.Schema.Notation 10 | 11 | scalar :json_string, name: "JsonString" do 12 | description(""" 13 | The `Json` scalar type represents arbitrary json string data, represented as UTF-8 14 | character sequences. The Json type is most often used to represent a free-form 15 | human-readable json string. 16 | """) 17 | 18 | serialize(&encode/1) 19 | parse(&decode/1) 20 | end 21 | 22 | def decode(%Absinthe.Blueprint.Input.String{value: value}) do 23 | case Jason.decode(value) do 24 | {:ok, result} -> {:ok, result} 25 | _ -> :error 26 | end 27 | end 28 | 29 | def decode(%Absinthe.Blueprint.Input.Null{}) do 30 | {:ok, nil} 31 | end 32 | 33 | def decode(_) do 34 | :error 35 | end 36 | 37 | def encode(nil), do: nil 38 | def encode(value), do: Jason.encode!(value) 39 | end 40 | -------------------------------------------------------------------------------- /lib/trace_helpers.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.TraceHelpers do 6 | @moduledoc false 7 | 8 | defmacro trace(domain, resource, type, name, metadata, do: body) do 9 | quote do 10 | require Ash.Tracer 11 | domain = unquote(domain) 12 | resource = unquote(resource) 13 | type = unquote(type) 14 | name = unquote(name) 15 | metadata = unquote(metadata) 16 | 17 | Ash.Tracer.span type, 18 | AshGraphql.TraceHelpers.span_name(domain, resource, type, name), 19 | AshGraphql.Domain.Info.tracer(domain) do 20 | Ash.Tracer.set_metadata(AshGraphql.Domain.Info.tracer(domain), type, metadata) 21 | 22 | Ash.Tracer.telemetry_span [:ash, Ash.Domain.Info.short_name(domain), type], metadata do 23 | unquote(body) 24 | end 25 | end 26 | end 27 | end 28 | 29 | def span_name(domain, resource, type, name) 30 | when is_atom(domain) and is_atom(resource) and is_atom(type) and 31 | (is_atom(name) or is_binary(name)) do 32 | Ash.Domain.Info.span_name(domain, resource, "#{type}.#{name}") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/support/resources/channel/calculations/messages_calculation.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PageOfChannelMessagesCalculation do 6 | @moduledoc false 7 | use Ash.Resource.Calculation 8 | 9 | def load(_, _, context) do 10 | limit = context.arguments.limit || 100 11 | offset = context.arguments.offset || 0 12 | 13 | [ 14 | :channel_message_count, 15 | messages: 16 | AshGraphql.Test.Message 17 | |> Ash.Query.limit(limit) 18 | |> Ash.Query.offset(offset) 19 | |> Ash.Query.select([:type, :text]) 20 | ] 21 | end 22 | 23 | def calculate([channel], _, context) do 24 | limit = context.arguments.limit || 100 25 | offset = context.arguments.offset || 0 26 | 27 | {:ok, 28 | [ 29 | %{ 30 | count: channel.channel_message_count, 31 | has_next_page: channel.channel_message_count > offset + limit, 32 | results: 33 | channel.messages 34 | |> Enum.map( 35 | &%Ash.Union{type: AshGraphql.Test.MessageUnion.struct_to_name(&1), value: &1} 36 | ) 37 | } 38 | ]} 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/default_error_handler.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.DefaultErrorHandler do 6 | @moduledoc "Replaces any text in message or short_message with variables" 7 | 8 | def handle_error( 9 | %{message: message, short_message: short_message, vars: vars} = error, 10 | _context 11 | ) do 12 | %{ 13 | error 14 | | message: replace_vars(message, vars), 15 | short_message: replace_vars(short_message, vars) 16 | } 17 | end 18 | 19 | def handle_error(other, _), do: other 20 | 21 | defp replace_vars(string, vars) do 22 | vars = 23 | if is_map(vars) do 24 | vars 25 | else 26 | List.wrap(vars) 27 | end 28 | 29 | Enum.reduce(vars, string, fn {key, value}, acc -> 30 | if String.contains?(acc, "%{#{key}}") do 31 | String.replace(acc, "%{#{key}}", stringified_value(value)) 32 | else 33 | acc 34 | end 35 | end) 36 | end 37 | 38 | def stringified_value(value) when is_list(value), 39 | do: "[#{value |> Enum.map_join(", ", &stringified_value(&1))}]" 40 | 41 | def stringified_value(value), do: to_string(value) 42 | end 43 | -------------------------------------------------------------------------------- /lib/subscription/notifier.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Subscription.Notifier do 6 | @moduledoc """ 7 | AshNotifier that triggers absinthe if subscriptions are listening 8 | """ 9 | use Ash.Notifier 10 | 11 | alias AshGraphql.Resource.Info 12 | alias AshGraphql.Subscription.Batcher.Notification 13 | 14 | @impl Ash.Notifier 15 | def notify(%Ash.Notifier.Notification{} = notification) do 16 | pub_sub = Info.subscription_pubsub(notification.resource, notification.domain) 17 | 18 | for subscription <- 19 | AshGraphql.Resource.Info.subscriptions(notification.resource, notification.domain) do 20 | if notification.action.name in List.wrap(subscription.actions) or 21 | notification.action.type in List.wrap(subscription.action_types) do 22 | Absinthe.Subscription.publish( 23 | pub_sub, 24 | %Notification{ 25 | action_type: notification.action.type, 26 | data: notification.data, 27 | tenant: notification.changeset.tenant 28 | }, 29 | [{subscription.name, "*"}] 30 | ) 31 | end 32 | end 33 | 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/support/relay_ids/resources/resource_with_no_primary_key_get.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayIds.ResourceWithNoPrimaryKeyGet do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.RelayIds.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :resource_with_no_primary_key_get 15 | 16 | queries do 17 | get :get_resource_by_name, :get_by_name 18 | end 19 | 20 | mutations do 21 | create :create_resource, :create 22 | end 23 | end 24 | 25 | actions do 26 | default_accept(:*) 27 | defaults([:create, :update, :destroy, :read]) 28 | 29 | read(:get_by_name, get_by: :name) 30 | end 31 | 32 | attributes do 33 | uuid_primary_key(:id) 34 | attribute(:name, :string, allow_nil?: false, public?: true) 35 | end 36 | 37 | identities do 38 | identity(:name, [:name], pre_check_with: AshGraphql.Test.RelayIds.Domain) 39 | end 40 | 41 | relationships do 42 | has_many(:posts, AshGraphql.Test.RelayIds.Post, 43 | destination_attribute: :author_id, 44 | public?: true 45 | ) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/resources/product.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Product do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshGraphql.Test.Domain, 9 | data_layer: Ash.DataLayer.Ets, 10 | extensions: [AshGraphql.Resource] 11 | 12 | require Ash.Query 13 | 14 | graphql do 15 | type :product 16 | 17 | queries do 18 | get :get_product, :read 19 | end 20 | 21 | mutations do 22 | create :create_product, :create 23 | update :update_product, :update 24 | destroy :destroy_product, :destroy 25 | end 26 | 27 | subscriptions do 28 | pubsub AshGraphql.Test.PubSub 29 | 30 | subscribe(:product_events) do 31 | action_types([:create, :update, :destroy]) 32 | end 33 | end 34 | end 35 | 36 | multitenancy do 37 | strategy(:attribute) 38 | attribute(:organization_id) 39 | end 40 | 41 | actions do 42 | default_accept(:*) 43 | defaults([:create, :read, :update, :destroy]) 44 | end 45 | 46 | attributes do 47 | uuid_primary_key(:id) 48 | attribute(:organization_id, :integer, public?: true) 49 | attribute(:name, :string, public?: true) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/resource/verifiers/require_pkey_delimiter.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Verifiers.RequirePkeyDelimiter do 6 | # Ensures that the resource has a primary key called `id` 7 | @moduledoc false 8 | 9 | use Spark.Dsl.Verifier 10 | 11 | alias Spark.Dsl.Verifier 12 | 13 | def verify(dsl) do 14 | if Verifier.get_persisted(dsl, :embedded?) do 15 | :ok 16 | else 17 | primary_key = 18 | dsl 19 | |> Verifier.get_entities([:attributes]) 20 | |> Enum.filter(& &1.primary_key?) 21 | 22 | case primary_key do 23 | [] -> 24 | :ok 25 | 26 | [_single] -> 27 | :ok 28 | 29 | [_ | _] -> 30 | if Verifier.get_persisted(dsl, :primary_key) do 31 | :ok 32 | else 33 | module = Verifier.get_persisted(dsl, :module) 34 | 35 | raise Spark.Error.DslError, 36 | module: module, 37 | path: [:graphql, :primary_key_delimiter], 38 | message: 39 | "AshGraphql requires a `primary_key_delimiter` to be set for composite primary keys." 40 | end 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/support/resources/resource_with_type_inside_type.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ResourceWithTypeInsideType do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:resource_with_type_inside) 15 | 16 | queries do 17 | end 18 | 19 | mutations do 20 | action :create_type_inside_type, :custom_action 21 | action :retrieve_type_inside_type, :custom_action_two 22 | end 23 | end 24 | 25 | actions do 26 | default_accept(:*) 27 | 28 | action :custom_action, :boolean do 29 | argument(:type_with_type, AshGraphql.Test.TypeWithTypeInside, allow_nil?: false) 30 | 31 | run(fn _inputs, _ctx -> 32 | {:ok, true} 33 | end) 34 | end 35 | 36 | action :custom_action_two, AshGraphql.Test.CategoryHierarchy do 37 | run(fn _inputs, _ctx -> 38 | {:ok, %{categories: [%{name: "bananas"}]}} 39 | end) 40 | end 41 | end 42 | 43 | attributes do 44 | uuid_primary_key(:id) 45 | 46 | attribute(:foo, :string, public?: true) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import Config 6 | 7 | config :ash, :disable_async?, true 8 | config :ash, :validate_domain_resource_inclusion?, false 9 | config :ash, :validate_domain_config_inclusion?, false 10 | 11 | config :ash, :pub_sub, debug?: true 12 | config :logger, level: :info, default_formatter: [metadata: [:crash_reason]] 13 | 14 | if Mix.env() == :test do 15 | config :ash_graphql, :simulate_subscription_slowness?, true 16 | end 17 | 18 | config :ash_graphql, :authorize_update_destroy_with_error?, true 19 | 20 | if Mix.env() == :dev do 21 | config :git_ops, 22 | mix_project: AshGraphql.MixProject, 23 | github_handle_lookup?: true, 24 | changelog_file: "CHANGELOG.md", 25 | repository_url: "https://github.com/ash-project/ash_graphql", 26 | # Instructs the tool to manage your mix version in your `mix.exs` file 27 | # See below for more information 28 | manage_mix_version?: true, 29 | # Instructs the tool to manage the version in your README.md 30 | # Pass in `true` to use `"README.md"` or a string to customize 31 | manage_readme_version: [ 32 | "README.md", 33 | "documentation/tutorials/getting-started-with-graphql.md" 34 | ], 35 | version_tag_prefix: "v" 36 | end 37 | -------------------------------------------------------------------------------- /lib/subscriptions.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Subscription do 6 | @moduledoc """ 7 | Helpers for working with absinthe subscriptions 8 | """ 9 | 10 | import AshGraphql.ContextHelpers 11 | 12 | @doc """ 13 | Produce a query that will load the correct data for a subscription. 14 | """ 15 | def query_for_subscription( 16 | query, 17 | domain, 18 | %{context: context} = resolution, 19 | type_override \\ nil, 20 | nested \\ [] 21 | ) do 22 | query 23 | |> Ash.Query.new() 24 | |> Ash.Query.set_tenant(Map.get(context, :tenant)) 25 | |> Ash.Query.set_context(get_context(context)) 26 | |> AshGraphql.Graphql.Resolver.select_fields( 27 | query.resource, 28 | resolution, 29 | type_override, 30 | nested 31 | ) 32 | |> AshGraphql.Graphql.Resolver.load_fields( 33 | [ 34 | domain: domain, 35 | tenant: Map.get(context, :tenant), 36 | authorize?: AshGraphql.Domain.Info.authorize?(domain), 37 | actor: Map.get(context, :actor) 38 | ], 39 | query.resource, 40 | resolution, 41 | resolution.path, 42 | context, 43 | type_override, 44 | nested 45 | ) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/resources/channel/calculations/filter_by_actor_messages_calculation.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.PageOfFilterByActorChannelMessagesCalculation do 6 | @moduledoc false 7 | use Ash.Resource.Calculation 8 | 9 | def load(_, _, context) do 10 | limit = context.arguments.limit || 10 11 | offset = context.arguments.offset || 0 12 | 13 | [ 14 | :filter_by_user_channel_message_count, 15 | filter_by_actor_messages: 16 | AshGraphql.Test.Message 17 | |> Ash.Query.limit(limit) 18 | |> Ash.Query.offset(offset) 19 | |> Ash.Query.select([:type, :text]) 20 | ] 21 | end 22 | 23 | def calculate([channel], _, context) do 24 | limit = context.arguments.limit || 10 25 | offset = context.arguments.offset || 0 26 | 27 | {:ok, 28 | [ 29 | %{ 30 | count: channel.filter_by_user_channel_message_count, 31 | has_next_page: channel.filter_by_user_channel_message_count > offset + limit, 32 | results: 33 | channel.filter_by_actor_messages 34 | |> Enum.map( 35 | &%Ash.Union{type: AshGraphql.Test.MessageUnion.struct_to_name(&1), value: &1} 36 | ) 37 | } 38 | ]} 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/gf/attendee.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.Attendee do 6 | @moduledoc """ 7 | An attendee record for ane event. 8 | """ 9 | 10 | use Ash.Resource, 11 | domain: GF.Domain, 12 | data_layer: Ash.DataLayer.Ets, 13 | extensions: [AshGraphql.Resource], 14 | authorizers: [Ash.Policy.Authorizer] 15 | 16 | require Ash.Query 17 | require Ash.Sort 18 | 19 | alias GF.Member 20 | 21 | actions do 22 | default_accept(:*) 23 | defaults([:create, :update, :read, :destroy]) 24 | end 25 | 26 | attributes do 27 | uuid_primary_key(:id) 28 | 29 | attribute(:event_id, :uuid, public?: true) 30 | attribute(:member_id, :uuid, public?: true) 31 | 32 | create_timestamp(:inserted_at) 33 | update_timestamp(:updated_at) 34 | end 35 | 36 | relationships do 37 | belongs_to(:member, Member, public?: true) 38 | end 39 | 40 | policies do 41 | policy action(:read) do 42 | authorize_if(actor_present()) 43 | end 44 | end 45 | 46 | graphql do 47 | type :gf_attendee 48 | end 49 | 50 | code_interface do 51 | define(:get_by_id, action: :read, get_by: :id, not_found_error?: false) 52 | define(:create, action: :create) 53 | define(:update, action: :update) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/support/resources/tag.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Tag do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:tag) 15 | 16 | filterable_fields [:name] 17 | sortable_fields [:popularity] 18 | 19 | queries do 20 | get :get_tag, :read 21 | list :get_tags, :read 22 | end 23 | 24 | mutations do 25 | create :create_tag, :create 26 | destroy :destroy_tag, :destroy 27 | end 28 | end 29 | 30 | actions do 31 | default_accept(:*) 32 | defaults([:read, :update, :destroy]) 33 | 34 | create :create do 35 | primary?(true) 36 | end 37 | end 38 | 39 | attributes do 40 | uuid_primary_key(:id) 41 | 42 | attribute(:name, :string, public?: true) 43 | attribute(:popularity, :integer, public?: true) 44 | end 45 | 46 | identities do 47 | identity(:name, [:name], pre_check_with: AshGraphql.Test.Domain) 48 | end 49 | 50 | relationships do 51 | many_to_many(:posts, AshGraphql.Test.Post, 52 | through: AshGraphql.Test.PostTag, 53 | source_attribute_on_join_resource: :tag_id, 54 | destination_attribute_on_join_resource: :post_id 55 | ) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/support/resources/relay_tag.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayTag do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:relay_tag) 15 | 16 | queries do 17 | get :get_relay_tag, :read 18 | list :get_relay_tags, :read_paginated, relay?: true 19 | end 20 | 21 | mutations do 22 | create :create_relay_tag, :create 23 | destroy :destroy_relay_tag, :destroy 24 | end 25 | end 26 | 27 | actions do 28 | default_accept(:*) 29 | defaults([:create, :update, :destroy, :read]) 30 | 31 | read :read_paginated do 32 | pagination(required?: true, offset?: true, keyset?: true, countable: true) 33 | end 34 | end 35 | 36 | attributes do 37 | uuid_primary_key(:id) 38 | 39 | attribute(:name, :string, public?: true) 40 | end 41 | 42 | identities do 43 | identity(:name, [:name], pre_check_with: AshGraphql.Test.Domain) 44 | end 45 | 46 | relationships do 47 | many_to_many(:posts, AshGraphql.Test.Post, 48 | through: AshGraphql.Test.RelayPostTag, 49 | source_attribute_on_join_resource: :tag_id, 50 | destination_attribute_on_join_resource: :post_id 51 | ) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/resources/other_resource.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.OtherResource do 6 | @moduledoc false 7 | 8 | alias AshGraphql.Test.CommonMap 9 | alias AshGraphql.Test.CommonMapStruct 10 | 11 | use Ash.Resource, 12 | domain: AshGraphql.Test.OtherDomain, 13 | data_layer: Ash.DataLayer.Ets, 14 | extensions: [AshGraphql.Resource] 15 | 16 | graphql do 17 | type :other_resource 18 | 19 | queries do 20 | get :get_other_resource, :read 21 | list :list_other_resources, :read 22 | end 23 | 24 | mutations do 25 | create :create_other_resource_with_common_map, :create_with_common_map 26 | end 27 | end 28 | 29 | actions do 30 | read :read do 31 | primary?(true) 32 | end 33 | 34 | create :create_with_common_map do 35 | argument(:common_map_arg, {:array, CommonMap}) 36 | end 37 | end 38 | 39 | attributes do 40 | uuid_primary_key(:id) 41 | 42 | attribute :common_map_attribute, CommonMap do 43 | public?(true) 44 | end 45 | 46 | attribute :common_map_struct_attribute, CommonMapStruct do 47 | public?(true) 48 | end 49 | end 50 | 51 | calculations do 52 | calculate :common_map_calculation, CommonMap do 53 | public?(true) 54 | calculation(fn records, _ -> {:ok, []} end) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/support/types/status.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Status do 6 | @moduledoc false 7 | use Ash.Type 8 | 9 | @values [:open, :closed] 10 | @string_values Enum.map(@values, &to_string/1) 11 | 12 | def graphql_input_type(_), do: :status 13 | def graphql_type(_), do: :status 14 | 15 | @impl true 16 | def storage_type, do: :string 17 | 18 | @impl true 19 | def cast_input(value, _) when value in @values do 20 | {:ok, value} 21 | end 22 | 23 | def cast_input(value, _) when is_binary(value) do 24 | value = String.downcase(value) 25 | 26 | if value in @string_values do 27 | {:ok, String.to_existing_atom(value)} 28 | else 29 | :error 30 | end 31 | end 32 | 33 | @impl true 34 | def cast_stored(nil, _), do: {:ok, nil} 35 | 36 | def cast_stored(value, _) when value in @values do 37 | {:ok, value} 38 | end 39 | 40 | def cast_stored(value, _) when value in @string_values do 41 | {:ok, String.to_existing_atom(value)} 42 | rescue 43 | ArgumentError -> 44 | :error 45 | end 46 | 47 | def cast_stored(_, _), do: :error 48 | 49 | @impl true 50 | def dump_to_native(nil, _), do: {:ok, nil} 51 | 52 | def dump_to_native(value, _) when is_atom(value) do 53 | {:ok, to_string(value)} 54 | end 55 | 56 | def dump_to_native(_, _), do: :error 57 | end 58 | -------------------------------------------------------------------------------- /test/support/gf/event.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.Event do 6 | @moduledoc """ 7 | Event Ash resource. 8 | """ 9 | 10 | use Ash.Resource, 11 | domain: GF.Domain, 12 | data_layer: Ash.DataLayer.Ets, 13 | extensions: [AshGraphql.Resource] 14 | 15 | require Ash.Query 16 | 17 | alias GF.Attendee 18 | 19 | attributes do 20 | uuid_primary_key(:id) 21 | 22 | attribute(:description, :string, public?: true) 23 | attribute(:group_id, :uuid, public?: false) 24 | 25 | attribute(:start_at, :utc_datetime, public?: true) 26 | attribute(:title, :string, public?: true) 27 | 28 | create_timestamp(:inserted_at, public?: true) 29 | update_timestamp(:updated_at, public?: true) 30 | end 31 | 32 | multitenancy do 33 | strategy(:attribute) 34 | attribute(:group_id) 35 | global?(true) 36 | end 37 | 38 | actions do 39 | default_accept(:*) 40 | defaults([:create, :read, :update, :destroy]) 41 | end 42 | 43 | relationships do 44 | has_many(:attendees, Attendee, public?: true) 45 | end 46 | 47 | graphql do 48 | type :gf_event 49 | 50 | queries do 51 | get :get_event, :read 52 | end 53 | end 54 | 55 | code_interface do 56 | define(:get_by_id, action: :read, get_by: :id, not_found_error?: false) 57 | define(:create, action: :create) 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/support/relay_ids/resources/user.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayIds.User do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.RelayIds.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :user 15 | 16 | queries do 17 | get :get_user, :read 18 | end 19 | 20 | mutations do 21 | create :create_user, :create 22 | update :assign_posts, :assign_posts, relay_id_translations: [input: [post_ids: :post]] 23 | end 24 | end 25 | 26 | actions do 27 | default_accept(:*) 28 | defaults([:create, :update, :destroy, :read]) 29 | 30 | update :assign_posts do 31 | require_atomic?(false) 32 | argument(:post_ids, {:array, :uuid}) 33 | 34 | change(manage_relationship(:post_ids, :posts, value_is_key: :id, type: :append_and_remove)) 35 | end 36 | end 37 | 38 | attributes do 39 | uuid_primary_key(:id) 40 | attribute(:name, :string, public?: true) 41 | end 42 | 43 | relationships do 44 | has_many(:posts, AshGraphql.Test.RelayIds.Post, 45 | destination_attribute: :author_id, 46 | public?: true 47 | ) 48 | end 49 | 50 | calculations do 51 | calculate(:name_twice, :string, expr(name <> " " <> name), public?: true) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/support/resources/multitenant_tag.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.MultitenantTag do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:multitenant_tag) 15 | 16 | queries do 17 | get :get_multitenant_tag, :read 18 | list :get_multitenant_tags, :read 19 | end 20 | 21 | mutations do 22 | create :create_multitenant_tag, :create 23 | destroy :destroy_multitenant_tag, :destroy 24 | end 25 | end 26 | 27 | multitenancy do 28 | strategy(:context) 29 | end 30 | 31 | actions do 32 | default_accept(:*) 33 | defaults([:read, :update, :destroy]) 34 | 35 | create :create do 36 | primary?(true) 37 | end 38 | end 39 | 40 | attributes do 41 | uuid_primary_key(:id) 42 | 43 | attribute(:name, :string, public?: true) 44 | end 45 | 46 | identities do 47 | identity(:name, [:name], pre_check_with: AshGraphql.Test.Domain) 48 | end 49 | 50 | relationships do 51 | many_to_many(:posts, AshGraphql.Test.Post, 52 | through: AshGraphql.Test.MultitenantPostTag, 53 | source_attribute_on_join_resource: :tag_id, 54 | destination_attribute_on_join_resource: :post_id, 55 | public?: true 56 | ) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/root_level_errors_domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RootLevelErrorsDomain do 6 | @moduledoc false 7 | 8 | use Ash.Domain, 9 | extensions: [ 10 | AshGraphql.Domain 11 | ], 12 | otp_app: :ash_graphql 13 | 14 | graphql do 15 | root_level_errors? true 16 | end 17 | 18 | resources do 19 | resource(AshGraphql.Test.Comment) 20 | resource(AshGraphql.Test.CompositePrimaryKey) 21 | resource(AshGraphql.Test.CompositePrimaryKeyNotEncoded) 22 | resource(AshGraphql.Test.DoubleRelRecursive) 23 | resource(AshGraphql.Test.DoubleRelToRecursiveParentOfEmbed) 24 | resource(AshGraphql.Test.MapTypes) 25 | resource(AshGraphql.Test.MultitenantPostTag) 26 | resource(AshGraphql.Test.MultitenantTag) 27 | resource(AshGraphql.Test.NoGraphql) 28 | resource(AshGraphql.Test.NoObject) 29 | resource(AshGraphql.Test.NonIdPrimaryKey) 30 | resource(AshGraphql.Test.Post) 31 | resource(AshGraphql.Test.PostTag) 32 | resource(AshGraphql.Test.RelayPostTag) 33 | resource(AshGraphql.Test.RelayTag) 34 | resource(AshGraphql.Test.SponsoredComment) 35 | resource(AshGraphql.Test.Tag) 36 | resource(AshGraphql.Test.User) 37 | resource(AshGraphql.Test.Channel) 38 | resource(AshGraphql.Test.Message) 39 | resource(AshGraphql.Test.TextMessage) 40 | resource(AshGraphql.Test.ImageMessage) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/support/embeds.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Foo do 6 | @moduledoc false 7 | use Ash.Resource, 8 | data_layer: :embedded, 9 | extensions: [ 10 | AshGraphql.Resource 11 | ] 12 | 13 | graphql do 14 | type :foo_embed 15 | end 16 | 17 | attributes do 18 | attribute :type, :atom do 19 | public?(true) 20 | constraints(one_of: [:foo]) 21 | writable?(false) 22 | end 23 | 24 | attribute :foo, :string do 25 | public?(true) 26 | allow_nil? false 27 | end 28 | end 29 | 30 | calculations do 31 | calculate(:always_true, :boolean, expr(true), public?: true) 32 | calculate(:always_nil, :boolean, expr(nil), public?: true) 33 | end 34 | end 35 | 36 | defmodule Bar do 37 | @moduledoc false 38 | use Ash.Resource, 39 | data_layer: :embedded, 40 | extensions: [ 41 | AshGraphql.Resource 42 | ] 43 | 44 | graphql do 45 | type :bar_embed 46 | end 47 | 48 | attributes do 49 | attribute :type, :atom do 50 | public?(true) 51 | constraints(one_of: [:foo]) 52 | writable?(false) 53 | end 54 | 55 | attribute :bar, :string do 56 | public?(true) 57 | allow_nil? false 58 | end 59 | end 60 | 61 | calculations do 62 | calculate(:always_true, :boolean, expr(true), public?: true) 63 | calculate(:always_false, :boolean, expr(false), public?: true) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /documentation/topics/generic-actions.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Generic Actions 8 | 9 | Generic actions allow us to build any interface we want in Ash. AshGraphql 10 | has full support for generic actions, from type generation to data loading. 11 | 12 | This means that you can write actions that return records or lists of records 13 | and those will have all of their fields appropriately loadable, or you can have 14 | generic actions that return simple scalars, like integers or strings. 15 | 16 | ## Examples 17 | 18 | Here we have a simple generic action returning a scalar value. 19 | 20 | ```elixir 21 | graphql do 22 | queries do 23 | action :say_hello, :say_hello 24 | end 25 | end 26 | 27 | actions do 28 | action :say_hello, :string do 29 | argument :to, :string, allow_nil?: false 30 | 31 | run fn input, _ -> 32 | {:ok, "Hello, #{input.arguments.to}"} 33 | end 34 | end 35 | end 36 | ``` 37 | 38 | And here we have a generic action returning a list of records. 39 | 40 | ```elixir 41 | graphql do 42 | type :post 43 | 44 | queries do 45 | action :random_ten, :random_ten 46 | end 47 | end 48 | 49 | actions do 50 | action :random_ten, {:array, :struct} do 51 | constraints items: [instance_of: __MODULE__] 52 | 53 | run fn input, context -> 54 | # This is just an example, not an efficient way to get 55 | # ten random records 56 | with {:ok, records} <- Ash.read(__MODULE__) do 57 | {:ok, Enum.take_random(records, 10)} 58 | end 59 | end 60 | end 61 | end 62 | ``` 63 | -------------------------------------------------------------------------------- /test/support/resources/error_handling.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ErrorHandling do 6 | @moduledoc "Example resource with error handling module." 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :error_handling 15 | 16 | mutations do 17 | create :create_error_handling, :create 18 | update :update_error_handling, :update 19 | end 20 | 21 | error_handler {ErrorHandler, :handle_error, []} 22 | end 23 | 24 | actions do 25 | default_accept(:*) 26 | defaults([:read, :destroy, :create]) 27 | 28 | update :update do 29 | accept([:name]) 30 | 31 | validate(fn _changeset, _context -> 32 | {:error, "error no matter what"} 33 | end) 34 | 35 | require_atomic?(false) 36 | end 37 | end 38 | 39 | attributes do 40 | uuid_primary_key(:id) 41 | 42 | attribute :name, :string do 43 | allow_nil?(false) 44 | public?(true) 45 | end 46 | end 47 | 48 | identities do 49 | identity(:name, [:name], pre_check_with: AshGraphql.Test.Domain) 50 | end 51 | end 52 | 53 | defmodule ErrorHandler do 54 | @moduledoc false 55 | def handle_error(error, context) do 56 | %{action: action} = context 57 | 58 | case action do 59 | :update -> %{error | message: "replaced! update"} 60 | _ -> %{error | message: "replaced!"} 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/support/relay_ids/resources/comment.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayIds.Comment do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.RelayIds.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :comment 15 | 16 | queries do 17 | list :list_comments, :read 18 | end 19 | 20 | mutations do 21 | create :create_comment, :create 22 | end 23 | end 24 | 25 | actions do 26 | default_accept(:*) 27 | defaults([:create, :update, :destroy]) 28 | 29 | read :read do 30 | primary?(true) 31 | end 32 | 33 | read :paginated do 34 | pagination(required?: true, offset?: true, countable: true) 35 | end 36 | end 37 | 38 | attributes do 39 | uuid_primary_key(:id) 40 | attribute(:text, :string, public?: true) 41 | 42 | attribute :type, :atom do 43 | public?(true) 44 | writable?(false) 45 | default(:comment) 46 | constraints(one_of: [:comment, :reply]) 47 | end 48 | 49 | create_timestamp(:created_at) 50 | end 51 | 52 | calculations do 53 | calculate( 54 | :timestamp, 55 | :utc_datetime_usec, 56 | expr(created_at), 57 | public?: true 58 | ) 59 | end 60 | 61 | relationships do 62 | belongs_to(:post, AshGraphql.Test.RelayIds.Post, public?: true) 63 | 64 | belongs_to :author, AshGraphql.Test.RelayIds.User do 65 | public?(true) 66 | attribute_writable?(true) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/support/resources/sponsored_comment.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.SponsoredComment do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :sponsored_comment 15 | complexity {__MODULE__, :query_complexity} 16 | 17 | queries do 18 | get :get_sponsored_comment, :read, complexity: {__MODULE__, :query_complexity} 19 | end 20 | 21 | mutations do 22 | create :create_sponsored_comment, :create 23 | end 24 | end 25 | 26 | actions do 27 | default_accept(:*) 28 | defaults([:create, :update, :destroy]) 29 | 30 | read :read do 31 | primary?(true) 32 | end 33 | 34 | read :paginated do 35 | pagination(required?: true, offset?: true, countable: true) 36 | end 37 | end 38 | 39 | attributes do 40 | uuid_primary_key(:id) 41 | attribute(:text, :string, public?: true) 42 | 43 | attribute :type, :atom do 44 | public?(true) 45 | writable?(false) 46 | default(:sponsored) 47 | end 48 | end 49 | 50 | relationships do 51 | belongs_to(:post, AshGraphql.Test.Post, public?: true) 52 | end 53 | 54 | @doc "Sponsored comments are complex to serve, add 100 to the cost per comment" 55 | def query_complexity(%{limit: n}, child_complexity, _resolution) do 56 | n * (child_complexity + 100) 57 | end 58 | 59 | def query_complexity(_args, child_complexity, _resolution) do 60 | child_complexity + 100 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/resource/verifiers/verify_query_metadata.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Verifiers.VerifyQueryMetadata do 6 | # Ensures that queries for actions with metadata have a type set 7 | @moduledoc false 8 | use Spark.Dsl.Verifier 9 | 10 | alias Spark.Dsl.Transformer 11 | 12 | def verify(dsl) do 13 | dsl 14 | |> AshGraphql.Resource.Info.queries([]) 15 | |> Enum.reject(&(&1.type == :action)) 16 | |> Enum.each(fn query -> 17 | action = Ash.Resource.Info.action(dsl, query.action) 18 | show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name) 19 | 20 | metadata = 21 | action 22 | |> Map.get(:metadata, []) 23 | |> Enum.filter(&(&1.name in show_metadata)) 24 | 25 | if !Enum.empty?(metadata) && is_nil(query.type_name) do 26 | resource = Transformer.get_persisted(dsl, :module) 27 | 28 | raise Spark.Error.DslError, 29 | module: resource, 30 | message: """ 31 | Queries for actions with metadata must have a type configured on the query. 32 | 33 | The #{query.action} action on #{inspect(resource)} has the following metadata fields: 34 | 35 | #{Enum.map_join(action.metadata, "\n", &"* #{&1.name}")} 36 | 37 | To generate a new type and include the metadata in that type, provide a new type 38 | name, for example `type :user_with_token`. 39 | 40 | To ignore the generated metadata, use the same type as the default. 41 | """ 42 | end 43 | end) 44 | 45 | :ok 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/relay_ids/resources/post.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelayIds.Post do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.RelayIds.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | require Ash.Query 14 | 15 | graphql do 16 | type :post 17 | 18 | queries do 19 | get :get_post, :read 20 | list :post_library, :read 21 | end 22 | 23 | mutations do 24 | create :simple_create_post, :create, relay_id_translations: [input: [author_id: :user]] 25 | update :update_post, :update 26 | update :assign_author, :assign_author, relay_id_translations: [input: [author_id: :user]] 27 | destroy :delete_post, :destroy 28 | end 29 | end 30 | 31 | actions do 32 | default_accept(:*) 33 | defaults([:update, :read, :destroy]) 34 | 35 | create :create do 36 | primary?(true) 37 | argument(:author_id, :uuid) 38 | 39 | change(set_attribute(:author_id, arg(:author_id))) 40 | end 41 | 42 | update :assign_author do 43 | argument(:author_id, :uuid) 44 | 45 | change(set_attribute(:author_id, arg(:author_id))) 46 | end 47 | end 48 | 49 | attributes do 50 | uuid_primary_key(:id) 51 | attribute(:text, :string, public?: true) 52 | end 53 | 54 | relationships do 55 | belongs_to(:author, AshGraphql.Test.RelayIds.User) do 56 | public?(true) 57 | attribute_writable?(true) 58 | end 59 | 60 | has_many(:comments, AshGraphql.Test.RelayIds.Comment, public?: true) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/resource/verifiers/verify_domain_query_metadata.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Verifiers.VerifyDomainQueryMetadata do 6 | # Ensures that queries for actions with metadata have a type set 7 | @moduledoc false 8 | use Spark.Dsl.Verifier 9 | 10 | alias Spark.Dsl.Transformer 11 | 12 | def verify(dsl) do 13 | dsl 14 | |> AshGraphql.Domain.Info.queries() 15 | |> Enum.reject(&(&1.type == :action)) 16 | |> Enum.each(fn query -> 17 | action = Ash.Resource.Info.action(query.resource, query.action) 18 | show_metadata = query.show_metadata || Enum.map(Map.get(action, :metadata, []), & &1.name) 19 | 20 | metadata = 21 | action 22 | |> Map.get(:metadata, []) 23 | |> Enum.filter(&(&1.name in show_metadata)) 24 | 25 | if !Enum.empty?(metadata) && is_nil(query.type_name) do 26 | resource = Transformer.get_persisted(dsl, :module) 27 | 28 | raise Spark.Error.DslError, 29 | module: resource, 30 | message: """ 31 | Queries for actions with metadata must have a type configured on the query. 32 | 33 | The #{query.action} action on #{inspect(resource)} has the following metadata fields: 34 | 35 | #{Enum.map_join(action.metadata, "\n", &"* #{&1.name}")} 36 | 37 | To generate a new type and include the metadata in that type, provide a new type 38 | name, for example `type :user_with_token`. 39 | 40 | To ignore the generated metadata, use the same type as the default. 41 | """ 42 | end 43 | end) 44 | 45 | :ok 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/filter_sort_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.FilterSortTest do 6 | use ExUnit.Case, async: false 7 | 8 | setup do 9 | on_exit(fn -> 10 | Application.delete_env(:ash_graphql, AshGraphql.Test.Domain) 11 | 12 | try do 13 | AshGraphql.TestHelpers.stop_ets() 14 | rescue 15 | _ -> 16 | :ok 17 | end 18 | end) 19 | end 20 | 21 | test "filterable_fields option is applied" do 22 | resp = 23 | """ 24 | query { 25 | __type(name: "TagFilterInput") { 26 | name 27 | kind 28 | inputFields { 29 | name 30 | } 31 | } 32 | } 33 | 34 | """ 35 | |> Absinthe.run(AshGraphql.Test.Schema) 36 | 37 | assert {:ok, %{data: %{"__type" => %{"inputFields" => input_fields}}}} = resp 38 | 39 | assert input_fields |> Enum.find(fn field -> field["name"] == "name" end) 40 | refute input_fields |> Enum.find(fn field -> field["name"] == "popularity" end) 41 | end 42 | 43 | test "sortable_fields option is applied" do 44 | resp = 45 | """ 46 | query { 47 | __type(name: "TagSortField") { 48 | name 49 | kind 50 | enumValues { 51 | name 52 | } 53 | } 54 | } 55 | 56 | """ 57 | |> Absinthe.run(AshGraphql.Test.Schema) 58 | 59 | assert {:ok, %{data: %{"__type" => %{"enumValues" => sort_fields}}}} = resp 60 | 61 | assert sort_fields |> Enum.find(fn field -> field["name"] == "POPULARITY" end) 62 | refute sort_fields |> Enum.find(fn field -> field["name"] == "NAME" end) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /test/support/resources/movie.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Movie do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type(:movie) 15 | 16 | paginate_relationship_with(actors: :relay, reviews: :offset, awards: :keyset) 17 | 18 | queries do 19 | get :get_movie, :read do 20 | meta meta_string: "bar", meta_integer: 1 21 | end 22 | 23 | list :get_movies, :read, paginate_with: nil 24 | end 25 | 26 | mutations do 27 | create :create_movie, :create_with_actors 28 | 29 | update :update_movie, :update do 30 | meta meta_string: "bar", meta_integer: 1 31 | end 32 | 33 | destroy :destroy_movie, :destroy 34 | end 35 | end 36 | 37 | actions do 38 | default_accept(:*) 39 | defaults([:create, :read, :update, :destroy]) 40 | 41 | create :create_with_actors do 42 | argument :actor_ids, {:array, :uuid} do 43 | allow_nil? false 44 | constraints(min_length: 1) 45 | end 46 | 47 | change(manage_relationship(:actor_ids, :actors, type: :append)) 48 | end 49 | end 50 | 51 | attributes do 52 | uuid_primary_key(:id) 53 | 54 | attribute(:title, :string, public?: true) 55 | end 56 | 57 | relationships do 58 | many_to_many(:actors, AshGraphql.Test.Actor, 59 | through: AshGraphql.Test.MovieActor, 60 | public?: true 61 | ) 62 | 63 | has_many(:reviews, AshGraphql.Test.Review, public?: true) 64 | has_many(:awards, AshGraphql.Test.Award, public?: true) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/support/resources/double_rel_recursive.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.DoubleRelRecursive do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | alias AshGraphql.Test.DoubleRelEmbed 14 | alias AshGraphql.Test.DoubleRelRecursive 15 | alias AshGraphql.Test.DoubleRelToRecursiveParentOfEmbed 16 | alias AshGraphql.Test.DoubleRelType 17 | 18 | attributes do 19 | uuid_primary_key(:id) 20 | attribute(:type, DoubleRelType, allow_nil?: true, public?: true) 21 | attribute(:this, :string, allow_nil?: true, public?: true) 22 | attribute(:or_that, DoubleRelEmbed, allow_nil?: true, public?: true) 23 | end 24 | 25 | actions do 26 | default_accept(:*) 27 | defaults([:create, :read, :update, :destroy]) 28 | end 29 | 30 | graphql do 31 | type :double_rel_recursive 32 | end 33 | 34 | relationships do 35 | belongs_to :double_rel, DoubleRelToRecursiveParentOfEmbed do 36 | public?(true) 37 | source_attribute(:double_rel_id) 38 | allow_nil?(false) 39 | end 40 | 41 | belongs_to :myself, DoubleRelRecursive do 42 | source_attribute(:recursive_id) 43 | allow_nil?(false) 44 | public?(true) 45 | end 46 | 47 | has_many :first_rel, DoubleRelRecursive do 48 | public?(true) 49 | destination_attribute(:recursive_id) 50 | filter(expr(type == :first)) 51 | end 52 | 53 | has_many :second_rel, DoubleRelRecursive do 54 | public?(true) 55 | destination_attribute(:recursive_id) 56 | filter(expr(type == :second)) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/support/resources/resource_level_pubsub_resource.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ResourceLevelPubsubResource do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshGraphql.Test.Domain, 9 | data_layer: Ash.DataLayer.Ets, 10 | authorizers: [Ash.Policy.Authorizer], 11 | extensions: [AshGraphql.Resource] 12 | 13 | require Ash.Query 14 | 15 | graphql do 16 | type :resource_level_pubsub_resource 17 | 18 | queries do 19 | get :get_resource_level_pubsub_resource, :read 20 | end 21 | 22 | mutations do 23 | create :create_resource_level_pubsub_resource, :create 24 | update :update_resource_level_pubsub_resource, :update 25 | destroy :destroy_resource_level_pubsub_resource, :destroy 26 | end 27 | 28 | subscriptions do 29 | pubsub AshGraphql.Test.PubSub 30 | 31 | subscribe(:resource_level_pubsub_events) do 32 | action_types([:create, :update, :destroy]) 33 | end 34 | end 35 | end 36 | 37 | policies do 38 | bypass actor_attribute_equals(:role, :admin) do 39 | authorize_if(always()) 40 | end 41 | 42 | policy action(:read) do 43 | authorize_if(expr(actor_id == ^actor(:id))) 44 | end 45 | end 46 | 47 | field_policies do 48 | field_policy :* do 49 | authorize_if(always()) 50 | end 51 | end 52 | 53 | actions do 54 | default_accept(:*) 55 | defaults([:create, :read, :update, :destroy]) 56 | end 57 | 58 | attributes do 59 | uuid_primary_key(:id) 60 | attribute(:text, :string, public?: true) 61 | attribute(:actor_id, :integer, public?: true) 62 | create_timestamp(:created_at) 63 | update_timestamp(:updated_at) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/support/resources/domain_level_pubsub_resource.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.DomainLevelPubsubResource do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshGraphql.Test.Domain, 9 | data_layer: Ash.DataLayer.Ets, 10 | authorizers: [Ash.Policy.Authorizer], 11 | extensions: [AshGraphql.Resource] 12 | 13 | require Ash.Query 14 | 15 | graphql do 16 | type :domain_level_pubsub_resource 17 | 18 | queries do 19 | get :get_domain_level_pubsub_resource, :read 20 | end 21 | 22 | mutations do 23 | create :create_domain_level_pubsub_resource, :create 24 | update :update_domain_level_pubsub_resource, :update 25 | destroy :destroy_domain_level_pubsub_resource, :destroy 26 | end 27 | 28 | subscriptions do 29 | # No pubsub specified here - should inherit from domain 30 | subscribe(:domain_level_pubsub_events) do 31 | action_types([:create, :update, :destroy]) 32 | end 33 | end 34 | end 35 | 36 | policies do 37 | bypass actor_attribute_equals(:role, :admin) do 38 | authorize_if(always()) 39 | end 40 | 41 | policy action(:read) do 42 | authorize_if(expr(actor_id == ^actor(:id))) 43 | end 44 | end 45 | 46 | field_policies do 47 | field_policy :* do 48 | authorize_if(always()) 49 | end 50 | end 51 | 52 | actions do 53 | default_accept(:*) 54 | defaults([:create, :read, :update, :destroy]) 55 | end 56 | 57 | attributes do 58 | uuid_primary_key(:id) 59 | attribute(:text, :string, public?: true) 60 | attribute(:actor_id, :integer, public?: true) 61 | create_timestamp(:created_at) 62 | update_timestamp(:updated_at) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/domain/transformers/validate_actions.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Domain.Transformers.ValidateActions do 6 | # Ensures that all referenced actiosn exist 7 | @moduledoc false 8 | use Spark.Dsl.Transformer 9 | 10 | alias Spark.Dsl.Transformer 11 | 12 | def after_compile?, do: true 13 | 14 | def transform(dsl) do 15 | dsl 16 | |> Transformer.get_entities([:graphql, :queries]) 17 | |> Enum.concat(Transformer.get_entities(dsl, [:graphql, :mutations])) 18 | |> Enum.each(fn query_or_mutation -> 19 | type = 20 | case query_or_mutation do 21 | %AshGraphql.Resource.Query{} -> 22 | :read 23 | 24 | %AshGraphql.Resource.Action{} -> 25 | nil 26 | 27 | %AshGraphql.Resource.Mutation{type: type} -> 28 | type 29 | end 30 | 31 | available_actions = Ash.Resource.Info.actions(query_or_mutation.resource) 32 | 33 | available_actions = 34 | if type do 35 | Enum.filter(available_actions, fn action -> 36 | action.type == type 37 | end) 38 | else 39 | available_actions 40 | end 41 | 42 | action = 43 | Enum.find(available_actions, fn action -> 44 | action.name == query_or_mutation.action 45 | end) 46 | 47 | unless action do 48 | raise Spark.Error.DslError, 49 | module: Transformer.get_persisted(dsl, :module), 50 | message: """ 51 | No such action #{query_or_mutation.action} of type #{type} on #{inspect(query_or_mutation.resource)} 52 | 53 | Available #{type} actions: 54 | 55 | #{Enum.map_join(available_actions, ", ", & &1.name)} 56 | """ 57 | end 58 | end) 59 | 60 | {:ok, dsl} 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/resource/transformers/validate_actions.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Transformers.ValidateActions do 6 | # Ensures that all referenced actiosn exist 7 | @moduledoc false 8 | use Spark.Dsl.Transformer 9 | 10 | alias Spark.Dsl.Transformer 11 | 12 | def after_compile?, do: true 13 | 14 | def transform(dsl) do 15 | dsl 16 | |> Transformer.get_entities([:graphql, :queries]) 17 | |> Enum.concat(Transformer.get_entities(dsl, [:graphql, :mutations])) 18 | |> Enum.each(fn query_or_mutation -> 19 | type = 20 | case query_or_mutation do 21 | %AshGraphql.Resource.Query{} -> 22 | :read 23 | 24 | %AshGraphql.Resource.Action{} -> 25 | nil 26 | 27 | %AshGraphql.Resource.Mutation{type: type} -> 28 | type 29 | end 30 | 31 | available_actions = Transformer.get_entities(dsl, [:actions]) || [] 32 | 33 | available_actions = 34 | if type do 35 | Enum.filter(available_actions, fn action -> 36 | action.type == type 37 | end) 38 | else 39 | available_actions 40 | end 41 | 42 | action = 43 | Enum.find(available_actions, fn action -> 44 | action.name == query_or_mutation.action 45 | end) 46 | 47 | unless action do 48 | resource = Transformer.get_persisted(dsl, :module) 49 | 50 | raise Spark.Error.DslError, 51 | module: resource, 52 | message: """ 53 | No such action #{query_or_mutation.action} of type #{type} on #{inspect(resource)} 54 | 55 | Available #{type} actions: 56 | 57 | #{Enum.map_join(available_actions, ", ", & &1.name)} 58 | """ 59 | end 60 | end) 61 | 62 | {:ok, dsl} 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /documentation/topics/sdl-file.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Using the SDL File 8 | 9 | By passing the `generate_sdl_file` to `use AshGraphql`, AshGraphql will generate 10 | a schema file when you run `mix ash.codegen`. For example: 11 | 12 | ```elixir 13 | use AshGraphql, 14 | domains: [Domain1, Domain2], 15 | generate_sdl_file: "priv/schema.graphql" 16 | ``` 17 | 18 | > ### Ensure your schema is up to date, gitignored, or not generated {: .info} 19 | > 20 | > We suggest first adding `mix ash.codegen --check` to your CI/CD pipeline to 21 | > ensure the schema is always up-to-date. Alternatively you can add the file 22 | > to your `.gitignore`, or you can remove the `generate_sdl_file` option to skip 23 | > generating the file. 24 | 25 | With the `generate_sdl_file` option, calls to `mix ash.codegen ` will generate 26 | a `.graphql` file at the specified path. 27 | 28 | ## Generating on Recompilation 29 | 30 | 31 | ```elixir 32 | use AshGraphql, 33 | domains: [Domain1, Domain2], 34 | generate_sdl_file: "priv/schema.graphql", 35 | auto_generate_sdl_file?: true 36 | ``` 37 | 38 | By specifying the `auto_generate_sdl_file?` option, the sdl file will be generated any time 39 | the schema recompiles. 40 | 41 | ## Why generate the SDL file? 42 | 43 | Some things that you can use this SDL file for: 44 | 45 | ### Documentation 46 | 47 | The schema file itself represents your entire GraphQL API definition, and examining it can be very useful. 48 | 49 | ### Code Generation 50 | 51 | You can use tools like [GraphQL codegen](https://the-guild.dev/graphql/codegen) to generate a client 52 | for your GraphQL API. 53 | 54 | ### Validating Changes 55 | 56 | Use the SDL file to check for breaking changes in your schema, especially if you are exposing a public API. 57 | A plug and play github action for this can be found here: https://the-guild.dev/graphql/inspector/docs/products/action 58 | -------------------------------------------------------------------------------- /lib/resource/verifiers/verify_paginate_relationship_with.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Verifiers.VerifyPaginateRelationshipWith do 6 | # Validates the paginate_relationship_with option 7 | @moduledoc false 8 | 9 | use Spark.Dsl.Verifier 10 | 11 | alias Spark.Dsl.Verifier 12 | 13 | @valid_strategies [ 14 | nil, 15 | :none, 16 | :keyset, 17 | :offset, 18 | :relay 19 | ] 20 | 21 | def verify(dsl) do 22 | many_relationship_names = 23 | dsl 24 | |> Verifier.get_entities([:relationships]) 25 | |> Enum.filter(&(&1.cardinality == :many)) 26 | |> Enum.map(& &1.name) 27 | 28 | dsl 29 | |> Verifier.get_option([:graphql], :paginate_relationship_with, []) 30 | |> Enum.each(fn {relationship_name, strategy} -> 31 | cond do 32 | relationship_name not in many_relationship_names -> 33 | module = Verifier.get_persisted(dsl, :module) 34 | 35 | raise Spark.Error.DslError, 36 | module: module, 37 | path: [:graphql, :paginate_relationship_with], 38 | message: """ 39 | #{relationship_name} is not a relationship with cardinality many. 40 | """ 41 | 42 | strategy not in @valid_strategies -> 43 | module = Verifier.get_persisted(dsl, :module) 44 | choices = Enum.map_join(@valid_strategies, ", ", &inspect/1) 45 | 46 | raise Spark.Error.DslError, 47 | module: module, 48 | path: [:graphql, :paginate_relationship_with], 49 | message: """ 50 | #{inspect(strategy)} is not a valid pagination strategy for relationships. 51 | 52 | Available strategies: #{choices} 53 | """ 54 | 55 | true -> 56 | :ok 57 | end 58 | end) 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /documentation/topics/custom-queries-and-mutations.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Custom Queries & Mutations 8 | 9 | You can define your own queries and mutations in your schema, 10 | using Absinthe's tooling. See their docs for more. 11 | 12 | > ### You probably don't need this! {: .info} 13 | > 14 | > You can define generic actions in your resources which can return any 15 | > type that you want, and those generic actions will automatically get 16 | > all of the goodness of AshGraphql, with automatic data loading and 17 | > type derivation, etc. See the [generic actions guide](/documentation/topics/generic-actions.md) for more. 18 | 19 | ## Using AshGraphql's types 20 | 21 | If you want to return resource types defined by AshGraphql, however, 22 | you will need to use `AshGraphql.load_fields_on_query/2` to ensure that any 23 | requested fields are loaded. 24 | 25 | For example: 26 | 27 | ```elixir 28 | require Ash.Query 29 | 30 | query do 31 | field :custom_get_post, :post do 32 | arg(:id, non_null(:id)) 33 | 34 | resolve(fn %{id: post_id}, resolution -> 35 | MyApp.Blog.Post 36 | |> Ash.Query.filter(id == ^post_id) 37 | |> AshGraphql.load_fields_on_query(resolution) 38 | |> Ash.read_one(not_found_error?: true) 39 | |> AshGraphql.handle_errors(MyApp.Blog.Post, resolution) 40 | end) 41 | end 42 | end 43 | ``` 44 | 45 | Alternatively, if you have records already that you need to load data on, use `AshGraphql.load_fields/3`: 46 | 47 | ```elixir 48 | query do 49 | field :custom_get_post, :post do 50 | arg(:id, non_null(:id)) 51 | 52 | resolve(fn %{id: post_id}, resolution -> 53 | with {:ok, post} when not is_nil(post) <- Ash.get(MyApp.Blog.Post, post_id) do 54 | AshGraphql.load_fields(post, MyApp.Blog.Post, resolution) 55 | end 56 | |> AshGraphql.handle_errors(MyApp.Blog.Post, resolution) 57 | end) 58 | end 59 | end 60 | ``` 61 | -------------------------------------------------------------------------------- /test/lazyinit_post_search_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.LazyInitTestPostSearchTest do 6 | use ExUnit.Case, async: false 7 | 8 | require Ash.Query 9 | 10 | setup do 11 | on_exit(fn -> 12 | AshGraphql.TestHelpers.stop_ets() 13 | end) 14 | end 15 | 16 | describe "lazyinit post search" do 17 | setup do 18 | letters = ["a", "b", "c", "d", "e"] 19 | 20 | for text <- letters do 21 | post = 22 | AshGraphql.Test.Post 23 | |> Ash.Changeset.for_create(:create, text: text, published: true) 24 | |> Ash.create!() 25 | 26 | for text <- letters do 27 | AshGraphql.Test.Comment 28 | |> Ash.Changeset.for_create(:create, text: text) 29 | |> Ash.Changeset.manage_relationship(:post, post, type: :append_and_remove) 30 | |> Ash.create!() 31 | end 32 | end 33 | 34 | :ok 35 | end 36 | 37 | test "should self reference predicate" do 38 | resp = 39 | """ 40 | query LazyinitSearch($predicate: PredicateInput) { 41 | lazyinitSearch(predicate: $predicate) { 42 | text 43 | } 44 | } 45 | """ 46 | |> Absinthe.run(AshGraphql.Test.Schema, 47 | variables: %{ 48 | "predicate" => %{ 49 | "condition" => "and", 50 | "predicates" => [ 51 | %{ 52 | "operator" => "eq", 53 | "field" => "text", 54 | "value" => "a" 55 | }, 56 | %{ 57 | "condition" => "eq", 58 | "field" => "text", 59 | "value" => "b" 60 | } 61 | ] 62 | } 63 | } 64 | ) 65 | 66 | assert {:ok, %{data: _}} = resp 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/resource/managed_relationship.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.ManagedRelationship do 6 | @moduledoc "Represents a managed relationship configuration on a mutation" 7 | 8 | defstruct [ 9 | :argument, 10 | :action, 11 | :types, 12 | :type_name, 13 | :lookup_with_primary_key?, 14 | :lookup_identities, 15 | :ignore?, 16 | :__spark_metadata__ 17 | ] 18 | 19 | @schema [ 20 | argument: [ 21 | type: :atom, 22 | doc: "The argument for which an input object should be derived.", 23 | required: true 24 | ], 25 | action: [ 26 | type: :atom, 27 | doc: "The action that accepts the argument" 28 | ], 29 | lookup_with_primary_key?: [ 30 | type: :boolean, 31 | doc: """ 32 | If the managed_relationship has `on_lookup` behavior, this option determines whether or not the primary key is provided in the input object for looking up. 33 | """ 34 | ], 35 | lookup_identities: [ 36 | type: {:list, :atom}, 37 | doc: """ 38 | Determines which identities are provided in the input object for looking up, if there is `on_lookup` behavior. Defalts to the `use_identities` option. 39 | """ 40 | ], 41 | type_name: [ 42 | type: :atom, 43 | doc: """ 44 | The name of the input object that will be derived. Defaults to `___input` 45 | """ 46 | ], 47 | types: [ 48 | type: :any, 49 | doc: """ 50 | A keyword list of field names to their graphql type identifiers. 51 | """ 52 | ], 53 | ignore?: [ 54 | type: :boolean, 55 | default: false, 56 | doc: """ 57 | Use this to ignore a given managed relationship, preventing `auto? true` from deriving a type for it. 58 | """ 59 | ] 60 | ] 61 | 62 | def schema, do: @schema 63 | end 64 | -------------------------------------------------------------------------------- /documentation/topics/use-unions-with-graphql.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Use Unions with GraphQL 8 | 9 | Unions must be defined with `Ash.Type.NewType`: 10 | 11 | ```elixir 12 | defmodule MyApp.Armor do 13 | use Ash.Type.NewType, subtype_of: :union, constraints: [ 14 | types: [ 15 | plate: [ 16 | # This is an embedded resource, with its own fields 17 | type: MyApp.Armor.Plate 18 | ], 19 | chain_mail: [ 20 | # And so is this 21 | type: MyApp.Armor.ChainMail 22 | ], 23 | custom: [ 24 | type: :string 25 | ] 26 | ] 27 | ] 28 | 29 | use AshGraphql.Type 30 | 31 | # Add this to define the union in ash_graphql 32 | def graphql_type(_), do: :armor 33 | end 34 | ``` 35 | 36 | By default, the type you would get for this on input and output would look something like this: 37 | 38 | ``` 39 | type Armor = {plate: {value: Plate}} | {chain_mail: {value: ChainMail}} | {custom: {value: String}} 40 | ``` 41 | 42 | We do this by default to solve for potentially ambiguous types. An example of this might be if you had multiple different types of strings in a union, and you wanted the client to be able to tell exactly which type of string they'd been given. i.e `{social: {value: "555-55-5555"}} | {phone_number: {value: "555-5555"}}`. 43 | 44 | However, you can clean the type in cases where you have no such conflicts by by providing 45 | 46 | ```elixir 47 | # Put anything in here that does not need to be named/nested with `{type_name: {value: value}}` 48 | def graphql_unnested_unions(_constraints), do: [:plate, :chain_mail] 49 | ``` 50 | 51 | Which, in this case, would yield: 52 | 53 | ``` 54 | type Armor = Plate | ChainMail | {custom: {value: String}} 55 | ``` 56 | 57 | ## Bypassing type generation for a union 58 | 59 | Add the `graphql_define_type?/1` callback, like so, to skip Ash's generation (i.e if you're defining it yourself) 60 | 61 | ```elixir 62 | @impl true 63 | def graphql_define_type?(_), do: false 64 | ``` 65 | -------------------------------------------------------------------------------- /test/support/schema.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Schema do 6 | @moduledoc false 7 | 8 | use Absinthe.Schema 9 | 10 | @domains [AshGraphql.Test.Domain, AshGraphql.Test.OtherDomain] 11 | 12 | use AshGraphql, domains: @domains, generate_sdl_file: "priv/schema.graphql" 13 | 14 | def middleware(middleware, _field, %Absinthe.Type.Object{identifier: identifier}) 15 | when identifier in [:query, :mutation, :subscription] do 16 | middleware ++ [AshGraphql.MetaMiddleware] 17 | end 18 | 19 | def middleware(middleware, _field, _object) do 20 | middleware 21 | end 22 | 23 | query do 24 | field :custom_get_post, :post do 25 | arg(:id, non_null(:id)) 26 | 27 | resolve(fn %{id: post_id}, resolution -> 28 | with {:ok, post} when not is_nil(post) <- Ash.get(AshGraphql.Test.Post, post_id) do 29 | post 30 | |> AshGraphql.load_fields(AshGraphql.Test.Post, resolution) 31 | end 32 | |> AshGraphql.handle_errors(AshGraphql.Test.Post, resolution) 33 | end) 34 | end 35 | 36 | field :custom_get_post_query, :post do 37 | arg(:id, non_null(:id)) 38 | 39 | resolve(fn %{id: post_id}, resolution -> 40 | AshGraphql.Test.Post 41 | |> Ash.Query.do_filter(id: post_id) 42 | |> AshGraphql.load_fields_on_query(resolution) 43 | |> Ash.read_one(not_found_error?: true) 44 | |> AshGraphql.handle_errors(AshGraphql.Test.Post, resolution) 45 | end) 46 | end 47 | end 48 | 49 | mutation do 50 | end 51 | 52 | object :foo do 53 | field(:foo, :string) 54 | field(:bar, :string) 55 | end 56 | 57 | input_object :foo_input do 58 | field(:foo, non_null(:string)) 59 | field(:bar, non_null(:string)) 60 | end 61 | 62 | enum :status do 63 | value(:open, description: "The post is open") 64 | value(:closed, description: "The post is closed") 65 | end 66 | 67 | subscription do 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/resource/subscription.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Subscription do 6 | @moduledoc "Represents a configured query on a resource" 7 | defstruct [ 8 | :name, 9 | :resource, 10 | :actions, 11 | :action_types, 12 | :read_action, 13 | :actor, 14 | :hide_inputs, 15 | :relay_id_translations, 16 | :meta, 17 | :__spark_metadata__ 18 | ] 19 | 20 | @subscription_schema [ 21 | name: [ 22 | type: :atom, 23 | doc: "The name to use for the subscription." 24 | ], 25 | actor: [ 26 | type: 27 | {:spark_function_behaviour, AshGraphql.Subscription.Actor, 28 | {AshGraphql.Subscription.ActorFunction, 1}}, 29 | doc: "The actor to use for authorization." 30 | ], 31 | actions: [ 32 | type: {:or, [{:list, :atom}, :atom]}, 33 | doc: "The create/update/destroy actions the subsciption should listen to." 34 | ], 35 | action_types: [ 36 | type: {:or, [{:list, :atom}, :atom]}, 37 | doc: "The type of actions the subsciption should listen to." 38 | ], 39 | read_action: [ 40 | type: :atom, 41 | doc: "The read action to use for reading data" 42 | ], 43 | hide_inputs: [ 44 | type: {:list, :atom}, 45 | doc: 46 | "A list of inputs to hide from the subscription, usable if the read action has arguments.", 47 | default: [] 48 | ], 49 | relay_id_translations: [ 50 | type: :keyword_list, 51 | doc: """ 52 | A keyword list indicating arguments or attributes that have to be translated from global Relay IDs to internal IDs. See the [Relay guide](/documentation/topics/relay.md#translating-relay-global-ids-passed-as-arguments) for more. 53 | """, 54 | default: [] 55 | ], 56 | meta: [ 57 | type: :keyword_list, 58 | doc: "A keyword list of metadata to include in the subscription." 59 | ] 60 | ] 61 | 62 | def schema, do: @subscription_schema 63 | end 64 | -------------------------------------------------------------------------------- /lib/domain/info.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Domain.Info do 6 | @moduledoc "Introspection helpers for AshGraphql.Domain" 7 | 8 | alias Spark.Dsl.Extension 9 | 10 | @doc "Whether or not to run authorization on this domain" 11 | def authorize?(domain) do 12 | Extension.get_opt(domain, [:graphql], :authorize?, true) 13 | end 14 | 15 | @doc "The tracer to use for the given schema" 16 | def tracer(domain) do 17 | domain 18 | |> Extension.get_opt([:graphql], :tracer, nil, true) 19 | |> List.wrap() 20 | |> Enum.concat(List.wrap(Application.get_env(:ash, :tracer))) 21 | end 22 | 23 | @doc "Whether or not to surface errors to the root of the response" 24 | def root_level_errors?(domain) do 25 | Extension.get_opt(domain, [:graphql], :root_level_errors?, false, true) 26 | end 27 | 28 | @doc "An error handler for errors produced by the domain" 29 | def error_handler(domain) do 30 | Extension.get_opt( 31 | domain, 32 | [:graphql], 33 | :error_handler, 34 | {AshGraphql.DefaultErrorHandler, :handle_error, []}, 35 | true 36 | ) 37 | end 38 | 39 | @doc "The queries exposed by the domain" 40 | def queries(resource) do 41 | Extension.get_entities(resource, [:graphql, :queries]) || [] 42 | end 43 | 44 | @doc "The mutations exposed by the domain" 45 | def mutations(resource) do 46 | Extension.get_entities(resource, [:graphql, :mutations]) || [] 47 | end 48 | 49 | def subscriptions(resource) do 50 | Extension.get_entities(resource, [:graphql, :subscriptions]) || [] 51 | end 52 | 53 | @doc "The pubsub module configured for subscriptions in this domain" 54 | def subscription_pubsub(domain) do 55 | Extension.get_opt(domain, [:graphql, :subscriptions], :pubsub) 56 | end 57 | 58 | @doc "Whether or not to render raised errors in the GraphQL response" 59 | def show_raised_errors?(domain) do 60 | Extension.get_opt(domain, [:graphql], :show_raised_errors?, false, true) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /documentation/topics/monitoring.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Monitoring 8 | 9 | Please read [the Ash monitoring guide](https://hexdocs.pm/ash/monitoring.html) for more information. Here we simply cover the additional traces & telemetry events that we publish from this extension. 10 | 11 | A tracer can be configured in the domain. It will fallback to the global tracer configuration `config :ash, :tracer, Tracer` 12 | 13 | ```elixir 14 | graphql do 15 | tracer MyApp.Tracer 16 | end 17 | ``` 18 | 19 | ## Traces 20 | 21 | Each graphql resolver, and batch resolution of the underlying data loader, will produce a span with an appropriate name. We also set a `source: :graphql` metadata if you want to filter them out or annotate them in some way. 22 | 23 | ## Telemetry 24 | 25 | AshGraphql emits the following telemetry events, suffixed with `:start` and `:stop`. Start events have `system_time` measurements, and stop events have `system_time` and `duration` measurements. All times will be in the native time unit. 26 | 27 | - `[:ash, , :gql_mutation]` - The execution of a mutation. Use `resource_short_name` and `mutation` (or `action`) metadata to break down measurements. 28 | - `[:ash, , :gql_query]` - The execution of a mutation. Use `resource_short_name` and `query` (or `action`) metadata to break down measurements. 29 | 30 | - `[:ash, , :gql_relationship]` - The resolution of a relationship. Use `resource_short_name` and `relationship` metadata to break down measurements. 31 | 32 | - `[:ash, , :gql_calculation]` - The resolution of a calculation. Use `resource_short_name` and `calculation` metadata to break down measurements. 33 | 34 | - `[:ash, , :gql_relationship_batch]` - The resolution of a batch of relationships by the data loader. Use `resource_short_name` and `relationship` metadata to break down measurements. 35 | 36 | - `[:ash, , :gql_calculation_batch]` - The resolution of a batch of calculations by the data loader. Use `resource_short_name` and `calculation` metadata to break down measurements. 37 | -------------------------------------------------------------------------------- /documentation/topics/relay.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Relay 8 | 9 | Enabling Relay for a resource sets it up to follow the [Relay specification](https://relay.dev/graphql/connections.htm). 10 | 11 | The two changes that are made currently are: 12 | 13 | - the type for the resource will implement the `Node` interface 14 | - pagination over that resource will behave as a `Connection`. 15 | 16 | ## Using Ash's built-in Relay support 17 | 18 | Set `relay? true` on the resource: 19 | 20 | ```elixir 21 | graphql do 22 | relay? true 23 | 24 | ... 25 | end 26 | ``` 27 | 28 | ## Relay Global IDs 29 | 30 | Use the following option to generate Relay Global IDs (see 31 | [here](https://relay.dev/graphql/objectidentification.htm)). 32 | 33 | ```elixir 34 | use AshGraphql, relay_ids?: true 35 | ``` 36 | 37 | This allows refetching a node using the `node` query and passing its global ID. 38 | 39 | ### Translating Relay Global IDs passed as arguments 40 | 41 | When `relay_ids?: true` is passed, users of the API will have access only to the global IDs, so they 42 | will also need to use them when an ID is required as argument. You actions, though, internally use the 43 | normal IDs defined by the data layer. 44 | 45 | To handle the translation between the two ID domains, you can use the `relay_id_translations` 46 | option. With this, you can define a list of arguments that will be translated from Relay global IDs 47 | to internal IDs. 48 | 49 | For example, if you have a `Post` resource with an action to create a post associated with an 50 | author: 51 | 52 | ```elixir 53 | create :create do 54 | argument :author_id, :uuid 55 | 56 | # Do stuff with author_id 57 | end 58 | ``` 59 | 60 | You can add this to the mutation connected to that action: 61 | 62 | ```elixir 63 | mutations do 64 | create :create_post, :create do 65 | relay_id_translations [input: [author_id: :user]] 66 | end 67 | end 68 | ``` 69 | 70 | ## Using with Absinthe.Relay instead of Ash's relay type 71 | 72 | Use the following option when calling `use AshGraphql` 73 | 74 | ```elixir 75 | use AshGraphql, define_relay_types?: false 76 | ``` 77 | -------------------------------------------------------------------------------- /lib/graphql/errors.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Errors do 6 | @moduledoc """ 7 | Utilities for working with errors in custom resolvers. 8 | """ 9 | require Logger 10 | 11 | @doc """ 12 | Transform an error or list of errors into the response for graphql. 13 | """ 14 | def to_errors(errors, context, domain, resource, action) do 15 | errors 16 | |> AshGraphql.Graphql.Resolver.unwrap_errors() 17 | |> Enum.map(fn error -> 18 | if AshGraphql.Error.impl_for(error) do 19 | error = AshGraphql.Error.to_error(error) 20 | context = Map.put(context, :action, action) 21 | 22 | resource_handled_error = 23 | case AshGraphql.Resource.Info.error_handler(resource) do 24 | nil -> 25 | error 26 | 27 | {m, f, a} -> 28 | apply(m, f, [error, context | a]) 29 | end 30 | 31 | case AshGraphql.Domain.Info.error_handler(domain) do 32 | nil -> 33 | resource_handled_error 34 | 35 | {m, f, a} -> 36 | apply(m, f, [resource_handled_error, context | a]) 37 | end 38 | else 39 | uuid = Ash.UUID.generate() 40 | 41 | if is_exception(error) do 42 | case error do 43 | %{stacktrace: %{stacktrace: stacktrace}} -> 44 | Logger.warning( 45 | "`#{uuid}`: AshGraphql.Error not implemented for error:\n\n#{Exception.format(:error, error, stacktrace)}" 46 | ) 47 | 48 | error -> 49 | Logger.warning( 50 | "`#{uuid}`: AshGraphql.Error not implemented for error:\n\n#{Exception.format(:error, error)}" 51 | ) 52 | end 53 | else 54 | Logger.warning( 55 | "`#{uuid}`: AshGraphql.Error not implemented for error:\n\n#{inspect(error)}" 56 | ) 57 | end 58 | 59 | %{ 60 | message: "something went wrong. Unique error id: `#{uuid}`" 61 | } 62 | end 63 | end) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /documentation/topics/use-enums-with-graphql.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Use Enums with GraphQL 8 | 9 | If you define an `Ash.Type.Enum`, that enum type can be used both in attributes _and_ arguments. You will need to add `graphql_type/0` to your implementation. AshGraphql will ensure that a single type is defined for it, which will be reused across all occurrences. If an enum 10 | type is referenced, but does not have `graphql_type/0` defined, it will 11 | be treated as a string input. 12 | 13 | For example: 14 | 15 | ```elixir 16 | defmodule AshPostgres.Test.Types.Status do 17 | @moduledoc false 18 | use Ash.Type.Enum, values: [:open, :closed] 19 | 20 | def graphql_type(_), do: :ticket_status 21 | 22 | # Optionally, remap the names used in GraphQL, for instance if you have a value like `:"10"` 23 | # that value is not compatible with GraphQL 24 | 25 | def graphql_rename_value(:"10"), do: :ten 26 | def graphql_rename_value(value), do: value 27 | 28 | # You can also provide descriptions for the enum values, which will be exposed in the GraphQL 29 | # schema. 30 | # Remember to have a fallback clause that returns nil if you don't provide descriptions for all 31 | # values. 32 | 33 | def graphql_describe_enum_value(:open), do: "The post is open" 34 | def graphql_describe_enum_value(_), do: nil 35 | end 36 | 37 | ``` 38 | 39 | ### Using custom absinthe types 40 | 41 | You can implement a custom enum by first adding the enum type to your absinthe schema (more [here](https://hexdocs.pm/absinthe/Absinthe.Type.Enum.html)). Then you can define a custom Ash type that refers to that absinthe enum type. 42 | 43 | ```elixir 44 | # In your absinthe schema: 45 | 46 | enum :status do 47 | value(:open, description: "The post is open") 48 | value(:closed, description: "The post is closed") 49 | end 50 | ``` 51 | 52 | ```elixir 53 | # Your custom Ash Type 54 | defmodule AshGraphql.Test.Status do 55 | use Ash.Type.Enum, values: [:open, :closed] 56 | 57 | use AshGraphql.Type 58 | 59 | @impl true 60 | # tell Ash not to define the type for that enum 61 | def graphql_define_type?(_), do: false 62 | end 63 | ``` 64 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | spark_locals_without_parens = [ 6 | action: 2, 7 | action: 3, 8 | action: 4, 9 | action_types: 1, 10 | actions: 1, 11 | actor: 1, 12 | allow_nil?: 1, 13 | args: 1, 14 | argument_input_types: 1, 15 | argument_names: 1, 16 | as_mutation?: 1, 17 | attribute_input_types: 1, 18 | attribute_types: 1, 19 | authorize?: 1, 20 | auto?: 1, 21 | complexity: 1, 22 | create: 2, 23 | create: 3, 24 | create: 4, 25 | depth_limit: 1, 26 | derive_filter?: 1, 27 | derive_sort?: 1, 28 | description: 1, 29 | destroy: 2, 30 | destroy: 3, 31 | destroy: 4, 32 | encode_primary_key?: 1, 33 | error_handler: 1, 34 | error_location: 1, 35 | field_names: 1, 36 | filterable_fields: 1, 37 | generate_object?: 1, 38 | get: 2, 39 | get: 3, 40 | get: 4, 41 | hide_fields: 1, 42 | hide_inputs: 1, 43 | identity: 1, 44 | ignore?: 1, 45 | keyset_field: 1, 46 | list: 2, 47 | list: 3, 48 | list: 4, 49 | lookup_identities: 1, 50 | lookup_with_primary_key?: 1, 51 | managed_relationship: 2, 52 | managed_relationship: 3, 53 | meta: 1, 54 | metadata_names: 1, 55 | metadata_types: 1, 56 | modify_resolution: 1, 57 | nullable_fields: 1, 58 | paginate_relationship_with: 1, 59 | paginate_with: 1, 60 | primary_key_delimiter: 1, 61 | pubsub: 1, 62 | read_action: 1, 63 | read_one: 2, 64 | read_one: 3, 65 | read_one: 4, 66 | relationships: 1, 67 | relay?: 1, 68 | relay_id_translations: 1, 69 | root_level_errors?: 1, 70 | show_fields: 1, 71 | show_metadata: 1, 72 | show_raised_errors?: 1, 73 | sortable_fields: 1, 74 | subscribe: 1, 75 | subscribe: 2, 76 | subscribe: 3, 77 | tracer: 1, 78 | type: 1, 79 | type_name: 1, 80 | types: 1, 81 | update: 2, 82 | update: 3, 83 | update: 4, 84 | upsert?: 1, 85 | upsert_identity: 1 86 | ] 87 | 88 | [ 89 | inputs: ["{mix,.formatter}.exs", "{config,lib,test,benchmarks}/**/*.{ex,exs}"], 90 | locals_without_parens: spark_locals_without_parens, 91 | export: [ 92 | locals_without_parens: spark_locals_without_parens 93 | ] 94 | ] 95 | -------------------------------------------------------------------------------- /test/gf_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule GF.Test do 6 | use ExUnit.Case 7 | 8 | test "basic" do 9 | group = 10 | GF.Group 11 | |> Ash.Changeset.for_create(:create, %{abbreviation: "TG", name: "Test Group"}) 12 | |> Ash.create!(authorize?: false) 13 | 14 | Ash.DataLayer.Simple.set_data(GF.Group, [group]) 15 | 16 | event = 17 | GF.Event 18 | |> Ash.Changeset.for_create(:create, %{title: "Test Event"}, tenant: group.id) 19 | |> Ash.create!(authorize?: false) 20 | 21 | assert event.id 22 | 23 | Ash.DataLayer.Simple.set_data(GF.Event, [event]) 24 | 25 | assert Ash.get!(GF.Event, event.id, authorize?: false) 26 | 27 | member = 28 | GF.Member 29 | |> Ash.Changeset.for_create( 30 | :create, 31 | %{email: "test@example.com", name: "Test Member", status: :active}, 32 | tenant: group.id 33 | ) 34 | |> Ash.create!(authorize?: false) 35 | 36 | actor = 37 | GF.Member 38 | |> Ash.Changeset.for_create( 39 | :create, 40 | %{email: "actor@example.com", name: "Actor Member", status: :inactive}, 41 | tenant: group.id 42 | ) 43 | |> Ash.create!(authorize?: false) 44 | 45 | Ash.DataLayer.Simple.set_data(GF.Member, [member, actor]) 46 | 47 | attendee = 48 | GF.Attendee 49 | |> Ash.Changeset.for_create(:create, %{event_id: event.id, member_id: member.id}) 50 | |> Ash.create!(authorize?: false) 51 | 52 | Ash.DataLayer.Simple.set_data(GF.Attendee, [attendee]) 53 | 54 | assert attendee.id 55 | 56 | {:ok, %{data: data}} = 57 | """ 58 | query GetEvent($id: ID!) { 59 | getEvent(id: $id) { 60 | id 61 | title 62 | attendees(filter: {member: {status: {eq: ACTIVE}}}) { 63 | id 64 | member { 65 | id 66 | name 67 | } 68 | } 69 | } 70 | } 71 | """ 72 | |> Absinthe.run(GF.AshGraphqlSchema, 73 | variables: %{"id" => event.id}, 74 | context: %{actor: actor} 75 | ) 76 | 77 | assert data["getEvent"] 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/support/resources/resource_with_union.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ResourceWithUnion do 6 | @moduledoc false 7 | 8 | alias AshGraphql.Test.PersonMap 9 | alias AshGraphql.Test.PersonRegularStruct 10 | alias AshGraphql.Test.PersonTypedStructData 11 | 12 | defmodule Union do 13 | @moduledoc false 14 | 15 | use Ash.Type.NewType, 16 | subtype_of: :union, 17 | constraints: [ 18 | types: [ 19 | member_array_boolean: [ 20 | type: {:array, :boolean}, 21 | tag: :type, 22 | tag_value: "member_array_boolean" 23 | ], 24 | member_string: [ 25 | type: :string, 26 | tag: :type, 27 | tag_value: "member_string" 28 | ], 29 | member_map: [ 30 | type: PersonMap, 31 | tag: :type, 32 | tag_value: "member_map" 33 | ], 34 | member_typed_struct: [ 35 | type: PersonTypedStructData, 36 | tag: :type, 37 | tag_value: "member_typed_struct" 38 | ], 39 | member_regular_struct: [ 40 | type: PersonRegularStruct, 41 | tag: :type, 42 | tag_value: "member_regular_struct" 43 | ] 44 | ] 45 | ] 46 | 47 | use AshGraphql.Type 48 | 49 | @impl true 50 | def graphql_type(_), do: :uniontype 51 | 52 | @impl true 53 | def graphql_input_type(_), do: :uniontype_input 54 | end 55 | 56 | use Ash.Resource, 57 | domain: AshGraphql.Test.Domain, 58 | data_layer: Ash.DataLayer.Ets, 59 | extensions: [AshGraphql.Resource] 60 | 61 | graphql do 62 | type(:resource_with_union) 63 | 64 | queries do 65 | end 66 | 67 | mutations do 68 | action(:action_with_union_arg, :action_with_union_arg) 69 | end 70 | end 71 | 72 | actions do 73 | default_accept(:*) 74 | 75 | action :action_with_union_arg, :boolean do 76 | argument(:union_arg, Union, allow_nil?: false) 77 | 78 | run(fn _inputs, _ctx -> 79 | {:ok, true} 80 | end) 81 | end 82 | end 83 | 84 | attributes do 85 | uuid_primary_key(:id) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ![Logo](https://github.com/ash-project/ash/blob/main/logos/cropped-for-header-black-text.png?raw=true#gh-light-mode-only) 8 | ![Logo](https://github.com/ash-project/ash/blob/main/logos/cropped-for-header-white-text.png?raw=true#gh-dark-mode-only) 9 | 10 | ![Elixir CI](https://github.com/ash-project/ash_graphql/workflows/CI/badge.svg) 11 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 12 | [![Hex version badge](https://img.shields.io/hexpm/v/ash_graphql.svg)](https://hex.pm/packages/ash_graphql) 13 | [![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/ash_graphql) 14 | [![REUSE status](https://api.reuse.software/badge/github.com/ash-project/ash_graphql)](https://api.reuse.software/info/github.com/ash-project/ash_graphql) 15 | 16 | # AshGraphql 17 | 18 | Welcome! This is the extension for building GraphQL APIs with [Ash](https://hexdocs.pm/ash). The generated GraphQL APIs are powered by [Absinthe](http://hexdocs.pm/absinthe). Generate a powerful Graphql API in minutes! 19 | 20 | ## Tutorials 21 | 22 | - [Getting Started with GraphQL](documentation/tutorials/getting-started-with-graphql.md) 23 | 24 | ## Topics 25 | 26 | - [Authorize with GraphQL](documentation/topics/authorize-with-graphql.md) 27 | - [Handle Errors](documentation/topics/handle-errors.md) 28 | - [Monitoring](documentation/topics/monitoring.md) 29 | - [Use JSON with GraphQL](documentation/topics/use-json-with-graphql.md) 30 | - [Use Subscriptions with GraphQL](documentation/topics/use-subscriptions-with-graphql.md) 31 | - [GraphQL Generation](documentation/topics/graphql-generation.md) 32 | - [Modifying the Resolution](documentation/topics/modifying-the-resolution.md) 33 | - [Relay](documentation/topics/relay.md) 34 | - [Use Enums with GraphQL](documentation/topics/use-enums-with-graphql.md) 35 | - [Use Maps with GraphQL](documentation/topics/use-maps-with-graphql.md) 36 | - [Use Unions with GraphQL](documentation/topics/use-unions-with-graphql.md) 37 | - [Upgrading to 1.0](documentation/topics/upgrade.md) 38 | 39 | ## Reference 40 | 41 | - [AshGraphql.Resource DSL](documentation/dsls/DSL-AshGraphql.Resource.md) 42 | - [AshGraphql.Domain DSL](documentation/dsls/DSL-AshGraphql.Domain.md) 43 | -------------------------------------------------------------------------------- /lib/graphql/id_translator.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Graphql.IdTranslator do 6 | @moduledoc false 7 | 8 | def translate_relay_ids(%{state: :unresolved} = resolution, relay_id_translations) do 9 | arguments = 10 | Enum.reduce(relay_id_translations, resolution.arguments, &process/2) 11 | 12 | %{resolution | arguments: arguments} 13 | end 14 | 15 | def translate_relay_ids(resolution, _relay_id_translations) do 16 | resolution 17 | end 18 | 19 | defp process({field, nested_translations}, args) when is_list(nested_translations) do 20 | case Map.get(args, field) do 21 | subtree when is_map(subtree) -> 22 | new_subtree = Enum.reduce(nested_translations, subtree, &process/2) 23 | Map.put(args, field, new_subtree) 24 | 25 | elements when is_list(elements) -> 26 | new_elements = 27 | Enum.map(elements, fn element -> 28 | Enum.reduce(nested_translations, element, &process/2) 29 | end) 30 | 31 | Map.put(args, field, new_elements) 32 | 33 | _ -> 34 | args 35 | end 36 | end 37 | 38 | defp process({field, type}, args) when is_atom(type) do 39 | case Map.get(args, field) do 40 | id when is_binary(id) -> 41 | case AshGraphql.Resource.decode_relay_id(id) do 42 | {:ok, %{type: ^type, id: decoded_id}} -> 43 | Map.put(args, field, decoded_id) 44 | 45 | _ -> 46 | # If we fail to decode for the correct type, we just skip translation 47 | # This will be marked as an invalid input down the line 48 | args 49 | end 50 | 51 | [id | _] = ids when is_binary(id) -> 52 | decoded_ids = 53 | Enum.map(ids, fn id -> 54 | case AshGraphql.Resource.decode_relay_id(id) do 55 | {:ok, %{type: ^type, id: decoded_id}} -> 56 | decoded_id 57 | 58 | _ -> 59 | # If we fail to decode for the correct type, we just skip translation 60 | # This will be marked as an invalid input down the line 61 | id 62 | end 63 | end) 64 | 65 | Map.put(args, field, decoded_ids) 66 | 67 | _ -> 68 | args 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/resource/verifiers/verify_subscription_actions.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Verifiers.VerifySubscriptionActions do 6 | # Validates the paginate_relationship_with option 7 | @moduledoc false 8 | 9 | use Spark.Dsl.Verifier 10 | 11 | alias Spark.Dsl.Transformer 12 | 13 | def verify(dsl) do 14 | dsl 15 | |> AshGraphql.Resource.Info.subscriptions(Ash.Resource.Info.domain(dsl)) 16 | |> Enum.each(&verify_actions(dsl, &1)) 17 | 18 | :ok 19 | end 20 | 21 | defp verify_actions(dsl, subscription) do 22 | unless MapSet.subset?( 23 | MapSet.new(List.wrap(subscription.action_types)), 24 | MapSet.new([:create, :update, :destroy]) 25 | ) do 26 | raise Spark.Error.DslError, 27 | module: Transformer.get_persisted(dsl, :module), 28 | message: "`action_types` values must be on of `[:create, :update, :destroy]`.", 29 | path: [:graphql, :subscriptions, subscription.name, :action_types] 30 | end 31 | 32 | missing_write_actions = 33 | MapSet.difference( 34 | MapSet.new(List.wrap(subscription.actions)), 35 | MapSet.new( 36 | Ash.Resource.Info.actions(dsl) 37 | |> Stream.filter(&(&1.type in [:create, :update, :destroy])) 38 | |> Enum.map(& &1.name) 39 | ) 40 | ) 41 | 42 | unless Enum.empty?(missing_write_actions) do 43 | raise Spark.Error.DslError, 44 | module: Transformer.get_persisted(dsl, :module), 45 | message: 46 | "The actions #{Enum.join(missing_write_actions, ", ")} do not exist on the resource.", 47 | path: [:graphql, :subscriptions, subscription.name, :actions] 48 | end 49 | 50 | unless is_nil(subscription.read_action) or 51 | subscription.read_action in (Ash.Resource.Info.actions(dsl) 52 | |> Stream.filter(&(&1.type == :read)) 53 | |> Enum.map(& &1.name)) do 54 | raise Spark.Error.DslError, 55 | module: Transformer.get_persisted(dsl, :module), 56 | message: "The read action #{subscription.read_action} does not exist on the resource.", 57 | path: [:graphql, :subscriptions, subscription.name, :read_action] 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/support/resources/channel/channel_simple.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ChannelSimple do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | extensions: [AshGraphql.Resource] 11 | 12 | require Ash.Query 13 | 14 | graphql do 15 | type :channel_simple 16 | 17 | mutations do 18 | update :update_channel, :update_channel, read_action: :read_channel, identity: false 19 | end 20 | end 21 | 22 | actions do 23 | default_accept(:*) 24 | 25 | create(:create, primary?: true) 26 | 27 | read(:read, primary?: true) 28 | 29 | update(:update, primary?: true) 30 | 31 | destroy(:destroy, primary?: true) 32 | 33 | read :read_channel do 34 | argument(:channel_id, :uuid, allow_nil?: false) 35 | 36 | get?(true) 37 | 38 | prepare(fn query, _ -> 39 | channel_id = Ash.Query.get_argument(query, :channel_id) 40 | 41 | case AshGraphql.Test.Channel 42 | |> Ash.Query.for_read(:read, %{}) 43 | |> Ash.Query.filter(id == ^channel_id) 44 | |> Ash.read_one() do 45 | {:ok, channel} -> 46 | query 47 | |> Ash.DataLayer.Simple.set_data([ 48 | struct(AshGraphql.Test.ChannelSimple, %{ 49 | channel: channel 50 | }) 51 | ]) 52 | 53 | {:error, error} -> 54 | query |> Ash.Query.add_error(error) 55 | end 56 | end) 57 | end 58 | 59 | update :update_channel do 60 | require_atomic?(false) 61 | 62 | argument(:name, :string, allow_nil?: false) 63 | 64 | change(fn changeset, _ -> 65 | name = Ash.Changeset.get_argument(changeset, :name) 66 | 67 | channel = 68 | changeset.data.channel 69 | |> Ash.Changeset.for_update(:update, name: name) 70 | |> Ash.update!() 71 | 72 | %{changeset | data: %{changeset.data | channel: channel}} 73 | end) 74 | end 75 | end 76 | 77 | attributes do 78 | uuid_primary_key(:id) 79 | 80 | attribute :channel, :struct do 81 | constraints(instance_of: AshGraphql.Test.Channel) 82 | allow_nil?(false) 83 | public?(true) 84 | end 85 | 86 | create_timestamp(:created_at, public?: true) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/mix/tasks/ash_graphql.install.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | if Code.ensure_loaded?(Igniter) do 6 | defmodule Mix.Tasks.AshGraphql.Install do 7 | @moduledoc "Installs AshGraphql. Should be run with `mix igniter.install ash_graphql`" 8 | @shortdoc @moduledoc 9 | require Igniter.Code.Common 10 | use Igniter.Mix.Task 11 | 12 | @impl true 13 | def info(_argv, _source) do 14 | %Igniter.Mix.Task.Info{ 15 | schema: [ 16 | yes: :boolean 17 | ] 18 | } 19 | end 20 | 21 | @impl true 22 | def igniter(igniter) do 23 | igniter = 24 | igniter 25 | |> Igniter.Project.Formatter.import_dep(:absinthe) 26 | |> Igniter.Project.Formatter.import_dep(:ash_graphql) 27 | |> Igniter.Project.Formatter.add_formatter_plugin(Absinthe.Formatter) 28 | |> Igniter.Project.Config.configure( 29 | "config.exs", 30 | :ash_graphql, 31 | [:authorize_update_destroy_with_error?], 32 | true 33 | ) 34 | |> Spark.Igniter.prepend_to_section_order(:"Ash.Resource", [:graphql]) 35 | |> Spark.Igniter.prepend_to_section_order(:"Ash.Domain", [:graphql]) 36 | 37 | schema_name = Igniter.Libs.Phoenix.web_module_name(igniter, "GraphqlSchema") 38 | socket_name = Igniter.Libs.Phoenix.web_module_name(igniter, "GraphqlSocket") 39 | 40 | {igniter, candidate_ash_graphql_schemas} = 41 | AshGraphql.Igniter.ash_graphql_schemas(igniter) 42 | 43 | if Enum.empty?(candidate_ash_graphql_schemas) do 44 | igniter 45 | |> AshGraphql.Igniter.setup_absinthe_schema(schema_name) 46 | |> AshGraphql.Igniter.setup_phoenix(schema_name, socket_name, igniter.args.options) 47 | else 48 | igniter 49 | |> Igniter.add_warning("AshGraphql schema already exists, skipping installation.") 50 | end 51 | end 52 | end 53 | else 54 | defmodule Mix.Tasks.AshGraphql.Install do 55 | @moduledoc "Installs AshGraphql. Should be run with `mix igniter.install ash_graphql`" 56 | @shortdoc @moduledoc 57 | 58 | use Mix.Task 59 | 60 | def run(_argv) do 61 | Mix.shell().error(""" 62 | The task 'ash_graphql.install' requires igniter to be run. 63 | 64 | Please install igniter and try again. 65 | 66 | For more information, see: https://hexdocs.pm/igniter 67 | """) 68 | 69 | exit({:shutdown, 1}) 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /test/meta_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.MetaTest do 6 | use ExUnit.Case, async: false 7 | 8 | setup do 9 | on_exit(fn -> 10 | Application.delete_env(:ash_graphql, AshGraphql.Test.Domain) 11 | 12 | AshGraphql.TestHelpers.stop_ets() 13 | end) 14 | end 15 | 16 | describe "meta attibute for query" do 17 | test "should inlcude the meta keywordlist in the context" do 18 | movie = 19 | AshGraphql.Test.Movie 20 | |> Ash.Changeset.for_create(:create, title: "Title") 21 | |> Ash.create!() 22 | 23 | """ 24 | query getMovie { 25 | getMovie(id: "#{movie.id}") { 26 | id 27 | } 28 | } 29 | """ 30 | |> Absinthe.run(AshGraphql.Test.Schema) 31 | 32 | assert_receive {:test_meta, :meta_string, "bar"} 33 | assert_receive {:test_meta, :meta_integer, 1} 34 | end 35 | end 36 | 37 | describe "meta attibute for mutation" do 38 | test "should inlcude the meta keywordlist in the context" do 39 | movie = 40 | AshGraphql.Test.Movie 41 | |> Ash.Changeset.for_create(:create, title: "Title") 42 | |> Ash.create!() 43 | 44 | """ 45 | mutation UpdateMovie($id: ID!, $input: UpdateMovieInput!) { 46 | updateMovie(id: $id, input: $input) { 47 | result { 48 | title 49 | } 50 | } 51 | } 52 | """ 53 | |> Absinthe.run(AshGraphql.Test.Schema, 54 | variables: %{"id" => movie.id, "input" => %{"title" => "Updated Title 1"}} 55 | ) 56 | 57 | assert_receive {:test_meta, :meta_string, "bar"} 58 | assert_receive {:test_meta, :meta_integer, 1} 59 | end 60 | end 61 | 62 | describe "meta attibute for subscription" do 63 | test "should inlcude the meta keywordlist in the context" do 64 | """ 65 | mutation CreateSubscribable($input: CreateSubscribableInput) { 66 | createSubscribable(input: $input) { 67 | result{ 68 | id 69 | text 70 | } 71 | errors{ 72 | message 73 | } 74 | } 75 | } 76 | """ 77 | |> Absinthe.run(AshGraphql.Test.Schema, 78 | variables: %{"input" => %{"text" => "foo"}} 79 | ) 80 | 81 | assert_receive {:test_meta, :meta_string, "bar"} 82 | assert_receive {:test_meta, :meta_integer, 1} 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/domain/transformers/validate_compatible_names.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Domain.Transformers.ValidateCompatibleNames do 6 | # Ensures that all field names are valid or remapped to something valid exist 7 | @moduledoc false 8 | use Spark.Dsl.Transformer 9 | 10 | alias Spark.Dsl.Transformer 11 | 12 | def after_compile?, do: true 13 | 14 | def transform(dsl) do 15 | dsl 16 | |> Transformer.get_entities([:graphql, :queries]) 17 | |> Enum.concat(Transformer.get_entities(dsl, [:graphql, :mutations])) 18 | |> Enum.each(fn query_or_mutation -> 19 | argument_names = AshGraphql.Resource.Info.argument_names(query_or_mutation.resource) 20 | action = Ash.Resource.Info.action(query_or_mutation.resource, query_or_mutation.action) 21 | 22 | Enum.each(action.arguments, fn argument -> 23 | name = argument_names[action.name][argument.name] || argument.name 24 | 25 | if invalid_name?(name) do 26 | raise_invalid_argument_name_error( 27 | query_or_mutation.resource, 28 | action, 29 | argument.name, 30 | name 31 | ) 32 | end 33 | end) 34 | end) 35 | 36 | {:ok, dsl} 37 | end 38 | 39 | defp invalid_name?(name) do 40 | Regex.match?(~r/_+\d/, to_string(name)) 41 | end 42 | 43 | defp raise_invalid_argument_name_error(resource, action, argument_name, name) do 44 | path = [:actions, action.type, action.name, :argument, argument_name] 45 | 46 | raise Spark.Error.DslError, 47 | module: resource, 48 | path: path, 49 | message: """ 50 | Name #{name} is invalid. 51 | 52 | Due to issues in the underlying tooling with camel/snake case conversion of names that 53 | include underscores immediately preceding integers, a different name must be provided to 54 | use in the graphql. To do so, add a mapping in your configured argument_names, i.e 55 | 56 | graphql do 57 | ... 58 | 59 | argument_names #{action.name}: [#{argument_name}: :#{make_name_better(name)}] 60 | 61 | ... 62 | end 63 | 64 | 65 | For more information on the underlying issue, see: https://github.com/absinthe-graphql/absinthe/issues/601 66 | """ 67 | end 68 | 69 | defp make_name_better(name) do 70 | name 71 | |> to_string() 72 | |> String.replace(~r/_+\d/, fn v -> 73 | String.trim_leading(v, "_") 74 | end) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/support/resources/comment.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Comment do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | graphql do 14 | type :comment 15 | 16 | queries do 17 | list :list_comments, :read 18 | action :list_ranked_comments, :ranked_comments 19 | end 20 | 21 | mutations do 22 | create :create_comment, :create 23 | end 24 | end 25 | 26 | actions do 27 | default_accept(:*) 28 | defaults([:create, :update, :destroy]) 29 | 30 | read :read do 31 | primary?(true) 32 | end 33 | 34 | create :with_required do 35 | argument(:text, :string, allow_nil?: false) 36 | argument(:required, :string, allow_nil?: false) 37 | change(set_attribute(:text, arg(:text))) 38 | end 39 | 40 | read :paginated do 41 | pagination(required?: true, offset?: true, countable: true) 42 | end 43 | 44 | action :ranked_comments, {:array, RankedComment} do 45 | run(fn _input, _ctx -> 46 | res = 47 | Ash.read!(__MODULE__) 48 | |> Enum.with_index() 49 | |> Enum.map(fn {c, i} -> 50 | %{ 51 | rank: i, 52 | comment: c 53 | } 54 | end) 55 | 56 | {:ok, res} 57 | end) 58 | end 59 | end 60 | 61 | attributes do 62 | uuid_primary_key(:id) 63 | attribute(:text, :string, public?: true) 64 | 65 | attribute :type, :atom do 66 | public?(true) 67 | writable?(false) 68 | default(:comment) 69 | constraints(one_of: [:comment, :reply]) 70 | end 71 | 72 | create_timestamp(:created_at) 73 | end 74 | 75 | calculations do 76 | calculate( 77 | :timestamp, 78 | :utc_datetime_usec, 79 | expr(created_at), 80 | public?: true 81 | ) 82 | 83 | calculate :arg_returned, 84 | :integer, 85 | expr(^arg(:seconds)) do 86 | argument(:seconds, :integer, allow_nil?: false) 87 | public?(true) 88 | end 89 | end 90 | 91 | relationships do 92 | belongs_to(:post, AshGraphql.Test.Post, public?: true) 93 | 94 | belongs_to :author, AshGraphql.Test.User do 95 | public?(true) 96 | attribute_writable?(true) 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/support/domain.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Domain do 6 | @moduledoc false 7 | 8 | use Ash.Domain, 9 | extensions: [ 10 | AshGraphql.Domain 11 | ], 12 | otp_app: :ash_graphql 13 | 14 | graphql do 15 | queries do 16 | get AshGraphql.Test.Comment, :get_comment, :read 17 | list AshGraphql.Test.Post, :post_score, :score 18 | end 19 | 20 | subscriptions do 21 | pubsub AshGraphql.Test.PubSub 22 | 23 | subscribe AshGraphql.Test.Subscribable, :subscribed_on_domain do 24 | action_types(:create) 25 | end 26 | 27 | subscribe AshGraphql.Test.DomainLevelPubsubResource, :domain_pubsub_subscription do 28 | action_types([:create, :update, :destroy]) 29 | end 30 | end 31 | end 32 | 33 | resources do 34 | resource(AshGraphql.Test.Actor) 35 | resource(AshGraphql.Test.ActorAgent) 36 | resource(AshGraphql.Test.Agent) 37 | resource(AshGraphql.Test.Award) 38 | resource(AshGraphql.Test.Comment) 39 | resource(AshGraphql.Test.CompositePrimaryKey) 40 | resource(AshGraphql.Test.CompositePrimaryKeyNotEncoded) 41 | resource(AshGraphql.Test.DoubleRelRecursive) 42 | resource(AshGraphql.Test.DoubleRelToRecursiveParentOfEmbed) 43 | resource(AshGraphql.Test.MapTypes) 44 | resource(AshGraphql.Test.Movie) 45 | resource(AshGraphql.Test.MovieActor) 46 | resource(AshGraphql.Test.MultitenantPostTag) 47 | resource(AshGraphql.Test.MultitenantTag) 48 | resource(AshGraphql.Test.NoGraphql) 49 | resource(AshGraphql.Test.NoObject) 50 | resource(AshGraphql.Test.NonIdPrimaryKey) 51 | resource(AshGraphql.Test.Post) 52 | resource(AshGraphql.Test.PostTag) 53 | resource(AshGraphql.Test.RelayPostTag) 54 | resource(AshGraphql.Test.RelayTag) 55 | resource(AshGraphql.Test.Review) 56 | resource(AshGraphql.Test.SponsoredComment) 57 | resource(AshGraphql.Test.Tag) 58 | resource(AshGraphql.Test.User) 59 | resource(AshGraphql.Test.Channel) 60 | resource(AshGraphql.Test.ChannelSimple) 61 | resource(AshGraphql.Test.Message) 62 | resource(AshGraphql.Test.MessageViewableUser) 63 | resource(AshGraphql.Test.TextMessage) 64 | resource(AshGraphql.Test.ImageMessage) 65 | resource(AshGraphql.Test.Subscribable) 66 | resource(AshGraphql.Test.DomainLevelPubsubResource) 67 | resource(AshGraphql.Test.ResourceLevelPubsubResource) 68 | resource(AshGraphql.Test.ErrorHandling) 69 | resource(AshGraphql.Test.ResourceWithTypeInsideType) 70 | resource(AshGraphql.Test.Product) 71 | resource(AshGraphql.Test.ResourceWithUnion) 72 | resource(AshGraphql.Test.ResourceWithTypedStruct) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/domain/verifiers/verify_subscription_pubsub.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Domain.Verifiers.VerifySubscriptionPubsub do 6 | @moduledoc """ 7 | Verifies that pubsub is properly configured for subscriptions at the domain level. 8 | 9 | This verifier ensures that: 10 | - If a domain has subscriptions, it either has pubsub configured or all its resources with subscriptions have pubsub configured 11 | - Resources with subscriptions have pubsub available either at the resource level or domain level 12 | """ 13 | 14 | use Spark.Dsl.Verifier 15 | 16 | def verify(dsl) do 17 | domain_subscriptions = AshGraphql.Domain.Info.subscriptions(dsl) 18 | domain_pubsub = AshGraphql.Domain.Info.subscription_pubsub(dsl) 19 | 20 | if is_nil(domain_pubsub) do 21 | already_checked = 22 | if domain_subscriptions != [] do 23 | domain_subscriptions 24 | |> Enum.map(fn subscription -> 25 | resource = subscription.resource 26 | 27 | Code.ensure_loaded!(resource) 28 | 29 | resource_pubsub = AshGraphql.Resource.Info.subscription_pubsub(resource) 30 | 31 | unless resource_pubsub do 32 | raise Spark.Error.DslError, 33 | module: Spark.Dsl.Transformer.get_persisted(resource.spark_dsl_config(), :module), 34 | message: 35 | "Domain subscription for #{inspect(resource)} requires pubsub to be configured either at the domain level or on the resource #{inspect(resource)} itself.", 36 | path: [:graphql, :subscriptions, subscription.name] 37 | end 38 | 39 | resource 40 | end) 41 | else 42 | [] 43 | end 44 | 45 | dsl 46 | |> Ash.Domain.Info.resources() 47 | |> Kernel.--(already_checked) 48 | |> Enum.each(fn resource -> 49 | Code.ensure_loaded!(resource) 50 | 51 | resource_subscriptions = AshGraphql.Resource.Info.subscriptions(resource, dsl) 52 | 53 | if resource_subscriptions != [] do 54 | resource_pubsub = AshGraphql.Resource.Info.subscription_pubsub(resource) 55 | 56 | unless resource_pubsub do 57 | raise Spark.Error.DslError, 58 | module: Spark.Dsl.Transformer.get_persisted(resource.spark_dsl_config(), :module), 59 | message: 60 | "Resource #{inspect(resource)} has subscriptions but no pubsub module configured. A pubsub module must be specified either at the resource level (in the subscriptions section) or at the domain level (in the domain's graphql subscriptions section).", 61 | path: [:graphql, :subscriptions, :pubsub] 62 | end 63 | end 64 | end) 65 | end 66 | 67 | :ok 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/enum_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.EnumTest do 6 | use ExUnit.Case, async: false 7 | 8 | setup do 9 | on_exit(fn -> 10 | Application.delete_env(:ash_graphql, AshGraphql.Test.Domain) 11 | 12 | AshGraphql.TestHelpers.stop_ets() 13 | end) 14 | end 15 | 16 | test "enum without value descriptions returns a nil description" do 17 | {:ok, %{data: data}} = 18 | """ 19 | query { 20 | __type(name: "StatusEnum") { 21 | enumValues { 22 | name 23 | description 24 | } 25 | } 26 | } 27 | """ 28 | |> Absinthe.run(AshGraphql.Test.Schema) 29 | 30 | assert %{"name" => "CLOSED", "description" => nil} = 31 | data["__type"]["enumValues"] 32 | |> Enum.find(fn value -> value["name"] == "CLOSED" end) 33 | 34 | assert %{"name" => "OPEN", "description" => nil} = 35 | data["__type"]["enumValues"] 36 | |> Enum.find(fn value -> value["name"] == "OPEN" end) 37 | end 38 | 39 | test "Ash.Type.Enum value descriptions are used as description source" do 40 | {:ok, %{data: data}} = 41 | """ 42 | query { 43 | __type(name: "EnumWithAshDescription") { 44 | enumValues { 45 | name 46 | description 47 | } 48 | } 49 | } 50 | """ 51 | |> Absinthe.run(AshGraphql.Test.Schema) 52 | 53 | assert %{"name" => "FIZZ", "description" => "A fizz"} = 54 | data["__type"]["enumValues"] 55 | |> Enum.find(fn value -> value["name"] == "FIZZ" end) 56 | 57 | assert %{"name" => "BUZZ", "description" => "A buzz"} = 58 | data["__type"]["enumValues"] 59 | |> Enum.find(fn value -> value["name"] == "BUZZ" end) 60 | end 61 | 62 | test "graphql_describe_enum_value/1 overrides Ash.Type.Enum descriptions" do 63 | {:ok, %{data: data}} = 64 | """ 65 | query { 66 | __type(name: "EnumWithAshGraphqlDescription") { 67 | enumValues { 68 | name 69 | description 70 | } 71 | } 72 | } 73 | """ 74 | |> Absinthe.run(AshGraphql.Test.Schema) 75 | 76 | assert %{"name" => "FOO", "description" => "A foo"} = 77 | data["__type"]["enumValues"] 78 | |> Enum.find(fn value -> value["name"] == "FOO" end) 79 | 80 | assert %{"name" => "BAR", "description" => "A bar"} = 81 | data["__type"]["enumValues"] 82 | |> Enum.find(fn value -> value["name"] == "BAR" end) 83 | 84 | assert %{"name" => "NO_DESCRIPTION", "description" => nil} = 85 | data["__type"]["enumValues"] 86 | |> Enum.find(fn value -> value["name"] == "NO_DESCRIPTION" end) 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/resource/verifiers/verify_argument_input_types.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Resource.Verifiers.VerifyArgumentInputTypes do 6 | # Ensures that argument_input_types configuration is properly formatted 7 | @moduledoc false 8 | use Spark.Dsl.Verifier 9 | 10 | alias Spark.Dsl.Transformer 11 | 12 | def verify(dsl) do 13 | resource = Transformer.get_persisted(dsl, :module) 14 | argument_input_types = AshGraphql.Resource.Info.argument_input_types(dsl) 15 | 16 | if argument_input_types && argument_input_types != [] do 17 | actions = Ash.Resource.Info.actions(dsl) 18 | action_names = MapSet.new(actions, & &1.name) 19 | 20 | Enum.each(argument_input_types, fn {key, value} -> 21 | cond do 22 | not is_atom(key) -> 23 | raise Spark.Error.DslError, 24 | module: resource, 25 | path: [:graphql, :argument_input_types], 26 | message: """ 27 | Invalid argument_input_types configuration. Keys must be action names (atoms). 28 | 29 | Found: #{inspect(key)} 30 | 31 | Expected format: 32 | argument_input_types action_name: [argument_name: :type, ...] 33 | """ 34 | 35 | not MapSet.member?(action_names, key) -> 36 | available_actions = actions |> Enum.map(& &1.name) |> Enum.sort() 37 | 38 | raise Spark.Error.DslError, 39 | module: resource, 40 | path: [:graphql, :argument_input_types], 41 | message: """ 42 | Invalid argument_input_types configuration. Action #{inspect(key)} does not exist on #{inspect(resource)}. 43 | 44 | Available actions: #{inspect(available_actions)} 45 | 46 | Expected format: 47 | argument_input_types action_name: [argument_name: :type, ...] 48 | """ 49 | 50 | not is_list(value) or not Keyword.keyword?(value) -> 51 | raise Spark.Error.DslError, 52 | module: resource, 53 | path: [:graphql, :argument_input_types], 54 | message: """ 55 | Invalid argument_input_types configuration for action #{inspect(key)}. 56 | 57 | Found: #{inspect(value)} 58 | 59 | Expected a keyword list of argument names to types, like: 60 | argument_input_types #{key}: [argument_name: :string, another_arg: :integer] 61 | 62 | If you meant to configure a single argument across all actions, you need to specify which action: 63 | argument_input_types some_action_name: [#{key}: #{inspect(value)}] 64 | """ 65 | 66 | true -> 67 | :ok 68 | end 69 | end) 70 | end 71 | 72 | :ok 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /test/support/resources/channel/channel.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Channel do 6 | @moduledoc false 7 | 8 | use Ash.Resource, 9 | domain: AshGraphql.Test.Domain, 10 | data_layer: Ash.DataLayer.Ets, 11 | extensions: [AshGraphql.Resource] 12 | 13 | require Ash.Query 14 | 15 | graphql do 16 | type :channel 17 | 18 | queries do 19 | get :channel, :read 20 | end 21 | end 22 | 23 | actions do 24 | default_accept(:*) 25 | 26 | create :create do 27 | primary?(true) 28 | end 29 | 30 | read(:read, primary?: true) 31 | 32 | update(:update, primary?: true) 33 | 34 | destroy(:destroy, primary?: true) 35 | end 36 | 37 | attributes do 38 | uuid_primary_key(:id) 39 | 40 | attribute(:name, :string, public?: true, allow_nil?: false) 41 | 42 | create_timestamp(:created_at, public?: true) 43 | end 44 | 45 | calculations do 46 | calculate( 47 | :direct_channel_messages, 48 | {:array, AshGraphql.Test.MessageUnion}, 49 | fn records, _ -> 50 | records = Ash.load!(records, :messages) 51 | 52 | {:ok, 53 | Enum.map(records, fn record -> 54 | record.messages 55 | |> Enum.map( 56 | &%Ash.Union{type: AshGraphql.Test.MessageUnion.struct_to_name(&1), value: &1} 57 | ) 58 | end)} 59 | end, 60 | public?: true 61 | ) 62 | 63 | calculate :indirect_channel_messages, 64 | AshGraphql.Test.PageOfChannelMessages, 65 | AshGraphql.Test.PageOfChannelMessagesCalculation do 66 | public?(true) 67 | 68 | argument :offset, :integer do 69 | default(0) 70 | end 71 | 72 | argument :limit, :integer do 73 | default(10) 74 | end 75 | end 76 | 77 | calculate :filter_by_actor_channel_messages, 78 | AshGraphql.Test.PageOfFilterByActorChannelMessages, 79 | AshGraphql.Test.PageOfFilterByActorChannelMessagesCalculation do 80 | public?(true) 81 | 82 | argument :offset, :integer do 83 | default(0) 84 | end 85 | 86 | argument :limit, :integer do 87 | default(10) 88 | end 89 | end 90 | end 91 | 92 | aggregates do 93 | count(:channel_message_count, :messages, public?: true) 94 | 95 | count(:filter_by_user_channel_message_count, :filter_by_actor_messages, public?: true) 96 | end 97 | 98 | relationships do 99 | has_many(:messages, AshGraphql.Test.Message, public?: true) 100 | 101 | has_many(:filter_by_actor_messages, AshGraphql.Test.Message, 102 | public?: true, 103 | filter: expr(exists(message_users, user_id == ^actor(:id))) 104 | ) 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /test/support/resources/resource_with_typed_struct.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.ResourceWithTypedStruct do 6 | @moduledoc false 7 | 8 | alias AshGraphql.Test.PersonTypedStructData 9 | 10 | use Ash.Resource, 11 | domain: AshGraphql.Test.Domain, 12 | data_layer: Ash.DataLayer.Ets, 13 | extensions: [AshGraphql.Resource] 14 | 15 | require Ash.Query 16 | 17 | graphql do 18 | type :resource_with_typed_struct 19 | 20 | queries do 21 | get :get_typed_struct_resource, :read 22 | list :list_typed_struct_resources, :read 23 | end 24 | 25 | mutations do 26 | create :create_typed_struct_resource, :create 27 | update :update_typed_struct_resource, :update 28 | destroy :destroy_typed_struct_resource, :destroy 29 | action(:create_from_typed_struct, :create_from_typed_struct) 30 | action(:get_as_typed_struct, :get_as_typed_struct) 31 | end 32 | end 33 | 34 | actions do 35 | default_accept(:*) 36 | defaults([:create, :read, :update, :destroy]) 37 | 38 | action :create_from_typed_struct, :uuid do 39 | argument(:person_data, PersonTypedStructData, allow_nil?: false) 40 | 41 | run(fn input, _context -> 42 | case Ash.Changeset.get_argument(input, :person_data) do 43 | %PersonTypedStructData{name: name, age: age, email: email} -> 44 | __MODULE__ 45 | |> Ash.Changeset.new() 46 | |> Ash.Changeset.change_attribute(:name, name) 47 | |> Ash.Changeset.change_attribute(:age, age) 48 | |> Ash.Changeset.change_attribute(:email, email) 49 | |> Ash.Changeset.for_create(:create) 50 | |> Ash.create() 51 | |> case do 52 | {:ok, %{id: id}} -> {:ok, id} 53 | _ -> {:ok, -1} 54 | end 55 | 56 | _ -> 57 | {:ok, -1} 58 | end 59 | end) 60 | end 61 | 62 | action :get_as_typed_struct, PersonTypedStructData do 63 | argument(:id, :uuid, allow_nil?: false) 64 | 65 | run(fn %{arguments: %{id: id}}, _context -> 66 | case Ash.get(AshGraphql.Test.ResourceWithTypedStruct, id) do 67 | {:ok, %{name: name, age: age, email: email}} -> 68 | {:ok, %PersonTypedStructData{name: name, age: age, email: email}} 69 | 70 | {:error, _} = error -> 71 | error 72 | 73 | nil -> 74 | {:error, "Resource not found"} 75 | end 76 | end) 77 | end 78 | end 79 | 80 | attributes do 81 | uuid_primary_key(:id) 82 | attribute(:name, :string, public?: true) 83 | attribute(:age, :integer, public?: true) 84 | attribute(:email, :string, public?: true) 85 | attribute(:created_at, :utc_datetime_usec, public?: true, default: &DateTime.utc_now/0) 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/subscription/config.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Subscription.Config do 6 | @moduledoc """ 7 | Creates a config function used for the absinthe subscription definition 8 | 9 | See https://github.com/absinthe-graphql/absinthe/blob/3d0823bd71c2ebb94357a5588c723e053de8c66a/lib/absinthe/schema/notation.ex#L58 10 | """ 11 | alias AshGraphql.Resource.Subscription 12 | 13 | # sobelow_skip ["DOS.StringToAtom"] 14 | def create_config(%Subscription{} = subscription, _domain, resource) do 15 | config_module = String.to_atom(Macro.camelize(Atom.to_string(subscription.name)) <> ".Config") 16 | 17 | Module.create( 18 | config_module, 19 | quote generated: true, 20 | bind_quoted: [subscription: Macro.escape(subscription), resource: resource] do 21 | require Ash.Query 22 | alias AshGraphql.Graphql.Resolver 23 | 24 | @subscription subscription 25 | @resource resource 26 | def config(args, %{context: context}) do 27 | read_action = 28 | @subscription.read_action || Ash.Resource.Info.primary_action!(@resource, :read).name 29 | 30 | actor = 31 | case @subscription.actor do 32 | {module, opts} -> 33 | module.actor(context[:actor], opts) 34 | 35 | _ -> 36 | context[:actor] 37 | end 38 | 39 | # check with Ash.can? to make sure the user is able to read the resource 40 | # otherwise we return an error here instead of just never sending something 41 | # in the subscription 42 | case Ash.can( 43 | @resource 44 | |> Ash.Query.new() 45 | # not sure if we need this here 46 | |> Ash.Query.do_filter( 47 | Resolver.massage_filter(@resource, Map.get(args, :filter)) 48 | ) 49 | |> Ash.Query.set_tenant(context[:tenant]) 50 | |> Ash.Query.for_read(read_action), 51 | actor, 52 | tenant: context[:tenant], 53 | run_queries?: false, 54 | alter_source?: true 55 | ) do 56 | {:ok, true} -> 57 | {:ok, topic: "*", context_id: create_context_id(args, actor, context[:tenant])} 58 | 59 | {:ok, true, _} -> 60 | {:ok, topic: "*", context_id: create_context_id(args, actor, context[:tenant])} 61 | 62 | _ -> 63 | {:error, "unauthorized"} 64 | end 65 | end 66 | 67 | def create_context_id(args, actor, tenant) do 68 | Base.encode64(:crypto.hash(:sha256, :erlang.term_to_binary({args, actor, tenant}))) 69 | end 70 | end, 71 | Macro.Env.location(__ENV__) 72 | ) 73 | 74 | &config_module.config/2 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/support/resources/subscribable.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.Subscribable do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshGraphql.Test.Domain, 9 | data_layer: Ash.DataLayer.Ets, 10 | authorizers: [Ash.Policy.Authorizer], 11 | extensions: [AshGraphql.Resource] 12 | 13 | require Ash.Query 14 | 15 | graphql do 16 | type :subscribable 17 | 18 | queries do 19 | get :get_subscribable, :read 20 | end 21 | 22 | mutations do 23 | create :create_subscribable, :create do 24 | meta meta_string: "bar", meta_integer: 1 25 | end 26 | 27 | update :update_subscribable, :update 28 | destroy :destroy_subscribable, :destroy 29 | end 30 | 31 | subscriptions do 32 | pubsub AshGraphql.Test.PubSub 33 | 34 | subscribe(:subscribable_events) do 35 | action_types([:create, :update, :destroy]) 36 | end 37 | 38 | subscribe(:deduped_subscribable_events) do 39 | actions([:create, :update, :destroy]) 40 | read_action(:open_read) 41 | 42 | actor(fn _ -> 43 | %{id: -1, role: :deduped_actor} 44 | end) 45 | end 46 | 47 | subscribe(:subscribable_events_with_arguments) do 48 | read_action(:read_with_arg) 49 | actions([:create]) 50 | end 51 | end 52 | end 53 | 54 | policies do 55 | bypass actor_attribute_equals(:role, :admin) do 56 | authorize_if(always()) 57 | end 58 | 59 | policy action(:read) do 60 | authorize_if(relates_to_actor_via([:actor])) 61 | end 62 | 63 | policy action([:open_read, :read_with_arg]) do 64 | authorize_if(always()) 65 | end 66 | end 67 | 68 | field_policies do 69 | field_policy :hidden_field do 70 | authorize_if(actor_attribute_equals(:role, :admin)) 71 | end 72 | 73 | field_policy :* do 74 | authorize_if(always()) 75 | end 76 | end 77 | 78 | actions do 79 | default_accept(:*) 80 | defaults([:create, :read, :update, :destroy]) 81 | 82 | read(:open_read) 83 | 84 | read :read_with_arg do 85 | argument(:topic, :string) do 86 | allow_nil? false 87 | end 88 | 89 | filter(expr(topic == ^arg(:topic))) 90 | end 91 | end 92 | 93 | attributes do 94 | uuid_primary_key(:id) 95 | 96 | attribute(:hidden_field, :string) do 97 | public?(true) 98 | default("hidden") 99 | allow_nil?(false) 100 | end 101 | 102 | attribute(:text, :string, public?: true) 103 | attribute(:topic, :string, public?: true) 104 | create_timestamp(:created_at) 105 | update_timestamp(:updated_at) 106 | end 107 | 108 | relationships do 109 | belongs_to(:actor, AshGraphql.Test.Actor, public?: true) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/support/resources/relay_subscribable.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 ash_graphql contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshGraphql.Test.RelaySubscribable do 6 | @moduledoc false 7 | use Ash.Resource, 8 | domain: AshGraphql.Test.RelayDomain, 9 | data_layer: Ash.DataLayer.Ets, 10 | authorizers: [Ash.Policy.Authorizer], 11 | extensions: [AshGraphql.Resource] 12 | 13 | require Ash.Query 14 | 15 | graphql do 16 | type :relay_subscribable 17 | 18 | mutations do 19 | update :update_relay_subscribable, :update 20 | destroy :destroy_subscribable_relay, :destroy 21 | end 22 | 23 | subscriptions do 24 | pubsub AshGraphql.Test.PubSub 25 | 26 | subscribe(:subscribable_events_relay) do 27 | action_types([:create, :update, :destroy]) 28 | end 29 | 30 | subscribe(:subscribable_deleted_relay) do 31 | action_types(:destroy) 32 | end 33 | 34 | subscribe(:subscribable_events_relay_with_arguments) do 35 | read_action(:read_with_arg) 36 | actions([:create]) 37 | end 38 | 39 | subscribe(:subscribable_events_relay_with_id_filter) do 40 | read_action(:read_with_id_arg) 41 | action_types([:create, :update, :destroy]) 42 | relay_id_translations(subscribable_id: :relay_subscribable) 43 | end 44 | end 45 | end 46 | 47 | policies do 48 | bypass actor_attribute_equals(:role, :admin) do 49 | authorize_if(always()) 50 | end 51 | 52 | policy action(:read) do 53 | authorize_if(expr(actor_id == ^actor(:id))) 54 | end 55 | 56 | policy action([:open_read, :read_with_arg, :read_with_id_arg]) do 57 | authorize_if(always()) 58 | end 59 | end 60 | 61 | field_policies do 62 | field_policy :hidden_field do 63 | authorize_if(actor_attribute_equals(:role, :admin)) 64 | end 65 | 66 | field_policy :* do 67 | authorize_if(always()) 68 | end 69 | end 70 | 71 | actions do 72 | default_accept(:*) 73 | defaults([:create, :read, :update, :destroy]) 74 | 75 | read(:open_read) 76 | 77 | read :read_with_arg do 78 | argument(:topic, :string) do 79 | allow_nil? false 80 | end 81 | 82 | filter(expr(topic == ^arg(:topic))) 83 | end 84 | 85 | read :read_with_id_arg do 86 | argument(:subscribable_id, :uuid) do 87 | allow_nil? false 88 | end 89 | 90 | filter(expr(id == ^arg(:subscribable_id))) 91 | end 92 | end 93 | 94 | attributes do 95 | uuid_primary_key(:id) 96 | 97 | attribute(:hidden_field, :string) do 98 | public?(true) 99 | default("hidden") 100 | allow_nil?(false) 101 | end 102 | 103 | attribute(:text, :string, public?: true) 104 | attribute(:topic, :string, public?: true) 105 | attribute(:actor_id, :integer, public?: true) 106 | create_timestamp(:created_at) 107 | update_timestamp(:updated_at) 108 | end 109 | end 110 | --------------------------------------------------------------------------------