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
34 changes: 22 additions & 12 deletions lib/ash_typescript/codegen/schema_core.ex
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ defmodule AshTypescript.Codegen.SchemaCore do
schema_type =
get_type(formatter, %{type: field_type, constraints: field_constraints}, context)

schema_type = if allow_nil, do: formatter.wrap_optional(schema_type), else: schema_type
schema_type = maybe_wrap_nullable_optional(formatter, schema_type, allow_nil, allow_nil)

base_name =
if field_name_mappings && Keyword.has_key?(field_name_mappings, field_name),
Expand Down Expand Up @@ -373,21 +373,24 @@ defmodule AshTypescript.Codegen.SchemaCore do
# ─────────────────────────────────────────────────────────────────

defp process_argument_field(formatter, resource, action, arg) do
optional = arg.allow_nil? || arg.default != nil
nullable = arg.allow_nil?
omittable = arg.allow_nil? || arg.default != nil
formatted_name = format_argument_for_client(resource, action.name, arg.name)
schema_type = get_type(formatter, arg)
schema_type = if optional, do: formatter.wrap_optional(schema_type), else: schema_type
schema_type = maybe_wrap_nullable_optional(formatter, schema_type, nullable, omittable)
{formatted_name, schema_type}
end

defp process_accept_field(formatter, resource, field_name, action) do
attr = Ash.Resource.Info.attribute(resource, field_name)

optional =
{nullable, omittable} =
if action.type in [:update, :destroy] do
field_name not in action.require_attributes
{attr.allow_nil?, field_name not in action.require_attributes}
else
field_name in action.allow_nil_input || attr.allow_nil? || attr.default != nil
nullable = attr.allow_nil? || field_name in action.allow_nil_input
omittable = nullable || attr.default != nil
{nullable, omittable}
end

formatted_name =
Expand All @@ -398,10 +401,19 @@ defmodule AshTypescript.Codegen.SchemaCore do
)

schema_type = get_type(formatter, attr)
schema_type = if optional, do: formatter.wrap_optional(schema_type), else: schema_type
schema_type = maybe_wrap_nullable_optional(formatter, schema_type, nullable, omittable)
{formatted_name, schema_type}
end

@doc """
Wraps a schema string with `wrap_nullable` (innermost) and/or `wrap_optional`
(outermost) based on the two booleans, using the given formatter.
"""
def maybe_wrap_nullable_optional(formatter, schema, nullable?, omittable?) do
schema = if nullable?, do: formatter.wrap_nullable(schema), else: schema
if omittable?, do: formatter.wrap_optional(schema), else: schema
end

defp format_argument_for_client(resource, action_name, arg_name) do
mapped = AshTypescript.Resource.Info.get_mapped_argument_name(resource, action_name, arg_name)

Expand Down Expand Up @@ -452,11 +464,9 @@ defmodule AshTypescript.Codegen.SchemaCore do
)

schema_type = get_type(formatter, attr)

schema_type =
if attr.allow_nil? || attr.default != nil,
do: formatter.wrap_optional(schema_type),
else: schema_type
nullable = attr.allow_nil?
omittable = attr.allow_nil? || attr.default != nil
schema_type = maybe_wrap_nullable_optional(formatter, schema_type, nullable, omittable)

" #{formatted_name}: #{schema_type},"
end)
Expand Down
19 changes: 18 additions & 1 deletion lib/ash_typescript/codegen/schema_formatter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,26 @@ defmodule AshTypescript.Codegen.SchemaFormatter do
@doc "Wrap an inner schema string in an array type."
@callback wrap_array(inner :: String.t()) :: String.t()

@doc "Wrap a schema string as optional/nullable."
@doc """
Wrap a schema string as omittable — i.e. the field may be absent from the
input object. In zod this is `.optional()`; in valibot, `v.optional(...)`.
Both libraries' optional accepts `undefined` only — not `null`. To accept
`null`, compose with `wrap_nullable/1`.
"""
@callback wrap_optional(schema :: String.t()) :: String.t()

@doc """
Wrap a schema string as nullable — i.e. the field's value may be `null`.
In zod this is `.nullable()`; in valibot, `v.nullable(...)`.

For fields that may be both omitted *and* null (the common case for nullable
Ash attributes — `JSON.stringify` drops `undefined` keys, so clearing a
nullable attribute requires sending `"field": null`), compose with
`wrap_optional/1`. Convention: apply `wrap_nullable` first (innermost), then
`wrap_optional`. The result is equivalent to zod's `.nullish()` shorthand.
"""
@callback wrap_nullable(schema :: String.t()) :: String.t()

@doc "Wrap a comma-joined set of `key: schema` fields in an inline object schema."
@callback wrap_object(fields :: String.t()) :: String.t()

Expand Down
2 changes: 2 additions & 0 deletions lib/ash_typescript/codegen/valibot_schema_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ defmodule AshTypescript.Codegen.ValibotSchemaGenerator do
@impl true
def wrap_optional(schema), do: "v.optional(#{schema})"
@impl true
def wrap_nullable(schema), do: "v.nullable(#{schema})"
@impl true
def wrap_object(fields), do: "v.object({ #{fields} })"
@impl true
def wrap_union(schemas), do: "v.union([#{schemas}])"
Expand Down
2 changes: 2 additions & 0 deletions lib/ash_typescript/codegen/zod_schema_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ defmodule AshTypescript.Codegen.ZodSchemaGenerator do
@impl true
def wrap_optional(schema), do: "#{schema}.optional()"
@impl true
def wrap_nullable(schema), do: "#{schema}.nullable()"
@impl true
def wrap_object(fields), do: "z.object({ #{fields} })"
@impl true
def wrap_union(schemas), do: "z.union([#{schemas}])"
Expand Down
64 changes: 15 additions & 49 deletions lib/ash_typescript/rpc/codegen/type_generators/input_types.ex
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,7 @@ defmodule AshTypescript.Rpc.Codegen.TypeGenerators.InputTypes do
case action.type do
:read ->
arguments = Enum.filter(action.arguments, & &1.public?)

if arguments != [] do
Enum.map(arguments, fn arg ->
optional = arg.allow_nil? || arg.default != nil

formatted_arg_name =
format_argument_name_for_client(resource, action.name, arg.name)

{formatted_arg_name, get_ts_input_type(arg), optional}
end)
else
[]
end
Enum.map(arguments, &build_argument_field(resource, action, &1))

:create ->
accepts = Ash.Resource.Info.action(resource, action.name).accept || []
Expand All @@ -62,12 +50,10 @@ defmodule AshTypescript.Rpc.Codegen.TypeGenerators.InputTypes do
accept_field_defs =
Enum.map(accepts, fn field_name ->
attr = Ash.Resource.Info.attribute(resource, field_name)

optional =
field_name in action.allow_nil_input || attr.allow_nil? || attr.default != nil

nullable = attr.allow_nil? || field_name in action.allow_nil_input
optional = nullable || attr.default != nil
base_type = AshTypescript.Codegen.get_ts_input_type(attr)
field_type = if attr.allow_nil?, do: "#{base_type} | null", else: base_type
field_type = if nullable, do: "#{base_type} | null", else: base_type

formatted_field_name =
AshTypescript.FieldFormatter.format_field_for_client(
Expand All @@ -80,14 +66,7 @@ defmodule AshTypescript.Rpc.Codegen.TypeGenerators.InputTypes do
end)

argument_field_defs =
Enum.map(arguments, fn arg ->
optional = arg.allow_nil? || arg.default != nil

formatted_arg_name =
format_argument_name_for_client(resource, action.name, arg.name)

{formatted_arg_name, get_ts_input_type(arg), optional}
end)
Enum.map(arguments, &build_argument_field(resource, action, &1))

accept_field_defs ++ argument_field_defs
else
Expand Down Expand Up @@ -115,17 +94,8 @@ defmodule AshTypescript.Rpc.Codegen.TypeGenerators.InputTypes do
{formatted_field_name, field_type, optional}
end)

arguments = Enum.filter(action.arguments, & &1.public?)

argument_field_defs =
Enum.map(arguments, fn arg ->
optional = arg.allow_nil? || arg.default != nil

formatted_arg_name =
format_argument_name_for_client(resource, action.name, arg.name)

{formatted_arg_name, get_ts_input_type(arg), optional}
end)
Enum.map(arguments, &build_argument_field(resource, action, &1))

accept_field_defs ++ argument_field_defs
else
Expand All @@ -134,19 +104,7 @@ defmodule AshTypescript.Rpc.Codegen.TypeGenerators.InputTypes do

:action ->
arguments = Enum.filter(action.arguments, & &1.public?)

if arguments != [] do
Enum.map(arguments, fn arg ->
optional = arg.allow_nil? || arg.default != nil

formatted_arg_name =
format_argument_name_for_client(resource, action.name, arg.name)

{formatted_arg_name, get_ts_input_type(arg), optional}
end)
else
[]
end
Enum.map(arguments, &build_argument_field(resource, action, &1))
end

field_lines =
Expand All @@ -164,6 +122,14 @@ defmodule AshTypescript.Rpc.Codegen.TypeGenerators.InputTypes do
end
end

defp build_argument_field(resource, action, arg) do
optional = arg.allow_nil? || arg.default != nil
base_type = get_ts_input_type(arg)
field_type = if arg.allow_nil?, do: "#{base_type} | null", else: base_type
formatted_arg_name = format_argument_name_for_client(resource, action.name, arg.name)
{formatted_arg_name, field_type, optional}
end

# Helper to format argument name for client output
# If mapped, use the string directly; otherwise apply formatter
defp format_argument_name_for_client(resource, action_name, arg_name) do
Expand Down
21 changes: 16 additions & 5 deletions lib/ash_typescript/typed_controller/codegen/route_renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ defmodule AshTypescript.TypedController.Codegen.RouteRenderer do
import AshTypescript.Helpers, only: [format_output_field: 1]
import AshTypescript.Codegen.TypeMapper, only: [get_ts_input_type: 1]

alias AshTypescript.Codegen.SchemaCore
alias AshTypescript.Codegen.ZodSchemaGenerator

@mutation_methods [:post, :patch, :put, :delete]

@doc """
Expand Down Expand Up @@ -112,7 +115,8 @@ defmodule AshTypescript.TypedController.Codegen.RouteRenderer do

fields =
Enum.map_join(query_args, "; ", fn arg ->
ts_type = get_ts_input_type(%{type: arg.type, constraints: arg.constraints || []})
base_type = get_ts_input_type(%{type: arg.type, constraints: arg.constraints || []})
ts_type = if arg.allow_nil?, do: "#{base_type} | null", else: base_type
opt = if arg.allow_nil? || arg.default != nil, do: "?", else: ""
"#{format_output_field(arg.name)}#{opt}: #{ts_type}"
end)
Expand Down Expand Up @@ -228,14 +232,20 @@ defmodule AshTypescript.TypedController.Codegen.RouteRenderer do
resolved_type = Ash.Type.get_type(arg.type)

zod_type =
AshTypescript.Codegen.ZodSchemaGenerator.get_zod_type(%{
ZodSchemaGenerator.get_zod_type(%{
type: resolved_type,
constraints: arg.constraints || [],
allow_nil?: arg.allow_nil?
})

optional = arg.allow_nil? || arg.default != nil
zod_type = if optional, do: "#{zod_type}.optional()", else: zod_type
zod_type =
SchemaCore.maybe_wrap_nullable_optional(
ZodSchemaGenerator,
zod_type,
arg.allow_nil?,
arg.allow_nil? || arg.default != nil
)

" #{format_output_field(arg.name)}: #{zod_type},"
end)

Expand Down Expand Up @@ -263,7 +273,8 @@ defmodule AshTypescript.TypedController.Codegen.RouteRenderer do
|> non_path_args(path_params)
|> Enum.map(fn arg ->
optional = arg.allow_nil? || arg.default != nil
ts_type = get_ts_input_type(%{type: arg.type, constraints: arg.constraints || []})
base_type = get_ts_input_type(%{type: arg.type, constraints: arg.constraints || []})
ts_type = if arg.allow_nil?, do: "#{base_type} | null", else: base_type
{format_output_field(arg.name), ts_type, optional}
end)
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ defmodule AshTypescript.Resource.TypescriptFieldNamesCodegenTest do
# Check that mapped field names are used in the generated Zod schemas
assert zod_code =~ "field1: z.string()"
assert zod_code =~ "isActive: z.boolean()"
assert zod_code =~ "line2: z.string().optional()"
assert zod_code =~ "line2: z.string().nullable().optional()"

# Make sure the original names are NOT in the generated code
refute zod_code =~ "field_1:"
Expand Down
5 changes: 3 additions & 2 deletions test/ash_typescript/rpc/rpc_input_optionality_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ defmodule AshTypescript.RpcInputOptionalityTest do

input_type = List.first(input_type_match)

# heroImageUrl is optional via allow_nil_input even though the attribute has allow_nil?: false
assert input_type =~ "heroImageUrl?: string;"
# heroImageUrl is omittable AND nullable via allow_nil_input, even though
# the attribute has allow_nil?: false. So the TS type permits `null` too.
assert input_type =~ "heroImageUrl?: string | null;"
end

test "attribute not in allow_nil_input remains required when allow_nil?: false", %{
Expand Down
24 changes: 13 additions & 11 deletions test/ash_typescript/rpc/valibot_constraints_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ defmodule AshTypescript.Rpc.ValibotConstraintsTest do

Mirrors `ZodConstraintsTest` but verifies Valibot-specific output:
- Constraints use `v.pipe()` composition, not method chaining
- Optional fields wrap as `v.optional(schema)`, not `schema.optional()`
- Omittable fields wrap as `v.optional(schema)`, not `schema.optional()`
- Nullable + omittable fields wrap as `v.optional(v.nullable(schema))`
- Enums use `v.picklist([...])`, not `z.enum([...])`
- UUID uses `v.pipe(v.string(), v.uuid())`, not `z.uuid()`
- Required non-nullable strings get `v.pipe(v.string(), v.minLength(1))`, not `z.string().min(1)`
Expand Down Expand Up @@ -53,11 +54,11 @@ defmodule AshTypescript.Rpc.ValibotConstraintsTest do
assert schema =~ "title: v.pipe(v.string(), v.minLength(1))"
end

test "optional string without constraints generates v.optional(v.string())" do
test "nullable+omittable string without constraints generates v.optional(v.nullable(v.string()))" do
action = Ash.Resource.Info.action(OrgTodo, :create)
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")

assert schema =~ "description: v.optional(v.string())"
assert schema =~ "description: v.optional(v.nullable(v.string()))"
refute schema =~ ~r/description.*v\.minLength/
refute schema =~ ~r/description.*v\.maxLength/
end
Expand Down Expand Up @@ -224,14 +225,14 @@ defmodule AshTypescript.Rpc.ValibotConstraintsTest do
end
end

describe "Optional float constraints in Valibot schemas" do
test "optional float with constraints uses v.optional wrapping v.pipe" do
describe "Nullable+omittable float constraints in Valibot schemas" do
test "nullable+omittable float with constraints wraps v.pipe in v.optional(v.nullable(...))" do
action = Ash.Resource.Info.action(OrgTodo, :create)
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")

# optional_rating is optional with min/max constraints
# optional_rating is allow_nil? true with min/max constraints
assert schema =~
"optionalRating: v.optional(v.pipe(v.number(), v.minValue(0.0), v.maxValue(5.0)))"
"optionalRating: v.optional(v.nullable(v.pipe(v.number(), v.minValue(0.0), v.maxValue(5.0))))"
end
end

Expand Down Expand Up @@ -273,12 +274,12 @@ defmodule AshTypescript.Rpc.ValibotConstraintsTest do
assert schema =~ "countryCode: v.pipe(v.string(), v.minLength(1), v.regex(/^[A-Z]{2}$/i))"
end

test "optional ci_string with constraints" do
test "nullable+omittable ci_string with constraints wraps v.pipe in v.optional(v.nullable(...))" do
action = Ash.Resource.Info.action(OrgTodo, :create)
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")

assert schema =~
"optionalNickname: v.optional(v.pipe(v.string(), v.minLength(2), v.maxLength(15)))"
"optionalNickname: v.optional(v.nullable(v.pipe(v.string(), v.minLength(2), v.maxLength(15))))"
end

test "ci_string with case-insensitive regex includes i flag" do
Expand Down Expand Up @@ -354,11 +355,12 @@ defmodule AshTypescript.Rpc.ValibotConstraintsTest do
"caseInsensitiveCode: v.pipe(v.string(), v.minLength(1), v.regex(/^[A-Z]{3}-\\d{4}$/i))"
end

test "optional field with regex constraint" do
test "nullable+omittable field with regex constraint wraps in v.optional(v.nullable(...))" do
action = Ash.Resource.Info.action(OrgTodo, :create)
schema = ValibotSchemaGenerator.generate_valibot_schema(OrgTodo, action, "create_org_todo")

assert schema =~ "optionalUrl: v.optional(v.pipe(v.string(), v.regex(/^https?:\\/\\/.+/)))"
assert schema =~
"optionalUrl: v.optional(v.nullable(v.pipe(v.string(), v.regex(/^https?:\\/\\/.+/))))"
end

test "regex constraints are properly escaped for JavaScript" do
Expand Down
Loading
Loading