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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abcbdb41173e1bb789719ff85fbf595a619daf0cdb08ec260e0532601af9db96
4
- data.tar.gz: 5649df9de3c18bd6d81347e12f23552d75420c8a5b5166d9d325797162ec0196
3
+ metadata.gz: 2d23bb8daf103c59a7cb41cd7eff8a7e2346ab19171228283ab990214e7aa1a6
4
+ data.tar.gz: 22b5712ffa475a57026411d6e9f2345e42a56999f3efc5f2e7ee8b63a3d39449
5
5
  SHA512:
6
- metadata.gz: 281121330df0dbbb626d99103db9ad4a63c8691a850f0419fb58f3de42bb3fc6be44d1b404473ae46830b8ea66dabad1e195b0e980a6b68888f4f026a607d24e
7
- data.tar.gz: dff40d3630dd26e743db6b48472c906fc416ad5a5bf7503340f6708eaf5105a6e13dd96dd6042a78437a232b01d6e2ff3ed6a4a85938f0cf3a609f2a200a5d17
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.string()` |
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(/regex/)` (preserves `/i` case-insensitivity) |
143
- | `inclusion: { in: n..m }` (Range) | `.min(n).max(m)` |
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 produces `z.enum(["pending", "approved"])` as the base type. `presence: true` becomes redundant once the values are restricted, so it's dropped from the chain.
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(/^[^@\s]+@[^@\s]+$/),
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(/^[^@\s]+@[^@\s]+$/),
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:) # rubocop:disable Lint/UnusedMethodArgument
27
- content
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 ? "InputSchema" : "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
- "#{column.name}: #{type_str}"
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 (values = string_array_inclusion_values(column))
41
- build_inclusion_enum_type(column, values, 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)
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 string_array_inclusion_values(column)
48
- return nil unless STRING_TYPES.include?(column.type)
61
+ def string_array_inclusion(column, validations)
62
+ return nil if column.array || !STRING_TYPES.include?(column.type)
49
63
 
50
- inclusion = inspector.validations_for(column.name).find { |v| string_array_inclusion?(v) }
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, values, input_schema:)
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
- values,
64
- nullable: column.nullable,
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
- nullable: column.nullable,
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.nullable,
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 = derive_type_name(schema_name)
6
+ def emit(schema_name:, schema_body:, type_name: nil)
7
+ type_name ||= derive_type_name(schema_name)
8
8
 
9
9
  <<~TYPESCRIPT
10
10
  import { z } from "zod";
@@ -16,8 +16,8 @@ module ZodRails
16
16
  end
17
17
 
18
18
  def emit_combined(response:, input:)
19
- response_type = derive_type_name(response[:name])
20
- input_type = derive_type_name(input[:name])
19
+ response_type = response[:type_name] || derive_type_name(response[:name])
20
+ input_type = input[:type_name] || derive_type_name(input[:name])
21
21
 
22
22
  <<~TYPESCRIPT
23
23
  import { z } from "zod";
@@ -35,7 +35,7 @@ module ZodRails
35
35
  private
36
36
 
37
37
  def derive_type_name(schema_name)
38
- schema_name.sub(/Schema$/, "").sub(/InputSchema$/, "Input")
38
+ schema_name.sub(/InputSchema$/, "Input").sub(/Schema$/, "")
39
39
  end
40
40
  end
41
41
  end
@@ -18,27 +18,32 @@ module ZodRails
18
18
 
19
19
  def generate_content(model_class)
20
20
  inspector = Introspection::ModelInspector.new(model_class)
21
- excluded = ZodRails.configuration.excluded_columns
22
- builder = Generation::SchemaBuilder.new(inspector, excluded_columns: excluded)
21
+ config = ZodRails.configuration
22
+ builder = Generation::SchemaBuilder.new(
23
+ inspector,
24
+ excluded_columns: config.excluded_columns,
25
+ schema_suffix: config.schema_suffix,
26
+ input_schema_suffix: config.input_schema_suffix
27
+ )
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
- input_schema = {
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
- files = model_classes.map { |klass| generate(klass) }
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, has_default: 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 { |k| "\"#{escape_quotes(k)}\"" }
9
- base = "z.enum([#{quoted.join(", ")}])"
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.escape_quotes(str)
16
- str.to_s.gsub('"', '\\"')
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 :escape_quotes, :determine_suffix
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.string()",
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
- time: "z.string()",
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, has_default: 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
- STRING_ZOD_TYPES.include?(base_type) ? ".min(1)" : ""
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 "" unless NUMERIC_ZOD_TYPES.include?(base_type)
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 "" unless STRING_ZOD_TYPES.include?(base_type)
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
- js_pattern = convert_ruby_regex_to_js(regex)
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 { |v| %("#{escape_quotes(v)}") }.join(", ")
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.escape_quotes(str)
116
- str.to_s.gsub('"', '\\"')
117
- end
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.handle_presence_constraint(base_type, constraints)
144
- constraints[:min] = [constraints[:min] || 0, 1].max if STRING_ZOD_TYPES.include?(base_type)
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.handle_length_constraint(validation, base_type, constraints)
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
- js_pattern = convert_ruby_regex_to_js(regex)
163
- constraints[:others] << ".regex(/#{js_pattern}/#{regex_flags_for(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]})" if constraints[:min]&.positive?
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 range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
188
+ def self.apply_range_inclusion(range, base_type, constraints)
189
+ return unless NUMERIC_ZOD_TYPES.include?(base_type)
191
190
 
192
- constraints[:min] = [constraints[:min] || 0, range.begin].max
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) && range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
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[:min] = [constraints[:min] || 0, range.begin].max
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, :escape_quotes,
224
- :convert_ruby_regex_to_js, :regex_flags_for, :collect_constraints, :build_chain,
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
- resolved << Object.const_get(name)
11
- rescue NameError
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZodRails
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.1"
5
5
  end
data/lib/zod_rails.rb CHANGED
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "logger"
4
+ require "json"
5
+ require "active_record"
4
6
  require_relative "zod_rails/version"
5
7
  require_relative "zod_rails/configuration"
6
8
  require_relative "zod_rails/mapping/type_mapper"
9
+ require_relative "zod_rails/mapping/regexp_mapper"
7
10
  require_relative "zod_rails/mapping/validation_mapper"
8
11
  require_relative "zod_rails/mapping/enum_mapper"
9
12
  require_relative "zod_rails/introspection/column_info"
data/sig/zod_rails.rbs CHANGED
@@ -1,4 +1,38 @@
1
1
  module ZodRails
2
2
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
3
+
4
+ class Error < StandardError
5
+ end
6
+
7
+ def self.logger: () -> Logger
8
+ def self.logger=: (Logger) -> Logger
9
+ def self.configuration: () -> Configuration
10
+ def self.configure: [A] () { (Configuration) -> A } -> A
11
+ def self.reset_configuration!: () -> Configuration
12
+
13
+ class Configuration
14
+ attr_accessor output_dir: String
15
+ attr_accessor schema_suffix: String
16
+ attr_accessor input_schema_suffix: String
17
+ attr_accessor generate_input_schemas: bool
18
+ attr_accessor excluded_columns: Array[String]
19
+ attr_accessor models: Array[String]
20
+ attr_accessor post_generate_command: String?
21
+
22
+ def initialize: () -> void
23
+ end
24
+
25
+ class Generator
26
+ attr_reader output_dir: String
27
+
28
+ def initialize: (output_dir: String) -> void
29
+ def generate: (singleton(ActiveRecord::Base)) -> String
30
+ def generate_content: (singleton(ActiveRecord::Base)) -> Hash[Symbol, String]
31
+ def generate_all: (Array[singleton(ActiveRecord::Base)]) -> Array[String]
32
+ def check: (Array[singleton(ActiveRecord::Base)]) -> Array[Hash[Symbol, String | Symbol]]
33
+ end
34
+
35
+ module ModelResolver
36
+ def self.resolve: (Array[String]) -> Hash[Symbol, Array[untyped]]
37
+ end
4
38
  end
metadata CHANGED
@@ -1,15 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zod_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.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-05-27 00:00:00.000000000 Z
11
+ date: 2026-08-03 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
13
33
  - !ruby/object:Gem::Dependency
14
34
  name: logger
15
35
  requirement: !ruby/object:Gem::Requirement
@@ -24,6 +44,26 @@ dependencies:
24
44
  - - "~>"
25
45
  - !ruby/object:Gem::Version
26
46
  version: '1.6'
47
+ - !ruby/object:Gem::Dependency
48
+ name: railties
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.0'
54
+ - - "<"
55
+ - !ruby/object:Gem::Version
56
+ version: '9'
57
+ type: :runtime
58
+ prerelease: false
59
+ version_requirements: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '7.0'
64
+ - - "<"
65
+ - !ruby/object:Gem::Version
66
+ version: '9'
27
67
  description: Ruby gem that introspects ActiveRecord models and generates TypeScript
28
68
  files with Zod schemas for type-safe frontend validation
29
69
  email:
@@ -32,12 +72,9 @@ executables: []
32
72
  extensions: []
33
73
  extra_rdoc_files: []
34
74
  files:
35
- - ".github/workflows/ci.yml"
36
- - ".github/workflows/release.yml"
75
+ - CHANGELOG.md
37
76
  - LICENSE
38
77
  - README.md
39
- - Rakefile
40
- - lib/tasks/zod_rails.rake
41
78
  - lib/zod_rails.rb
42
79
  - lib/zod_rails/configuration.rb
43
80
  - lib/zod_rails/generation/file_writer.rb
@@ -48,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/main/CHANGELOG.md
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: []
@@ -1,32 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [trunk]
6
- pull_request:
7
- branches: [trunk]
8
- workflow_call:
9
-
10
- jobs:
11
- test:
12
- name: Ruby ${{ matrix.ruby-version }}
13
- runs-on: ubuntu-latest
14
- strategy:
15
- fail-fast: false
16
- matrix:
17
- ruby-version: ['3.2', '3.3', '3.4']
18
-
19
- steps:
20
- - uses: actions/checkout@v4
21
-
22
- - name: Set up Ruby ${{ matrix.ruby-version }}
23
- uses: ruby/setup-ruby@v1
24
- with:
25
- ruby-version: ${{ matrix.ruby-version }}
26
- bundler-cache: true
27
-
28
- - name: Run tests
29
- run: bundle exec rspec --format documentation
30
-
31
- - name: Run linter
32
- run: bundle exec rubocop --parallel
@@ -1,33 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- jobs:
9
- test:
10
- uses: ./.github/workflows/ci.yml
11
-
12
- release:
13
- needs: test
14
- runs-on: ubuntu-latest
15
-
16
- permissions:
17
- contents: write
18
- id-token: write
19
-
20
- steps:
21
- - uses: actions/checkout@v4
22
-
23
- - name: Set up Ruby
24
- uses: ruby/setup-ruby@v1
25
- with:
26
- ruby-version: "3.3"
27
- bundler-cache: true
28
-
29
- - name: Build gem
30
- run: gem build *.gemspec
31
-
32
- - name: Publish to RubyGems
33
- uses: rubygems/release-gem@v1
data/Rakefile DELETED
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
5
-
6
- RSpec::Core::RakeTask.new(:spec)
7
-
8
- require "rubocop/rake_task"
9
-
10
- RuboCop::RakeTask.new
11
-
12
- task default: %i[spec rubocop]
@@ -1,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