zod_rails 0.1.6 → 0.3.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: f8e1e956f2954b2200c39a4b467529911806337883bd2674b31b7a271ef560a8
4
+ data.tar.gz: 681139d70905f6c34ee190f629f437e015a2eb1313a76940d42b0eaf6a8ffabe
5
5
  SHA512:
6
- metadata.gz: 16e639e56112401f8ffbab59263642fd0c244f5a08be5ed17b8d68eb185e7f6294d849596378eef24123f65ef4b2a79bb2e9dd71e1970154ad616288b334b8b3
7
- data.tar.gz: 11adc59ed2fbab1da351ab6981ce9de6ff3830c8491e40ebe8ccc31eb5d0f9fd93ef148da41a803d8767f0be6d4f72412c640c755c4793ec4ee161a10b6fcbb5
6
+ metadata.gz: 30a4ce819ee99ac5bcdd83e482c16cd2029136af74e6e64c04a03960c8640991cef144eab6562eb431a0205c655c857687e78f023ffe66aaa9d8c53a953a7b1e
7
+ data.tar.gz: 7744055379a755592beadb1010992deeba5ef0fac4d0ea5719bd67d98191264d9671d0a4ee6806e2c62a7e97e0a69997d0bd8f80eca7ed6002c52d59ae7c236c
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to ZodRails are documented here.
4
+
5
+ ## Unreleased
6
+
7
+ ## 0.3.0 - 2026-08-03
8
+
9
+ ### Upgrade Notes
10
+
11
+ - Regenerate committed schemas after upgrading. Presence, range, regular-expression, and datetime output has changed
12
+ to more closely match Rails behavior.
13
+ - `schema_suffix`, `input_schema_suffix`, and `generate_input_schemas` now take effect. Applications that configured
14
+ these previously ignored options will see the configured output for the first time.
15
+ - Configured model names must resolve to concrete ActiveRecord models; nonmodels and abstract models now produce a
16
+ targeted configuration error.
17
+
18
+ ### Fixed
19
+
20
+ - Honor custom schema suffixes and `generate_input_schemas`.
21
+ - Generate safe TypeScript for unusual column names, enum values, and regular expressions.
22
+ - Preserve negative, zero, exclusive, beginless, and endless numeric range bounds.
23
+ - Match Rails presence semantics for whitespace-only strings and required nullable columns.
24
+ - Accept ISO datetimes with timezone offsets and detect database expression defaults.
25
+ - Keep preserved custom blocks from causing false drift reports.
26
+ - Detect output filename collisions before writing files.
27
+
28
+ ### Changed
29
+
30
+ - Declare ActiveRecord and Railties as runtime dependencies.
31
+ - Reject configured constants that are not concrete ActiveRecord models.
32
+ - Skip dynamic numericality constraints and Ruby extended-mode regular expressions instead of emitting invalid TypeScript.
33
+
34
+ ## 0.2.0
35
+
36
+ - Added response and input schema generation, enums, validation mapping, namespaced models, drift detection,
37
+ custom block preservation, dry runs, and formatter integration.
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
 
@@ -117,7 +118,7 @@ end
117
118
  | `decimal` | `z.string()` (preserves `BigDecimal` precision) |
118
119
  | `boolean` | `z.boolean()` |
119
120
  | `date` | `z.iso.date()` |
120
- | `datetime`, `timestamp` | `z.iso.datetime()` |
121
+ | `datetime`, `timestamp` | `z.iso.datetime({ offset: true })` |
121
122
  | `json`, `jsonb` | `z.json()` |
122
123
  | `uuid` | `z.uuid()` |
123
124
  | `time` | `z.string()` |
@@ -130,7 +131,7 @@ ZodRails introspects your model validations and maps them to Zod constraints:
130
131
 
131
132
  | Rails Validation | Zod Constraint |
132
133
  |------------------|----------------|
133
- | `presence: true` | `.min(1)` for string/text columns |
134
+ | `presence: true` | `.min(1).refine(...)` for string/text columns, including whitespace-only rejection |
134
135
  | `length: { minimum: n }` | `.min(n)` |
135
136
  | `length: { maximum: n }` | `.max(n)` |
136
137
  | `length: { is: n }` | `.length(n)` |
@@ -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(new RegExp(...))` with safe escaping and compatible flags |
143
+ | `inclusion: { in: n..m }` (Range) | `.gte(n).lte(m)`; exclusive and open-ended bounds are preserved |
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 restricts the result with `z.enum(["pending", "approved"])`. Other compatible validators on the attribute are retained before the enum restriction.
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
 
@@ -162,20 +175,22 @@ import { z } from "zod";
162
175
 
163
176
  export const UserSchema = z.object({
164
177
  id: z.int(),
165
- email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
166
- name: z.string().min(2).max(100),
178
+ email: z.string().min(1).refine((value) => value.trim().length > 0).regex(new RegExp("^[^@\\s]+@[^@\\s]+$")),
179
+ name: z.string().min(2).max(100).refine((value) => value.trim().length > 0),
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
- created_at: z.iso.datetime(),
170
- updated_at: z.iso.datetime()
183
+ created_at: z.iso.datetime({ offset: true }),
184
+ updated_at: z.iso.datetime({ offset: true })
171
185
  });
172
186
 
173
187
  export type User = z.infer<typeof UserSchema>;
174
188
 
175
189
  export const UserInputSchema = z.object({
176
- email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
177
- name: z.string().min(2).max(100),
190
+ email: z.string().min(1).refine((value) => value.trim().length > 0).regex(new RegExp("^[^@\\s]+@[^@\\s]+$")),
191
+ name: z.string().min(2).max(100).refine((value) => value.trim().length > 0),
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,62 @@ 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
+
269
+ Because `zod_rails:check` compares generated bytes, a formatter that rewrites those bytes can report drift. Prefer a formatter configuration that accepts the generated style, or run the same deterministic formatter before checking committed files.
270
+
200
271
  ## Integrating with Forms
201
272
 
202
273
  ZodRails pairs well with form libraries that support Zod:
@@ -235,10 +306,17 @@ async function fetchUser(id: number): Promise<User> {
235
306
 
236
307
  ## CI/CD Integration
237
308
 
238
- Add schema generation to your build process to catch type mismatches early:
309
+ Use `zod_rails:check` to catch missed regenerations:
239
310
 
240
311
  ```yaml
241
312
  # .github/workflows/ci.yml
313
+ - name: Check Zod schemas are up to date
314
+ run: bin/rails zod_rails:check
315
+ ```
316
+
317
+ 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:
318
+
319
+ ```yaml
242
320
  - name: Generate Zod schemas
243
321
  run: bin/rails zod_rails:generate
244
322
 
@@ -264,6 +342,28 @@ Ensure validations are defined on the model class, not in concerns that might no
264
342
 
265
343
  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
344
 
345
+ ### Serialization and validation limits
346
+
347
+ ZodRails generates a useful static approximation of database columns and unconditional model validations. It cannot reproduce validations that require database access, another attribute, or runtime model state. Conditional and context-specific validations, dynamic numericality values, uniqueness, and unsupported Ruby regular-expression modes are skipped.
348
+
349
+ The generated wire types must match your serializers. In particular, `bigint` and `decimal` map to strings to avoid JavaScript precision loss; configure your API serializer to emit strings for those attributes. Review generated schemas when using adapter-specific or custom ActiveRecord types.
350
+
351
+ ### Misconfigured model names
352
+
353
+ 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:
354
+
355
+ ```
356
+ ZodRails: 2 model(s) in config.models could not be loaded:
357
+ - Useer
358
+ - Postt
359
+
360
+ Check the model names in config/initializers/zod_rails.rb.
361
+ ```
362
+
363
+ ### Namespaced models
364
+
365
+ 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).
366
+
267
367
  ## Releasing
268
368
 
269
369
  1. Bump the version in `lib/zod_rails/version.rb`
@@ -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,14 @@ 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:)
27
+ full_path = File.join(output_dir, filename)
28
+ File.exist?(full_path) ? splice_custom_blocks(content, File.read(full_path)) : content
18
29
  end
19
30
 
20
31
  def output_path_for(model_name)
@@ -27,6 +38,24 @@ module ZodRails
27
38
 
28
39
  private
29
40
 
41
+ def splice_custom_blocks(new_content, existing)
42
+ imports = existing[IMPORTS_BLOCK_RE]
43
+ tail = existing[TAIL_BLOCK_RE]
44
+
45
+ result = new_content
46
+ result = insert_imports_block(result, imports) if imports
47
+ result = append_tail_block(result, tail) if tail
48
+ result
49
+ end
50
+
51
+ def insert_imports_block(content, imports_block)
52
+ content.sub(ZOD_IMPORT_RE, "\\1\n#{imports_block}")
53
+ end
54
+
55
+ def append_tail_block(content, tail_block)
56
+ "#{content.rstrip}\n\n#{tail_block}"
57
+ end
58
+
30
59
  def underscore(str)
31
60
  str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
32
61
  .gsub(/([a-z\d])([A-Z])/, '\1_\2')
@@ -3,11 +3,17 @@
3
3
  module ZodRails
4
4
  module Generation
5
5
  class SchemaBuilder
6
- attr_reader :inspector, :excluded_columns
6
+ STRING_TYPES = %i[string text].freeze
7
+ NULLABILITY_SUFFIX_RE = /(\.(?:nullable|nullish|optional)\(\))\z/
8
+ TYPESCRIPT_IDENTIFIER_RE = /\A[$A-Z_a-z][$\w]*\z/
7
9
 
8
- def initialize(inspector, excluded_columns: [])
10
+ attr_reader :inspector, :excluded_columns, :schema_suffix, :input_schema_suffix
11
+
12
+ def initialize(inspector, excluded_columns: [], schema_suffix: "Schema", input_schema_suffix: "InputSchema")
9
13
  @inspector = inspector
10
14
  @excluded_columns = excluded_columns.map(&:to_s)
15
+ @schema_suffix = schema_suffix
16
+ @input_schema_suffix = input_schema_suffix
11
17
  end
12
18
 
13
19
  def build(input_schema: false)
@@ -20,40 +26,77 @@ module ZodRails
20
26
  end
21
27
 
22
28
  def schema_name(input_schema: false)
23
- suffix = input_schema ? "InputSchema" : "Schema"
24
- "#{inspector.model_name}#{suffix}"
29
+ suffix = input_schema ? input_schema_suffix : schema_suffix
30
+ name = "#{inspector.model_name.gsub("::", "")}#{suffix}"
31
+ return name if name.match?(TYPESCRIPT_IDENTIFIER_RE)
32
+
33
+ raise ZodRails::Error, "Invalid TypeScript schema name: #{name.inspect}"
34
+ end
35
+
36
+ def type_name(input_schema: false)
37
+ name = inspector.model_name.gsub("::", "")
38
+ input_schema ? "#{name}Input" : name
25
39
  end
26
40
 
27
41
  private
28
42
 
29
43
  def field_definition(column, input_schema:)
30
44
  type_str = build_type_string(column, input_schema: input_schema)
31
- "#{column.name}: #{type_str}"
45
+ key = column.name.match?(TYPESCRIPT_IDENTIFIER_RE) ? column.name : JSON.generate(column.name)
46
+ "#{key}: #{type_str}"
32
47
  end
33
48
 
34
49
  def build_type_string(column, input_schema:)
50
+ validations = inspector.validations_for(column.name)
51
+
35
52
  if enum_column?(column.name)
36
- build_enum_type(column, input_schema: input_schema)
53
+ build_enum_type(column, validations, input_schema: input_schema)
54
+ elsif (inclusion = string_array_inclusion(column, validations))
55
+ build_inclusion_enum_type(column, inclusion, validations, input_schema: input_schema)
37
56
  else
38
- build_regular_type(column, input_schema: input_schema)
57
+ build_regular_type(column, validations, input_schema: input_schema)
39
58
  end
40
59
  end
41
60
 
42
- def build_enum_type(column, input_schema:)
61
+ def string_array_inclusion(column, validations)
62
+ return nil unless STRING_TYPES.include?(column.type)
63
+
64
+ validations.find { |validation| string_array_inclusion?(validation) }
65
+ end
66
+
67
+ def string_array_inclusion?(validation)
68
+ return false unless validation.kind == :inclusion && !validation.conditional?
69
+
70
+ values = validation.options[:in]
71
+ values.is_a?(Array) && !values.empty? && values.all? { |x| x.is_a?(String) }
72
+ end
73
+
74
+ def build_inclusion_enum_type(column, inclusion, validations, input_schema:)
75
+ remaining = validations.reject { |validation| validation.equal?(inclusion) }
76
+ Mapping::EnumMapper.call(
77
+ inclusion.options[:in],
78
+ validation_chain: Mapping::ValidationMapper.call_all(remaining, base_type: :string),
79
+ nullable: nullable?(column, validations),
80
+ input_schema: input_schema,
81
+ has_default: column.has_default
82
+ )
83
+ end
84
+
85
+ def build_enum_type(column, validations, input_schema:)
43
86
  values = inspector.enums[column.name]
44
87
  Mapping::EnumMapper.call(
45
88
  values,
46
- nullable: column.nullable,
89
+ validation_chain: Mapping::ValidationMapper.call_all(validations, base_type: :string),
90
+ nullable: nullable?(column, validations),
47
91
  input_schema: input_schema,
48
92
  has_default: column.has_default
49
93
  )
50
94
  end
51
95
 
52
- def build_regular_type(column, input_schema:)
53
- validations = inspector.validations_for(column.name)
96
+ def build_regular_type(column, validations, input_schema:)
54
97
  base_type = Mapping::TypeMapper.call(
55
98
  column.type,
56
- nullable: column.nullable,
99
+ nullable: nullable?(column, validations),
57
100
  input_schema: input_schema,
58
101
  has_default: column.has_default
59
102
  )
@@ -65,13 +108,8 @@ module ZodRails
65
108
  def insert_validation_chain(base_type, validation_chain)
66
109
  return base_type if validation_chain.empty?
67
110
 
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
111
+ if (match = base_type.match(NULLABILITY_SUFFIX_RE))
112
+ base_type.sub(NULLABILITY_SUFFIX_RE, "#{validation_chain}#{match[0]}")
75
113
  else
76
114
  "#{base_type}#{validation_chain}"
77
115
  end
@@ -81,6 +119,13 @@ module ZodRails
81
119
  inspector.enums.key?(column_name)
82
120
  end
83
121
 
122
+ def nullable?(column, validations)
123
+ column.nullable && validations.none? do |validation|
124
+ validation.kind == :presence && !validation.conditional? &&
125
+ !validation.options[:allow_nil] && !validation.options[:allow_blank]
126
+ end
127
+ end
128
+
84
129
  def filtered_columns
85
130
  inspector.columns.reject { |col| excluded_columns.include?(col.name) }
86
131
  end
@@ -3,8 +3,8 @@
3
3
  module ZodRails
4
4
  module Generation
5
5
  class TypescriptEmitter
6
- def emit(schema_name:, schema_body:)
7
- type_name = derive_type_name(schema_name)
6
+ def emit(schema_name:, schema_body:, type_name: nil)
7
+ type_name ||= derive_type_name(schema_name)
8
8
 
9
9
  <<~TYPESCRIPT
10
10
  import { z } from "zod";
@@ -16,8 +16,8 @@ module ZodRails
16
16
  end
17
17
 
18
18
  def emit_combined(response:, input:)
19
- response_type = derive_type_name(response[:name])
20
- input_type = derive_type_name(input[:name])
19
+ response_type = response[:type_name] || derive_type_name(response[:name])
20
+ input_type = input[:type_name] || derive_type_name(input[:name])
21
21
 
22
22
  <<~TYPESCRIPT
23
23
  import { z } from "zod";
@@ -35,7 +35,7 @@ module ZodRails
35
35
  private
36
36
 
37
37
  def derive_type_name(schema_name)
38
- schema_name.sub(/Schema$/, "").sub(/InputSchema$/, "Input")
38
+ schema_name.sub(/InputSchema$/, "Input").sub(/Schema$/, "")
39
39
  end
40
40
  end
41
41
  end
@@ -11,29 +11,88 @@ 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
- excluded = ZodRails.configuration.excluded_columns
16
- builder = Generation::SchemaBuilder.new(inspector, excluded_columns: excluded)
21
+ config = ZodRails.configuration
22
+ builder = Generation::SchemaBuilder.new(
23
+ inspector,
24
+ excluded_columns: config.excluded_columns,
25
+ schema_suffix: config.schema_suffix,
26
+ input_schema_suffix: config.input_schema_suffix
27
+ )
17
28
 
18
29
  response_schema = {
19
30
  name: builder.schema_name,
31
+ type_name: builder.type_name,
20
32
  body: builder.build
21
33
  }
34
+ content = emit_content(builder, response_schema, generate_input: config.generate_input_schemas)
35
+ { filename: file_writer.output_path_for(inspector.model_name), content: content }
36
+ end
37
+
38
+ def generate_all(model_classes)
39
+ targets = model_classes.map { |klass| generate_content(klass) }
40
+ duplicate = targets.group_by { |target| target[:filename] }.find { |_filename, matches| matches.size > 1 }
41
+ raise ZodRails::Error, "Output filename collision: #{duplicate.first}" if duplicate
42
+
43
+ files = targets.map do |target|
44
+ file_writer.write(filename: target[:filename], content: target[:content])
45
+ target[:filename]
46
+ end
47
+ run_post_generate_command
48
+ files
49
+ end
50
+
51
+ def check(model_classes)
52
+ model_classes.each_with_object([]) do |klass, drift|
53
+ target = generate_content(klass)
54
+ full_path = File.join(output_dir, target[:filename])
55
+ expected = file_writer.preview(filename: target[:filename], content: target[:content])
22
56
 
23
- input_schema = {
57
+ if !File.exist?(full_path)
58
+ drift << { filename: target[:filename], status: :missing }
59
+ elsif File.read(full_path) != expected
60
+ drift << { filename: target[:filename], status: :drifted }
61
+ end
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def emit_content(builder, response, generate_input:)
68
+ unless generate_input
69
+ return emitter.emit(
70
+ schema_name: response[:name], schema_body: response[:body], type_name: response[:type_name]
71
+ )
72
+ end
73
+
74
+ input = {
24
75
  name: builder.schema_name(input_schema: true),
76
+ type_name: builder.type_name(input_schema: true),
25
77
  body: builder.build(input_schema: true)
26
78
  }
79
+ if input[:name] == response[:name]
80
+ raise ZodRails::Error, "Response and input schemas have the same export name: #{input[:name]}"
81
+ end
27
82
 
28
- content = emitter.emit_combined(response: response_schema, input: input_schema)
29
- filename = file_writer.output_path_for(inspector.model_name)
30
-
31
- file_writer.write(filename: filename, content: content)
32
- filename
83
+ emitter.emit_combined(response: response, input: input)
33
84
  end
34
85
 
35
- def generate_all(model_classes)
36
- model_classes.map { |klass| generate(klass) }
86
+ def run_post_generate_command
87
+ cmd = ZodRails.configuration.post_generate_command
88
+ return if cmd.nil? || cmd.to_s.strip.empty?
89
+
90
+ ok = system(cmd)
91
+ return if ok
92
+
93
+ exit_status = Process.last_status&.exitstatus
94
+ raise ZodRails::Error,
95
+ "post_generate_command failed (exit #{exit_status}): #{cmd}"
37
96
  end
38
97
  end
39
98
  end
@@ -18,7 +18,8 @@ module ZodRails
18
18
  name: column.name,
19
19
  type: column.type,
20
20
  nullable: column.null,
21
- has_default: !column.default.nil?
21
+ has_default: !column.default.nil? ||
22
+ (column.respond_to?(:default_function) && !column.default_function.nil?)
22
23
  )
23
24
  end
24
25
 
@@ -3,18 +3,16 @@
3
3
  module ZodRails
4
4
  module Mapping
5
5
  class EnumMapper
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(", ")}])"
6
+ def self.call(values, nullable: false, input_schema: false, has_default: false, validation_chain: "")
7
+ names = values.is_a?(Hash) ? values.keys : values
8
+ quoted = names.map { |name| JSON.generate(name.to_s) }
9
+ enum = "z.enum([#{quoted.join(", ")}])"
10
+ base = validation_chain.empty? ? enum : "z.string()#{validation_chain}.pipe(#{enum})"
9
11
 
10
12
  suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
11
13
  "#{base}#{suffix}"
12
14
  end
13
15
 
14
- def self.escape_quotes(str)
15
- str.to_s.gsub('"', '\\"')
16
- end
17
-
18
16
  def self.determine_suffix(nullable:, input_schema:, has_default:)
19
17
  return "" unless nullable || has_default
20
18
 
@@ -25,7 +23,7 @@ module ZodRails
25
23
  end
26
24
  end
27
25
 
28
- private_class_method :escape_quotes, :determine_suffix
26
+ private_class_method :determine_suffix
29
27
  end
30
28
  end
31
29
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZodRails
4
+ module Mapping
5
+ class RegexpMapper
6
+ UNSUPPORTED_TOKENS = %w[\\K \\R (?> (?~].freeze
7
+
8
+ def self.call(regex)
9
+ if unsupported?(regex)
10
+ ZodRails.logger.warn("ZodRails: Skipping regexp with Ruby-only semantics: #{regex.inspect}")
11
+ return nil
12
+ end
13
+
14
+ pattern = regex.source
15
+ .gsub("\\A", "^")
16
+ .gsub("\\z", "(?![\\s\\S])")
17
+ .gsub("\\Z", "(?=(?:\\n)?(?![\\s\\S]))")
18
+ flags = flags_for(regex)
19
+ args = [JSON.generate(pattern), (JSON.generate(flags) unless flags.empty?)].compact
20
+ "new RegExp(#{args.join(", ")})"
21
+ end
22
+
23
+ def self.flags_for(regex)
24
+ flags = +""
25
+ flags << "i" if regex.options.anybits?(Regexp::IGNORECASE)
26
+ flags << "s" if regex.options.anybits?(Regexp::MULTILINE)
27
+ flags
28
+ end
29
+
30
+ def self.unsupported?(regex)
31
+ regex.options.anybits?(Regexp::EXTENDED) || UNSUPPORTED_TOKENS.any? { |token| regex.source.include?(token) }
32
+ end
33
+ private_class_method :flags_for, :unsupported?
34
+ end
35
+ end
36
+ end
@@ -12,7 +12,8 @@ module ZodRails
12
12
  decimal: "z.string()",
13
13
  boolean: "z.boolean()",
14
14
  date: "z.iso.date()",
15
- datetime: "z.iso.datetime()",
15
+ datetime: "z.iso.datetime({ offset: true })",
16
+ timestamp: "z.iso.datetime({ offset: true })",
16
17
  time: "z.string()",
17
18
  json: "z.json()",
18
19
  jsonb: "z.json()",
@@ -14,14 +14,14 @@ module ZodRails
14
14
  STRING_ZOD_TYPES = %i[string text].freeze
15
15
 
16
16
  def self.call(validation, base_type:)
17
- return "" if validation.conditional?
17
+ return "" if validation.conditional? || no_op_presence?(validation)
18
18
 
19
19
  case validation.kind
20
20
  when :presence then map_presence(validation, base_type)
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
@@ -37,7 +37,7 @@ module ZodRails
37
37
  end
38
38
 
39
39
  def self.map_presence(_validation, base_type)
40
- STRING_ZOD_TYPES.include?(base_type) ? ".min(1)" : ""
40
+ STRING_ZOD_TYPES.include?(base_type) ? ".min(1)#{presence_suffix}" : ""
41
41
  end
42
42
 
43
43
  def self.map_length(validation, base_type)
@@ -61,7 +61,7 @@ module ZodRails
61
61
 
62
62
  validation.options.filter_map do |key, value|
63
63
  method = NUMERICALITY_MAP[key]
64
- ".#{method}(#{value})" if method
64
+ ".#{method}(#{value})" if method && static_number?(value)
65
65
  end.join
66
66
  end
67
67
 
@@ -71,37 +71,64 @@ module ZodRails
71
71
  regex = validation.options[:with]
72
72
  return "" unless regex
73
73
 
74
- js_pattern = convert_ruby_regex_to_js(regex)
75
- ".regex(/#{js_pattern}/)"
74
+ RegexpMapper.call(regex)&.then { |expression| ".regex(#{expression})" } || ""
76
75
  end
77
76
 
78
- def self.map_inclusion(validation)
77
+ def self.map_inclusion(validation, base_type)
79
78
  values = validation.options[:in] || validation.options[:within]
80
79
  return "" unless values.is_a?(Array)
81
80
 
82
- ""
81
+ build_array_inclusion_suffix(values, base_type) || ""
83
82
  end
84
83
 
85
- def self.convert_ruby_regex_to_js(regex)
86
- pattern = regex.source
87
- pattern = pattern.gsub("\\A", "^")
88
- pattern.gsub(/\\z/i, "$")
84
+ def self.build_array_inclusion_suffix(values, base_type)
85
+ return nil if values.empty?
86
+
87
+ if string_array_for_string_type?(values, base_type)
88
+ build_string_enum_suffix(values)
89
+ elsif numeric_array_for_numeric_type?(values, base_type)
90
+ build_numeric_literal_suffix(values)
91
+ end
92
+ end
93
+
94
+ def self.string_array_for_string_type?(values, base_type)
95
+ STRING_ZOD_TYPES.include?(base_type) && values.all? { |v| v.is_a?(String) }
96
+ end
97
+
98
+ def self.numeric_array_for_numeric_type?(values, base_type)
99
+ NUMERIC_ZOD_TYPES.include?(base_type) && values.all? { |v| v.is_a?(Numeric) }
100
+ end
101
+
102
+ def self.build_string_enum_suffix(values)
103
+ quoted = values.map { |value| JSON.generate(value) }.join(", ")
104
+ ".pipe(z.enum([#{quoted}]))"
105
+ end
106
+
107
+ def self.build_numeric_literal_suffix(values)
108
+ return nil unless values.all? { |value| static_number?(value) }
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}]))"
89
113
  end
90
114
 
91
115
  def self.collect_constraints(validation, base_type, constraints)
92
- return if validation.conditional?
116
+ return if validation.conditional? || no_op_presence?(validation)
93
117
 
94
118
  case validation.kind
95
119
  when :presence then handle_presence_constraint(base_type, constraints)
96
120
  when :length then handle_length_constraint(validation, base_type, constraints)
97
121
  when :numericality then handle_numericality_constraint(validation, base_type, constraints)
98
122
  when :format then handle_format_constraint(validation, base_type, constraints)
99
- when :inclusion then handle_inclusion_constraint(validation, constraints)
123
+ when :inclusion then handle_inclusion_constraint(validation, base_type, constraints)
100
124
  end
101
125
  end
102
126
 
103
127
  def self.handle_presence_constraint(base_type, constraints)
104
- constraints[:min] = [constraints[:min] || 0, 1].max if STRING_ZOD_TYPES.include?(base_type)
128
+ return unless STRING_ZOD_TYPES.include?(base_type)
129
+
130
+ constraints[:min] = [constraints[:min] || 0, 1].max
131
+ constraints[:others] << presence_suffix
105
132
  end
106
133
 
107
134
  def self.handle_length_constraint(validation, base_type, constraints)
@@ -119,8 +146,8 @@ module ZodRails
119
146
  regex = validation.options[:with]
120
147
  return unless regex
121
148
 
122
- js_pattern = convert_ruby_regex_to_js(regex)
123
- constraints[:others] << ".regex(/#{js_pattern}/)"
149
+ expression = RegexpMapper.call(regex)
150
+ constraints[:others] << ".regex(#{expression})" if expression
124
151
  end
125
152
 
126
153
  def self.build_chain(constraints)
@@ -129,7 +156,7 @@ module ZodRails
129
156
  if constraints[:length]
130
157
  parts << ".length(#{constraints[:length]})"
131
158
  else
132
- parts << ".min(#{constraints[:min]})" if constraints[:min]&.positive?
159
+ parts << ".min(#{constraints[:min]})" unless constraints[:min].nil?
133
160
  parts << ".max(#{constraints[:max].to_i})" if constraints[:max] && constraints[:max] != Float::INFINITY
134
161
  end
135
162
 
@@ -137,42 +164,67 @@ module ZodRails
137
164
  parts.join
138
165
  end
139
166
 
140
- def self.handle_inclusion_constraint(validation, constraints)
167
+ def self.handle_inclusion_constraint(validation, base_type, constraints)
141
168
  values = validation.options[:in] || validation.options[:within]
142
- return unless values
143
169
 
144
170
  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
171
+ when Range then apply_range_inclusion(values, base_type, constraints)
172
+ when Array then apply_array_inclusion(values, base_type, constraints)
150
173
  end
151
174
  end
152
175
 
176
+ def self.apply_range_inclusion(range, base_type, constraints)
177
+ return unless NUMERIC_ZOD_TYPES.include?(base_type)
178
+
179
+ apply_numeric_range(range, constraints)
180
+ end
181
+
182
+ def self.apply_array_inclusion(values, base_type, constraints)
183
+ suffix = build_array_inclusion_suffix(values, base_type)
184
+ constraints[:others] << suffix if suffix
185
+ end
186
+
153
187
  def self.handle_numericality_constraint(validation, base_type, constraints)
154
188
  return unless NUMERIC_ZOD_TYPES.include?(base_type)
155
189
 
156
190
  validation.options.each do |key, value|
157
191
  if key == :in
158
192
  apply_numeric_range(value, constraints)
159
- elsif (method = NUMERICALITY_MAP[key])
193
+ elsif (method = NUMERICALITY_MAP[key]) && static_number?(value)
160
194
  constraints[:others] << ".#{method}(#{value})"
161
195
  end
162
196
  end
163
197
  end
164
198
 
165
199
  def self.apply_numeric_range(range, constraints)
166
- return unless range.is_a?(Range) && range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
200
+ return unless range.is_a?(Range)
201
+
202
+ constraints[:others] << ".gte(#{range.begin})" if static_number?(range.begin)
203
+ return unless static_number?(range.end)
204
+
205
+ constraints[:others] << ".#{range.exclude_end? ? "lt" : "lte"}(#{range.end})"
206
+ end
207
+
208
+ def self.presence_suffix
209
+ '.refine((value) => value.trim().length > 0, { message: "can\'t be blank" })'
210
+ end
211
+
212
+ def self.static_number?(value)
213
+ value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
214
+ end
167
215
 
168
- constraints[:min] = [constraints[:min] || 0, range.begin].max
169
- constraints[:max] = [constraints[:max] || Float::INFINITY, range.end].min
216
+ def self.no_op_presence?(validation)
217
+ validation.kind == :presence && validation.options[:allow_blank]
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, :collect_constraints, :build_chain,
174
224
  :handle_presence_constraint, :handle_length_constraint, :handle_format_constraint,
175
- :handle_inclusion_constraint, :handle_numericality_constraint, :apply_numeric_range
225
+ :handle_inclusion_constraint, :apply_range_inclusion, :apply_array_inclusion,
226
+ :handle_numericality_constraint, :apply_numeric_range, :presence_suffix, :static_number?,
227
+ :no_op_presence?
176
228
  end
177
229
  end
178
230
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZodRails
4
+ module ModelResolver
5
+ def self.resolve(names)
6
+ resolved = []
7
+ missing = []
8
+ invalid = []
9
+
10
+ names.each do |name|
11
+ model = resolve_constant(name)
12
+ if active_record_model?(model)
13
+ resolved << model
14
+ else
15
+ invalid << name
16
+ end
17
+ rescue NameError => e
18
+ raise unless missing_constant?(e, name)
19
+
20
+ missing << name
21
+ end
22
+
23
+ { resolved: resolved, missing: missing, invalid: invalid }
24
+ end
25
+
26
+ def self.resolve_constant(name)
27
+ name.split("::").reject(&:empty?).reduce(Object) do |namespace, constant_name|
28
+ namespace.const_get(constant_name, false)
29
+ end
30
+ end
31
+
32
+ def self.active_record_model?(constant)
33
+ constant.is_a?(Class) && constant < ActiveRecord::Base && !constant.abstract_class?
34
+ end
35
+
36
+ def self.missing_constant?(error, requested_name)
37
+ requested_name.split("::").include?(error.name.to_s)
38
+ end
39
+
40
+ private_class_method :resolve_constant, :active_record_model?, :missing_constant?
41
+ end
42
+ 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.3.0"
5
5
  end
data/lib/zod_rails.rb CHANGED
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "json"
5
+ require "active_record"
4
6
  require_relative "zod_rails/version"
5
7
  require_relative "zod_rails/configuration"
6
8
  require_relative "zod_rails/mapping/type_mapper"
9
+ require_relative "zod_rails/mapping/regexp_mapper"
7
10
  require_relative "zod_rails/mapping/validation_mapper"
8
11
  require_relative "zod_rails/mapping/enum_mapper"
9
12
  require_relative "zod_rails/introspection/column_info"
@@ -12,6 +15,7 @@ require_relative "zod_rails/introspection/model_inspector"
12
15
  require_relative "zod_rails/generation/schema_builder"
13
16
  require_relative "zod_rails/generation/typescript_emitter"
14
17
  require_relative "zod_rails/generation/file_writer"
18
+ require_relative "zod_rails/model_resolver"
15
19
  require_relative "zod_rails/generator"
16
20
  require_relative "zod_rails/railtie" if defined?(Rails::Railtie)
17
21
 
data/sig/zod_rails.rbs CHANGED
@@ -1,4 +1,38 @@
1
1
  module ZodRails
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ def self.logger: () -> Logger
8
+ def self.logger=: (Logger) -> Logger
9
+ def self.configuration: () -> Configuration
10
+ def self.configure: [A] () { (Configuration) -> A } -> A
11
+ def self.reset_configuration!: () -> Configuration
12
+
13
+ class Configuration
14
+ attr_accessor output_dir: String
15
+ attr_accessor schema_suffix: String
16
+ attr_accessor input_schema_suffix: String
17
+ attr_accessor generate_input_schemas: bool
18
+ attr_accessor excluded_columns: Array[String]
19
+ attr_accessor models: Array[String]
20
+ attr_accessor post_generate_command: String?
21
+
22
+ def initialize: () -> void
23
+ end
24
+
25
+ class Generator
26
+ attr_reader output_dir: String
27
+
28
+ def initialize: (output_dir: String) -> void
29
+ def generate: (singleton(ActiveRecord::Base)) -> String
30
+ def generate_content: (singleton(ActiveRecord::Base)) -> Hash[Symbol, String]
31
+ def generate_all: (Array[singleton(ActiveRecord::Base)]) -> Array[String]
32
+ def check: (Array[singleton(ActiveRecord::Base)]) -> Array[Hash[Symbol, String | Symbol]]
33
+ end
34
+
35
+ module ModelResolver
36
+ def self.resolve: (Array[String]) -> Hash[Symbol, Array[untyped]]
37
+ end
4
38
  end
metadata CHANGED
@@ -1,15 +1,35 @@
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.3.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-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: logger
15
35
  requirement: !ruby/object:Gem::Requirement
@@ -24,6 +44,26 @@ dependencies:
24
44
  - - "~>"
25
45
  - !ruby/object:Gem::Version
26
46
  version: '1.6'
47
+ - !ruby/object:Gem::Dependency
48
+ name: railties
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '9'
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '7.0'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '9'
27
67
  description: Ruby gem that introspects ActiveRecord models and generates TypeScript
28
68
  files with Zod schemas for type-safe frontend validation
29
69
  email:
@@ -32,12 +72,9 @@ executables: []
32
72
  extensions: []
33
73
  extra_rdoc_files: []
34
74
  files:
35
- - ".github/workflows/ci.yml"
36
- - ".github/workflows/release.yml"
75
+ - CHANGELOG.md
37
76
  - LICENSE
38
77
  - README.md
39
- - Rakefile
40
- - lib/tasks/zod_rails.rake
41
78
  - lib/zod_rails.rb
42
79
  - lib/zod_rails/configuration.rb
43
80
  - lib/zod_rails/generation/file_writer.rb
@@ -48,19 +85,20 @@ files:
48
85
  - lib/zod_rails/introspection/model_inspector.rb
49
86
  - lib/zod_rails/introspection/validation_info.rb
50
87
  - lib/zod_rails/mapping/enum_mapper.rb
88
+ - lib/zod_rails/mapping/regexp_mapper.rb
51
89
  - lib/zod_rails/mapping/type_mapper.rb
52
90
  - lib/zod_rails/mapping/validation_mapper.rb
91
+ - lib/zod_rails/model_resolver.rb
53
92
  - lib/zod_rails/railtie.rb
54
93
  - lib/zod_rails/version.rb
55
94
  - sig/zod_rails.rbs
56
- - zod_rails.png
57
95
  homepage: https://github.com/mathisto/zod_rails
58
96
  licenses:
59
97
  - MIT
60
98
  metadata:
61
99
  homepage_uri: https://github.com/mathisto/zod_rails
62
100
  source_code_uri: https://github.com/mathisto/zod_rails
63
- changelog_uri: https://github.com/mathisto/zod_rails/blob/main/CHANGELOG.md
101
+ changelog_uri: https://github.com/mathisto/zod_rails/blob/trunk/CHANGELOG.md
64
102
  rubygems_mfa_required: 'true'
65
103
  post_install_message:
66
104
  rdoc_options: []
@@ -1,32 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [trunk]
6
- pull_request:
7
- branches: [trunk]
8
- workflow_call:
9
-
10
- jobs:
11
- test:
12
- name: Ruby ${{ matrix.ruby-version }}
13
- runs-on: ubuntu-latest
14
- strategy:
15
- fail-fast: false
16
- matrix:
17
- ruby-version: ['3.2', '3.3', '3.4']
18
-
19
- steps:
20
- - uses: actions/checkout@v4
21
-
22
- - name: Set up Ruby ${{ matrix.ruby-version }}
23
- uses: ruby/setup-ruby@v1
24
- with:
25
- ruby-version: ${{ matrix.ruby-version }}
26
- bundler-cache: true
27
-
28
- - name: Run tests
29
- run: bundle exec rspec --format documentation
30
-
31
- - name: Run linter
32
- run: bundle exec rubocop --parallel
@@ -1,33 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- jobs:
9
- test:
10
- uses: ./.github/workflows/ci.yml
11
-
12
- release:
13
- needs: test
14
- runs-on: ubuntu-latest
15
-
16
- permissions:
17
- contents: write
18
- id-token: write
19
-
20
- steps:
21
- - uses: actions/checkout@v4
22
-
23
- - name: Set up Ruby
24
- uses: ruby/setup-ruby@v1
25
- with:
26
- ruby-version: "3.3"
27
- bundler-cache: true
28
-
29
- - name: Build gem
30
- run: gem build *.gemspec
31
-
32
- - name: Publish to RubyGems
33
- uses: rubygems/release-gem@v1
data/Rakefile DELETED
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
5
-
6
- RSpec::Core::RakeTask.new(:spec)
7
-
8
- require "rubocop/rake_task"
9
-
10
- RuboCop::RakeTask.new
11
-
12
- task default: %i[spec rubocop]
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- namespace :zod_rails do
4
- desc "Generate Zod schemas for configured models"
5
- task generate: :environment do
6
- config = ZodRails.configuration
7
- generator = ZodRails::Generator.new(output_dir: config.output_dir)
8
-
9
- models = config.models.map(&:constantize)
10
-
11
- if models.empty?
12
- puts "No models configured. Add models to ZodRails.configure { |c| c.models = ['User', 'Article'] }"
13
- exit 1
14
- end
15
-
16
- generated = generator.generate_all(models)
17
- puts "Generated #{generated.size} schema file(s):"
18
- generated.each { |f| puts " - #{f}" }
19
- end
20
-
21
- desc "Generate Zod schema for a specific model"
22
- task :generate_model, [:model_name] => :environment do |_t, args|
23
- model_name = args[:model_name]
24
- unless model_name
25
- puts "Usage: rails zod_rails:generate_model[ModelName]"
26
- exit 1
27
- end
28
-
29
- config = ZodRails.configuration
30
- generator = ZodRails::Generator.new(output_dir: config.output_dir)
31
-
32
- model_class = model_name.constantize
33
- filename = generator.generate(model_class)
34
- puts "Generated: #{filename}"
35
- end
36
- end
data/zod_rails.png DELETED
Binary file