zod_rails 0.3.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: f8e1e956f2954b2200c39a4b467529911806337883bd2674b31b7a271ef560a8
4
- data.tar.gz: 681139d70905f6c34ee190f629f437e015a2eb1313a76940d42b0eaf6a8ffabe
3
+ metadata.gz: 2d23bb8daf103c59a7cb41cd7eff8a7e2346ab19171228283ab990214e7aa1a6
4
+ data.tar.gz: 22b5712ffa475a57026411d6e9f2345e42a56999f3efc5f2e7ee8b63a3d39449
5
5
  SHA512:
6
- metadata.gz: 30a4ce819ee99ac5bcdd83e482c16cd2029136af74e6e64c04a03960c8640991cef144eab6562eb431a0205c655c857687e78f023ffe66aaa9d8c53a953a7b1e
7
- data.tar.gz: 7744055379a755592beadb1010992deeba5ef0fac4d0ea5719bd67d98191264d9671d0a4ee6806e2c62a7e97e0a69997d0bd8f80eca7ed6002c52d59ae7c236c
6
+ metadata.gz: 18f8e43a66304819f9d0e14a50822c2c869a79da08c54772d2775f6393b590f55f156774f7404003ce2a169654138b83d4e136d4ce581be558100651136a03c6
7
+ data.tar.gz: 9ecd30fe73d9a791e9642be2895308becd45752c9d1e84435e9a82be7a882c8a6bd80b307e0629d7d96c4c2ef873907da064478d03f908febb50aa4d32884309
data/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ All notable changes to ZodRails are documented here.
4
4
 
5
5
  ## Unreleased
6
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
+
7
25
  ## 0.3.0 - 2026-08-03
8
26
 
9
27
  ### Upgrade Notes
data/README.md CHANGED
@@ -112,19 +112,24 @@ 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
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:
@@ -346,7 +351,15 @@ For custom types not in the mapping table, ZodRails falls back to `z.unknown()`.
346
351
 
347
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.
348
353
 
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.
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.
350
363
 
351
364
  ### Misconfigured model names
352
365
 
@@ -380,8 +393,13 @@ After checking out the repo:
380
393
  ```bash
381
394
  bundle install
382
395
  bundle exec rspec
396
+ bun install --frozen-lockfile
397
+ bun run test:contracts
383
398
  ```
384
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
+
385
403
  ## Contributing
386
404
 
387
405
  1. Fork it
@@ -59,7 +59,7 @@ module ZodRails
59
59
  end
60
60
 
61
61
  def string_array_inclusion(column, validations)
62
- return nil unless STRING_TYPES.include?(column.type)
62
+ return nil if column.array || !STRING_TYPES.include?(column.type)
63
63
 
64
64
  validations.find { |validation| string_array_inclusion?(validation) }
65
65
  end
@@ -75,10 +75,11 @@ module ZodRails
75
75
  remaining = validations.reject { |validation| validation.equal?(inclusion) }
76
76
  Mapping::EnumMapper.call(
77
77
  inclusion.options[:in],
78
- validation_chain: Mapping::ValidationMapper.call_all(remaining, base_type: :string),
78
+ validation_chain: Mapping::ValidationMapper.call_all(remaining, base_type: :string, array: column.array),
79
79
  nullable: nullable?(column, validations),
80
80
  input_schema: input_schema,
81
- has_default: column.has_default
81
+ has_default: column.has_default,
82
+ array: column.array
82
83
  )
83
84
  end
84
85
 
@@ -86,10 +87,12 @@ module ZodRails
86
87
  values = inspector.enums[column.name]
87
88
  Mapping::EnumMapper.call(
88
89
  values,
89
- validation_chain: Mapping::ValidationMapper.call_all(validations, base_type: :string),
90
+ validation_chain: Mapping::ValidationMapper.call_all(validations, base_type: :string, array: column.array),
90
91
  nullable: nullable?(column, validations),
91
92
  input_schema: input_schema,
92
- 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)
93
96
  )
94
97
  end
95
98
 
@@ -98,10 +101,12 @@ module ZodRails
98
101
  column.type,
99
102
  nullable: nullable?(column, validations),
100
103
  input_schema: input_schema,
101
- 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)
102
107
  )
103
108
 
104
- 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)
105
110
  insert_validation_chain(base_type, validation_chain)
106
111
  end
107
112
 
@@ -126,6 +131,13 @@ module ZodRails
126
131
  end
127
132
  end
128
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
+
129
141
  def filtered_columns
130
142
  inspector.columns.reject { |col| excluded_columns.include?(col.name) }
131
143
  end
@@ -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
 
@@ -19,7 +20,8 @@ module ZodRails
19
20
  type: column.type,
20
21
  nullable: column.null,
21
22
  has_default: !column.default.nil? ||
22
- (column.respond_to?(:default_function) && !column.default_function.nil?)
23
+ (column.respond_to?(:default_function) && !column.default_function.nil?),
24
+ array: column.respond_to?(:array?) && column.array?
23
25
  )
24
26
  end
25
27
 
@@ -28,12 +30,13 @@ module ZodRails
28
30
  name == other.name &&
29
31
  type == other.type &&
30
32
  nullable == other.nullable &&
31
- has_default == other.has_default
33
+ has_default == other.has_default &&
34
+ array == other.array
32
35
  end
33
36
  alias eql? ==
34
37
 
35
38
  def hash
36
- [self.class, name, type, nullable, has_default].hash
39
+ [self.class, name, type, nullable, has_default, array].hash
37
40
  end
38
41
  end
39
42
  end
@@ -3,16 +3,30 @@
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, validation_chain: "")
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
11
  quoted = names.map { |name| JSON.generate(name.to_s) }
9
12
  enum = "z.enum([#{quoted.join(", ")}])"
10
- base = validation_chain.empty? ? enum : "z.string()#{validation_chain}.pipe(#{enum})"
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
11
20
 
12
21
  suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
13
22
  "#{base}#{suffix}"
14
23
  end
15
24
 
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}"
28
+ end
29
+
16
30
  def self.determine_suffix(nullable:, input_schema:, has_default:)
17
31
  return "" unless nullable || has_default
18
32
 
@@ -23,7 +37,7 @@ module ZodRails
23
37
  end
24
38
  end
25
39
 
26
- private_class_method :determine_suffix
40
+ private_class_method :array_schema, :determine_suffix
27
41
  end
28
42
  end
29
43
  end
@@ -7,25 +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
15
  datetime: "z.iso.datetime({ offset: true })",
16
16
  timestamp: "z.iso.datetime({ offset: true })",
17
- time: "z.string()",
17
+ time: "z.iso.datetime({ offset: true })",
18
18
  json: "z.json()",
19
19
  jsonb: "z.json()",
20
20
  uuid: "z.uuid()",
21
21
  binary: "z.string()"
22
22
  }.freeze
23
23
 
24
- 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, "")
25
27
  base = TYPE_MAP.fetch(type.to_sym) do
26
28
  ZodRails.logger.warn("ZodRails: Unknown type '#{type}', falling back to z.unknown()")
27
29
  "z.unknown()"
28
30
  end
31
+ base = "#{base}#{element_validation_chain}"
32
+ base = "z.array(#{base})" if array
29
33
 
30
34
  suffix = determine_suffix(nullable: nullable, input_schema: input_schema, has_default: has_default)
31
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:)
16
+ def self.call(validation, base_type:, array: false)
17
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)
39
+ def self.map_presence(_validation, base_type, array)
40
+ return ".min(1)" if array
41
+
40
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,8 +58,8 @@ 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]
@@ -65,8 +67,8 @@ module ZodRails
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
@@ -74,7 +76,9 @@ module ZodRails
74
76
  RegexpMapper.call(regex)&.then { |expression| ".regex(#{expression})" } || ""
75
77
  end
76
78
 
77
- def self.map_inclusion(validation, base_type)
79
+ def self.map_inclusion(validation, base_type, array)
80
+ return "" if array
81
+
78
82
  values = validation.options[:in] || validation.options[:within]
79
83
  return "" unless values.is_a?(Array)
80
84
 
@@ -112,27 +116,35 @@ module ZodRails
112
116
  ".pipe(z.union([#{literals}]))"
113
117
  end
114
118
 
115
- def self.collect_constraints(validation, base_type, constraints)
119
+ def self.collect_constraints(validation, base_type, constraints, array)
116
120
  return if validation.conditional? || no_op_presence?(validation)
121
+ return collect_array_constraints(validation, base_type, constraints) if array
117
122
 
118
123
  case validation.kind
119
- when :presence then handle_presence_constraint(base_type, constraints)
120
- 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)
121
126
  when :numericality then handle_numericality_constraint(validation, base_type, constraints)
122
127
  when :format then handle_format_constraint(validation, base_type, constraints)
123
128
  when :inclusion then handle_inclusion_constraint(validation, base_type, constraints)
124
129
  end
125
130
  end
126
131
 
127
- def self.handle_presence_constraint(base_type, constraints)
128
- return unless 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
137
+ end
138
+
139
+ def self.handle_presence_constraint(base_type, constraints, array)
140
+ return unless array || STRING_ZOD_TYPES.include?(base_type)
129
141
 
130
142
  constraints[:min] = [constraints[:min] || 0, 1].max
131
- constraints[:others] << presence_suffix
143
+ constraints[:others] << presence_suffix unless array
132
144
  end
133
145
 
134
- def self.handle_length_constraint(validation, base_type, constraints)
135
- return unless STRING_ZOD_TYPES.include?(base_type)
146
+ def self.handle_length_constraint(validation, base_type, constraints, array)
147
+ return unless array || STRING_ZOD_TYPES.include?(base_type)
136
148
 
137
149
  opts = validation.options
138
150
  constraints[:length] = opts[:is] if opts[:is]
@@ -213,14 +225,13 @@ module ZodRails
213
225
  value.is_a?(Numeric) && (!value.respond_to?(:finite?) || value.finite?)
214
226
  end
215
227
 
216
- def self.no_op_presence?(validation)
217
- validation.kind == :presence && validation.options[:allow_blank]
218
- end
228
+ def self.no_op_presence?(validation) = validation.kind == :presence && validation.options[:allow_blank]
219
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, :collect_constraints, :build_chain,
233
+ :build_numeric_literal_suffix, :collect_constraints, :collect_array_constraints,
234
+ :build_chain,
224
235
  :handle_presence_constraint, :handle_length_constraint, :handle_format_constraint,
225
236
  :handle_inclusion_constraint, :apply_range_inclusion, :apply_array_inclusion,
226
237
  :handle_numericality_constraint, :apply_numeric_range, :presence_suffix, :static_number?,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ZodRails
4
- VERSION = "0.3.0"
4
+ VERSION = "0.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zod_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Kelly