@@ -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
532682end
0 commit comments