Skip to content

Commit f0cf091

Browse files
committed
fix: use type constraints in zod schema generation
1 parent 02c35f4 commit f0cf091

File tree

11 files changed

+2247
-3
lines changed

11 files changed

+2247
-3
lines changed

.check.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
{:compile_generated, "mix cmd --cd test/ts npm run compileGenerated"},
2424
{:compile_should_pass, "mix cmd --cd test/ts npm run compileShouldPass"},
2525
{:compile_should_fail, "mix cmd --cd test/ts npm run compileShouldFail"},
26+
{:test_zod, "mix cmd --cd test/ts npm run testZod"},
2627

2728
## custom new tools may be added (mix tasks or arbitrary commands)
2829
# {:my_mix_task, command: "mix release", env: %{"MIX_ENV" => "prod"}},

lib/ash_typescript/rpc/zod_schema_generator.ex

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,45 @@ defmodule AshTypescript.Rpc.ZodSchemaGenerator do
4848
end
4949

5050
def get_zod_type(%{type: Ash.Type.Atom}, _), do: "z.string()"
51+
52+
def get_zod_type(%{type: Ash.Type.String, constraints: constraints, allow_nil?: false}, _)
53+
when constraints != [] do
54+
build_string_zod_with_constraints(constraints, true)
55+
end
56+
57+
def get_zod_type(%{type: Ash.Type.String, constraints: constraints}, _)
58+
when constraints != [] do
59+
build_string_zod_with_constraints(constraints, false)
60+
end
61+
5162
def get_zod_type(%{type: Ash.Type.String, allow_nil?: false}, _), do: "z.string().min(1)"
5263
def get_zod_type(%{type: Ash.Type.String}, _), do: "z.string()"
64+
65+
def get_zod_type(%{type: Ash.Type.CiString, constraints: constraints, allow_nil?: false}, _)
66+
when constraints != [] do
67+
build_string_zod_with_constraints(constraints, true)
68+
end
69+
70+
def get_zod_type(%{type: Ash.Type.CiString, constraints: constraints}, _)
71+
when constraints != [] do
72+
build_string_zod_with_constraints(constraints, false)
73+
end
74+
75+
def get_zod_type(%{type: Ash.Type.CiString, allow_nil?: false}, _), do: "z.string().min(1)"
5376
def get_zod_type(%{type: Ash.Type.CiString}, _), do: "z.string()"
77+
78+
def get_zod_type(%{type: Ash.Type.Integer, constraints: constraints}, _)
79+
when constraints != [] do
80+
build_integer_zod_with_constraints(constraints)
81+
end
82+
5483
def get_zod_type(%{type: Ash.Type.Integer}, _), do: "z.number().int()"
84+
85+
def get_zod_type(%{type: Ash.Type.Float, constraints: constraints}, _)
86+
when constraints != [] do
87+
build_float_zod_with_constraints(constraints)
88+
end
89+
5590
def get_zod_type(%{type: Ash.Type.Float}, _), do: "z.number()"
5691
def get_zod_type(%{type: Ash.Type.Decimal}, _), do: "z.string()"
5792
def get_zod_type(%{type: Ash.Type.Boolean}, _), do: "z.boolean()"
@@ -529,4 +564,119 @@ defmodule AshTypescript.Rpc.ZodSchemaGenerator do
529564
action.arguments != []
530565
end
531566
end
567+
568+
defp build_integer_zod_with_constraints(constraints) do
569+
base = "z.number().int()"
570+
571+
base
572+
|> add_min_constraint(Keyword.get(constraints, :min))
573+
|> add_max_constraint(Keyword.get(constraints, :max))
574+
end
575+
576+
defp build_float_zod_with_constraints(constraints) do
577+
base = "z.number()"
578+
579+
base
580+
|> add_min_constraint(Keyword.get(constraints, :min))
581+
|> add_max_constraint(Keyword.get(constraints, :max))
582+
|> add_gt_constraint(Keyword.get(constraints, :greater_than))
583+
|> add_lt_constraint(Keyword.get(constraints, :less_than))
584+
end
585+
586+
defp build_string_zod_with_constraints(constraints, require_non_empty) do
587+
base = "z.string()"
588+
min_length = Keyword.get(constraints, :min_length)
589+
max_length = Keyword.get(constraints, :max_length)
590+
591+
# If require_non_empty is true and no min_length is set, default to min(1)
592+
effective_min_length =
593+
if require_non_empty && is_nil(min_length) do
594+
1
595+
else
596+
min_length
597+
end
598+
599+
base
600+
|> add_string_min_length(effective_min_length)
601+
|> add_string_max_length(max_length)
602+
|> add_string_regex(Keyword.get(constraints, :match))
603+
end
604+
605+
defp add_min_constraint(zod_str, nil), do: zod_str
606+
defp add_min_constraint(zod_str, min), do: "#{zod_str}.min(#{min})"
607+
608+
defp add_max_constraint(zod_str, nil), do: zod_str
609+
defp add_max_constraint(zod_str, max), do: "#{zod_str}.max(#{max})"
610+
611+
defp add_gt_constraint(zod_str, nil), do: zod_str
612+
defp add_gt_constraint(zod_str, gt), do: "#{zod_str}.gt(#{gt})"
613+
614+
defp add_lt_constraint(zod_str, nil), do: zod_str
615+
defp add_lt_constraint(zod_str, lt), do: "#{zod_str}.lt(#{lt})"
616+
617+
defp add_string_min_length(zod_str, nil), do: zod_str
618+
defp add_string_min_length(zod_str, min), do: "#{zod_str}.min(#{min})"
619+
620+
defp add_string_max_length(zod_str, nil), do: zod_str
621+
defp add_string_max_length(zod_str, max), do: "#{zod_str}.max(#{max})"
622+
623+
defp add_string_regex(zod_str, nil), do: zod_str
624+
625+
defp add_string_regex(zod_str, regex) when is_struct(regex, Regex) do
626+
source = Regex.source(regex)
627+
628+
# Skip regex conversion for PCRE-specific features to avoid silent validation errors
629+
if regex_is_safe_for_js?(source) do
630+
opts = Regex.opts(regex)
631+
632+
js_flags =
633+
[]
634+
|> then(fn flags -> if :caseless in opts, do: ["i" | flags], else: flags end)
635+
|> then(fn flags -> if :multiline in opts, do: ["m" | flags], else: flags end)
636+
|> then(fn flags -> if :dotall in opts, do: ["s" | flags], else: flags end)
637+
|> Enum.join()
638+
639+
escaped_source = String.replace(source, "/", "\\/")
640+
641+
"#{zod_str}.regex(/#{escaped_source}/#{js_flags})"
642+
else
643+
# Server-side Ash validation will still enforce the constraint
644+
zod_str
645+
end
646+
end
647+
648+
defp add_string_regex(zod_str, {Spark.Regex, :cache, [pattern, opts]}) do
649+
regex = Spark.Regex.cache(pattern, opts)
650+
add_string_regex(zod_str, regex)
651+
end
652+
653+
defp add_string_regex(zod_str, _other), do: zod_str
654+
655+
# Checks if a regex pattern uses PCRE-specific features incompatible with JavaScript
656+
defp regex_is_safe_for_js?(source) do
657+
pcre_only_patterns = [
658+
# Lookbehind assertions
659+
~r/\(\?<[!=]/,
660+
# Possessive quantifiers
661+
~r/[*+?]\+/,
662+
# Atomic groups
663+
~r/\(\?>/,
664+
# PCRE named captures (JS uses (?<name> which is safe)
665+
~r/\(\?P</,
666+
# Inline modifiers
667+
~r/\(\?[imsxADSUXJ]/,
668+
# Recursion
669+
~r/\(\?R\)/,
670+
# Subroutines
671+
~r/\(\?[0-9]/,
672+
# Conditionals
673+
~r/\(\?\([^)]+\)/,
674+
# PCRE-specific anchors
675+
~r/\\[AG]/,
676+
# Unicode properties (syntax differs)
677+
~r/\\[pP]\{/
678+
]
679+
680+
not Enum.any?(pcre_only_patterns, fn pattern -> Regex.match?(pattern, source) end)
681+
end
532682
end

0 commit comments

Comments
 (0)