zod_rails 0.1.5 → 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 +4 -4
- data/README.md +101 -7
- data/lib/tasks/zod_rails.rake +66 -9
- data/lib/zod_rails/configuration.rb +3 -1
- data/lib/zod_rails/generation/file_writer.rb +29 -1
- data/lib/zod_rails/generation/schema_builder.rb +31 -8
- data/lib/zod_rails/generator.rb +38 -3
- data/lib/zod_rails/mapping/enum_mapper.rb +3 -2
- data/lib/zod_rails/mapping/validation_mapper.rb +67 -15
- data/lib/zod_rails/model_resolver.rb +18 -0
- data/lib/zod_rails/version.rb +1 -1
- data/lib/zod_rails.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: abcbdb41173e1bb789719ff85fbf595a619daf0cdb08ec260e0532601af9db96
|
|
4
|
+
data.tar.gz: 5649df9de3c18bd6d81347e12f23552d75420c8a5b5166d9d325797162ec0196
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
|
@@ -120,6 +121,8 @@ end
|
|
|
120
121
|
| `datetime`, `timestamp` | `z.iso.datetime()` |
|
|
121
122
|
| `json`, `jsonb` | `z.json()` |
|
|
122
123
|
| `uuid` | `z.uuid()` |
|
|
124
|
+
| `time` | `z.string()` |
|
|
125
|
+
| `binary` | `z.string()` |
|
|
123
126
|
| `enum` | `z.enum([...])` |
|
|
124
127
|
|
|
125
128
|
## Validation Mappings
|
|
@@ -128,7 +131,7 @@ ZodRails introspects your model validations and maps them to Zod constraints:
|
|
|
128
131
|
|
|
129
132
|
| Rails Validation | Zod Constraint |
|
|
130
133
|
|------------------|----------------|
|
|
131
|
-
| `presence: true` | `.min(1)` for
|
|
134
|
+
| `presence: true` | `.min(1)` for string/text columns |
|
|
132
135
|
| `length: { minimum: n }` | `.min(n)` |
|
|
133
136
|
| `length: { maximum: n }` | `.max(n)` |
|
|
134
137
|
| `length: { is: n }` | `.length(n)` |
|
|
@@ -136,8 +139,19 @@ ZodRails introspects your model validations and maps them to Zod constraints:
|
|
|
136
139
|
| `numericality: { greater_than_or_equal_to: n }` | `.gte(n)` |
|
|
137
140
|
| `numericality: { less_than: n }` | `.lt(n)` |
|
|
138
141
|
| `numericality: { less_than_or_equal_to: n }` | `.lte(n)` |
|
|
139
|
-
| `format: { with: /regex/ }` | `.regex(/regex/)` |
|
|
140
|
-
| `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.
|
|
141
155
|
|
|
142
156
|
## Generated Output Example
|
|
143
157
|
|
|
@@ -150,6 +164,7 @@ class User < ApplicationRecord
|
|
|
150
164
|
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
151
165
|
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
|
|
152
166
|
validates :age, numericality: { greater_than: 0, less_than: 150 }, allow_nil: true
|
|
167
|
+
validates :status, inclusion: { in: %w[pending active suspended] }
|
|
153
168
|
end
|
|
154
169
|
```
|
|
155
170
|
|
|
@@ -160,9 +175,10 @@ import { z } from "zod";
|
|
|
160
175
|
|
|
161
176
|
export const UserSchema = z.object({
|
|
162
177
|
id: z.int(),
|
|
163
|
-
email: z.string().min(1).regex(
|
|
178
|
+
email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
|
|
164
179
|
name: z.string().min(2).max(100),
|
|
165
180
|
age: z.int().gt(0).lt(150).nullable(),
|
|
181
|
+
status: z.enum(["pending", "active", "suspended"]),
|
|
166
182
|
role: z.enum(["member", "admin", "moderator"]),
|
|
167
183
|
created_at: z.iso.datetime(),
|
|
168
184
|
updated_at: z.iso.datetime()
|
|
@@ -171,9 +187,10 @@ export const UserSchema = z.object({
|
|
|
171
187
|
export type User = z.infer<typeof UserSchema>;
|
|
172
188
|
|
|
173
189
|
export const UserInputSchema = z.object({
|
|
174
|
-
email: z.string().min(1).regex(
|
|
190
|
+
email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
|
|
175
191
|
name: z.string().min(2).max(100),
|
|
176
192
|
age: z.int().gt(0).lt(150).nullish(),
|
|
193
|
+
status: z.enum(["pending", "active", "suspended"]),
|
|
177
194
|
role: z.enum(["member", "admin", "moderator"]).optional()
|
|
178
195
|
});
|
|
179
196
|
|
|
@@ -193,7 +210,61 @@ ZodRails generates two schema variants:
|
|
|
193
210
|
- Represents data for form submission
|
|
194
211
|
- Excludes configured columns (defaults: `id`, `created_at`, `updated_at`)
|
|
195
212
|
- Uses `.optional()` for columns with database defaults
|
|
196
|
-
- Uses `.nullish()` for nullable columns
|
|
213
|
+
- Uses `.nullish()` for nullable columns (accepts both `null` and `undefined`)
|
|
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.
|
|
197
268
|
|
|
198
269
|
## Integrating with Forms
|
|
199
270
|
|
|
@@ -233,10 +304,17 @@ async function fetchUser(id: number): Promise<User> {
|
|
|
233
304
|
|
|
234
305
|
## CI/CD Integration
|
|
235
306
|
|
|
236
|
-
|
|
307
|
+
Use `zod_rails:check` to catch missed regenerations:
|
|
237
308
|
|
|
238
309
|
```yaml
|
|
239
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
|
|
240
318
|
- name: Generate Zod schemas
|
|
241
319
|
run: bin/rails zod_rails:generate
|
|
242
320
|
|
|
@@ -262,6 +340,22 @@ Ensure validations are defined on the model class, not in concerns that might no
|
|
|
262
340
|
|
|
263
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.
|
|
264
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
|
+
|
|
265
359
|
## Releasing
|
|
266
360
|
|
|
267
361
|
1. Bump the version in `lib/zod_rails/version.rb`
|
data/lib/tasks/zod_rails.rake
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
69
|
-
|
|
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
|
data/lib/zod_rails/generator.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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, :
|
|
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
|
data/lib/zod_rails/version.rb
CHANGED
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.
|
|
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-
|
|
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
|