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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +111 -11
- data/lib/zod_rails/configuration.rb +3 -1
- data/lib/zod_rails/generation/file_writer.rb +30 -1
- data/lib/zod_rails/generation/schema_builder.rb +64 -19
- data/lib/zod_rails/generation/typescript_emitter.rb +5 -5
- data/lib/zod_rails/generator.rb +69 -10
- data/lib/zod_rails/introspection/column_info.rb +2 -1
- data/lib/zod_rails/mapping/enum_mapper.rb +6 -8
- data/lib/zod_rails/mapping/regexp_mapper.rb +36 -0
- data/lib/zod_rails/mapping/type_mapper.rb +2 -1
- data/lib/zod_rails/mapping/validation_mapper.rb +83 -31
- data/lib/zod_rails/model_resolver.rb +42 -0
- data/lib/zod_rails/version.rb +1 -1
- data/lib/zod_rails.rb +4 -0
- data/sig/zod_rails.rbs +35 -1
- metadata +46 -8
- data/.github/workflows/ci.yml +0 -32
- data/.github/workflows/release.yml +0 -33
- data/Rakefile +0 -12
- data/lib/tasks/zod_rails.rake +0 -36
- data/zod_rails.png +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8e1e956f2954b2200c39a4b467529911806337883bd2674b31b7a271ef560a8
|
|
4
|
+
data.tar.gz: 681139d70905f6c34ee190f629f437e015a2eb1313a76940d42b0eaf6a8ffabe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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(
|
|
142
|
-
| `inclusion: { in: n..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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(/
|
|
38
|
+
schema_name.sub(/InputSchema$/, "Input").sub(/Schema$/, "")
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
end
|
data/lib/zod_rails/generator.rb
CHANGED
|
@@ -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
|
-
|
|
16
|
-
builder = Generation::SchemaBuilder.new(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
36
|
-
|
|
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
|
|
@@ -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
|
-
|
|
8
|
-
|
|
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 :
|
|
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
|
-
|
|
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.
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
constraints[:others] << ".regex(
|
|
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]})"
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
:
|
|
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, :
|
|
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
|
data/lib/zod_rails/version.rb
CHANGED
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
|
-
|
|
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.
|
|
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-
|
|
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
|
-
-
|
|
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/
|
|
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: []
|
data/.github/workflows/ci.yml
DELETED
|
@@ -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
data/lib/tasks/zod_rails.rake
DELETED
|
@@ -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
|