Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ defmodule AshTypescript.Rpc.Codegen.Helpers.ActionIntrospection do
`classify_return_type/2` for consistent handling of all type variants.
"""

alias AshTypescript.TypeSystem.Introspection

# Container types that can have field constraints for field selection
@field_constrained_types [Ash.Type.Map, Ash.Type.Keyword, Ash.Type.Tuple]

Expand Down Expand Up @@ -159,7 +161,10 @@ defmodule AshTypescript.Rpc.Codegen.Helpers.ActionIntrospection do
defp check_action_returns(action) do
{base_type, constraints, is_array} = unwrap_return_type(action)

case classify_return_type(base_type, constraints) do
{unwrapped_type, unwrapped_constraints} =
Introspection.unwrap_new_type(base_type, constraints)

case classify_return_type(unwrapped_type, unwrapped_constraints) do
{:resource, module} ->
if is_array do
{:ok, :array_of_resource, module}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshTypescript.Rpc.Codegen.Helpers.ActionIntrospectionNewTypeTest do
@moduledoc """
Tests that ActionIntrospection correctly classifies NewType-wrapped return types.

These tests verify that `action_returns_field_selectable_type?/1` unwraps NewTypes
before classification, matching the runtime behavior in field_selector.ex.
"""
use ExUnit.Case, async: true

alias AshTypescript.Rpc.Codegen.Helpers.ActionIntrospection

describe "action_returns_field_selectable_type?/1 with NewType-wrapped map" do
test "single NewType wrapping :map with fields returns {:ok, :typed_map, fields}" do
action = %{
type: :action,
returns: AshTypescript.Test.Suggestion,
constraints: []
}

result = ActionIntrospection.action_returns_field_selectable_type?(action)

assert {:ok, :typed_map, fields} = result
assert Keyword.has_key?(fields, :name)
assert Keyword.has_key?(fields, :category)
assert Keyword.has_key?(fields, :score)
end

test "array of NewType wrapping :map with fields returns {:ok, :array_of_typed_map, fields}" do
action = %{
type: :action,
returns: {:array, AshTypescript.Test.Suggestion},
constraints: []
}

result = ActionIntrospection.action_returns_field_selectable_type?(action)

assert {:ok, :array_of_typed_map, fields} = result
assert Keyword.has_key?(fields, :name)
assert Keyword.has_key?(fields, :category)
assert Keyword.has_key?(fields, :score)
end
end

describe "action_returns_field_selectable_type?/1 preserves existing behavior" do
test "non-action type returns :not_generic_action" do
action = %{type: :read}

assert {:error, :not_generic_action} =
ActionIntrospection.action_returns_field_selectable_type?(action)
end

test "direct Ash.Type.Map with fields still works" do
action = %{
type: :action,
returns: Ash.Type.Map,
constraints: [
fields: [
total: [type: :integer],
count: [type: :integer]
]
]
}

assert {:ok, :typed_map, fields} =
ActionIntrospection.action_returns_field_selectable_type?(action)

assert Keyword.has_key?(fields, :total)
assert Keyword.has_key?(fields, :count)
end

test "direct Ash.Type.Map without fields returns unconstrained_map" do
action = %{
type: :action,
returns: Ash.Type.Map,
constraints: []
}

assert {:ok, :unconstrained_map, nil} =
ActionIntrospection.action_returns_field_selectable_type?(action)
end
end
end
84 changes: 84 additions & 0 deletions test/ash_typescript/rpc/rpc_run_action_generic_actions_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,90 @@ defmodule AshTypescript.Rpc.RpcRunActionGenericActionsTest do
end
end

describe "NewType wrapping map return type (get_suggestion)" do
setup do
conn = TestHelpers.build_rpc_conn()
%{conn: conn}
end

test "processes valid fields correctly for NewType map", %{conn: conn} do
result =
Rpc.run_action(:ash_typescript, conn, %{
"action" => "get_suggestion",
"input" => %{"query" => "test"},
"fields" => ["name", "score"]
})

assert result["success"] == true
data = result["data"]

assert is_map(data)
assert Map.has_key?(data, "name")
assert Map.has_key?(data, "score")
refute Map.has_key?(data, "category")

assert data["name"] == "Test Suggestion"
assert data["score"] == 85
end

test "processes all fields for NewType map", %{conn: conn} do
result =
Rpc.run_action(:ash_typescript, conn, %{
"action" => "get_suggestion",
"input" => %{"query" => "test"},
"fields" => ["name", "category", "score"]
})

assert result["success"] == true
data = result["data"]

assert Map.has_key?(data, "name")
assert Map.has_key?(data, "category")
assert Map.has_key?(data, "score")
assert map_size(data) == 3
end

test "codegen generates fields type for NewType map return", _context do
{:ok, typescript} = AshTypescript.Test.CodegenTestHelper.generate_all_content()

assert typescript =~ "GetSuggestionFields"
end
end

describe "array of NewType wrapping map return type (list_suggestions)" do
setup do
conn = TestHelpers.build_rpc_conn()
%{conn: conn}
end

test "processes valid fields correctly for array of NewType maps", %{conn: conn} do
result =
Rpc.run_action(:ash_typescript, conn, %{
"action" => "list_suggestions",
"input" => %{"query" => "test"},
"fields" => ["name", "score"]
})

assert result["success"] == true
data = result["data"]

assert is_list(data)
assert length(data) == 2

Enum.each(data, fn item ->
assert Map.has_key?(item, "name")
assert Map.has_key?(item, "score")
refute Map.has_key?(item, "category")
end)
end

test "codegen generates fields type for array of NewType map return", _context do
{:ok, typescript} = AshTypescript.Test.CodegenTestHelper.generate_all_content()

assert typescript =~ "ListSuggestionsFields"
end
end

describe "date array return type action (get_important_dates)" do
setup do
conn = TestHelpers.build_rpc_conn()
Expand Down
2 changes: 2 additions & 0 deletions test/support/domain.ex
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ defmodule AshTypescript.Test.Domain do
rpc_action :destroy_task, :destroy
rpc_action :get_task_stats, :get_task_stats
rpc_action :list_task_stats, :list_task_stats
rpc_action :get_suggestion, :get_suggestion
rpc_action :list_suggestions, :list_suggestions

rpc_action :read_tasks_with_mapped_metadata, :read_with_invalid_metadata_names,
show_metadata: [:meta_1, :is_valid?, :field_2],
Expand Down
20 changes: 20 additions & 0 deletions test/support/resources/task.ex
Original file line number Diff line number Diff line change
Expand Up @@ -234,5 +234,25 @@ defmodule AshTypescript.Test.Task do
{:ok, stats_list}
end
end

action :get_suggestion, AshTypescript.Test.Suggestion do
argument :query, :string, allow_nil?: false

run fn _input, _context ->
{:ok, %{name: "Test Suggestion", category: nil, score: 85}}
end
end

action :list_suggestions, {:array, AshTypescript.Test.Suggestion} do
argument :query, :string, default: ""

run fn _input, _context ->
{:ok,
[
%{name: "Suggestion A", category: "work", score: 90},
%{name: "Suggestion B", category: nil, score: 75}
]}
end
end
end
end
21 changes: 21 additions & 0 deletions test/support/resources/typed_structs/suggestion.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshTypescript.Test.Suggestion do
@moduledoc """
NewType wrapping :map with field constraints for testing codegen field selection.

This type has no typescript_field_names/0 callback — fields are already TS-safe.
Used to verify that codegen correctly unwraps NewTypes before classifying return types.
"""
use Ash.Type.NewType,
subtype_of: :map,
constraints: [
fields: [
name: [type: :string, allow_nil?: false],
category: [type: :string, allow_nil?: true],
score: [type: :integer, allow_nil?: false]
]
]
end
Loading