zod_rails 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3bbdef55113e82c23ae843f93fb14074b05799036856202bc615028373993c1
4
- data.tar.gz: b638454c118e1f666b971769492f5787bf4e7d20bc736dbac106b77d80689bc2
3
+ metadata.gz: abcbdb41173e1bb789719ff85fbf595a619daf0cdb08ec260e0532601af9db96
4
+ data.tar.gz: 5649df9de3c18bd6d81347e12f23552d75420c8a5b5166d9d325797162ec0196
5
5
  SHA512:
6
- metadata.gz: 16e639e56112401f8ffbab59263642fd0c244f5a08be5ed17b8d68eb185e7f6294d849596378eef24123f65ef4b2a79bb2e9dd71e1970154ad616288b334b8b3
7
- data.tar.gz: 11adc59ed2fbab1da351ab6981ce9de6ff3830c8491e40ebe8ccc31eb5d0f9fd93ef148da41a803d8767f0be6d4f72412c640c755c4793ec4ee161a10b6fcbb5
6
+ metadata.gz: 281121330df0dbbb626d99103db9ad4a63c8691a850f0419fb58f3de42bb3fc6be44d1b404473ae46830b8ea66dabad1e195b0e980a6b68888f4f026a607d24e
7
+ data.tar.gz: dff40d3630dd26e743db6b48472c906fc416ad5a5bf7503340f6708eaf5105a6e13dd96dd6042a78437a232b01d6e2ff3ed6a4a85938f0cf3a609f2a200a5d17
data/README.md CHANGED
@@ -92,6 +92,7 @@ const formData = UserInputSchema.parse(formValues);
92
92
  | `input_schema_suffix` | `InputSchema` | Suffix for input schemas (e.g., `UserInputSchema`) |
93
93
  | `generate_input_schemas` | `true` | Whether to generate input schemas |
94
94
  | `excluded_columns` | `["id", "created_at", "updated_at"]` | Columns to exclude from input schemas |
95
+ | `post_generate_command` | `nil` | Shell command to run after a successful generation (e.g., your formatter) |
95
96
 
96
97
  ### Full Configuration Example
97
98
 
@@ -138,8 +139,19 @@ ZodRails introspects your model validations and maps them to Zod constraints:
138
139
  | `numericality: { greater_than_or_equal_to: n }` | `.gte(n)` |
139
140
  | `numericality: { less_than: n }` | `.lt(n)` |
140
141
  | `numericality: { less_than_or_equal_to: n }` | `.lte(n)` |
141
- | `format: { with: /regex/ }` | `.regex(/regex/)` |
142
- | `inclusion: { in: n..m }` | `.min(n).max(m)` |
142
+ | `format: { with: /regex/ }` | `.regex(/regex/)` (preserves `/i` case-insensitivity) |
143
+ | `inclusion: { in: n..m }` (Range) | `.min(n).max(m)` |
144
+ | `inclusion: { in: %w[a b c] }` (Array, string column) | `z.enum(["a", "b", "c"])` as the base type |
145
+ | `inclusion: { in: [1, 5, 10] }` (Array, integer column) | `.pipe(z.union([z.literal(1), z.literal(5), z.literal(10)]))` |
146
+
147
+ ### `inclusion` vs. Rails `enum`
148
+
149
+ The Rails `enum` macro and a string column with `validates :foo, inclusion: { in: %w[...] }` both end up as `z.enum([...])` in the generated TypeScript:
150
+
151
+ - `enum :role, { member: 0, admin: 1 }` introspects through ActiveRecord's `defined_enums` and emits `z.enum(["member", "admin"])`.
152
+ - `validates :decision, inclusion: { in: %w[pending approved] }` on a string column is detected by `SchemaBuilder` and produces `z.enum(["pending", "approved"])` as the base type. `presence: true` becomes redundant once the values are restricted, so it's dropped from the chain.
153
+
154
+ If you mix both (`enum` macro AND a separate `inclusion` validator on the same column), the `enum` macro wins.
143
155
 
144
156
  ## Generated Output Example
145
157
 
@@ -152,6 +164,7 @@ class User < ApplicationRecord
152
164
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
153
165
  validates :name, presence: true, length: { minimum: 2, maximum: 100 }
154
166
  validates :age, numericality: { greater_than: 0, less_than: 150 }, allow_nil: true
167
+ validates :status, inclusion: { in: %w[pending active suspended] }
155
168
  end
156
169
  ```
157
170
 
@@ -165,6 +178,7 @@ export const UserSchema = z.object({
165
178
  email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
166
179
  name: z.string().min(2).max(100),
167
180
  age: z.int().gt(0).lt(150).nullable(),
181
+ status: z.enum(["pending", "active", "suspended"]),
168
182
  role: z.enum(["member", "admin", "moderator"]),
169
183
  created_at: z.iso.datetime(),
170
184
  updated_at: z.iso.datetime()
@@ -176,6 +190,7 @@ export const UserInputSchema = z.object({
176
190
  email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
177
191
  name: z.string().min(2).max(100),
178
192
  age: z.int().gt(0).lt(150).nullish(),
193
+ status: z.enum(["pending", "active", "suspended"]),
179
194
  role: z.enum(["member", "admin", "moderator"]).optional()
180
195
  });
181
196
 
@@ -197,6 +212,60 @@ ZodRails generates two schema variants:
197
212
  - Uses `.optional()` for columns with database defaults
198
213
  - Uses `.nullish()` for nullable columns (accepts both `null` and `undefined`)
199
214
 
215
+ ## Preserving Hand-Written Code
216
+
217
+ The generator overwrites files in `output_dir` on every run. If you want to keep hand-written schemas, types, or imports next to the generated ones, wrap them in sentinel comments — the writer will preserve anything between the markers verbatim across regens.
218
+
219
+ Two block markers are recognized per file:
220
+
221
+ ```typescript
222
+ import { z } from "zod";
223
+
224
+ // ZOD_RAILS:CUSTOM:IMPORTS:BEGIN
225
+ import { customValidator } from "./shared";
226
+ // ZOD_RAILS:CUSTOM:IMPORTS:END
227
+
228
+ export const ArticleSchema = z.object({ /* generated */ });
229
+
230
+ export type Article = z.infer<typeof ArticleSchema>;
231
+
232
+ // ZOD_RAILS:CUSTOM:BEGIN
233
+ export const ArticleResponseSchema = z.object({
234
+ article: ArticleSchema,
235
+ meta: z.object({ count: z.int() }),
236
+ });
237
+ // ZOD_RAILS:CUSTOM:END
238
+ ```
239
+
240
+ - **Imports block** lives right after the `import { z } from "zod";` line. Use it for any external imports your custom code needs.
241
+ - **Tail block** lives at the end of the file. Use it for additional schemas, response wrappers, helper types, etc.
242
+
243
+ Both blocks are optional. If you don't add them, the file is overwritten as before. Hand-edits *outside* the markers will still be lost on regen — wrap them, or move them to a separate file.
244
+
245
+ ## Drift Detection in CI
246
+
247
+ `bin/rails zod_rails:check` regenerates schemas in memory and compares them against the files on disk. Exits 0 if everything is up to date, 1 with a list of out-of-date files otherwise. Wire it into your CI to catch the case where someone updated a model but forgot to regenerate:
248
+
249
+ ```yaml
250
+ - name: Check Zod schemas are up to date
251
+ run: bin/rails zod_rails:check
252
+ ```
253
+
254
+ For local iteration, `DRY_RUN=1 bin/rails zod_rails:generate` prints the same drift list without writing anything.
255
+
256
+ ## Formatter Integration
257
+
258
+ If your TypeScript project runs prettier, biome, or a similar formatter with conventions that differ from the gem's output (single quotes, trailing commas, line width…), set `post_generate_command` and the gem will hand off to your formatter after a successful generation:
259
+
260
+ ```ruby
261
+ ZodRails.configure do |config|
262
+ config.post_generate_command =
263
+ "bun run prettier --write 'app/javascript/schemas/**/*.ts'"
264
+ end
265
+ ```
266
+
267
+ The command runs with your project's working directory. A nonzero exit raises `ZodRails::Error` so CI catches misconfiguration, and the generated files are still written before the formatter runs.
268
+
200
269
  ## Integrating with Forms
201
270
 
202
271
  ZodRails pairs well with form libraries that support Zod:
@@ -235,10 +304,17 @@ async function fetchUser(id: number): Promise<User> {
235
304
 
236
305
  ## CI/CD Integration
237
306
 
238
- Add schema generation to your build process to catch type mismatches early:
307
+ Use `zod_rails:check` to catch missed regenerations:
239
308
 
240
309
  ```yaml
241
310
  # .github/workflows/ci.yml
311
+ - name: Check Zod schemas are up to date
312
+ run: bin/rails zod_rails:check
313
+ ```
314
+
315
+ This works whether or not the generated schemas are committed to the same repo. If they are committed, the older `git diff --exit-code` approach also works:
316
+
317
+ ```yaml
242
318
  - name: Generate Zod schemas
243
319
  run: bin/rails zod_rails:generate
244
320
 
@@ -264,6 +340,22 @@ Ensure validations are defined on the model class, not in concerns that might no
264
340
 
265
341
  For custom types not in the mapping table, ZodRails falls back to `z.unknown()`. Open an issue if you need support for additional types.
266
342
 
343
+ ### Misconfigured model names
344
+
345
+ A typo in `config.models` no longer raises an `uninitialized constant` backtrace. The generator collects every unresolvable name and prints them all in one report:
346
+
347
+ ```
348
+ ZodRails: 2 model(s) in config.models could not be loaded:
349
+ - Useer
350
+ - Postt
351
+
352
+ Check the model names in config/initializers/zod_rails.rb.
353
+ ```
354
+
355
+ ### Namespaced models
356
+
357
+ A model like `Admin::User` writes to `admin/user.ts` and exports `AdminUserSchema` / `AdminUser` (the namespace separator is collapsed for the TypeScript identifier — `::` is not valid in a TS identifier).
358
+
267
359
  ## Releasing
268
360
 
269
361
  1. Bump the version in `lib/zod_rails/version.rb`
@@ -1,21 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  namespace :zod_rails do
4
- desc "Generate Zod schemas for configured models"
4
+ desc "Generate Zod schemas for configured models (DRY_RUN=1 to preview only)"
5
5
  task generate: :environment do
6
6
  config = ZodRails.configuration
7
7
  generator = ZodRails::Generator.new(output_dir: config.output_dir)
8
8
 
9
- models = config.models.map(&:constantize)
10
-
11
- if models.empty?
9
+ if config.models.empty?
12
10
  puts "No models configured. Add models to ZodRails.configure { |c| c.models = ['User', 'Article'] }"
13
11
  exit 1
14
12
  end
15
13
 
16
- generated = generator.generate_all(models)
17
- puts "Generated #{generated.size} schema file(s):"
18
- generated.each { |f| puts " - #{f}" }
14
+ resolution = ZodRails::ModelResolver.resolve(config.models)
15
+
16
+ unless resolution[:missing].empty?
17
+ puts "ZodRails: #{resolution[:missing].size} model(s) in config.models could not be loaded:"
18
+ resolution[:missing].each { |m| puts " - #{m}" }
19
+ puts ""
20
+ puts "Check the model names in config/initializers/zod_rails.rb."
21
+ exit 1
22
+ end
23
+
24
+ models = resolution[:resolved]
25
+
26
+ if ENV["DRY_RUN"] == "1"
27
+ drift = generator.check(models)
28
+ if drift.empty?
29
+ puts "ZodRails (dry run): no changes."
30
+ else
31
+ puts "ZodRails (dry run): would update #{drift.size} file(s):"
32
+ drift.each { |d| puts " - #{d[:filename]} (#{d[:status]})" }
33
+ end
34
+ else
35
+ generated = generator.generate_all(models)
36
+ puts "Generated #{generated.size} schema file(s):"
37
+ generated.each { |f| puts " - #{f}" }
38
+ end
39
+ end
40
+
41
+ desc "Check whether generated schemas match the current models (exits nonzero on drift)"
42
+ task check: :environment do
43
+ config = ZodRails.configuration
44
+ generator = ZodRails::Generator.new(output_dir: config.output_dir)
45
+
46
+ if config.models.empty?
47
+ puts "No models configured. Nothing to check."
48
+ exit 0
49
+ end
50
+
51
+ resolution = ZodRails::ModelResolver.resolve(config.models)
52
+
53
+ unless resolution[:missing].empty?
54
+ puts "ZodRails: #{resolution[:missing].size} model(s) in config.models could not be loaded:"
55
+ resolution[:missing].each { |m| puts " - #{m}" }
56
+ exit 1
57
+ end
58
+
59
+ drift = generator.check(resolution[:resolved])
60
+
61
+ if drift.empty?
62
+ puts "ZodRails: generated schemas are up to date."
63
+ exit 0
64
+ end
65
+
66
+ puts "ZodRails: #{drift.size} file(s) out of date:"
67
+ drift.each { |d| puts " - #{d[:filename]} (#{d[:status]})" }
68
+ puts ""
69
+ puts "Run `bin/rails zod_rails:generate` to update."
70
+ exit 1
19
71
  end
20
72
 
21
73
  desc "Generate Zod schema for a specific model"
@@ -29,8 +81,13 @@ namespace :zod_rails do
29
81
  config = ZodRails.configuration
30
82
  generator = ZodRails::Generator.new(output_dir: config.output_dir)
31
83
 
32
- model_class = model_name.constantize
33
- filename = generator.generate(model_class)
84
+ resolution = ZodRails::ModelResolver.resolve([model_name])
85
+ if resolution[:missing].any?
86
+ puts "ZodRails: model '#{model_name}' could not be loaded. Check the spelling."
87
+ exit 1
88
+ end
89
+
90
+ filename = generator.generate(resolution[:resolved].first)
34
91
  puts "Generated: #{filename}"
35
92
  end
36
93
  end
@@ -3,7 +3,8 @@
3
3
  module ZodRails
4
4
  class Configuration
5
5
  attr_accessor :output_dir, :schema_suffix, :input_schema_suffix,
6
- :generate_input_schemas, :excluded_columns, :models
6
+ :generate_input_schemas, :excluded_columns, :models,
7
+ :post_generate_command
7
8
 
8
9
  def initialize
9
10
  @output_dir = "app/javascript/schemas"
@@ -12,6 +13,7 @@ module ZodRails
12
13
  @generate_input_schemas = true
13
14
  @excluded_columns = %w[id created_at updated_at]
14
15
  @models = []
16
+ @post_generate_command = nil
15
17
  end
16
18
  end
17
19
  end
@@ -5,6 +5,10 @@ require "fileutils"
5
5
  module ZodRails
6
6
  module Generation
7
7
  class FileWriter
8
+ IMPORTS_BLOCK_RE = %r{^// ZOD_RAILS:CUSTOM:IMPORTS:BEGIN\n.*?^// ZOD_RAILS:CUSTOM:IMPORTS:END\n}m
9
+ TAIL_BLOCK_RE = %r{^// ZOD_RAILS:CUSTOM:BEGIN\n.*?^// ZOD_RAILS:CUSTOM:END\n}m
10
+ ZOD_IMPORT_RE = /^(import \{ z \} from "zod";\n)/
11
+
8
12
  attr_reader :output_dir
9
13
 
10
14
  def initialize(output_dir:)
@@ -14,7 +18,13 @@ module ZodRails
14
18
  def write(filename:, content:)
15
19
  full_path = File.join(output_dir, filename)
16
20
  FileUtils.mkdir_p(File.dirname(full_path))
17
- File.write(full_path, content)
21
+
22
+ final = File.exist?(full_path) ? splice_custom_blocks(content, File.read(full_path)) : content
23
+ File.write(full_path, final)
24
+ end
25
+
26
+ def preview(filename:, content:) # rubocop:disable Lint/UnusedMethodArgument
27
+ content
18
28
  end
19
29
 
20
30
  def output_path_for(model_name)
@@ -27,6 +37,24 @@ module ZodRails
27
37
 
28
38
  private
29
39
 
40
+ def splice_custom_blocks(new_content, existing)
41
+ imports = existing[IMPORTS_BLOCK_RE]
42
+ tail = existing[TAIL_BLOCK_RE]
43
+
44
+ result = new_content
45
+ result = insert_imports_block(result, imports) if imports
46
+ result = append_tail_block(result, tail) if tail
47
+ result
48
+ end
49
+
50
+ def insert_imports_block(content, imports_block)
51
+ content.sub(ZOD_IMPORT_RE, "\\1\n#{imports_block}")
52
+ end
53
+
54
+ def append_tail_block(content, tail_block)
55
+ "#{content.rstrip}\n\n#{tail_block}"
56
+ end
57
+
30
58
  def underscore(str)
31
59
  str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
32
60
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
@@ -3,6 +3,9 @@
3
3
  module ZodRails
4
4
  module Generation
5
5
  class SchemaBuilder
6
+ STRING_TYPES = %i[string text].freeze
7
+ NULLABILITY_SUFFIX_RE = /(\.(?:nullable|nullish|optional)\(\))\z/
8
+
6
9
  attr_reader :inspector, :excluded_columns
7
10
 
8
11
  def initialize(inspector, excluded_columns: [])
@@ -21,7 +24,7 @@ module ZodRails
21
24
 
22
25
  def schema_name(input_schema: false)
23
26
  suffix = input_schema ? "InputSchema" : "Schema"
24
- "#{inspector.model_name}#{suffix}"
27
+ "#{inspector.model_name.gsub("::", "")}#{suffix}"
25
28
  end
26
29
 
27
30
  private
@@ -34,11 +37,36 @@ module ZodRails
34
37
  def build_type_string(column, input_schema:)
35
38
  if enum_column?(column.name)
36
39
  build_enum_type(column, input_schema: input_schema)
40
+ elsif (values = string_array_inclusion_values(column))
41
+ build_inclusion_enum_type(column, values, input_schema: input_schema)
37
42
  else
38
43
  build_regular_type(column, input_schema: input_schema)
39
44
  end
40
45
  end
41
46
 
47
+ def string_array_inclusion_values(column)
48
+ return nil unless STRING_TYPES.include?(column.type)
49
+
50
+ inclusion = inspector.validations_for(column.name).find { |v| string_array_inclusion?(v) }
51
+ inclusion&.options&.[](:in)
52
+ end
53
+
54
+ def string_array_inclusion?(validation)
55
+ return false unless validation.kind == :inclusion && !validation.conditional?
56
+
57
+ values = validation.options[:in]
58
+ values.is_a?(Array) && !values.empty? && values.all? { |x| x.is_a?(String) }
59
+ end
60
+
61
+ def build_inclusion_enum_type(column, values, input_schema:)
62
+ Mapping::EnumMapper.call(
63
+ values,
64
+ nullable: column.nullable,
65
+ input_schema: input_schema,
66
+ has_default: column.has_default
67
+ )
68
+ end
69
+
42
70
  def build_enum_type(column, input_schema:)
43
71
  values = inspector.enums[column.name]
44
72
  Mapping::EnumMapper.call(
@@ -65,13 +93,8 @@ module ZodRails
65
93
  def insert_validation_chain(base_type, validation_chain)
66
94
  return base_type if validation_chain.empty?
67
95
 
68
- if base_type.include?(".nullable()") || base_type.include?(".nullish()") || base_type.include?(".optional()")
69
- suffix_match = base_type.match(/(\.(nullable|nullish|optional)\(\))$/)
70
- if suffix_match
71
- base_type.sub(suffix_match[0], "#{validation_chain}#{suffix_match[0]}")
72
- else
73
- "#{base_type}#{validation_chain}"
74
- end
96
+ if (match = base_type.match(NULLABILITY_SUFFIX_RE))
97
+ base_type.sub(NULLABILITY_SUFFIX_RE, "#{validation_chain}#{match[0]}")
75
98
  else
76
99
  "#{base_type}#{validation_chain}"
77
100
  end
@@ -11,6 +11,12 @@ module ZodRails
11
11
  end
12
12
 
13
13
  def generate(model_class)
14
+ result = generate_content(model_class)
15
+ file_writer.write(filename: result[:filename], content: result[:content])
16
+ result[:filename]
17
+ end
18
+
19
+ def generate_content(model_class)
14
20
  inspector = Introspection::ModelInspector.new(model_class)
15
21
  excluded = ZodRails.configuration.excluded_columns
16
22
  builder = Generation::SchemaBuilder.new(inspector, excluded_columns: excluded)
@@ -28,12 +34,41 @@ module ZodRails
28
34
  content = emitter.emit_combined(response: response_schema, input: input_schema)
29
35
  filename = file_writer.output_path_for(inspector.model_name)
30
36
 
31
- file_writer.write(filename: filename, content: content)
32
- filename
37
+ { filename: filename, content: content }
33
38
  end
34
39
 
35
40
  def generate_all(model_classes)
36
- model_classes.map { |klass| generate(klass) }
41
+ files = model_classes.map { |klass| generate(klass) }
42
+ run_post_generate_command
43
+ files
44
+ end
45
+
46
+ def check(model_classes)
47
+ model_classes.each_with_object([]) do |klass, drift|
48
+ target = generate_content(klass)
49
+ full_path = File.join(output_dir, target[:filename])
50
+ expected = file_writer.preview(filename: target[:filename], content: target[:content])
51
+
52
+ if !File.exist?(full_path)
53
+ drift << { filename: target[:filename], status: :missing }
54
+ elsif File.read(full_path) != expected
55
+ drift << { filename: target[:filename], status: :drifted }
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def run_post_generate_command
63
+ cmd = ZodRails.configuration.post_generate_command
64
+ return if cmd.nil? || cmd.to_s.strip.empty?
65
+
66
+ ok = system(cmd)
67
+ return if ok
68
+
69
+ exit_status = Process.last_status&.exitstatus
70
+ raise ZodRails::Error,
71
+ "post_generate_command failed (exit #{exit_status}): #{cmd}"
37
72
  end
38
73
  end
39
74
  end
@@ -4,8 +4,9 @@ module ZodRails
4
4
  module Mapping
5
5
  class EnumMapper
6
6
  def self.call(values, nullable: false, input_schema: false, has_default: false)
7
- keys = values.keys.map { |k| "\"#{escape_quotes(k)}\"" }
8
- base = "z.enum([#{keys.join(", ")}])"
7
+ names = values.is_a?(Hash) ? values.keys : values
8
+ quoted = names.map { |k| "\"#{escape_quotes(k)}\"" }
9
+ base = "z.enum([#{quoted.join(", ")}])"
9
10
 
10
11
  suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
11
12
  "#{base}#{suffix}"
@@ -21,7 +21,7 @@ module ZodRails
21
21
  when :length then map_length(validation, base_type)
22
22
  when :numericality then map_numericality(validation, base_type)
23
23
  when :format then map_format(validation, base_type)
24
- when :inclusion then map_inclusion(validation)
24
+ when :inclusion then map_inclusion(validation, base_type)
25
25
  else ""
26
26
  end
27
27
  end
@@ -72,14 +72,48 @@ module ZodRails
72
72
  return "" unless regex
73
73
 
74
74
  js_pattern = convert_ruby_regex_to_js(regex)
75
- ".regex(/#{js_pattern}/)"
75
+ ".regex(/#{js_pattern}/#{regex_flags_for(regex)})"
76
76
  end
77
77
 
78
- def self.map_inclusion(validation)
78
+ def self.map_inclusion(validation, base_type)
79
79
  values = validation.options[:in] || validation.options[:within]
80
80
  return "" unless values.is_a?(Array)
81
81
 
82
- ""
82
+ build_array_inclusion_suffix(values, base_type) || ""
83
+ end
84
+
85
+ def self.build_array_inclusion_suffix(values, base_type)
86
+ return nil if values.empty?
87
+
88
+ if string_array_for_string_type?(values, base_type)
89
+ build_string_enum_suffix(values)
90
+ elsif numeric_array_for_numeric_type?(values, base_type)
91
+ build_numeric_literal_suffix(values)
92
+ end
93
+ end
94
+
95
+ def self.string_array_for_string_type?(values, base_type)
96
+ STRING_ZOD_TYPES.include?(base_type) && values.all? { |v| v.is_a?(String) }
97
+ end
98
+
99
+ def self.numeric_array_for_numeric_type?(values, base_type)
100
+ NUMERIC_ZOD_TYPES.include?(base_type) && values.all? { |v| v.is_a?(Numeric) }
101
+ end
102
+
103
+ def self.build_string_enum_suffix(values)
104
+ quoted = values.map { |v| %("#{escape_quotes(v)}") }.join(", ")
105
+ ".pipe(z.enum([#{quoted}]))"
106
+ end
107
+
108
+ def self.build_numeric_literal_suffix(values)
109
+ return ".pipe(z.literal(#{values.first}))" if values.length == 1
110
+
111
+ literals = values.map { |v| "z.literal(#{v})" }.join(", ")
112
+ ".pipe(z.union([#{literals}]))"
113
+ end
114
+
115
+ def self.escape_quotes(str)
116
+ str.to_s.gsub('"', '\\"')
83
117
  end
84
118
 
85
119
  def self.convert_ruby_regex_to_js(regex)
@@ -88,6 +122,12 @@ module ZodRails
88
122
  pattern.gsub(/\\z/i, "$")
89
123
  end
90
124
 
125
+ def self.regex_flags_for(regex)
126
+ flags = +""
127
+ flags << "i" if regex.options.anybits?(Regexp::IGNORECASE)
128
+ flags
129
+ end
130
+
91
131
  def self.collect_constraints(validation, base_type, constraints)
92
132
  return if validation.conditional?
93
133
 
@@ -96,7 +136,7 @@ module ZodRails
96
136
  when :length then handle_length_constraint(validation, base_type, constraints)
97
137
  when :numericality then handle_numericality_constraint(validation, base_type, constraints)
98
138
  when :format then handle_format_constraint(validation, base_type, constraints)
99
- when :inclusion then handle_inclusion_constraint(validation, constraints)
139
+ when :inclusion then handle_inclusion_constraint(validation, base_type, constraints)
100
140
  end
101
141
  end
102
142
 
@@ -120,7 +160,7 @@ module ZodRails
120
160
  return unless regex
121
161
 
122
162
  js_pattern = convert_ruby_regex_to_js(regex)
123
- constraints[:others] << ".regex(/#{js_pattern}/)"
163
+ constraints[:others] << ".regex(/#{js_pattern}/#{regex_flags_for(regex)})"
124
164
  end
125
165
 
126
166
  def self.build_chain(constraints)
@@ -137,19 +177,27 @@ module ZodRails
137
177
  parts.join
138
178
  end
139
179
 
140
- def self.handle_inclusion_constraint(validation, constraints)
180
+ def self.handle_inclusion_constraint(validation, base_type, constraints)
141
181
  values = validation.options[:in] || validation.options[:within]
142
- return unless values
143
182
 
144
183
  case values
145
- when Range
146
- if values.begin.is_a?(Numeric) && values.end.is_a?(Numeric)
147
- constraints[:min] = [constraints[:min] || 0, values.begin].max
148
- constraints[:max] = [constraints[:max] || Float::INFINITY, values.end].min
149
- end
184
+ when Range then apply_range_inclusion(values, constraints)
185
+ when Array then apply_array_inclusion(values, base_type, constraints)
150
186
  end
151
187
  end
152
188
 
189
+ def self.apply_range_inclusion(range, constraints)
190
+ return unless range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
191
+
192
+ constraints[:min] = [constraints[:min] || 0, range.begin].max
193
+ constraints[:max] = [constraints[:max] || Float::INFINITY, range.end].min
194
+ end
195
+
196
+ def self.apply_array_inclusion(values, base_type, constraints)
197
+ suffix = build_array_inclusion_suffix(values, base_type)
198
+ constraints[:others] << suffix if suffix
199
+ end
200
+
153
201
  def self.handle_numericality_constraint(validation, base_type, constraints)
154
202
  return unless NUMERIC_ZOD_TYPES.include?(base_type)
155
203
 
@@ -170,9 +218,13 @@ module ZodRails
170
218
  end
171
219
 
172
220
  private_class_method :map_presence, :map_length, :map_numericality, :map_format, :map_inclusion,
173
- :convert_ruby_regex_to_js, :collect_constraints, :build_chain,
221
+ :build_array_inclusion_suffix, :string_array_for_string_type?,
222
+ :numeric_array_for_numeric_type?, :build_string_enum_suffix,
223
+ :build_numeric_literal_suffix, :escape_quotes,
224
+ :convert_ruby_regex_to_js, :regex_flags_for, :collect_constraints, :build_chain,
174
225
  :handle_presence_constraint, :handle_length_constraint, :handle_format_constraint,
175
- :handle_inclusion_constraint, :handle_numericality_constraint, :apply_numeric_range
226
+ :handle_inclusion_constraint, :apply_range_inclusion, :apply_array_inclusion,
227
+ :handle_numericality_constraint, :apply_numeric_range
176
228
  end
177
229
  end
178
230
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZodRails
4
+ module ModelResolver
5
+ def self.resolve(names)
6
+ resolved = []
7
+ missing = []
8
+
9
+ names.each do |name|
10
+ resolved << Object.const_get(name)
11
+ rescue NameError
12
+ missing << name
13
+ end
14
+
15
+ { resolved: resolved, missing: missing }
16
+ end
17
+ end
18
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZodRails
4
- VERSION = "0.1.6"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/zod_rails.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "zod_rails/introspection/model_inspector"
12
12
  require_relative "zod_rails/generation/schema_builder"
13
13
  require_relative "zod_rails/generation/typescript_emitter"
14
14
  require_relative "zod_rails/generation/file_writer"
15
+ require_relative "zod_rails/model_resolver"
15
16
  require_relative "zod_rails/generator"
16
17
  require_relative "zod_rails/railtie" if defined?(Rails::Railtie)
17
18
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zod_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kelly
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-17 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: logger
@@ -50,6 +50,7 @@ files:
50
50
  - lib/zod_rails/mapping/enum_mapper.rb
51
51
  - lib/zod_rails/mapping/type_mapper.rb
52
52
  - lib/zod_rails/mapping/validation_mapper.rb
53
+ - lib/zod_rails/model_resolver.rb
53
54
  - lib/zod_rails/railtie.rb
54
55
  - lib/zod_rails/version.rb
55
56
  - sig/zod_rails.rbs