From 794d9a514c69e5948bd25c87bd7a8d7513d0e7b6 Mon Sep 17 00:00:00 2001 From: Jaylynn Tautu Date: Fri, 12 Jun 2026 03:21:55 -0700 Subject: [PATCH 1/3] Support field?: false calculations in GraphQL schema --- lib/resource/resource.ex | 4 +++- test/filter_sort_test.exs | 34 ++++++++++++++++++++++++++++++++++ test/read_test.exs | 28 +++++++++------------------- 3 files changed, 46 insertions(+), 20 deletions(-) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 19760d5b..54caa912 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -5192,7 +5192,9 @@ defmodule AshGraphql.Resource do resource |> Ash.Resource.Info.public_calculations() - |> Enum.filter(&AshGraphql.Resource.Info.show_field?(resource, &1.name)) + |> Enum.filter( + &(AshGraphql.Resource.Info.show_field?(resource, &1.name) and Map.get(&1, :field?, true)) + ) |> Enum.map(fn calculation -> name = field_names[calculation.name] || calculation.name field_type = calculation_type(calculation, resource) diff --git a/test/filter_sort_test.exs b/test/filter_sort_test.exs index b2857495..dcf5ee88 100644 --- a/test/filter_sort_test.exs +++ b/test/filter_sort_test.exs @@ -155,4 +155,38 @@ defmodule AshGraphql.FilterSortTest do assert sort_fields |> Enum.find(fn field -> field["name"] == "POPULARITY" end) refute sort_fields |> Enum.find(fn field -> field["name"] == "NAME" end) end + + test "field?: false calculations are included in filter input" do + resp = + """ + query { + __type(name: "PostFilterInput") { + inputFields { + name + } + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema) + + assert {:ok, %{data: %{"__type" => %{"inputFields" => input_fields}}}} = resp + assert Enum.any?(input_fields, &(&1["name"] == "nonFieldCalc")) + end + + test "field?: false calculations are included in sort fields" do + resp = + """ + query { + __type(name: "PostSortField") { + enumValues { + name + } + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema) + + assert {:ok, %{data: %{"__type" => %{"enumValues" => sort_fields}}}} = resp + assert Enum.any?(sort_fields, &(&1["name"] == "NON_FIELD_CALC")) + end end diff --git a/test/read_test.exs b/test/read_test.exs index 3328425a..5eada650 100644 --- a/test/read_test.exs +++ b/test/read_test.exs @@ -540,31 +540,21 @@ defmodule AshGraphql.ReadTest do result end - test "field?: false calculations can be queried via graphql" do - AshGraphql.Test.Post - |> Ash.Changeset.for_create(:create, text: "bar", text1: "hello", published: true) - |> Ash.create!() - + test "field?: false calculations are not exposed as response fields" do resp = """ - query PostLibrary($published: Boolean) { - postLibrary(published: $published) { - nonFieldCalc + query { + __type(name: "Post") { + fields { + name + } } } """ - |> Absinthe.run(AshGraphql.Test.Schema, - variables: %{ - "published" => true - } - ) - - assert {:ok, result} = resp - - refute Map.has_key?(result, :errors) + |> Absinthe.run(AshGraphql.Test.Schema) - assert %{data: %{"postLibrary" => [%{"nonFieldCalc" => "non_field: hello"}]}} = - result + assert {:ok, %{data: %{"__type" => %{"fields" => fields}}}} = resp + refute Enum.any?(fields, &(&1["name"] == "nonFieldCalc")) end test "the same calculation can be loaded twice with different arguments via aliases" do From 21f48359f77dcc79cf7a5ecb366161eea98ea764 Mon Sep 17 00:00:00 2001 From: Jaylynn Tautu Date: Tue, 30 Jun 2026 18:01:02 -0700 Subject: [PATCH 2/3] Add opt-in for non-field calculations --- lib/resource/info.ex | 11 ++++++++ lib/resource/resource.ex | 8 +++++- test/read_test.exs | 46 +++++++++++++++++++++++++++++++++- test/support/resources/post.ex | 6 +++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/lib/resource/info.ex b/lib/resource/info.ex index 5e9e0e87..0b6f322d 100644 --- a/lib/resource/info.ex +++ b/lib/resource/info.ex @@ -185,6 +185,17 @@ defmodule AshGraphql.Resource.Info do Extension.get_opt(resource, [:graphql], :show_fields, nil) end + @doc "`field?: false` calculations to expose as GraphQL response fields" + def include_non_field_calculations(resource) do + Extension.get_opt(resource, [:graphql], :include_non_field_calculations, []) + end + + @doc "Whether a calculation should be exposed as a GraphQL response field" + def graphql_calculation_field?(resource, calculation) do + Map.get(calculation, :field?, true) or + calculation.name in include_non_field_calculations(resource) + end + @doc "Wether or not a given field will be shown" def show_field?(resource, field) do hide_fields = hide_fields(resource) diff --git a/lib/resource/resource.ex b/lib/resource/resource.ex index 54caa912..041f67fb 100644 --- a/lib/resource/resource.ex +++ b/lib/resource/resource.ex @@ -474,6 +474,11 @@ defmodule AshGraphql.Resource do doc: "A list of attributes to show in the domain. If not specified includes all (excluding `hide_fiels`)." ], + include_non_field_calculations: [ + type: {:list, :atom}, + default: [], + doc: "A list of `field?: false` calculations to expose as GraphQL response fields." + ], argument_names: [ type: :keyword_list, doc: @@ -5193,7 +5198,8 @@ defmodule AshGraphql.Resource do resource |> Ash.Resource.Info.public_calculations() |> Enum.filter( - &(AshGraphql.Resource.Info.show_field?(resource, &1.name) and Map.get(&1, :field?, true)) + &(AshGraphql.Resource.Info.show_field?(resource, &1.name) and + AshGraphql.Resource.Info.graphql_calculation_field?(resource, &1)) ) |> Enum.map(fn calculation -> name = field_names[calculation.name] || calculation.name diff --git a/test/read_test.exs b/test/read_test.exs index 5eada650..70967ad0 100644 --- a/test/read_test.exs +++ b/test/read_test.exs @@ -540,7 +540,7 @@ defmodule AshGraphql.ReadTest do result end - test "field?: false calculations are not exposed as response fields" do + test "field?: false calculations are not exposed as response fields by default" do resp = """ query { @@ -557,6 +557,50 @@ defmodule AshGraphql.ReadTest do refute Enum.any?(fields, &(&1["name"] == "nonFieldCalc")) end + test "include_non_field_calculations exposes listed field?: false calculations" do + resp = + """ + query { + __type(name: "Post") { + fields { + name + } + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema) + + assert {:ok, %{data: %{"__type" => %{"fields" => fields}}}} = resp + assert Enum.any?(fields, &(&1["name"] == "includedNonFieldCalc")) + end + + test "included non-field calculations resolve from loaded calculations" do + AshGraphql.Test.Post + |> Ash.Changeset.for_create(:create, text: "bar", text1: "hello", published: true) + |> Ash.create!() + + resp = + """ + query PostLibrary($published: Boolean) { + postLibrary(published: $published) { + includedNonFieldCalc + } + } + """ + |> Absinthe.run(AshGraphql.Test.Schema, + variables: %{ + "published" => true + } + ) + + assert {:ok, result} = resp + + refute Map.has_key?(result, :errors) + + assert %{data: %{"postLibrary" => [%{"includedNonFieldCalc" => "included_non_field: hello"}]}} = + result + end + test "the same calculation can be loaded twice with different arguments via aliases" do AshGraphql.Test.Post |> Ash.Changeset.for_create(:create, text: "bar", text1: "1", text2: "2", published: true) diff --git a/test/support/resources/post.ex b/test/support/resources/post.ex index 71b78544..d4d059c4 100644 --- a/test/support/resources/post.ex +++ b/test/support/resources/post.ex @@ -195,6 +195,7 @@ defmodule AshGraphql.Test.Post do ] field_names text_1_and_2: :text1_and2 + include_non_field_calculations([:included_non_field_calc]) keyset_field :keyset paginate_relationship_with unpaginated_comments: :none @@ -668,6 +669,11 @@ defmodule AshGraphql.Test.Post do public?(true) field?(false) end + + calculate :included_non_field_calc, :string, expr("included_non_field: " <> text1) do + public?(true) + field?(false) + end end aggregates do From 9fdb3c0fabf81a4e01f808074fe3114244286014 Mon Sep 17 00:00:00 2001 From: Jaylynn Tautu Date: Tue, 30 Jun 2026 18:47:47 -0700 Subject: [PATCH 3/3] Update generated DSL docs --- .formatter.exs | 1 + documentation/dsls/DSL-AshGraphql.Resource.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.formatter.exs b/.formatter.exs index a28f3bde..9b550ba5 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -44,6 +44,7 @@ spark_locals_without_parens = [ hide_inputs: 1, identity: 1, ignore?: 1, + include_non_field_calculations: 1, keyset_field: 1, list: 2, list: 3, diff --git a/documentation/dsls/DSL-AshGraphql.Resource.md b/documentation/dsls/DSL-AshGraphql.Resource.md index 028cf070..43e7f8f5 100644 --- a/documentation/dsls/DSL-AshGraphql.Resource.md +++ b/documentation/dsls/DSL-AshGraphql.Resource.md @@ -72,6 +72,7 @@ end | [`field_names`](#graphql-field_names){: #graphql-field_names } | `keyword` | | A keyword list of name overrides for attributes. | | [`hide_fields`](#graphql-hide_fields){: #graphql-hide_fields } | `list(atom)` | | A list of attributes to hide from the domain | | [`show_fields`](#graphql-show_fields){: #graphql-show_fields } | `list(atom)` | | A list of attributes to show in the domain. If not specified includes all (excluding `hide_fiels`). | +| [`include_non_field_calculations`](#graphql-include_non_field_calculations){: #graphql-include_non_field_calculations } | `list(atom)` | `[]` | A list of `field?: false` calculations to expose as GraphQL response fields. | | [`argument_names`](#graphql-argument_names){: #graphql-argument_names } | `keyword` | | A nested keyword list of action names, to argument name remappings. i.e `create: [arg_name: :new_name]` | | [`keyset_field`](#graphql-keyset_field){: #graphql-keyset_field } | `atom` | | If set, the keyset will be displayed on all read actions in this field. It will be `nil` unless at least one of the read actions on a resource uses keyset pagination or it is the result of a mutation | | [`attribute_types`](#graphql-attribute_types){: #graphql-attribute_types } | `keyword` | | A keyword list of type overrides for attributes. The type overrides should refer to types available in the graphql (absinthe) schema. `list_of/1` and `non_null/1` helpers can be used. |