zod_rails 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abcbdb41173e1bb789719ff85fbf595a619daf0cdb08ec260e0532601af9db96
4
- data.tar.gz: 5649df9de3c18bd6d81347e12f23552d75420c8a5b5166d9d325797162ec0196
3
+ metadata.gz: f8e1e956f2954b2200c39a4b467529911806337883bd2674b31b7a271ef560a8
4
+ data.tar.gz: 681139d70905f6c34ee190f629f437e015a2eb1313a76940d42b0eaf6a8ffabe
5
5
  SHA512:
6
- metadata.gz: 281121330df0dbbb626d99103db9ad4a63c8691a850f0419fb58f3de42bb3fc6be44d1b404473ae46830b8ea66dabad1e195b0e980a6b68888f4f026a607d24e
7
- data.tar.gz: dff40d3630dd26e743db6b48472c906fc416ad5a5bf7503340f6708eaf5105a6e13dd96dd6042a78437a232b01d6e2ff3ed6a4a85938f0cf3a609f2a200a5d17
6
+ metadata.gz: 30a4ce819ee99ac5bcdd83e482c16cd2029136af74e6e64c04a03960c8640991cef144eab6562eb431a0205c655c857687e78f023ffe66aaa9d8c53a953a7b1e
7
+ data.tar.gz: 7744055379a755592beadb1010992deeba5ef0fac4d0ea5719bd67d98191264d9671d0a4ee6806e2c62a7e97e0a69997d0bd8f80eca7ed6002c52d59ae7c236c
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to ZodRails are documented here.
4
+
5
+ ## Unreleased
6
+
7
+ ## 0.3.0 - 2026-08-03
8
+
9
+ ### Upgrade Notes
10
+
11
+ - Regenerate committed schemas after upgrading. Presence, range, regular-expression, and datetime output has changed
12
+ to more closely match Rails behavior.
13
+ - `schema_suffix`, `input_schema_suffix`, and `generate_input_schemas` now take effect. Applications that configured
14
+ these previously ignored options will see the configured output for the first time.
15
+ - Configured model names must resolve to concrete ActiveRecord models; nonmodels and abstract models now produce a
16
+ targeted configuration error.
17
+
18
+ ### Fixed
19
+
20
+ - Honor custom schema suffixes and `generate_input_schemas`.
21
+ - Generate safe TypeScript for unusual column names, enum values, and regular expressions.
22
+ - Preserve negative, zero, exclusive, beginless, and endless numeric range bounds.
23
+ - Match Rails presence semantics for whitespace-only strings and required nullable columns.
24
+ - Accept ISO datetimes with timezone offsets and detect database expression defaults.
25
+ - Keep preserved custom blocks from causing false drift reports.
26
+ - Detect output filename collisions before writing files.
27
+
28
+ ### Changed
29
+
30
+ - Declare ActiveRecord and Railties as runtime dependencies.
31
+ - Reject configured constants that are not concrete ActiveRecord models.
32
+ - Skip dynamic numericality constraints and Ruby extended-mode regular expressions instead of emitting invalid TypeScript.
33
+
34
+ ## 0.2.0
35
+
36
+ - Added response and input schema generation, enums, validation mapping, namespaced models, drift detection,
37
+ custom block preservation, dry runs, and formatter integration.
data/README.md CHANGED
@@ -118,7 +118,7 @@ end
118
118
  | `decimal` | `z.string()` (preserves `BigDecimal` precision) |
119
119
  | `boolean` | `z.boolean()` |
120
120
  | `date` | `z.iso.date()` |
121
- | `datetime`, `timestamp` | `z.iso.datetime()` |
121
+ | `datetime`, `timestamp` | `z.iso.datetime({ offset: true })` |
122
122
  | `json`, `jsonb` | `z.json()` |
123
123
  | `uuid` | `z.uuid()` |
124
124
  | `time` | `z.string()` |
@@ -131,7 +131,7 @@ ZodRails introspects your model validations and maps them to Zod constraints:
131
131
 
132
132
  | Rails Validation | Zod Constraint |
133
133
  |------------------|----------------|
134
- | `presence: true` | `.min(1)` for string/text columns |
134
+ | `presence: true` | `.min(1).refine(...)` for string/text columns, including whitespace-only rejection |
135
135
  | `length: { minimum: n }` | `.min(n)` |
136
136
  | `length: { maximum: n }` | `.max(n)` |
137
137
  | `length: { is: n }` | `.length(n)` |
@@ -139,8 +139,8 @@ ZodRails introspects your model validations and maps them to Zod constraints:
139
139
  | `numericality: { greater_than_or_equal_to: n }` | `.gte(n)` |
140
140
  | `numericality: { less_than: n }` | `.lt(n)` |
141
141
  | `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)` |
142
+ | `format: { with: /regex/ }` | `.regex(new RegExp(...))` with safe escaping and compatible flags |
143
+ | `inclusion: { in: n..m }` (Range) | `.gte(n).lte(m)`; exclusive and open-ended bounds are preserved |
144
144
  | `inclusion: { in: %w[a b c] }` (Array, string column) | `z.enum(["a", "b", "c"])` as the base type |
145
145
  | `inclusion: { in: [1, 5, 10] }` (Array, integer column) | `.pipe(z.union([z.literal(1), z.literal(5), z.literal(10)]))` |
146
146
 
@@ -149,7 +149,7 @@ ZodRails introspects your model validations and maps them to Zod constraints:
149
149
  The Rails `enum` macro and a string column with `validates :foo, inclusion: { in: %w[...] }` both end up as `z.enum([...])` in the generated TypeScript:
150
150
 
151
151
  - `enum :role, { member: 0, admin: 1 }` introspects through ActiveRecord's `defined_enums` and emits `z.enum(["member", "admin"])`.
152
- - `validates :decision, inclusion: { in: %w[pending approved] }` on a string column is detected by `SchemaBuilder` and produces `z.enum(["pending", "approved"])` as the base type. `presence: true` becomes redundant once the values are restricted, so it's dropped from the chain.
152
+ - `validates :decision, inclusion: { in: %w[pending approved] }` on a string column is detected by `SchemaBuilder` and restricts the result with `z.enum(["pending", "approved"])`. Other compatible validators on the attribute are retained before the enum restriction.
153
153
 
154
154
  If you mix both (`enum` macro AND a separate `inclusion` validator on the same column), the `enum` macro wins.
155
155
 
@@ -175,20 +175,20 @@ import { z } from "zod";
175
175
 
176
176
  export const UserSchema = z.object({
177
177
  id: z.int(),
178
- email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
179
- name: z.string().min(2).max(100),
178
+ email: z.string().min(1).refine((value) => value.trim().length > 0).regex(new RegExp("^[^@\\s]+@[^@\\s]+$")),
179
+ name: z.string().min(2).max(100).refine((value) => value.trim().length > 0),
180
180
  age: z.int().gt(0).lt(150).nullable(),
181
181
  status: z.enum(["pending", "active", "suspended"]),
182
182
  role: z.enum(["member", "admin", "moderator"]),
183
- created_at: z.iso.datetime(),
184
- updated_at: z.iso.datetime()
183
+ created_at: z.iso.datetime({ offset: true }),
184
+ updated_at: z.iso.datetime({ offset: true })
185
185
  });
186
186
 
187
187
  export type User = z.infer<typeof UserSchema>;
188
188
 
189
189
  export const UserInputSchema = z.object({
190
- email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
191
- name: z.string().min(2).max(100),
190
+ email: z.string().min(1).refine((value) => value.trim().length > 0).regex(new RegExp("^[^@\\s]+@[^@\\s]+$")),
191
+ name: z.string().min(2).max(100).refine((value) => value.trim().length > 0),
192
192
  age: z.int().gt(0).lt(150).nullish(),
193
193
  status: z.enum(["pending", "active", "suspended"]),
194
194
  role: z.enum(["member", "admin", "moderator"]).optional()
@@ -266,6 +266,8 @@ end
266
266
 
267
267
  The command runs with your project's working directory. A nonzero exit raises `ZodRails::Error` so CI catches misconfiguration, and the generated files are still written before the formatter runs.
268
268
 
269
+ Because `zod_rails:check` compares generated bytes, a formatter that rewrites those bytes can report drift. Prefer a formatter configuration that accepts the generated style, or run the same deterministic formatter before checking committed files.
270
+
269
271
  ## Integrating with Forms
270
272
 
271
273
  ZodRails pairs well with form libraries that support Zod:
@@ -340,6 +342,12 @@ Ensure validations are defined on the model class, not in concerns that might no
340
342
 
341
343
  For custom types not in the mapping table, ZodRails falls back to `z.unknown()`. Open an issue if you need support for additional types.
342
344
 
345
+ ### Serialization and validation limits
346
+
347
+ ZodRails generates a useful static approximation of database columns and unconditional model validations. It cannot reproduce validations that require database access, another attribute, or runtime model state. Conditional and context-specific validations, dynamic numericality values, uniqueness, and unsupported Ruby regular-expression modes are skipped.
348
+
349
+ The generated wire types must match your serializers. In particular, `bigint` and `decimal` map to strings to avoid JavaScript precision loss; configure your API serializer to emit strings for those attributes. Review generated schemas when using adapter-specific or custom ActiveRecord types.
350
+
343
351
  ### Misconfigured model names
344
352
 
345
353
  A typo in `config.models` no longer raises an `uninitialized constant` backtrace. The generator collects every unresolvable name and prints them all in one report:
@@ -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)
61
+ def string_array_inclusion(column, validations)
48
62
  return nil unless 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,30 +71,32 @@ 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),
79
+ nullable: nullable?(column, validations),
65
80
  input_schema: input_schema,
66
81
  has_default: column.has_default
67
82
  )
68
83
  end
69
84
 
70
- def build_enum_type(column, input_schema:)
85
+ def build_enum_type(column, validations, input_schema:)
71
86
  values = inspector.enums[column.name]
72
87
  Mapping::EnumMapper.call(
73
88
  values,
74
- nullable: column.nullable,
89
+ validation_chain: Mapping::ValidationMapper.call_all(validations, base_type: :string),
90
+ nullable: nullable?(column, validations),
75
91
  input_schema: input_schema,
76
92
  has_default: column.has_default
77
93
  )
78
94
  end
79
95
 
80
- def build_regular_type(column, input_schema:)
81
- validations = inspector.validations_for(column.name)
96
+ def build_regular_type(column, validations, input_schema:)
82
97
  base_type = Mapping::TypeMapper.call(
83
98
  column.type,
84
- nullable: column.nullable,
99
+ nullable: nullable?(column, validations),
85
100
  input_schema: input_schema,
86
101
  has_default: column.has_default
87
102
  )
@@ -104,6 +119,13 @@ module ZodRails
104
119
  inspector.enums.key?(column_name)
105
120
  end
106
121
 
122
+ def nullable?(column, validations)
123
+ column.nullable && validations.none? do |validation|
124
+ validation.kind == :presence && !validation.conditional? &&
125
+ !validation.options[:allow_nil] && !validation.options[:allow_blank]
126
+ end
127
+ end
128
+
107
129
  def filtered_columns
108
130
  inspector.columns.reject { |col| excluded_columns.include?(col.name) }
109
131
  end
@@ -3,8 +3,8 @@
3
3
  module ZodRails
4
4
  module Generation
5
5
  class TypescriptEmitter
6
- def emit(schema_name:, schema_body:)
7
- type_name = 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?
@@ -18,7 +18,8 @@ module ZodRails
18
18
  name: column.name,
19
19
  type: column.type,
20
20
  nullable: column.null,
21
- has_default: !column.default.nil?
21
+ has_default: !column.default.nil? ||
22
+ (column.respond_to?(:default_function) && !column.default_function.nil?)
22
23
  )
23
24
  end
24
25
 
@@ -3,19 +3,16 @@
3
3
  module ZodRails
4
4
  module Mapping
5
5
  class EnumMapper
6
- def self.call(values, nullable: false, input_schema: false, has_default: false)
6
+ def self.call(values, nullable: false, input_schema: false, has_default: false, validation_chain: "")
7
7
  names = values.is_a?(Hash) ? values.keys : values
8
- quoted = names.map { |k| "\"#{escape_quotes(k)}\"" }
9
- base = "z.enum([#{quoted.join(", ")}])"
8
+ quoted = names.map { |name| JSON.generate(name.to_s) }
9
+ enum = "z.enum([#{quoted.join(", ")}])"
10
+ base = validation_chain.empty? ? enum : "z.string()#{validation_chain}.pipe(#{enum})"
10
11
 
11
12
  suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
12
13
  "#{base}#{suffix}"
13
14
  end
14
15
 
15
- def self.escape_quotes(str)
16
- str.to_s.gsub('"', '\\"')
17
- end
18
-
19
16
  def self.determine_suffix(nullable:, input_schema:, has_default:)
20
17
  return "" unless nullable || has_default
21
18
 
@@ -26,7 +23,7 @@ module ZodRails
26
23
  end
27
24
  end
28
25
 
29
- private_class_method :escape_quotes, :determine_suffix
26
+ private_class_method :determine_suffix
30
27
  end
31
28
  end
32
29
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZodRails
4
+ module Mapping
5
+ class RegexpMapper
6
+ UNSUPPORTED_TOKENS = %w[\\K \\R (?> (?~].freeze
7
+
8
+ def self.call(regex)
9
+ if unsupported?(regex)
10
+ ZodRails.logger.warn("ZodRails: Skipping regexp with Ruby-only semantics: #{regex.inspect}")
11
+ return nil
12
+ end
13
+
14
+ pattern = regex.source
15
+ .gsub("\\A", "^")
16
+ .gsub("\\z", "(?![\\s\\S])")
17
+ .gsub("\\Z", "(?=(?:\\n)?(?![\\s\\S]))")
18
+ flags = flags_for(regex)
19
+ args = [JSON.generate(pattern), (JSON.generate(flags) unless flags.empty?)].compact
20
+ "new RegExp(#{args.join(", ")})"
21
+ end
22
+
23
+ def self.flags_for(regex)
24
+ flags = +""
25
+ flags << "i" if regex.options.anybits?(Regexp::IGNORECASE)
26
+ flags << "s" if regex.options.anybits?(Regexp::MULTILINE)
27
+ flags
28
+ end
29
+
30
+ def self.unsupported?(regex)
31
+ regex.options.anybits?(Regexp::EXTENDED) || UNSUPPORTED_TOKENS.any? { |token| regex.source.include?(token) }
32
+ end
33
+ private_class_method :flags_for, :unsupported?
34
+ end
35
+ end
36
+ end
@@ -12,7 +12,8 @@ module ZodRails
12
12
  decimal: "z.string()",
13
13
  boolean: "z.boolean()",
14
14
  date: "z.iso.date()",
15
- datetime: "z.iso.datetime()",
15
+ datetime: "z.iso.datetime({ offset: true })",
16
+ timestamp: "z.iso.datetime({ offset: true })",
16
17
  time: "z.string()",
17
18
  json: "z.json()",
18
19
  jsonb: "z.json()",
@@ -14,7 +14,7 @@ module ZodRails
14
14
  STRING_ZOD_TYPES = %i[string text].freeze
15
15
 
16
16
  def self.call(validation, base_type:)
17
- return "" if validation.conditional?
17
+ return "" if validation.conditional? || no_op_presence?(validation)
18
18
 
19
19
  case validation.kind
20
20
  when :presence then map_presence(validation, base_type)
@@ -37,7 +37,7 @@ module ZodRails
37
37
  end
38
38
 
39
39
  def self.map_presence(_validation, base_type)
40
- STRING_ZOD_TYPES.include?(base_type) ? ".min(1)" : ""
40
+ STRING_ZOD_TYPES.include?(base_type) ? ".min(1)#{presence_suffix}" : ""
41
41
  end
42
42
 
43
43
  def self.map_length(validation, base_type)
@@ -61,7 +61,7 @@ module ZodRails
61
61
 
62
62
  validation.options.filter_map do |key, value|
63
63
  method = NUMERICALITY_MAP[key]
64
- ".#{method}(#{value})" if method
64
+ ".#{method}(#{value})" if method && static_number?(value)
65
65
  end.join
66
66
  end
67
67
 
@@ -71,8 +71,7 @@ module ZodRails
71
71
  regex = validation.options[:with]
72
72
  return "" unless regex
73
73
 
74
- js_pattern = convert_ruby_regex_to_js(regex)
75
- ".regex(/#{js_pattern}/#{regex_flags_for(regex)})"
74
+ RegexpMapper.call(regex)&.then { |expression| ".regex(#{expression})" } || ""
76
75
  end
77
76
 
78
77
  def self.map_inclusion(validation, base_type)
@@ -101,35 +100,20 @@ module ZodRails
101
100
  end
102
101
 
103
102
  def self.build_string_enum_suffix(values)
104
- quoted = values.map { |v| %("#{escape_quotes(v)}") }.join(", ")
103
+ quoted = values.map { |value| JSON.generate(value) }.join(", ")
105
104
  ".pipe(z.enum([#{quoted}]))"
106
105
  end
107
106
 
108
107
  def self.build_numeric_literal_suffix(values)
108
+ return nil unless values.all? { |value| static_number?(value) }
109
109
  return ".pipe(z.literal(#{values.first}))" if values.length == 1
110
110
 
111
111
  literals = values.map { |v| "z.literal(#{v})" }.join(", ")
112
112
  ".pipe(z.union([#{literals}]))"
113
113
  end
114
114
 
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
115
  def self.collect_constraints(validation, base_type, constraints)
132
- return if validation.conditional?
116
+ return if validation.conditional? || no_op_presence?(validation)
133
117
 
134
118
  case validation.kind
135
119
  when :presence then handle_presence_constraint(base_type, constraints)
@@ -141,7 +125,10 @@ module ZodRails
141
125
  end
142
126
 
143
127
  def self.handle_presence_constraint(base_type, constraints)
144
- constraints[:min] = [constraints[:min] || 0, 1].max if STRING_ZOD_TYPES.include?(base_type)
128
+ return unless STRING_ZOD_TYPES.include?(base_type)
129
+
130
+ constraints[:min] = [constraints[:min] || 0, 1].max
131
+ constraints[:others] << presence_suffix
145
132
  end
146
133
 
147
134
  def self.handle_length_constraint(validation, base_type, constraints)
@@ -159,8 +146,8 @@ module ZodRails
159
146
  regex = validation.options[:with]
160
147
  return unless regex
161
148
 
162
- js_pattern = convert_ruby_regex_to_js(regex)
163
- constraints[:others] << ".regex(/#{js_pattern}/#{regex_flags_for(regex)})"
149
+ expression = RegexpMapper.call(regex)
150
+ constraints[:others] << ".regex(#{expression})" if expression
164
151
  end
165
152
 
166
153
  def self.build_chain(constraints)
@@ -169,7 +156,7 @@ module ZodRails
169
156
  if constraints[:length]
170
157
  parts << ".length(#{constraints[:length]})"
171
158
  else
172
- parts << ".min(#{constraints[:min]})" if constraints[:min]&.positive?
159
+ parts << ".min(#{constraints[:min]})" unless constraints[:min].nil?
173
160
  parts << ".max(#{constraints[:max].to_i})" if constraints[:max] && constraints[:max] != Float::INFINITY
174
161
  end
175
162
 
@@ -181,16 +168,15 @@ module ZodRails
181
168
  values = validation.options[:in] || validation.options[:within]
182
169
 
183
170
  case values
184
- when Range then apply_range_inclusion(values, constraints)
171
+ when Range then apply_range_inclusion(values, base_type, constraints)
185
172
  when Array then apply_array_inclusion(values, base_type, constraints)
186
173
  end
187
174
  end
188
175
 
189
- def self.apply_range_inclusion(range, constraints)
190
- return unless range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
176
+ def self.apply_range_inclusion(range, base_type, constraints)
177
+ return unless NUMERIC_ZOD_TYPES.include?(base_type)
191
178
 
192
- constraints[:min] = [constraints[:min] || 0, range.begin].max
193
- constraints[:max] = [constraints[:max] || Float::INFINITY, range.end].min
179
+ apply_numeric_range(range, constraints)
194
180
  end
195
181
 
196
182
  def self.apply_array_inclusion(values, base_type, constraints)
@@ -204,27 +190,41 @@ module ZodRails
204
190
  validation.options.each do |key, value|
205
191
  if key == :in
206
192
  apply_numeric_range(value, constraints)
207
- elsif (method = NUMERICALITY_MAP[key])
193
+ elsif (method = NUMERICALITY_MAP[key]) && static_number?(value)
208
194
  constraints[:others] << ".#{method}(#{value})"
209
195
  end
210
196
  end
211
197
  end
212
198
 
213
199
  def self.apply_numeric_range(range, constraints)
214
- return unless range.is_a?(Range) && range.begin.is_a?(Numeric) && range.end.is_a?(Numeric)
200
+ return unless range.is_a?(Range)
201
+
202
+ constraints[:others] << ".gte(#{range.begin})" if static_number?(range.begin)
203
+ return unless static_number?(range.end)
204
+
205
+ constraints[:others] << ".#{range.exclude_end? ? "lt" : "lte"}(#{range.end})"
206
+ end
207
+
208
+ def self.presence_suffix
209
+ '.refine((value) => value.trim().length > 0, { message: "can\'t be blank" })'
210
+ end
211
+
212
+ def self.static_number?(value)
213
+ value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
214
+ end
215
215
 
216
- constraints[:min] = [constraints[:min] || 0, range.begin].max
217
- constraints[:max] = [constraints[:max] || Float::INFINITY, range.end].min
216
+ def self.no_op_presence?(validation)
217
+ validation.kind == :presence && validation.options[:allow_blank]
218
218
  end
219
219
 
220
220
  private_class_method :map_presence, :map_length, :map_numericality, :map_format, :map_inclusion,
221
221
  :build_array_inclusion_suffix, :string_array_for_string_type?,
222
222
  :numeric_array_for_numeric_type?, :build_string_enum_suffix,
223
- :build_numeric_literal_suffix, :escape_quotes,
224
- :convert_ruby_regex_to_js, :regex_flags_for, :collect_constraints, :build_chain,
223
+ :build_numeric_literal_suffix, :collect_constraints, :build_chain,
225
224
  :handle_presence_constraint, :handle_length_constraint, :handle_format_constraint,
226
225
  :handle_inclusion_constraint, :apply_range_inclusion, :apply_array_inclusion,
227
- :handle_numericality_constraint, :apply_numeric_range
226
+ :handle_numericality_constraint, :apply_numeric_range, :presence_suffix, :static_number?,
227
+ :no_op_presence?
228
228
  end
229
229
  end
230
230
  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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kelly
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-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