diff --git a/lib/ash_typescript/codegen/schema_core.ex b/lib/ash_typescript/codegen/schema_core.ex index e1645d63..4794a505 100644 --- a/lib/ash_typescript/codegen/schema_core.ex +++ b/lib/ash_typescript/codegen/schema_core.ex @@ -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), @@ -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 = @@ -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) @@ -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) diff --git a/lib/ash_typescript/codegen/schema_formatter.ex b/lib/ash_typescript/codegen/schema_formatter.ex index bd4e886c..64fd4d1a 100644 --- a/lib/ash_typescript/codegen/schema_formatter.ex +++ b/lib/ash_typescript/codegen/schema_formatter.ex @@ -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() diff --git a/lib/ash_typescript/codegen/valibot_schema_generator.ex b/lib/ash_typescript/codegen/valibot_schema_generator.ex index fa583dab..3f07fb4a 100644 --- a/lib/ash_typescript/codegen/valibot_schema_generator.ex +++ b/lib/ash_typescript/codegen/valibot_schema_generator.ex @@ -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}])" diff --git a/lib/ash_typescript/codegen/zod_schema_generator.ex b/lib/ash_typescript/codegen/zod_schema_generator.ex index 43206c78..0d5558ae 100644 --- a/lib/ash_typescript/codegen/zod_schema_generator.ex +++ b/lib/ash_typescript/codegen/zod_schema_generator.ex @@ -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}])" diff --git a/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex b/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex index f8420ac3..1baba272 100644 --- a/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex +++ b/lib/ash_typescript/rpc/codegen/type_generators/input_types.ex @@ -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 || [] @@ -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( @@ -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 @@ -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 @@ -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 = @@ -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 diff --git a/lib/ash_typescript/typed_controller/codegen/route_renderer.ex b/lib/ash_typescript/typed_controller/codegen/route_renderer.ex index dd170229..2767a8d9 100644 --- a/lib/ash_typescript/typed_controller/codegen/route_renderer.ex +++ b/lib/ash_typescript/typed_controller/codegen/route_renderer.ex @@ -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 """ @@ -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) @@ -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) @@ -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 diff --git a/test/ash_typescript/resource/typescript_field_names_codegen_test.exs b/test/ash_typescript/resource/typescript_field_names_codegen_test.exs index 11dc3fd6..cf644ed2 100644 --- a/test/ash_typescript/resource/typescript_field_names_codegen_test.exs +++ b/test/ash_typescript/resource/typescript_field_names_codegen_test.exs @@ -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:" diff --git a/test/ash_typescript/rpc/rpc_input_optionality_test.exs b/test/ash_typescript/rpc/rpc_input_optionality_test.exs index 55495548..67c38dfa 100644 --- a/test/ash_typescript/rpc/rpc_input_optionality_test.exs +++ b/test/ash_typescript/rpc/rpc_input_optionality_test.exs @@ -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", %{ diff --git a/test/ash_typescript/rpc/valibot_constraints_test.exs b/test/ash_typescript/rpc/valibot_constraints_test.exs index 394b40d0..25bcd2a8 100644 --- a/test/ash_typescript/rpc/valibot_constraints_test.exs +++ b/test/ash_typescript/rpc/valibot_constraints_test.exs @@ -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)` @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/test/ash_typescript/rpc/zod_constraints_test.exs b/test/ash_typescript/rpc/zod_constraints_test.exs index 712e4581..2ca55b14 100644 --- a/test/ash_typescript/rpc/zod_constraints_test.exs +++ b/test/ash_typescript/rpc/zod_constraints_test.exs @@ -67,11 +67,11 @@ defmodule AshTypescript.Rpc.ZodConstraintsTest do assert zod_schema =~ "title: z.string().min(1)" end - test "optional string field without constraints generates basic z.string().optional()" do + test "nullable+omittable string field without constraints is .nullable().optional()" do action = Ash.Resource.Info.action(OrgTodo, :create) zod_schema = ZodSchemaGenerator.generate_zod_schema(OrgTodo, action, "create_org_todo") - assert zod_schema =~ "description: z.string().optional()" + assert zod_schema =~ "description: z.string().nullable().optional()" refute zod_schema =~ ~r/description.*\.min\(/ refute zod_schema =~ ~r/description.*\.max\(/ end @@ -223,12 +223,12 @@ defmodule AshTypescript.Rpc.ZodConstraintsTest do assert zod_schema =~ ".lt(1.0e6)" end - test "optional float with constraints" do + test "nullable+omittable float with constraints" do action = Ash.Resource.Info.action(OrgTodo, :create) zod_schema = ZodSchemaGenerator.generate_zod_schema(OrgTodo, action, "create_org_todo") - # optional_rating is optional with min/max constraints - assert zod_schema =~ "optionalRating: z.number().min(0.0).max(5.0).optional()" + # optional_rating is allow_nil? true with min/max constraints + assert zod_schema =~ "optionalRating: z.number().min(0.0).max(5.0).nullable().optional()" end test "float without constraints generates basic z.number()" do @@ -286,12 +286,12 @@ defmodule AshTypescript.Rpc.ZodConstraintsTest do assert zod_schema =~ "countryCode: z.string().min(1).regex(/^[A-Z]{2}$/i)" end - test "optional ci_string with constraints" do + test "nullable+omittable ci_string with constraints" do action = Ash.Resource.Info.action(OrgTodo, :create) zod_schema = ZodSchemaGenerator.generate_zod_schema(OrgTodo, action, "create_org_todo") - # optional_nickname is optional with min/max constraints - assert zod_schema =~ "optionalNickname: z.string().min(2).max(15).optional()" + # optional_nickname is allow_nil? true with min/max constraints + assert zod_schema =~ "optionalNickname: z.string().min(2).max(15).nullable().optional()" end test "ci_string constraints work same as regular string" do @@ -368,12 +368,13 @@ defmodule AshTypescript.Rpc.ZodConstraintsTest do assert zod_schema =~ "caseInsensitiveCode: z.string().min(1).regex(/^[A-Z]{3}-\\d{4}$/i)" end - test "optional field with regex constraint" do + test "nullable+omittable field with regex constraint" do action = Ash.Resource.Info.action(OrgTodo, :create) zod_schema = ZodSchemaGenerator.generate_zod_schema(OrgTodo, action, "create_org_todo") - # Optional URL field should have regex and .optional() - assert zod_schema =~ "optionalUrl: z.string().regex(/^https?:\\/\\/.+/).optional()" + # optional_url is allow_nil? true with a regex constraint + assert zod_schema =~ + "optionalUrl: z.string().regex(/^https?:\\/\\/.+/).nullable().optional()" end test "regex constraints are properly escaped for JavaScript" do diff --git a/test/ash_typescript/rpc/zod_mapped_fields_test.exs b/test/ash_typescript/rpc/zod_mapped_fields_test.exs index e21a60c0..38ea2280 100644 --- a/test/ash_typescript/rpc/zod_mapped_fields_test.exs +++ b/test/ash_typescript/rpc/zod_mapped_fields_test.exs @@ -244,11 +244,11 @@ defmodule AshTypescript.Rpc.ZodMappedFieldsTest do zod_schema = ZodSchemaGenerator.generate_zod_schema_for_resource(embedded_resource) - # notes is allow_nil?: true, should be optional - assert zod_schema =~ "notes: z.string().optional()" + # notes is allow_nil?: true, so nullable + omittable + assert zod_schema =~ "notes: z.string().nullable().optional()" - # priority_level is allow_nil?: true, has constraints [min: 1, max: 5], should be optional - assert zod_schema =~ "priorityLevel: z.number().int().min(1).max(5).optional()" + # priority_level is allow_nil?: true, has constraints [min: 1, max: 5], so nullable + omittable + assert zod_schema =~ "priorityLevel: z.number().int().min(1).max(5).nullable().optional()" # isPublic (from is_public?) has default value, should be optional assert zod_schema =~ "isPublic: z.boolean().optional()" @@ -392,44 +392,46 @@ defmodule AshTypescript.Rpc.ZodMappedFieldsTest do assert stats_section =~ "averageDuration: z.number()" end - test "typed struct optional fields are marked optional" do + test "typed struct nullable fields wrap with .nullable().optional()" do action = Ash.Resource.Info.action(Task, :update) zod_schema = ZodSchemaGenerator.generate_zod_schema(Task, action, "update_task") stats_section = String.split(zod_schema, "stats: ") |> Enum.at(1) - # Fields with defaults should be optional - assert stats_section =~ "totalCount: z.number().int().optional()" - assert stats_section =~ "isUrgent: z.boolean().optional()" + # Typed-struct fields with allow_nil? true (the default in `:fields` + # constraints) get nullable + optional wrappers. + assert stats_section =~ "totalCount: z.number().int().nullable().optional()" + assert stats_section =~ "isUrgent: z.boolean().nullable().optional()" # Should not have unmapped names refute stats_section =~ "is_urgent?" end - test "typed struct required fields are not marked optional" do + test "typed struct fields with allow_nil? true wrap with .nullable().optional()" do action = Ash.Resource.Info.action(Task, :update) zod_schema = ZodSchemaGenerator.generate_zod_schema(Task, action, "update_task") stats_section = String.split(zod_schema, "stats: ") |> Enum.at(1) - # completed? has no default, should be optional (can be omitted) - assert stats_section =~ "completed: z.boolean().optional()" + # completed? has allow_nil? true, so nullable + optional + assert stats_section =~ "completed: z.boolean().nullable().optional()" refute stats_section =~ "completed?:" - # averageDuration has no default and allow_nil is implicit, should be optional - assert stats_section =~ "averageDuration: z.number().optional()" + # averageDuration has allow_nil? true (implicit), so nullable + optional + assert stats_section =~ "averageDuration: z.number().nullable().optional()" end - test "typed struct in action accepts is marked optional" do + test "typed struct in nullable action accept wraps with .nullable().optional()" do action = Ash.Resource.Info.action(Task, :update) zod_schema = ZodSchemaGenerator.generate_zod_schema(Task, action, "update_task") - # The entire stats field should be optional in the update action + # The entire stats field is allow_nil? true on the resource attribute, + # so it gets nullable + optional in the update accept-field path. assert zod_schema =~ "stats: z.object({" - assert zod_schema =~ "}).optional()" + assert zod_schema =~ "}).nullable().optional()" end test "all typed struct fields use consistent mapped names" do diff --git a/test/ash_typescript/typed_controller/codegen_test.exs b/test/ash_typescript/typed_controller/codegen_test.exs index 40d6bc26..4a6da93a 100644 --- a/test/ash_typescript/typed_controller/codegen_test.exs +++ b/test/ash_typescript/typed_controller/codegen_test.exs @@ -89,7 +89,7 @@ defmodule AshTypescript.TypedController.CodegenTest do } do assert String.contains?(typescript, "export function providerPagePath(") assert String.contains?(typescript, "path: { provider: string }") - assert String.contains?(typescript, "query?: { tab?: string }") + assert String.contains?(typescript, "query?: { tab?: string | null }") end test "GET path helper with path params uses path object in URL template", %{ @@ -106,7 +106,7 @@ defmodule AshTypescript.TypedController.CodegenTest do typescript: typescript } do assert String.contains?(typescript, "export function searchPath(") - assert String.contains?(typescript, "query: { q: string; page?: number }") + assert String.contains?(typescript, "query: { q: string; page?: number | null }") end test "generates URLSearchParams for GET routes with query params", %{typescript: typescript} do @@ -132,7 +132,7 @@ defmodule AshTypescript.TypedController.CodegenTest do test "generates typed input type for login action", %{typescript: typescript} do assert String.contains?(typescript, "export type LoginInput = {") assert String.contains?(typescript, "code: string;") - assert String.contains?(typescript, "rememberMe?: boolean;") + assert String.contains?(typescript, "rememberMe?: boolean | null;") end test "generates async function for login with input param", %{typescript: typescript} do @@ -415,7 +415,7 @@ defmodule AshTypescript.TypedController.CodegenTest do } do assert String.contains?(typescript, "export type UpdateProviderInput = {") assert String.contains?(typescript, "enabled: boolean;") - assert String.contains?(typescript, "displayName?: string;") + assert String.contains?(typescript, "displayName?: string | null;") refute String.contains?(typescript, "UpdateProviderInput = {\n provider") end @@ -554,8 +554,8 @@ defmodule AshTypescript.TypedController.CodegenTest do end test "still generates query params for GET routes", %{typescript: typescript} do - assert String.contains?(typescript, "query?: { tab?: string }") - assert String.contains?(typescript, "query: { q: string; page?: number }") + assert String.contains?(typescript, "query?: { tab?: string | null }") + assert String.contains?(typescript, "query: { q: string; page?: number | null }") end test "does not generate TypedControllerConfig or helper", %{typescript: typescript} do @@ -707,13 +707,13 @@ defmodule AshTypescript.TypedController.CodegenTest do test "generates Zod schemas for mutation routes with input args", %{zod: zod} do assert String.contains?(zod, "export const loginZodSchema = z.object({") assert String.contains?(zod, "code: z.string().min(1),") - assert String.contains?(zod, "rememberMe: z.boolean().optional(),") + assert String.contains?(zod, "rememberMe: z.boolean().nullable().optional(),") end test "generates Zod schema for update_provider excluding path params", %{zod: zod} do assert String.contains?(zod, "export const updateProviderZodSchema = z.object({") assert String.contains?(zod, "enabled: z.boolean(),") - assert String.contains?(zod, "displayName: z.string().optional(),") + assert String.contains?(zod, "displayName: z.string().nullable().optional(),") [_, after_schema] = String.split(zod, "export const updateProviderZodSchema = z.object({", parts: 2) @@ -738,8 +738,8 @@ defmodule AshTypescript.TypedController.CodegenTest do [schema_body | _] = String.split(after_schema, "});", parts: 2) assert String.contains?(schema_body, "name: z.string().min(1),") - assert String.contains?(schema_body, "count: z.number().int().optional(),") - assert String.contains?(schema_body, "active: z.boolean().optional(),") + assert String.contains?(schema_body, "count: z.number().int().nullable().optional(),") + assert String.contains?(schema_body, "active: z.boolean().nullable().optional(),") end end @@ -783,7 +783,10 @@ defmodule AshTypescript.TypedController.CodegenTest do [schema_body | _] = String.split(after_schema, "});", parts: 2) - assert String.contains?(schema_body, "score: z.number().min(0).max(100).optional(),") + assert String.contains?( + schema_body, + "score: z.number().min(0).max(100).nullable().optional()," + ) end test "generates string max_length-only constraint", %{zod: zod} do @@ -792,7 +795,7 @@ defmodule AshTypescript.TypedController.CodegenTest do [schema_body | _] = String.split(after_schema, "});", parts: 2) - assert String.contains?(schema_body, "bio: z.string().max(500).optional(),") + assert String.contains?(schema_body, "bio: z.string().max(500).nullable().optional(),") end test "generates fixed-length string via min_length + max_length", %{zod: zod} do @@ -801,7 +804,10 @@ defmodule AshTypescript.TypedController.CodegenTest do [schema_body | _] = String.split(after_schema, "});", parts: 2) - assert String.contains?(schema_body, "inviteCode: z.string().min(8).max(8).optional(),") + assert String.contains?( + schema_body, + "inviteCode: z.string().min(8).max(8).nullable().optional()," + ) end test "generates corresponding TypeScript input type in routes file", %{typescript: typescript} do @@ -809,9 +815,9 @@ defmodule AshTypescript.TypedController.CodegenTest do assert String.contains?(typescript, "username: string;") assert String.contains?(typescript, "email: string;") assert String.contains?(typescript, "age: number;") - assert String.contains?(typescript, "score?: number;") - assert String.contains?(typescript, "bio?: string;") - assert String.contains?(typescript, "inviteCode?: string;") + assert String.contains?(typescript, "score?: number | null;") + assert String.contains?(typescript, "bio?: string | null;") + assert String.contains?(typescript, "inviteCode?: string | null;") end end @@ -828,7 +834,7 @@ defmodule AshTypescript.TypedController.CodegenTest do assert String.contains?(typescript, "export type CreateTaskInput = {") assert String.contains?(typescript, "title: string;") assert String.contains?(typescript, "metadata: TaskMetadataInputSchema;") - assert String.contains?(typescript, "priority?: number;") + assert String.contains?(typescript, "priority?: number | null;") end test "generates Zod schema referencing embedded resource Zod schema", %{zod: zod} do @@ -841,7 +847,11 @@ defmodule AshTypescript.TypedController.CodegenTest do assert String.contains?(schema_body, "title: z.string().min(1).max(200),") assert String.contains?(schema_body, "metadata: TaskMetadataZodSchema,") - assert String.contains?(schema_body, "priority: z.number().int().min(1).max(5).optional(),") + + assert String.contains?( + schema_body, + "priority: z.number().int().min(1).max(5).nullable().optional()," + ) end test "generates action function for create_task in routes file", %{typescript: typescript} do