From 2e197afa197b7d92382bcb468e2d0454721fbc92 Mon Sep 17 00:00:00 2001 From: Haim Kortovich Date: Mon, 1 Jun 2026 10:24:41 -0500 Subject: [PATCH 1/3] remove check for ash embedded resource (allow all types in attributes) --- lib/ash_oaskit/core/type_mapper.ex | 9 +--- test/ash_oaskit/kitchen_sink_test.exs | 14 ++++++ test/support/kitchen_sink_resources.ex | 61 ++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/lib/ash_oaskit/core/type_mapper.ex b/lib/ash_oaskit/core/type_mapper.ex index 2578906..8ab5b76 100644 --- a/lib/ash_oaskit/core/type_mapper.ex +++ b/lib/ash_oaskit/core/type_mapper.ex @@ -364,16 +364,9 @@ defmodule AshOaskit.TypeMapper do @spec embedded_resource?(atom()) :: boolean() defp embedded_resource?(type) when is_atom(type) do Code.ensure_loaded?(type) and - function_exported?(type, :spark_is, 0) and - Spark.Dsl.is?(type, Ash.Resource) and - ash_embedded?(type) + function_exported?(type, :spark_is, 0) end - # Checks if a resource is embedded using Ash.Resource.Info.embedded?/1 - # Called only after confirming the resource is a valid Ash.Resource via Spark.Dsl.is? - @spec ash_embedded?(atom()) :: boolean() - defp ash_embedded?(resource), do: Ash.Resource.Info.embedded?(resource) - # Check if attribute allows nil defp allow_nil?(%{allow_nil?: allow_nil?}), do: allow_nil? defp allow_nil?(_), do: true diff --git a/test/ash_oaskit/kitchen_sink_test.exs b/test/ash_oaskit/kitchen_sink_test.exs index a4b04c7..25d07ff 100644 --- a/test/ash_oaskit/kitchen_sink_test.exs +++ b/test/ash_oaskit/kitchen_sink_test.exs @@ -87,6 +87,20 @@ defmodule AshOaskit.KitchenSinkTest do end end + describe "custom type discriminated union tagged (actor)" do + test "3.1 uses custom discriminated union properties", %{spec_31: spec} do + actor = get_attr(spec, "actor") + assert %{ + "anyOf" => [ + %{"type" => "null"}, + %{"$ref" => "#/components/schemas/Person", "title" => "person"}, + %{"$ref" => "#/components/schemas/Company", "title" => "company"} + ], + "description" => "The actor (person or company) associated with this record" + } === actor + end + end + describe "deeply nested embedded resources" do test "venue references Venue via $ref (3.1)", %{spec_31: spec} do venue_attr = get_attr(spec, "venue") diff --git a/test/support/kitchen_sink_resources.ex b/test/support/kitchen_sink_resources.ex index 7656cd6..96f6f3a 100644 --- a/test/support/kitchen_sink_resources.ex +++ b/test/support/kitchen_sink_resources.ex @@ -45,6 +45,62 @@ defmodule AshOaskit.Test.ContentBlock do end # =========================================================================== +# Union Type with Constraints and Discriminator (reproduces bug) +# =========================================================================== + +defmodule AshOaskit.Test.Person do + @moduledoc """ + TypedStruct for Person variant. + """ + + use Ash.TypedStruct + + typed_struct do + field :type, :string, allow_nil?: false + field :name, :string, allow_nil?: false + field :email, :string, allow_nil?: false + field :age, :integer + end +end + +defmodule AshOaskit.Test.Company do + @moduledoc """ + TypedStruct for Company variant. + """ + + use Ash.TypedStruct + + typed_struct do + field :type, :string, allow_nil?: false + field :company_name, :string, allow_nil?: false + field :tax_id, :string, allow_nil?: false + field :employee_count, :integer + end +end + +defmodule AshOaskit.Test.Actor do + @moduledoc """ + Union type for Actor (person or company) with discriminator. + """ + + use Ash.Type.NewType, + subtype_of: :union, + constraints: [ + types: [ + person: [ + type: AshOaskit.Test.Person, + tag: :type, + tag_value: "person" + ], + company: [ + type: AshOaskit.Test.Company, + tag: :type, + tag_value: "company" + ] + ] + ] + end + # Custom type with json_schema/1 callback # =========================================================================== @@ -180,6 +236,11 @@ defmodule AshOaskit.Test.KitchenSink do description "Polymorphic content block (text, image, or code)" end + # --- Union with discriminator (Actor) --- + attribute :actor, AshOaskit.Test.Actor do + description "The actor (person or company) associated with this record" + end + # --- Custom type with json_schema/1 --- attribute :coordinates, AshOaskit.Test.Coordinate do description "Geographic coordinates via custom type" From c507dc6b139db25955073c64b556536d4d8dc323 Mon Sep 17 00:00:00 2001 From: Haim Kortovich Date: Mon, 1 Jun 2026 10:43:23 -0500 Subject: [PATCH 2/3] instead of removing embedded check add custom struct handling in normalize_complex_type --- lib/ash_oaskit/core/type_mapper.ex | 18 ++++++++++--- test/ash_oaskit/kitchen_sink_test.exs | 35 ++++++++++++++++++++------ test/support/kitchen_sink_resources.ex | 2 +- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/lib/ash_oaskit/core/type_mapper.ex b/lib/ash_oaskit/core/type_mapper.ex index 8ab5b76..f954b65 100644 --- a/lib/ash_oaskit/core/type_mapper.ex +++ b/lib/ash_oaskit/core/type_mapper.ex @@ -316,11 +316,16 @@ defmodule AshOaskit.TypeMapper do # Handle complex type checking for embedded resources, custom types, and unions defp normalize_complex_type(type) do cond do + # make sure callback always take priority + has_json_schema_callback?(type) -> + {:custom, get_custom_json_schema(type)} + embedded_resource?(type) -> {:embedded, type} - has_json_schema_callback?(type) -> - {:custom, get_custom_json_schema(type)} + # if type is a custom struct then it should return struct + is_atom(type) -> + {:struct, type} union_result = get_union_types(type) -> union_result @@ -364,9 +369,16 @@ defmodule AshOaskit.TypeMapper do @spec embedded_resource?(atom()) :: boolean() defp embedded_resource?(type) when is_atom(type) do Code.ensure_loaded?(type) and - function_exported?(type, :spark_is, 0) + function_exported?(type, :spark_is, 0) and + Spark.Dsl.is?(type, Ash.Resource) and + ash_embedded?(type) end + # Checks if a resource is embedded using Ash.Resource.Info.embedded?/1 + # Called only after confirming the resource is a valid Ash.Resource via Spark.Dsl.is? + @spec ash_embedded?(atom()) :: boolean() + defp ash_embedded?(resource), do: Ash.Resource.Info.embedded?(resource) + # Check if attribute allows nil defp allow_nil?(%{allow_nil?: allow_nil?}), do: allow_nil? defp allow_nil?(_), do: true diff --git a/test/ash_oaskit/kitchen_sink_test.exs b/test/ash_oaskit/kitchen_sink_test.exs index 25d07ff..7bf684e 100644 --- a/test/ash_oaskit/kitchen_sink_test.exs +++ b/test/ash_oaskit/kitchen_sink_test.exs @@ -90,14 +90,35 @@ defmodule AshOaskit.KitchenSinkTest do describe "custom type discriminated union tagged (actor)" do test "3.1 uses custom discriminated union properties", %{spec_31: spec} do actor = get_attr(spec, "actor") + assert %{ - "anyOf" => [ - %{"type" => "null"}, - %{"$ref" => "#/components/schemas/Person", "title" => "person"}, - %{"$ref" => "#/components/schemas/Company", "title" => "company"} - ], - "description" => "The actor (person or company) associated with this record" - } === actor + "anyOf" => [ + %{"type" => "null"}, + %{ + "title" => "person", + "description" => "Struct of type AshOaskit.Test.Person", + "properties" => %{ + "age" => %{"type" => "string"}, + "email" => %{"type" => "string"}, + "name" => %{"type" => "string"}, + "type" => %{"type" => "string"} + }, + "type" => "object" + }, + %{ + "title" => "company", + "description" => "Struct of type AshOaskit.Test.Company", + "properties" => %{ + "company_name" => %{"type" => "string"}, + "employee_count" => %{"type" => "string"}, + "tax_id" => %{"type" => "string"}, + "type" => %{"type" => "string"} + }, + "type" => "object" + } + ], + "description" => "The actor (person or company) associated with this record" + } === actor end end diff --git a/test/support/kitchen_sink_resources.ex b/test/support/kitchen_sink_resources.ex index 96f6f3a..5121540 100644 --- a/test/support/kitchen_sink_resources.ex +++ b/test/support/kitchen_sink_resources.ex @@ -99,7 +99,7 @@ defmodule AshOaskit.Test.Actor do ] ] ] - end +end # Custom type with json_schema/1 callback # =========================================================================== From 6ab66a55c01bdde91e9b20feb2501b63fe1d91d6 Mon Sep 17 00:00:00 2001 From: Haim Kortovich Date: Mon, 1 Jun 2026 11:25:29 -0500 Subject: [PATCH 3/3] improve struct check if is actually defined --- lib/ash_oaskit/core/type_mapper.ex | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/ash_oaskit/core/type_mapper.ex b/lib/ash_oaskit/core/type_mapper.ex index f954b65..19488d2 100644 --- a/lib/ash_oaskit/core/type_mapper.ex +++ b/lib/ash_oaskit/core/type_mapper.ex @@ -323,13 +323,13 @@ defmodule AshOaskit.TypeMapper do embedded_resource?(type) -> {:embedded, type} - # if type is a custom struct then it should return struct - is_atom(type) -> - {:struct, type} - union_result = get_union_types(type) -> union_result + # if type is a custom struct then it should return struct + is_defined_struct?(type) -> + {:struct, type} + true -> :string end @@ -379,6 +379,12 @@ defmodule AshOaskit.TypeMapper do @spec ash_embedded?(atom()) :: boolean() defp ash_embedded?(resource), do: Ash.Resource.Info.embedded?(resource) + @spec is_defined_struct?(atom()) :: boolean() + defp is_defined_struct?(type) when is_atom(type) do + Code.ensure_loaded?(type) and + function_exported?(type, :spark_is, 0) + end + # Check if attribute allows nil defp allow_nil?(%{allow_nil?: allow_nil?}), do: allow_nil? defp allow_nil?(_), do: true