zod_rails 0.2.0 → 0.3.1
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 +55 -0
- data/README.md +40 -14
- data/lib/zod_rails/generation/file_writer.rb +3 -2
- data/lib/zod_rails/generation/schema_builder.rb +59 -25
- data/lib/zod_rails/generation/typescript_emitter.rb +5 -5
- data/lib/zod_rails/generator.rb +37 -13
- data/lib/zod_rails/introspection/column_info.rb +9 -5
- data/lib/zod_rails/mapping/enum_mapper.rb +17 -6
- data/lib/zod_rails/mapping/regexp_mapper.rb +36 -0
- data/lib/zod_rails/mapping/type_mapper.rb +9 -4
- data/lib/zod_rails/mapping/validation_mapper.rb +72 -61
- data/lib/zod_rails/model_resolver.rb +27 -3
- data/lib/zod_rails/version.rb +1 -1
- data/lib/zod_rails.rb +3 -0
- data/sig/zod_rails.rbs +35 -1
- metadata +45 -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 -93
- 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: 2d23bb8daf103c59a7cb41cd7eff8a7e2346ab19171228283ab990214e7aa1a6
|
|
4
|
+
data.tar.gz: 22b5712ffa475a57026411d6e9f2345e42a56999f3efc5f2e7ee8b63a3d39449
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18f8e43a66304819f9d0e14a50822c2c869a79da08c54772d2775f6393b590f55f156774f7404003ce2a169654138b83d4e136d4ce581be558100651136a03c6
|
|
7
|
+
data.tar.gz: 9ecd30fe73d9a791e9642be2895308becd45752c9d1e84435e9a82be7a882c8a6bd80b307e0629d7d96c4c2ef873907da064478d03f908febb50aa4d32884309
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to ZodRails are documented here.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
## 0.3.1 - 2026-08-03
|
|
8
|
+
|
|
9
|
+
### Upgrade Notes
|
|
10
|
+
|
|
11
|
+
- Regenerate committed schemas after upgrading if your models contain PostgreSQL arrays, adapter-reported `:bigint`
|
|
12
|
+
columns, or `time` columns. Their generated wire schemas now match Rails payload shapes more closely.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Generate `z.array(...)` schemas for PostgreSQL array columns, with inclusion constraints on elements and
|
|
17
|
+
presence, length, default, and nullability constraints on the outer array.
|
|
18
|
+
- Map adapter-reported `:bigint` columns to the JSON number shape emitted by Rails.
|
|
19
|
+
- Validate Rails `time` payloads as ISO datetimes with timezone-offset support.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Run a generated-schema runtime contract against the pinned Zod version in CI.
|
|
24
|
+
|
|
25
|
+
## 0.3.0 - 2026-08-03
|
|
26
|
+
|
|
27
|
+
### Upgrade Notes
|
|
28
|
+
|
|
29
|
+
- Regenerate committed schemas after upgrading. Presence, range, regular-expression, and datetime output has changed
|
|
30
|
+
to more closely match Rails behavior.
|
|
31
|
+
- `schema_suffix`, `input_schema_suffix`, and `generate_input_schemas` now take effect. Applications that configured
|
|
32
|
+
these previously ignored options will see the configured output for the first time.
|
|
33
|
+
- Configured model names must resolve to concrete ActiveRecord models; nonmodels and abstract models now produce a
|
|
34
|
+
targeted configuration error.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- Honor custom schema suffixes and `generate_input_schemas`.
|
|
39
|
+
- Generate safe TypeScript for unusual column names, enum values, and regular expressions.
|
|
40
|
+
- Preserve negative, zero, exclusive, beginless, and endless numeric range bounds.
|
|
41
|
+
- Match Rails presence semantics for whitespace-only strings and required nullable columns.
|
|
42
|
+
- Accept ISO datetimes with timezone offsets and detect database expression defaults.
|
|
43
|
+
- Keep preserved custom blocks from causing false drift reports.
|
|
44
|
+
- Detect output filename collisions before writing files.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- Declare ActiveRecord and Railties as runtime dependencies.
|
|
49
|
+
- Reject configured constants that are not concrete ActiveRecord models.
|
|
50
|
+
- Skip dynamic numericality constraints and Ruby extended-mode regular expressions instead of emitting invalid TypeScript.
|
|
51
|
+
|
|
52
|
+
## 0.2.0
|
|
53
|
+
|
|
54
|
+
- Added response and input schema generation, enums, validation mapping, namespaced models, drift detection,
|
|
55
|
+
custom block preservation, dry runs, and formatter integration.
|
data/README.md
CHANGED
|
@@ -112,26 +112,31 @@ end
|
|
|
112
112
|
| Rails/DB Type | Zod Type |
|
|
113
113
|
|---------------|----------|
|
|
114
114
|
| `string`, `text` | `z.string()` |
|
|
115
|
-
| `integer` | `z.int()` |
|
|
115
|
+
| `integer`, `bigint` | `z.int()` |
|
|
116
116
|
| `float` | `z.number()` |
|
|
117
|
-
| `bigint` | `z.string()` (avoids JS `Number` overflow) |
|
|
118
117
|
| `decimal` | `z.string()` (preserves `BigDecimal` precision) |
|
|
119
118
|
| `boolean` | `z.boolean()` |
|
|
120
119
|
| `date` | `z.iso.date()` |
|
|
121
|
-
| `datetime`, `timestamp` | `z.iso.datetime()` |
|
|
120
|
+
| `datetime`, `timestamp` | `z.iso.datetime({ offset: true })` |
|
|
122
121
|
| `json`, `jsonb` | `z.json()` |
|
|
123
122
|
| `uuid` | `z.uuid()` |
|
|
124
|
-
| `time` | `z.
|
|
123
|
+
| `time` | `z.iso.datetime({ offset: true })` |
|
|
125
124
|
| `binary` | `z.string()` |
|
|
126
125
|
| `enum` | `z.enum([...])` |
|
|
127
126
|
|
|
127
|
+
PostgreSQL array columns wrap the element mapping with `z.array(...)`. Nullability and input optionality apply to the
|
|
128
|
+
array itself, so a nullable `string[]` becomes `z.array(z.string()).nullable()`, while a non-null array with a database
|
|
129
|
+
default becomes `z.array(z.string())` in the response schema and `z.array(z.string()).optional()` in the input schema.
|
|
130
|
+
Rails inclusion validators on array attributes constrain each element, so `inclusion: { in: %w[a b] }` produces an
|
|
131
|
+
element enum inside the array.
|
|
132
|
+
|
|
128
133
|
## Validation Mappings
|
|
129
134
|
|
|
130
135
|
ZodRails introspects your model validations and maps them to Zod constraints:
|
|
131
136
|
|
|
132
137
|
| Rails Validation | Zod Constraint |
|
|
133
138
|
|------------------|----------------|
|
|
134
|
-
| `presence: true` | `.min(1)` for string/text columns |
|
|
139
|
+
| `presence: true` | `.min(1).refine(...)` for string/text columns, including whitespace-only rejection |
|
|
135
140
|
| `length: { minimum: n }` | `.min(n)` |
|
|
136
141
|
| `length: { maximum: n }` | `.max(n)` |
|
|
137
142
|
| `length: { is: n }` | `.length(n)` |
|
|
@@ -139,8 +144,8 @@ ZodRails introspects your model validations and maps them to Zod constraints:
|
|
|
139
144
|
| `numericality: { greater_than_or_equal_to: n }` | `.gte(n)` |
|
|
140
145
|
| `numericality: { less_than: n }` | `.lt(n)` |
|
|
141
146
|
| `numericality: { less_than_or_equal_to: n }` | `.lte(n)` |
|
|
142
|
-
| `format: { with: /regex/ }` | `.regex(
|
|
143
|
-
| `inclusion: { in: n..m }` (Range) | `.
|
|
147
|
+
| `format: { with: /regex/ }` | `.regex(new RegExp(...))` with safe escaping and compatible flags |
|
|
148
|
+
| `inclusion: { in: n..m }` (Range) | `.gte(n).lte(m)`; exclusive and open-ended bounds are preserved |
|
|
144
149
|
| `inclusion: { in: %w[a b c] }` (Array, string column) | `z.enum(["a", "b", "c"])` as the base type |
|
|
145
150
|
| `inclusion: { in: [1, 5, 10] }` (Array, integer column) | `.pipe(z.union([z.literal(1), z.literal(5), z.literal(10)]))` |
|
|
146
151
|
|
|
@@ -149,7 +154,7 @@ ZodRails introspects your model validations and maps them to Zod constraints:
|
|
|
149
154
|
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
155
|
|
|
151
156
|
- `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
|
|
157
|
+
- `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
158
|
|
|
154
159
|
If you mix both (`enum` macro AND a separate `inclusion` validator on the same column), the `enum` macro wins.
|
|
155
160
|
|
|
@@ -175,20 +180,20 @@ import { z } from "zod";
|
|
|
175
180
|
|
|
176
181
|
export const UserSchema = z.object({
|
|
177
182
|
id: z.int(),
|
|
178
|
-
email: z.string().min(1).regex(
|
|
179
|
-
name: z.string().min(2).max(100),
|
|
183
|
+
email: z.string().min(1).refine((value) => value.trim().length > 0).regex(new RegExp("^[^@\\s]+@[^@\\s]+$")),
|
|
184
|
+
name: z.string().min(2).max(100).refine((value) => value.trim().length > 0),
|
|
180
185
|
age: z.int().gt(0).lt(150).nullable(),
|
|
181
186
|
status: z.enum(["pending", "active", "suspended"]),
|
|
182
187
|
role: z.enum(["member", "admin", "moderator"]),
|
|
183
|
-
created_at: z.iso.datetime(),
|
|
184
|
-
updated_at: z.iso.datetime()
|
|
188
|
+
created_at: z.iso.datetime({ offset: true }),
|
|
189
|
+
updated_at: z.iso.datetime({ offset: true })
|
|
185
190
|
});
|
|
186
191
|
|
|
187
192
|
export type User = z.infer<typeof UserSchema>;
|
|
188
193
|
|
|
189
194
|
export const UserInputSchema = z.object({
|
|
190
|
-
email: z.string().min(1).regex(
|
|
191
|
-
name: z.string().min(2).max(100),
|
|
195
|
+
email: z.string().min(1).refine((value) => value.trim().length > 0).regex(new RegExp("^[^@\\s]+@[^@\\s]+$")),
|
|
196
|
+
name: z.string().min(2).max(100).refine((value) => value.trim().length > 0),
|
|
192
197
|
age: z.int().gt(0).lt(150).nullish(),
|
|
193
198
|
status: z.enum(["pending", "active", "suspended"]),
|
|
194
199
|
role: z.enum(["member", "admin", "moderator"]).optional()
|
|
@@ -266,6 +271,8 @@ end
|
|
|
266
271
|
|
|
267
272
|
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
273
|
|
|
274
|
+
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.
|
|
275
|
+
|
|
269
276
|
## Integrating with Forms
|
|
270
277
|
|
|
271
278
|
ZodRails pairs well with form libraries that support Zod:
|
|
@@ -340,6 +347,20 @@ Ensure validations are defined on the model class, not in concerns that might no
|
|
|
340
347
|
|
|
341
348
|
For custom types not in the mapping table, ZodRails falls back to `z.unknown()`. Open an issue if you need support for additional types.
|
|
342
349
|
|
|
350
|
+
### Serialization and validation limits
|
|
351
|
+
|
|
352
|
+
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.
|
|
353
|
+
|
|
354
|
+
The generated wire types must match your serializers. Rails emits integer and bigint attributes as JSON numbers, so
|
|
355
|
+
ZodRails maps both to `z.int()`. Zod intentionally rejects integers outside JavaScript's safe integer range; serialize
|
|
356
|
+
large identifiers as strings and provide an application-owned schema when values can exceed that range. Decimal values
|
|
357
|
+
map to strings to preserve `BigDecimal` precision. Review generated schemas when using custom serializers,
|
|
358
|
+
adapter-specific types, or custom ActiveRecord types.
|
|
359
|
+
|
|
360
|
+
PostgreSQL permits null elements and multidimensional values without exposing either constraint through ordinary
|
|
361
|
+
column metadata. ZodRails currently generates one-dimensional arrays with non-null elements. Use an application-owned
|
|
362
|
+
schema when a column intentionally stores null elements or nested arrays.
|
|
363
|
+
|
|
343
364
|
### Misconfigured model names
|
|
344
365
|
|
|
345
366
|
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:
|
|
@@ -372,8 +393,13 @@ After checking out the repo:
|
|
|
372
393
|
```bash
|
|
373
394
|
bundle install
|
|
374
395
|
bundle exec rspec
|
|
396
|
+
bun install --frozen-lockfile
|
|
397
|
+
bun run test:contracts
|
|
375
398
|
```
|
|
376
399
|
|
|
400
|
+
The Bun contract test generates a TypeScript schema and parses representative Rails payload values with the pinned Zod
|
|
401
|
+
version. This complements the Ruby unit and golden tests by exercising the generated code at runtime.
|
|
402
|
+
|
|
377
403
|
## Contributing
|
|
378
404
|
|
|
379
405
|
1. Fork it
|
|
@@ -23,8 +23,9 @@ module ZodRails
|
|
|
23
23
|
File.write(full_path, final)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def preview(filename:, content:)
|
|
27
|
-
|
|
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
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def output_path_for(model_name)
|
|
@@ -5,12 +5,15 @@ module ZodRails
|
|
|
5
5
|
class SchemaBuilder
|
|
6
6
|
STRING_TYPES = %i[string text].freeze
|
|
7
7
|
NULLABILITY_SUFFIX_RE = /(\.(?:nullable|nullish|optional)\(\))\z/
|
|
8
|
+
TYPESCRIPT_IDENTIFIER_RE = /\A[$A-Z_a-z][$\w]*\z/
|
|
8
9
|
|
|
9
|
-
attr_reader :inspector, :excluded_columns
|
|
10
|
+
attr_reader :inspector, :excluded_columns, :schema_suffix, :input_schema_suffix
|
|
10
11
|
|
|
11
|
-
def initialize(inspector, excluded_columns: [])
|
|
12
|
+
def initialize(inspector, excluded_columns: [], schema_suffix: "Schema", input_schema_suffix: "InputSchema")
|
|
12
13
|
@inspector = inspector
|
|
13
14
|
@excluded_columns = excluded_columns.map(&:to_s)
|
|
15
|
+
@schema_suffix = schema_suffix
|
|
16
|
+
@input_schema_suffix = input_schema_suffix
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def build(input_schema: false)
|
|
@@ -23,32 +26,42 @@ module ZodRails
|
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def schema_name(input_schema: false)
|
|
26
|
-
suffix = input_schema ?
|
|
27
|
-
"#{inspector.model_name.gsub("::", "")}#{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
|
|
28
39
|
end
|
|
29
40
|
|
|
30
41
|
private
|
|
31
42
|
|
|
32
43
|
def field_definition(column, input_schema:)
|
|
33
44
|
type_str = build_type_string(column, input_schema: input_schema)
|
|
34
|
-
|
|
45
|
+
key = column.name.match?(TYPESCRIPT_IDENTIFIER_RE) ? column.name : JSON.generate(column.name)
|
|
46
|
+
"#{key}: #{type_str}"
|
|
35
47
|
end
|
|
36
48
|
|
|
37
49
|
def build_type_string(column, input_schema:)
|
|
50
|
+
validations = inspector.validations_for(column.name)
|
|
51
|
+
|
|
38
52
|
if enum_column?(column.name)
|
|
39
|
-
build_enum_type(column, input_schema: input_schema)
|
|
40
|
-
elsif (
|
|
41
|
-
build_inclusion_enum_type(column,
|
|
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)
|
|
42
56
|
else
|
|
43
|
-
build_regular_type(column, input_schema: input_schema)
|
|
57
|
+
build_regular_type(column, validations, input_schema: input_schema)
|
|
44
58
|
end
|
|
45
59
|
end
|
|
46
60
|
|
|
47
|
-
def
|
|
48
|
-
return nil
|
|
61
|
+
def string_array_inclusion(column, validations)
|
|
62
|
+
return nil if column.array || !STRING_TYPES.include?(column.type)
|
|
49
63
|
|
|
50
|
-
|
|
51
|
-
inclusion&.options&.[](:in)
|
|
64
|
+
validations.find { |validation| string_array_inclusion?(validation) }
|
|
52
65
|
end
|
|
53
66
|
|
|
54
67
|
def string_array_inclusion?(validation)
|
|
@@ -58,35 +71,42 @@ module ZodRails
|
|
|
58
71
|
values.is_a?(Array) && !values.empty? && values.all? { |x| x.is_a?(String) }
|
|
59
72
|
end
|
|
60
73
|
|
|
61
|
-
def build_inclusion_enum_type(column,
|
|
74
|
+
def build_inclusion_enum_type(column, inclusion, validations, input_schema:)
|
|
75
|
+
remaining = validations.reject { |validation| validation.equal?(inclusion) }
|
|
62
76
|
Mapping::EnumMapper.call(
|
|
63
|
-
|
|
64
|
-
|
|
77
|
+
inclusion.options[:in],
|
|
78
|
+
validation_chain: Mapping::ValidationMapper.call_all(remaining, base_type: :string, array: column.array),
|
|
79
|
+
nullable: nullable?(column, validations),
|
|
65
80
|
input_schema: input_schema,
|
|
66
|
-
has_default: column.has_default
|
|
81
|
+
has_default: column.has_default,
|
|
82
|
+
array: column.array
|
|
67
83
|
)
|
|
68
84
|
end
|
|
69
85
|
|
|
70
|
-
def build_enum_type(column, input_schema:)
|
|
86
|
+
def build_enum_type(column, validations, input_schema:)
|
|
71
87
|
values = inspector.enums[column.name]
|
|
72
88
|
Mapping::EnumMapper.call(
|
|
73
89
|
values,
|
|
74
|
-
|
|
90
|
+
validation_chain: Mapping::ValidationMapper.call_all(validations, base_type: :string, array: column.array),
|
|
91
|
+
nullable: nullable?(column, validations),
|
|
75
92
|
input_schema: input_schema,
|
|
76
|
-
has_default: column.has_default
|
|
93
|
+
has_default: column.has_default,
|
|
94
|
+
array: column.array,
|
|
95
|
+
element_validation_chain: array_element_validation_chain(column, validations)
|
|
77
96
|
)
|
|
78
97
|
end
|
|
79
98
|
|
|
80
|
-
def build_regular_type(column, input_schema:)
|
|
81
|
-
validations = inspector.validations_for(column.name)
|
|
99
|
+
def build_regular_type(column, validations, input_schema:)
|
|
82
100
|
base_type = Mapping::TypeMapper.call(
|
|
83
101
|
column.type,
|
|
84
|
-
nullable: column
|
|
102
|
+
nullable: nullable?(column, validations),
|
|
85
103
|
input_schema: input_schema,
|
|
86
|
-
has_default: column.has_default
|
|
104
|
+
has_default: column.has_default,
|
|
105
|
+
array: column.array,
|
|
106
|
+
element_validation_chain: array_element_validation_chain(column, validations)
|
|
87
107
|
)
|
|
88
108
|
|
|
89
|
-
validation_chain = Mapping::ValidationMapper.call_all(validations, base_type: column.type)
|
|
109
|
+
validation_chain = Mapping::ValidationMapper.call_all(validations, base_type: column.type, array: column.array)
|
|
90
110
|
insert_validation_chain(base_type, validation_chain)
|
|
91
111
|
end
|
|
92
112
|
|
|
@@ -104,6 +124,20 @@ module ZodRails
|
|
|
104
124
|
inspector.enums.key?(column_name)
|
|
105
125
|
end
|
|
106
126
|
|
|
127
|
+
def nullable?(column, validations)
|
|
128
|
+
column.nullable && validations.none? do |validation|
|
|
129
|
+
validation.kind == :presence && !validation.conditional? &&
|
|
130
|
+
!validation.options[:allow_nil] && !validation.options[:allow_blank]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def array_element_validation_chain(column, validations)
|
|
135
|
+
return "" unless column.array
|
|
136
|
+
|
|
137
|
+
element_validations = validations.select { |validation| validation.kind == :inclusion }
|
|
138
|
+
Mapping::ValidationMapper.call_all(element_validations, base_type: column.type)
|
|
139
|
+
end
|
|
140
|
+
|
|
107
141
|
def filtered_columns
|
|
108
142
|
inspector.columns.reject { |col| excluded_columns.include?(col.name) }
|
|
109
143
|
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
|
@@ -18,27 +18,32 @@ module ZodRails
|
|
|
18
18
|
|
|
19
19
|
def generate_content(model_class)
|
|
20
20
|
inspector = Introspection::ModelInspector.new(model_class)
|
|
21
|
-
|
|
22
|
-
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
|
+
)
|
|
23
28
|
|
|
24
29
|
response_schema = {
|
|
25
30
|
name: builder.schema_name,
|
|
31
|
+
type_name: builder.type_name,
|
|
26
32
|
body: builder.build
|
|
27
33
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
name: builder.schema_name(input_schema: true),
|
|
31
|
-
body: builder.build(input_schema: true)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
content = emitter.emit_combined(response: response_schema, input: input_schema)
|
|
35
|
-
filename = file_writer.output_path_for(inspector.model_name)
|
|
36
|
-
|
|
37
|
-
{ filename: filename, content: content }
|
|
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 }
|
|
38
36
|
end
|
|
39
37
|
|
|
40
38
|
def generate_all(model_classes)
|
|
41
|
-
|
|
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
|
|
42
47
|
run_post_generate_command
|
|
43
48
|
files
|
|
44
49
|
end
|
|
@@ -59,6 +64,25 @@ module ZodRails
|
|
|
59
64
|
|
|
60
65
|
private
|
|
61
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 = {
|
|
75
|
+
name: builder.schema_name(input_schema: true),
|
|
76
|
+
type_name: builder.type_name(input_schema: true),
|
|
77
|
+
body: builder.build(input_schema: true)
|
|
78
|
+
}
|
|
79
|
+
if input[:name] == response[:name]
|
|
80
|
+
raise ZodRails::Error, "Response and input schemas have the same export name: #{input[:name]}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
emitter.emit_combined(response: response, input: input)
|
|
84
|
+
end
|
|
85
|
+
|
|
62
86
|
def run_post_generate_command
|
|
63
87
|
cmd = ZodRails.configuration.post_generate_command
|
|
64
88
|
return if cmd.nil? || cmd.to_s.strip.empty?
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
module ZodRails
|
|
4
4
|
module Introspection
|
|
5
5
|
class ColumnInfo
|
|
6
|
-
attr_reader :name, :type, :nullable, :has_default
|
|
6
|
+
attr_reader :name, :type, :nullable, :has_default, :array
|
|
7
7
|
|
|
8
|
-
def initialize(name:, type:, nullable:, has_default:)
|
|
8
|
+
def initialize(name:, type:, nullable:, has_default:, array: false)
|
|
9
9
|
@name = name
|
|
10
10
|
@type = type
|
|
11
11
|
@nullable = nullable
|
|
12
12
|
@has_default = has_default
|
|
13
|
+
@array = array
|
|
13
14
|
freeze
|
|
14
15
|
end
|
|
15
16
|
|
|
@@ -18,7 +19,9 @@ module ZodRails
|
|
|
18
19
|
name: column.name,
|
|
19
20
|
type: column.type,
|
|
20
21
|
nullable: column.null,
|
|
21
|
-
has_default: !column.default.nil?
|
|
22
|
+
has_default: !column.default.nil? ||
|
|
23
|
+
(column.respond_to?(:default_function) && !column.default_function.nil?),
|
|
24
|
+
array: column.respond_to?(:array?) && column.array?
|
|
22
25
|
)
|
|
23
26
|
end
|
|
24
27
|
|
|
@@ -27,12 +30,13 @@ module ZodRails
|
|
|
27
30
|
name == other.name &&
|
|
28
31
|
type == other.type &&
|
|
29
32
|
nullable == other.nullable &&
|
|
30
|
-
has_default == other.has_default
|
|
33
|
+
has_default == other.has_default &&
|
|
34
|
+
array == other.array
|
|
31
35
|
end
|
|
32
36
|
alias eql? ==
|
|
33
37
|
|
|
34
38
|
def hash
|
|
35
|
-
[self.class, name, type, nullable, has_default].hash
|
|
39
|
+
[self.class, name, type, nullable, has_default, array].hash
|
|
36
40
|
end
|
|
37
41
|
end
|
|
38
42
|
end
|
|
@@ -3,17 +3,28 @@
|
|
|
3
3
|
module ZodRails
|
|
4
4
|
module Mapping
|
|
5
5
|
class EnumMapper
|
|
6
|
-
def self.call(values, nullable: false, input_schema: false,
|
|
6
|
+
def self.call(values, nullable: false, input_schema: false, array: false, **options)
|
|
7
|
+
has_default = options.fetch(:has_default, false)
|
|
8
|
+
validation_chain = options.fetch(:validation_chain, "")
|
|
9
|
+
element_validation_chain = options.fetch(:element_validation_chain, "")
|
|
7
10
|
names = values.is_a?(Hash) ? values.keys : values
|
|
8
|
-
quoted = names.map { |
|
|
9
|
-
|
|
11
|
+
quoted = names.map { |name| JSON.generate(name.to_s) }
|
|
12
|
+
enum = "z.enum([#{quoted.join(", ")}])"
|
|
13
|
+
base = if array
|
|
14
|
+
array_schema(enum, element_validation_chain, validation_chain)
|
|
15
|
+
elsif validation_chain.empty?
|
|
16
|
+
enum
|
|
17
|
+
else
|
|
18
|
+
"z.string()#{validation_chain}.pipe(#{enum})"
|
|
19
|
+
end
|
|
10
20
|
|
|
11
21
|
suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
|
|
12
22
|
"#{base}#{suffix}"
|
|
13
23
|
end
|
|
14
24
|
|
|
15
|
-
def self.
|
|
16
|
-
|
|
25
|
+
def self.array_schema(enum, element_validation_chain, array_validation_chain)
|
|
26
|
+
element = element_validation_chain.empty? ? enum : "z.string()#{element_validation_chain}.pipe(#{enum})"
|
|
27
|
+
"z.array(#{element})#{array_validation_chain}"
|
|
17
28
|
end
|
|
18
29
|
|
|
19
30
|
def self.determine_suffix(nullable:, input_schema:, has_default:)
|
|
@@ -26,7 +37,7 @@ module ZodRails
|
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
39
|
|
|
29
|
-
private_class_method :
|
|
40
|
+
private_class_method :array_schema, :determine_suffix
|
|
30
41
|
end
|
|
31
42
|
end
|
|
32
43
|
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
|
|
@@ -7,24 +7,29 @@ module ZodRails
|
|
|
7
7
|
string: "z.string()",
|
|
8
8
|
text: "z.string()",
|
|
9
9
|
integer: "z.int()",
|
|
10
|
-
bigint: "z.
|
|
10
|
+
bigint: "z.int()",
|
|
11
11
|
float: "z.number()",
|
|
12
12
|
decimal: "z.string()",
|
|
13
13
|
boolean: "z.boolean()",
|
|
14
14
|
date: "z.iso.date()",
|
|
15
|
-
datetime: "z.iso.datetime()",
|
|
16
|
-
|
|
15
|
+
datetime: "z.iso.datetime({ offset: true })",
|
|
16
|
+
timestamp: "z.iso.datetime({ offset: true })",
|
|
17
|
+
time: "z.iso.datetime({ offset: true })",
|
|
17
18
|
json: "z.json()",
|
|
18
19
|
jsonb: "z.json()",
|
|
19
20
|
uuid: "z.uuid()",
|
|
20
21
|
binary: "z.string()"
|
|
21
22
|
}.freeze
|
|
22
23
|
|
|
23
|
-
def self.call(type, nullable: false, input_schema: false,
|
|
24
|
+
def self.call(type, nullable: false, input_schema: false, array: false, **options)
|
|
25
|
+
has_default = options.fetch(:has_default, false)
|
|
26
|
+
element_validation_chain = options.fetch(:element_validation_chain, "")
|
|
24
27
|
base = TYPE_MAP.fetch(type.to_sym) do
|
|
25
28
|
ZodRails.logger.warn("ZodRails: Unknown type '#{type}', falling back to z.unknown()")
|
|
26
29
|
"z.unknown()"
|
|
27
30
|
end
|
|
31
|
+
base = "#{base}#{element_validation_chain}"
|
|
32
|
+
base = "z.array(#{base})" if array
|
|
28
33
|
|
|
29
34
|
suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
|
|
30
35
|
"#{base}#{suffix}"
|
|
@@ -13,35 +13,37 @@ module ZodRails
|
|
|
13
13
|
NUMERIC_ZOD_TYPES = %i[integer float].freeze
|
|
14
14
|
STRING_ZOD_TYPES = %i[string text].freeze
|
|
15
15
|
|
|
16
|
-
def self.call(validation, base_type:)
|
|
17
|
-
return "" if validation.conditional?
|
|
16
|
+
def self.call(validation, base_type:, array: false)
|
|
17
|
+
return "" if validation.conditional? || no_op_presence?(validation)
|
|
18
18
|
|
|
19
19
|
case validation.kind
|
|
20
|
-
when :presence then map_presence(validation, base_type)
|
|
21
|
-
when :length then map_length(validation, base_type)
|
|
22
|
-
when :numericality then map_numericality(validation, base_type)
|
|
23
|
-
when :format then map_format(validation, base_type)
|
|
24
|
-
when :inclusion then map_inclusion(validation, base_type)
|
|
20
|
+
when :presence then map_presence(validation, base_type, array)
|
|
21
|
+
when :length then map_length(validation, base_type, array)
|
|
22
|
+
when :numericality then map_numericality(validation, base_type, array)
|
|
23
|
+
when :format then map_format(validation, base_type, array)
|
|
24
|
+
when :inclusion then map_inclusion(validation, base_type, array)
|
|
25
25
|
else ""
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def self.call_all(validations, base_type:)
|
|
29
|
+
def self.call_all(validations, base_type:, array: false)
|
|
30
30
|
constraints = { min: nil, max: nil, length: nil, others: [] }
|
|
31
31
|
|
|
32
32
|
validations.each do |v|
|
|
33
|
-
collect_constraints(v, base_type, constraints)
|
|
33
|
+
collect_constraints(v, base_type, constraints, array)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
build_chain(constraints)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def self.map_presence(_validation, base_type)
|
|
40
|
-
|
|
39
|
+
def self.map_presence(_validation, base_type, array)
|
|
40
|
+
return ".min(1)" if array
|
|
41
|
+
|
|
42
|
+
STRING_ZOD_TYPES.include?(base_type) ? ".min(1)#{presence_suffix}" : ""
|
|
41
43
|
end
|
|
42
44
|
|
|
43
|
-
def self.map_length(validation, base_type)
|
|
44
|
-
return "" unless STRING_ZOD_TYPES.include?(base_type)
|
|
45
|
+
def self.map_length(validation, base_type, array)
|
|
46
|
+
return "" unless array || STRING_ZOD_TYPES.include?(base_type)
|
|
45
47
|
|
|
46
48
|
parts = []
|
|
47
49
|
opts = validation.options
|
|
@@ -56,26 +58,27 @@ module ZodRails
|
|
|
56
58
|
parts.join
|
|
57
59
|
end
|
|
58
60
|
|
|
59
|
-
def self.map_numericality(validation, base_type)
|
|
60
|
-
return ""
|
|
61
|
+
def self.map_numericality(validation, base_type, array)
|
|
62
|
+
return "" if array || !NUMERIC_ZOD_TYPES.include?(base_type)
|
|
61
63
|
|
|
62
64
|
validation.options.filter_map do |key, value|
|
|
63
65
|
method = NUMERICALITY_MAP[key]
|
|
64
|
-
".#{method}(#{value})" if method
|
|
66
|
+
".#{method}(#{value})" if method && static_number?(value)
|
|
65
67
|
end.join
|
|
66
68
|
end
|
|
67
69
|
|
|
68
|
-
def self.map_format(validation, base_type)
|
|
69
|
-
return ""
|
|
70
|
+
def self.map_format(validation, base_type, array)
|
|
71
|
+
return "" if array || !STRING_ZOD_TYPES.include?(base_type)
|
|
70
72
|
|
|
71
73
|
regex = validation.options[:with]
|
|
72
74
|
return "" unless regex
|
|
73
75
|
|
|
74
|
-
|
|
75
|
-
".regex(/#{js_pattern}/#{regex_flags_for(regex)})"
|
|
76
|
+
RegexpMapper.call(regex)&.then { |expression| ".regex(#{expression})" } || ""
|
|
76
77
|
end
|
|
77
78
|
|
|
78
|
-
def self.map_inclusion(validation, base_type)
|
|
79
|
+
def self.map_inclusion(validation, base_type, array)
|
|
80
|
+
return "" if array
|
|
81
|
+
|
|
79
82
|
values = validation.options[:in] || validation.options[:within]
|
|
80
83
|
return "" unless values.is_a?(Array)
|
|
81
84
|
|
|
@@ -101,51 +104,47 @@ module ZodRails
|
|
|
101
104
|
end
|
|
102
105
|
|
|
103
106
|
def self.build_string_enum_suffix(values)
|
|
104
|
-
quoted = values.map { |
|
|
107
|
+
quoted = values.map { |value| JSON.generate(value) }.join(", ")
|
|
105
108
|
".pipe(z.enum([#{quoted}]))"
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
def self.build_numeric_literal_suffix(values)
|
|
112
|
+
return nil unless values.all? { |value| static_number?(value) }
|
|
109
113
|
return ".pipe(z.literal(#{values.first}))" if values.length == 1
|
|
110
114
|
|
|
111
115
|
literals = values.map { |v| "z.literal(#{v})" }.join(", ")
|
|
112
116
|
".pipe(z.union([#{literals}]))"
|
|
113
117
|
end
|
|
114
118
|
|
|
115
|
-
def self.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def self.convert_ruby_regex_to_js(regex)
|
|
120
|
-
pattern = regex.source
|
|
121
|
-
pattern = pattern.gsub("\\A", "^")
|
|
122
|
-
pattern.gsub(/\\z/i, "$")
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def self.regex_flags_for(regex)
|
|
126
|
-
flags = +""
|
|
127
|
-
flags << "i" if regex.options.anybits?(Regexp::IGNORECASE)
|
|
128
|
-
flags
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def self.collect_constraints(validation, base_type, constraints)
|
|
132
|
-
return if validation.conditional?
|
|
119
|
+
def self.collect_constraints(validation, base_type, constraints, array)
|
|
120
|
+
return if validation.conditional? || no_op_presence?(validation)
|
|
121
|
+
return collect_array_constraints(validation, base_type, constraints) if array
|
|
133
122
|
|
|
134
123
|
case validation.kind
|
|
135
|
-
when :presence then handle_presence_constraint(base_type, constraints)
|
|
136
|
-
when :length then handle_length_constraint(validation, base_type, constraints)
|
|
124
|
+
when :presence then handle_presence_constraint(base_type, constraints, false)
|
|
125
|
+
when :length then handle_length_constraint(validation, base_type, constraints, false)
|
|
137
126
|
when :numericality then handle_numericality_constraint(validation, base_type, constraints)
|
|
138
127
|
when :format then handle_format_constraint(validation, base_type, constraints)
|
|
139
128
|
when :inclusion then handle_inclusion_constraint(validation, base_type, constraints)
|
|
140
129
|
end
|
|
141
130
|
end
|
|
142
131
|
|
|
143
|
-
def self.
|
|
144
|
-
|
|
132
|
+
def self.collect_array_constraints(validation, base_type, constraints)
|
|
133
|
+
case validation.kind
|
|
134
|
+
when :presence then handle_presence_constraint(base_type, constraints, true)
|
|
135
|
+
when :length then handle_length_constraint(validation, base_type, constraints, true)
|
|
136
|
+
end
|
|
145
137
|
end
|
|
146
138
|
|
|
147
|
-
def self.
|
|
148
|
-
return unless STRING_ZOD_TYPES.include?(base_type)
|
|
139
|
+
def self.handle_presence_constraint(base_type, constraints, array)
|
|
140
|
+
return unless array || STRING_ZOD_TYPES.include?(base_type)
|
|
141
|
+
|
|
142
|
+
constraints[:min] = [constraints[:min] || 0, 1].max
|
|
143
|
+
constraints[:others] << presence_suffix unless array
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.handle_length_constraint(validation, base_type, constraints, array)
|
|
147
|
+
return unless array || STRING_ZOD_TYPES.include?(base_type)
|
|
149
148
|
|
|
150
149
|
opts = validation.options
|
|
151
150
|
constraints[:length] = opts[:is] if opts[:is]
|
|
@@ -159,8 +158,8 @@ module ZodRails
|
|
|
159
158
|
regex = validation.options[:with]
|
|
160
159
|
return unless regex
|
|
161
160
|
|
|
162
|
-
|
|
163
|
-
constraints[:others] << ".regex(
|
|
161
|
+
expression = RegexpMapper.call(regex)
|
|
162
|
+
constraints[:others] << ".regex(#{expression})" if expression
|
|
164
163
|
end
|
|
165
164
|
|
|
166
165
|
def self.build_chain(constraints)
|
|
@@ -169,7 +168,7 @@ module ZodRails
|
|
|
169
168
|
if constraints[:length]
|
|
170
169
|
parts << ".length(#{constraints[:length]})"
|
|
171
170
|
else
|
|
172
|
-
parts << ".min(#{constraints[:min]})"
|
|
171
|
+
parts << ".min(#{constraints[:min]})" unless constraints[:min].nil?
|
|
173
172
|
parts << ".max(#{constraints[:max].to_i})" if constraints[:max] && constraints[:max] != Float::INFINITY
|
|
174
173
|
end
|
|
175
174
|
|
|
@@ -181,16 +180,15 @@ module ZodRails
|
|
|
181
180
|
values = validation.options[:in] || validation.options[:within]
|
|
182
181
|
|
|
183
182
|
case values
|
|
184
|
-
when Range then apply_range_inclusion(values, constraints)
|
|
183
|
+
when Range then apply_range_inclusion(values, base_type, constraints)
|
|
185
184
|
when Array then apply_array_inclusion(values, base_type, constraints)
|
|
186
185
|
end
|
|
187
186
|
end
|
|
188
187
|
|
|
189
|
-
def self.apply_range_inclusion(range, constraints)
|
|
190
|
-
return unless
|
|
188
|
+
def self.apply_range_inclusion(range, base_type, constraints)
|
|
189
|
+
return unless NUMERIC_ZOD_TYPES.include?(base_type)
|
|
191
190
|
|
|
192
|
-
|
|
193
|
-
constraints[:max] = [constraints[:max] || Float::INFINITY, range.end].min
|
|
191
|
+
apply_numeric_range(range, constraints)
|
|
194
192
|
end
|
|
195
193
|
|
|
196
194
|
def self.apply_array_inclusion(values, base_type, constraints)
|
|
@@ -204,27 +202,40 @@ module ZodRails
|
|
|
204
202
|
validation.options.each do |key, value|
|
|
205
203
|
if key == :in
|
|
206
204
|
apply_numeric_range(value, constraints)
|
|
207
|
-
elsif (method = NUMERICALITY_MAP[key])
|
|
205
|
+
elsif (method = NUMERICALITY_MAP[key]) && static_number?(value)
|
|
208
206
|
constraints[:others] << ".#{method}(#{value})"
|
|
209
207
|
end
|
|
210
208
|
end
|
|
211
209
|
end
|
|
212
210
|
|
|
213
211
|
def self.apply_numeric_range(range, constraints)
|
|
214
|
-
return unless range.is_a?(Range)
|
|
212
|
+
return unless range.is_a?(Range)
|
|
213
|
+
|
|
214
|
+
constraints[:others] << ".gte(#{range.begin})" if static_number?(range.begin)
|
|
215
|
+
return unless static_number?(range.end)
|
|
215
216
|
|
|
216
|
-
constraints[:
|
|
217
|
-
constraints[:max] = [constraints[:max] || Float::INFINITY, range.end].min
|
|
217
|
+
constraints[:others] << ".#{range.exclude_end? ? "lt" : "lte"}(#{range.end})"
|
|
218
218
|
end
|
|
219
219
|
|
|
220
|
+
def self.presence_suffix
|
|
221
|
+
'.refine((value) => value.trim().length > 0, { message: "can\'t be blank" })'
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def self.static_number?(value)
|
|
225
|
+
value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def self.no_op_presence?(validation) = validation.kind == :presence && validation.options[:allow_blank]
|
|
229
|
+
|
|
220
230
|
private_class_method :map_presence, :map_length, :map_numericality, :map_format, :map_inclusion,
|
|
221
231
|
:build_array_inclusion_suffix, :string_array_for_string_type?,
|
|
222
232
|
:numeric_array_for_numeric_type?, :build_string_enum_suffix,
|
|
223
|
-
:build_numeric_literal_suffix, :
|
|
224
|
-
:
|
|
233
|
+
:build_numeric_literal_suffix, :collect_constraints, :collect_array_constraints,
|
|
234
|
+
:build_chain,
|
|
225
235
|
:handle_presence_constraint, :handle_length_constraint, :handle_format_constraint,
|
|
226
236
|
:handle_inclusion_constraint, :apply_range_inclusion, :apply_array_inclusion,
|
|
227
|
-
:handle_numericality_constraint, :apply_numeric_range
|
|
237
|
+
:handle_numericality_constraint, :apply_numeric_range, :presence_suffix, :static_number?,
|
|
238
|
+
:no_op_presence?
|
|
228
239
|
end
|
|
229
240
|
end
|
|
230
241
|
end
|
|
@@ -5,14 +5,38 @@ module ZodRails
|
|
|
5
5
|
def self.resolve(names)
|
|
6
6
|
resolved = []
|
|
7
7
|
missing = []
|
|
8
|
+
invalid = []
|
|
8
9
|
|
|
9
10
|
names.each do |name|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+
|
|
12
20
|
missing << name
|
|
13
21
|
end
|
|
14
22
|
|
|
15
|
-
{ resolved: resolved, missing: missing }
|
|
23
|
+
{ resolved: resolved, missing: missing, invalid: invalid }
|
|
16
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?
|
|
17
41
|
end
|
|
18
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"
|
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.1
|
|
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,20 +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
|
|
53
91
|
- lib/zod_rails/model_resolver.rb
|
|
54
92
|
- lib/zod_rails/railtie.rb
|
|
55
93
|
- lib/zod_rails/version.rb
|
|
56
94
|
- sig/zod_rails.rbs
|
|
57
|
-
- zod_rails.png
|
|
58
95
|
homepage: https://github.com/mathisto/zod_rails
|
|
59
96
|
licenses:
|
|
60
97
|
- MIT
|
|
61
98
|
metadata:
|
|
62
99
|
homepage_uri: https://github.com/mathisto/zod_rails
|
|
63
100
|
source_code_uri: https://github.com/mathisto/zod_rails
|
|
64
|
-
changelog_uri: https://github.com/mathisto/zod_rails/blob/
|
|
101
|
+
changelog_uri: https://github.com/mathisto/zod_rails/blob/trunk/CHANGELOG.md
|
|
65
102
|
rubygems_mfa_required: 'true'
|
|
66
103
|
post_install_message:
|
|
67
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,93 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
namespace :zod_rails do
|
|
4
|
-
desc "Generate Zod schemas for configured models (DRY_RUN=1 to preview only)"
|
|
5
|
-
task generate: :environment do
|
|
6
|
-
config = ZodRails.configuration
|
|
7
|
-
generator = ZodRails::Generator.new(output_dir: config.output_dir)
|
|
8
|
-
|
|
9
|
-
if config.models.empty?
|
|
10
|
-
puts "No models configured. Add models to ZodRails.configure { |c| c.models = ['User', 'Article'] }"
|
|
11
|
-
exit 1
|
|
12
|
-
end
|
|
13
|
-
|
|
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
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
desc "Generate Zod schema for a specific model"
|
|
74
|
-
task :generate_model, [:model_name] => :environment do |_t, args|
|
|
75
|
-
model_name = args[:model_name]
|
|
76
|
-
unless model_name
|
|
77
|
-
puts "Usage: rails zod_rails:generate_model[ModelName]"
|
|
78
|
-
exit 1
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
config = ZodRails.configuration
|
|
82
|
-
generator = ZodRails::Generator.new(output_dir: config.output_dir)
|
|
83
|
-
|
|
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)
|
|
91
|
-
puts "Generated: #{filename}"
|
|
92
|
-
end
|
|
93
|
-
end
|
data/zod_rails.png
DELETED
|
Binary file
|