Skip to content

Commit 91554d5

Browse files
committed
fix: add support for generic actions returning typed struct(s)
1 parent 0ed4283 commit 91554d5

File tree

10 files changed

+655
-28
lines changed

10 files changed

+655
-28
lines changed

lib/ash_typescript/rpc/codegen.ex

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,16 @@ defmodule AshTypescript.Rpc.Codegen do
590590
{:error, :no_fields_defined}
591591
end
592592

593+
{:array, module} when is_atom(module) ->
594+
if AshTypescript.Codegen.is_typed_struct?(module) do
595+
constraints = action.constraints || []
596+
items_constraints = Keyword.get(constraints, :items, [])
597+
fields = Keyword.get(items_constraints, :fields, [])
598+
{:ok, :array_of_typed_struct, {module, fields}}
599+
else
600+
{:error, :not_field_selectable_type}
601+
end
602+
593603
map_like when map_like in [Ash.Type.Map, Ash.Type.Keyword, Ash.Type.Keyword] ->
594604
constraints = action.constraints || []
595605

@@ -599,6 +609,15 @@ defmodule AshTypescript.Rpc.Codegen do
599609
{:ok, :unconstrained_map, nil}
600610
end
601611

612+
module when is_atom(module) ->
613+
if AshTypescript.Codegen.is_typed_struct?(module) do
614+
constraints = action.constraints || []
615+
fields = Keyword.get(constraints, :fields, [])
616+
{:ok, :typed_struct, {module, fields}}
617+
else
618+
{:error, :not_field_selectable_type}
619+
end
620+
602621
_ ->
603622
{:error, :not_field_selectable_type}
604623
end
@@ -1068,8 +1087,8 @@ defmodule AshTypescript.Rpc.Codegen do
10681087
"""
10691088
end
10701089

1071-
{:ok, type, fields} when type in [:typed_map, :array_of_typed_map] ->
1072-
typed_map_schema = build_map_type(fields)
1090+
{:ok, type, value} when type in [:typed_map, :array_of_typed_map] ->
1091+
typed_map_schema = build_map_type(value)
10731092

10741093
if type == :array_of_typed_map do
10751094
"""
@@ -1089,8 +1108,43 @@ defmodule AshTypescript.Rpc.Codegen do
10891108
"""
10901109
end
10911110

1111+
{:ok, :typed_struct, {module, fields}} ->
1112+
field_name_mappings =
1113+
if function_exported?(module, :typescript_field_names, 0) do
1114+
module.typescript_field_names()
1115+
else
1116+
nil
1117+
end
1118+
1119+
typed_map_schema = build_map_type(fields, nil, field_name_mappings)
1120+
1121+
"""
1122+
export type #{rpc_action_name_pascal}Fields = UnifiedFieldSelection<#{typed_map_schema}>[];
1123+
1124+
type Infer#{rpc_action_name_pascal}Result<
1125+
Fields extends #{rpc_action_name_pascal}Fields,
1126+
> = InferResult<#{typed_map_schema}, Fields>;
1127+
"""
1128+
1129+
{:ok, :array_of_typed_struct, {module, fields}} ->
1130+
field_name_mappings =
1131+
if function_exported?(module, :typescript_field_names, 0) do
1132+
module.typescript_field_names()
1133+
else
1134+
nil
1135+
end
1136+
1137+
typed_map_schema = build_map_type(fields, nil, field_name_mappings)
1138+
1139+
"""
1140+
export type #{rpc_action_name_pascal}Fields = UnifiedFieldSelection<#{typed_map_schema}>[];
1141+
1142+
type Infer#{rpc_action_name_pascal}Result<
1143+
Fields extends #{rpc_action_name_pascal}Fields,
1144+
> = Array<InferResult<#{typed_map_schema}, Fields>>;
1145+
"""
1146+
10921147
{:ok, :unconstrained_map, _} ->
1093-
# Unconstrained maps return Record<string, any> without field selection
10941148
"""
10951149
type Infer#{rpc_action_name_pascal}Result = Record<string, any>;
10961150
"""
@@ -1820,7 +1874,13 @@ defmodule AshTypescript.Rpc.Codegen do
18201874

18211875
{updated_fields, true, "Fields extends #{rpc_action_name_pascal}Fields"}
18221876

1823-
{:ok, type, _fields} when type in [:typed_map, :array_of_typed_map] ->
1877+
{:ok, type, _fields}
1878+
when type in [
1879+
:typed_map,
1880+
:array_of_typed_map,
1881+
:typed_struct,
1882+
:array_of_typed_struct
1883+
] ->
18241884
updated_fields =
18251885
config_fields ++
18261886
[
@@ -2324,7 +2384,13 @@ defmodule AshTypescript.Rpc.Codegen do
23242384

23252385
{updated_fields, true, "Fields extends #{rpc_action_name_pascal}Fields"}
23262386

2327-
{:ok, type, _fields} when type in [:typed_map, :array_of_typed_map] ->
2387+
{:ok, type, _fields}
2388+
when type in [
2389+
:typed_map,
2390+
:array_of_typed_map,
2391+
:typed_struct,
2392+
:array_of_typed_struct
2393+
] ->
23282394
updated_fields =
23292395
config_fields ++
23302396
[

lib/ash_typescript/rpc/pipeline.ex

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,8 +204,15 @@ defmodule AshTypescript.Rpc.Pipeline do
204204
if unconstrained_map_action?(request.action) do
205205
{:ok, ResultProcessor.normalize_value_for_json(result)}
206206
else
207+
resource_for_mapping =
208+
if request.action.type == :action and returns_typed_struct?(request.action) do
209+
nil
210+
else
211+
request.resource
212+
end
213+
207214
filtered =
208-
ResultProcessor.process(result, request.extraction_template, request.resource)
215+
ResultProcessor.process(result, request.extraction_template, resource_for_mapping)
209216

210217
filtered_with_metadata = add_metadata(filtered, result, request)
211218

@@ -217,6 +224,19 @@ defmodule AshTypescript.Rpc.Pipeline do
217224
end
218225
end
219226

227+
defp returns_typed_struct?(action) do
228+
case action.returns do
229+
{:array, module} when is_atom(module) ->
230+
AshTypescript.Codegen.is_typed_struct?(module)
231+
232+
module when is_atom(module) ->
233+
AshTypescript.Codegen.is_typed_struct?(module)
234+
235+
_ ->
236+
false
237+
end
238+
end
239+
220240
@doc """
221241
Stage 4: Format output for client consumption.
222242

lib/ash_typescript/rpc/requested_fields_processor.ex

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,22 @@ defmodule AshTypescript.Rpc.RequestedFieldsProcessor do
132132
fake_attribute = %{type: type, constraints: constraints}
133133

134134
if is_typed_struct?(fake_attribute) do
135+
if requested_fields == [] do
136+
throw({:requires_field_selection, :typed_struct, nil})
137+
end
138+
135139
field_specs = Keyword.get(constraints, :fields, [])
140+
instance_of = Keyword.get(constraints, :instance_of)
141+
142+
field_name_mappings =
143+
if instance_of && function_exported?(instance_of, :typescript_field_names, 0) do
144+
instance_of.typescript_field_names()
145+
else
146+
[]
147+
end
136148

137149
{_field_names, template_items} =
138-
process_typed_struct_fields(requested_fields, field_specs, path)
150+
process_typed_struct_fields(requested_fields, field_specs, path, field_name_mappings)
139151

140152
{[], [], template_items}
141153
else
@@ -702,20 +714,33 @@ defmodule AshTypescript.Rpc.RequestedFieldsProcessor do
702714

703715
attribute = Ash.Resource.Info.attribute(resource, field_name)
704716
field_specs = Keyword.get(attribute.constraints, :fields, [])
717+
instance_of = Keyword.get(attribute.constraints, :instance_of)
718+
719+
field_name_mappings =
720+
if instance_of && function_exported?(instance_of, :typescript_field_names, 0) do
721+
instance_of.typescript_field_names()
722+
else
723+
[]
724+
end
705725

706726
new_path = path ++ [field_name]
707727

708728
{_field_names, template_items} =
709-
process_typed_struct_fields(nested_fields, field_specs, new_path)
729+
process_typed_struct_fields(nested_fields, field_specs, new_path, field_name_mappings)
710730

711731
new_select = select ++ [field_name]
712732

713733
{new_select, load, template ++ [{field_name, template_items}]}
714734
end
715735

716-
defp process_typed_struct_fields(requested_fields, field_specs, path) do
736+
defp process_typed_struct_fields(requested_fields, field_specs, path, field_name_mappings) do
717737
check_for_duplicate_fields(requested_fields, path)
718738

739+
reverse_mappings =
740+
Enum.into(field_name_mappings, %{}, fn {elixir_name, ts_name} ->
741+
{ts_name, elixir_name}
742+
end)
743+
719744
{field_names, template_items} =
720745
Enum.reduce(requested_fields, {[], []}, fn field, {names, template} ->
721746
case field do
@@ -727,32 +752,31 @@ defmodule AshTypescript.Rpc.RequestedFieldsProcessor do
727752
field_atom
728753
end
729754

730-
if Keyword.has_key?(field_specs, field_atom) do
731-
{names ++ [field_atom], template ++ [field_atom]}
755+
elixir_field_name = Map.get(reverse_mappings, field_atom, field_atom)
756+
757+
if Keyword.has_key?(field_specs, elixir_field_name) do
758+
{names ++ [elixir_field_name], template ++ [elixir_field_name]}
732759
else
733760
field_path = build_field_path(path, field_atom)
734761
throw({:unknown_field, field_atom, "typed_struct", field_path})
735762
end
736763

737764
%{} = field_map ->
738-
# Handle nested field selection for maps with field constraints
739765
{new_names, new_template} =
740766
Enum.reduce(field_map, {names, template}, fn {field_name, nested_fields}, {n, t} ->
741-
if Keyword.has_key?(field_specs, field_name) do
742-
field_spec = Keyword.get(field_specs, field_name)
767+
elixir_field_name = Map.get(reverse_mappings, field_name, field_name)
768+
769+
if Keyword.has_key?(field_specs, elixir_field_name) do
770+
field_spec = Keyword.get(field_specs, elixir_field_name)
743771
field_type = Keyword.get(field_spec, :type)
744772
field_constraints = Keyword.get(field_spec, :constraints, [])
745-
746-
# Determine the return type for this field
747773
field_return_type = {:ash_type, field_type, field_constraints}
748-
new_path = path ++ [field_name]
774+
new_path = path ++ [elixir_field_name]
749775

750-
# Process the nested fields based on the field's type
751776
{_nested_select, _nested_load, nested_template} =
752777
process_fields_for_type(field_return_type, nested_fields, new_path)
753778

754-
# For typed struct fields, we only need the template
755-
{n ++ [field_name], t ++ [{field_name, nested_template}]}
779+
{n ++ [elixir_field_name], t ++ [{elixir_field_name, nested_template}]}
756780
else
757781
field_path = build_field_path(path, field_name)
758782
throw({:unknown_field, field_name, "typed_struct", field_path})

lib/ash_typescript/rpc/result_processor.ex

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ defmodule AshTypescript.Rpc.ResultProcessor do
6868
when is_list(extraction_template) do
6969
is_tuple = is_tuple(data)
7070

71+
typed_struct_module =
72+
if is_map(data) and not is_tuple(data) and Map.has_key?(data, :__struct__) do
73+
module = data.__struct__
74+
if AshTypescript.Codegen.is_typed_struct?(module), do: module, else: nil
75+
else
76+
nil
77+
end
78+
7179
normalized_data =
7280
cond do
7381
is_tuple ->
@@ -80,16 +88,24 @@ defmodule AshTypescript.Rpc.ResultProcessor do
8088
normalize_data(data)
8189
end
8290

91+
effective_resource = resource || typed_struct_module
92+
8393
if is_tuple do
8494
normalized_data
8595
else
8696
Enum.reduce(extraction_template, %{}, fn field_spec, acc ->
8797
case field_spec do
8898
field_atom when is_atom(field_atom) or is_tuple(data) ->
89-
extract_simple_field(normalized_data, field_atom, acc, resource)
99+
extract_simple_field(normalized_data, field_atom, acc, effective_resource)
90100

91101
{field_atom, nested_template} when is_atom(field_atom) and is_list(nested_template) ->
92-
extract_nested_field(normalized_data, field_atom, nested_template, acc, resource)
102+
extract_nested_field(
103+
normalized_data,
104+
field_atom,
105+
nested_template,
106+
acc,
107+
effective_resource
108+
)
93109

94110
_ ->
95111
acc
@@ -103,12 +119,10 @@ defmodule AshTypescript.Rpc.ResultProcessor do
103119
normalize_data(data)
104120
end
105121

106-
# Extract a simple field, handling forbidden, not loaded, and nil cases
107122
defp extract_simple_field(normalized_data, field_atom, acc, resource) do
108-
# Map the Elixir field name to the TypeScript field name for output
109123
output_field_name =
110124
if resource do
111-
AshTypescript.Resource.Info.get_mapped_field_name(resource, field_atom)
125+
get_mapped_field_name(resource, field_atom)
112126
else
113127
field_atom
114128
end
@@ -128,12 +142,10 @@ defmodule AshTypescript.Rpc.ResultProcessor do
128142
end
129143
end
130144

131-
# Extract a nested field with template, handling forbidden, not loaded, and nil cases
132145
defp extract_nested_field(normalized_data, field_atom, nested_template, acc, resource) do
133-
# Map the Elixir field name to the TypeScript field name for output
134146
output_field_name =
135147
if resource do
136-
AshTypescript.Resource.Info.get_mapped_field_name(resource, field_atom)
148+
get_mapped_field_name(resource, field_atom)
137149
else
138150
field_atom
139151
end
@@ -428,4 +440,20 @@ defmodule AshTypescript.Rpc.ResultProcessor do
428440
nil
429441
end
430442
end
443+
444+
defp get_mapped_field_name(module, field_atom) when is_atom(module) do
445+
cond do
446+
Ash.Resource.Info.resource?(module) ->
447+
AshTypescript.Resource.Info.get_mapped_field_name(module, field_atom)
448+
449+
Code.ensure_loaded?(module) and function_exported?(module, :typescript_field_names, 0) ->
450+
mappings = module.typescript_field_names()
451+
Keyword.get(mappings, field_atom, field_atom)
452+
453+
true ->
454+
field_atom
455+
end
456+
end
457+
458+
defp get_mapped_field_name(_module, field_atom), do: field_atom
431459
end

0 commit comments

Comments
 (0)