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 +4 -4
- data/README.md +95 -3
- 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
|
|
|
@@ -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
|
-
|
|
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`
|
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
|