zod_rails 0.1.4
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 +7 -0
- data/.github/workflows/ci.yml +32 -0
- data/.github/workflows/release.yml +33 -0
- data/LICENSE +21 -0
- data/README.md +282 -0
- data/Rakefile +12 -0
- data/ZOD_RAILS.md +1178 -0
- data/lib/tasks/zod_rails.rake +36 -0
- data/lib/zod_rails/configuration.rb +17 -0
- data/lib/zod_rails/generation/file_writer.rb +37 -0
- data/lib/zod_rails/generation/schema_builder.rb +89 -0
- data/lib/zod_rails/generation/typescript_emitter.rb +42 -0
- data/lib/zod_rails/generator.rb +39 -0
- data/lib/zod_rails/introspection/column_info.rb +39 -0
- data/lib/zod_rails/introspection/model_inspector.rb +34 -0
- data/lib/zod_rails/introspection/validation_info.rb +48 -0
- data/lib/zod_rails/mapping/enum_mapper.rb +31 -0
- data/lib/zod_rails/mapping/type_mapper.rb +46 -0
- data/lib/zod_rails/mapping/validation_mapper.rb +163 -0
- data/lib/zod_rails/railtie.rb +19 -0
- data/lib/zod_rails/version.rb +5 -0
- data/lib/zod_rails.rb +40 -0
- data/sig/zod_rails.rbs +4 -0
- data/zod_rails.png +0 -0
- metadata +85 -0
data/ZOD_RAILS.md
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
# ZodRails: Complete Architectural Plan
|
|
2
|
+
|
|
3
|
+
> **Status**: Planning Phase
|
|
4
|
+
> **Last Updated**: 2025-01-13
|
|
5
|
+
> **Development Approach**: Prose-Driven TDD with RSpec
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Problem Statement](#1-problem-statement)
|
|
12
|
+
2. [Summary of Decisions](#2-summary-of-decisions)
|
|
13
|
+
3. [Gem File Structure](#3-gem-file-structure)
|
|
14
|
+
4. [Type Mapping Tables](#4-type-mapping-tables)
|
|
15
|
+
5. [Validation Mapping Tables](#5-validation-mapping-tables)
|
|
16
|
+
6. [Prose-Driven RSpec Specifications](#6-prose-driven-rspec-specifications)
|
|
17
|
+
7. [Example Generated Output](#7-example-generated-output)
|
|
18
|
+
8. [Configuration DSL](#8-configuration-dsl)
|
|
19
|
+
9. [Implementation Order](#9-implementation-order)
|
|
20
|
+
10. [Open Questions](#10-open-questions)
|
|
21
|
+
11. [Research Findings](#11-research-findings)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 1. Problem Statement
|
|
26
|
+
|
|
27
|
+
### The "Type Gap"
|
|
28
|
+
|
|
29
|
+
The core issue is that **ActiveRecord (Ruby)** and **Zod (TypeScript)** speak two different languages regarding data structure.
|
|
30
|
+
|
|
31
|
+
- **Implicit vs. Explicit**: Rails models are "magic." Attributes are discovered at runtime by querying the database schema. Zod requires explicit, static definitions.
|
|
32
|
+
|
|
33
|
+
- **Type Mapping**: A Rails `:datetime` needs to become a `z.coerce.date()`. A `:string` with a `validates :presence` needs to be `z.string().min(1)`, while an optional one needs `.nullable()`.
|
|
34
|
+
|
|
35
|
+
- **The Sync Problem**: Every time you run a migration in Rails, your Zod schemas are instantly out of date. Without a gem, you are manually typing the same structure in two places, which is the primary source of "undefined is not a function" errors in production.
|
|
36
|
+
|
|
37
|
+
### Why This Matters
|
|
38
|
+
|
|
39
|
+
Building this gem solves a major pain point in the Rails ecosystem. Currently, developers have to choose between:
|
|
40
|
+
|
|
41
|
+
- **Manual duplication** (Error prone)
|
|
42
|
+
- **JSON Schema intermediate steps** (Overly complex)
|
|
43
|
+
- **TypeScript Interfaces** (No runtime protection)
|
|
44
|
+
|
|
45
|
+
By creating a gem that outputs **Zod Schemas** specifically, you provide the frontend with a "contract" that actually has teeth—it won't just tell you the data is a string; it will crash safely or handle the error if the backend sends a null.
|
|
46
|
+
|
|
47
|
+
### Market Gap
|
|
48
|
+
|
|
49
|
+
**No gem exists that directly generates Zod schemas from Rails/ActiveRecord.** Research confirmed:
|
|
50
|
+
|
|
51
|
+
- `zod_rails` ❌ unclaimed
|
|
52
|
+
- `rails_zod` ❌ unclaimed
|
|
53
|
+
- `active_record_zod` ❌ unclaimed
|
|
54
|
+
|
|
55
|
+
The closest solutions are either archived, don't read from ActiveRecord, or require manual DSL definitions.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 2. Summary of Decisions
|
|
60
|
+
|
|
61
|
+
| Decision | Choice | Rationale |
|
|
62
|
+
|----------|--------|-----------|
|
|
63
|
+
| **Gem Name** | `zod_rails` | Clear, follows Rails gem conventions |
|
|
64
|
+
| **Output Strategy** | File generation + optional file watcher | Deterministic, version-controllable |
|
|
65
|
+
| **File Watcher** | `listen` (optional dependency) | Already in most Rails apps, actively maintained |
|
|
66
|
+
| **Validation Mapping** | Tier 1 + 2 for v1 | Covers 90% of use cases |
|
|
67
|
+
| **Association Strategy** | Columns-only by default | `belongs_to` FKs only; `has_one`/`has_many` require opt-in |
|
|
68
|
+
| **Schema Types** | Generate both Response + Input schemas | `ModelSchema` (full) + `ModelInputSchema` (forms) |
|
|
69
|
+
| **Property Naming** | `snake_case` by default | Matches Rails API responses; `camelCase` opt-in |
|
|
70
|
+
| **Serializer Awareness** | v2 roadmap item | Keep v1 scope focused |
|
|
71
|
+
| **Enum Handling** | String keys (modern Rails convention) | Matches typical API responses |
|
|
72
|
+
| **Development Approach** | Prose-driven TDD with RSpec | Specs as executable documentation |
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## 3. Gem File Structure
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
zod_rails/
|
|
80
|
+
├── .rspec # RSpec configuration
|
|
81
|
+
├── .rubocop.yml # Code style (optional)
|
|
82
|
+
├── Gemfile # Gem dependencies
|
|
83
|
+
├── LICENSE.txt # MIT License
|
|
84
|
+
├── README.md # Documentation
|
|
85
|
+
├── ROADMAP.md # Completed + Future features
|
|
86
|
+
├── ARCHITECTURE.md # This document
|
|
87
|
+
├── Rakefile # Gem build tasks
|
|
88
|
+
├── zod_rails.gemspec # Gem specification
|
|
89
|
+
│
|
|
90
|
+
├── lib/
|
|
91
|
+
│ ├── zod_rails.rb # Main entry point
|
|
92
|
+
│ ├── zod_rails/
|
|
93
|
+
│ │ ├── version.rb # Version constant
|
|
94
|
+
│ │ ├── configuration.rb # Configuration DSL
|
|
95
|
+
│ │ ├── railtie.rb # Rails integration (rake tasks)
|
|
96
|
+
│ │ │
|
|
97
|
+
│ │ ├── introspection/ # Reading from ActiveRecord
|
|
98
|
+
│ │ │ ├── model_inspector.rb # Reads columns, validators, enums
|
|
99
|
+
│ │ │ ├── column_info.rb # Value object for column metadata
|
|
100
|
+
│ │ │ └── validation_info.rb # Value object for validation metadata
|
|
101
|
+
│ │ │
|
|
102
|
+
│ │ ├── mapping/ # Type translation logic
|
|
103
|
+
│ │ │ ├── type_mapper.rb # Rails type → Zod type
|
|
104
|
+
│ │ │ ├── validation_mapper.rb # Rails validation → Zod chain
|
|
105
|
+
│ │ │ └── enum_mapper.rb # Rails enum → z.enum()
|
|
106
|
+
│ │ │
|
|
107
|
+
│ │ ├── generation/ # Output generation
|
|
108
|
+
│ │ │ ├── schema_builder.rb # Builds Zod schema string per model
|
|
109
|
+
│ │ │ ├── file_writer.rb # Writes the .ts file
|
|
110
|
+
│ │ │ └── typescript_emitter.rb # Formats TypeScript output
|
|
111
|
+
│ │ │
|
|
112
|
+
│ │ └── associations/ # Association handling
|
|
113
|
+
│ │ ├── association_resolver.rb # Determines how to represent assocs
|
|
114
|
+
│ │ └── nesting_strategy.rb # ID-only vs nested objects
|
|
115
|
+
│ │
|
|
116
|
+
│ └── tasks/
|
|
117
|
+
│ └── zod_rails.rake # Rake tasks (generate, watch)
|
|
118
|
+
│
|
|
119
|
+
└── spec/
|
|
120
|
+
├── spec_helper.rb # RSpec configuration
|
|
121
|
+
├── support/
|
|
122
|
+
│ ├── rails_app/ # Dummy Rails app for integration tests
|
|
123
|
+
│ └── model_helpers.rb # Test model factories
|
|
124
|
+
│
|
|
125
|
+
├── zod_rails/
|
|
126
|
+
│ ├── configuration_spec.rb
|
|
127
|
+
│ ├── introspection/
|
|
128
|
+
│ │ └── model_inspector_spec.rb
|
|
129
|
+
│ ├── mapping/
|
|
130
|
+
│ │ ├── type_mapper_spec.rb
|
|
131
|
+
│ │ ├── validation_mapper_spec.rb
|
|
132
|
+
│ │ └── enum_mapper_spec.rb
|
|
133
|
+
│ ├── generation/
|
|
134
|
+
│ │ ├── schema_builder_spec.rb
|
|
135
|
+
│ │ └── file_writer_spec.rb
|
|
136
|
+
│ └── associations/
|
|
137
|
+
│ └── association_resolver_spec.rb
|
|
138
|
+
│
|
|
139
|
+
└── integration/
|
|
140
|
+
├── full_generation_spec.rb # End-to-end tests
|
|
141
|
+
└── rails_integration_spec.rb # Rake task tests
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## 4. Type Mapping Tables
|
|
147
|
+
|
|
148
|
+
> **Zod Version**: This gem targets **Zod 4** (stable as of 2025). Zod 4 introduces top-level string formats and number formats that we prefer over the deprecated method equivalents.
|
|
149
|
+
|
|
150
|
+
### Column Types → Zod Types
|
|
151
|
+
|
|
152
|
+
| Rails Type | Zod Type | Notes |
|
|
153
|
+
|------------|----------|-------|
|
|
154
|
+
| `:string` | `z.string()` | |
|
|
155
|
+
| `:text` | `z.string()` | |
|
|
156
|
+
| `:integer` | `z.int()` | Zod 4 top-level, replaces `z.number().int()` |
|
|
157
|
+
| `:bigint` | `z.string()` | Avoids JS safe integer overflow; use `z.int()` only if values guaranteed safe |
|
|
158
|
+
| `:float` | `z.number()` | |
|
|
159
|
+
| `:decimal` | `z.string()` | Preserves precision; `z.number()` opt-in via config |
|
|
160
|
+
| `:boolean` | `z.boolean()` | |
|
|
161
|
+
| `:date` | `z.iso.date()` | ISO date string by default; `z.coerce.date()` opt-in |
|
|
162
|
+
| `:datetime` | `z.iso.datetime()` | ISO datetime string; `z.coerce.date()` opt-in for Date objects |
|
|
163
|
+
| `:time` | `z.string()` | ISO time string |
|
|
164
|
+
| `:json` / `:jsonb` | `z.json()` | Zod 4 native; accepts any JSON-encodable value |
|
|
165
|
+
| `:uuid` | `z.uuid()` | Zod 4 top-level |
|
|
166
|
+
| `:binary` | `z.string()` | Base64 encoded |
|
|
167
|
+
| `:array` (PostgreSQL) | `z.array(innerType)` | Depends on inner type |
|
|
168
|
+
|
|
169
|
+
### Zod 4 Number Formats (for strict typing)
|
|
170
|
+
|
|
171
|
+
| Use Case | Zod Type | Range |
|
|
172
|
+
|----------|----------|-------|
|
|
173
|
+
| General integer | `z.int()` | `[Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]` |
|
|
174
|
+
| 32-bit signed | `z.int32()` | `[-2147483648, 2147483647]` |
|
|
175
|
+
| 32-bit unsigned | `z.uint32()` | `[0, 4294967295]` |
|
|
176
|
+
| 32-bit float | `z.float32()` | IEEE 754 single precision |
|
|
177
|
+
| 64-bit float | `z.float64()` | IEEE 754 double precision |
|
|
178
|
+
|
|
179
|
+
These can be used via configuration for stricter database-aware typing.
|
|
180
|
+
|
|
181
|
+
### Nullability vs Optionality (CRITICAL)
|
|
182
|
+
|
|
183
|
+
Zod distinguishes three concepts that Rails conflates:
|
|
184
|
+
|
|
185
|
+
| Zod Method | Meaning | Use Case |
|
|
186
|
+
|------------|---------|----------|
|
|
187
|
+
| `.nullable()` | Accepts `null` value | Column allows NULL in DB |
|
|
188
|
+
| `.optional()` | Key can be missing (`undefined`) | PATCH payloads, sparse responses |
|
|
189
|
+
| `.nullish()` | Accepts `null` OR `undefined` | Flexible API contracts |
|
|
190
|
+
|
|
191
|
+
**Mapping Rules:**
|
|
192
|
+
|
|
193
|
+
| Rails Condition | Response Schema | Input Schema |
|
|
194
|
+
|-----------------|-----------------|--------------|
|
|
195
|
+
| `null: false` in DB | Required (no modifier) | Required |
|
|
196
|
+
| `null: true` in DB | `.nullable()` | `.nullish()` |
|
|
197
|
+
| `validates :presence` | Removes `.nullable()` | `.min(1)` for strings |
|
|
198
|
+
| `allow_nil: true` on validator | Preserves `.nullable()` | `.nullable()` |
|
|
199
|
+
| `allow_blank: true` on validator | Allows empty string | No `.min(1)` |
|
|
200
|
+
| DB default exists | Required in response | `.optional()` in input |
|
|
201
|
+
|
|
202
|
+
### Nullability Rules (Legacy - Simplified)
|
|
203
|
+
|
|
204
|
+
| Condition | Zod Output |
|
|
205
|
+
|-----------|------------|
|
|
206
|
+
| `null: false` in schema | Base type (required) |
|
|
207
|
+
| `null: true` or unspecified | `.nullable()` |
|
|
208
|
+
| `validates :presence` | `.min(1)` for strings, removes nullable |
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 5. Validation Mapping Tables
|
|
213
|
+
|
|
214
|
+
### Tier 1: Essential Validations (v1)
|
|
215
|
+
|
|
216
|
+
| Rails Validation | Zod Equivalent |
|
|
217
|
+
|------------------|----------------|
|
|
218
|
+
| `presence: true` | `.min(1)` (string), removes `.nullable()` |
|
|
219
|
+
| `length: { minimum: N }` | `.min(N)` |
|
|
220
|
+
| `length: { maximum: N }` | `.max(N)` |
|
|
221
|
+
| `length: { is: N }` | `.length(N)` |
|
|
222
|
+
| `length: { in: X..Y }` | `.min(X).max(Y)` |
|
|
223
|
+
| `inclusion: { in: [...] }` | `z.enum([...])` (replaces base type) |
|
|
224
|
+
| `numericality: true` | `z.number()` |
|
|
225
|
+
| `numericality: { only_integer: true }` | `.int()` |
|
|
226
|
+
| `numericality: { greater_than: N }` | `.gt(N)` |
|
|
227
|
+
| `numericality: { greater_than_or_equal_to: N }` | `.gte(N)` |
|
|
228
|
+
| `numericality: { less_than: N }` | `.lt(N)` |
|
|
229
|
+
| `numericality: { less_than_or_equal_to: N }` | `.lte(N)` |
|
|
230
|
+
| `numericality: { other_than: N }` | `.refine(v => v !== N)` |
|
|
231
|
+
| `numericality: { odd: true }` | `.refine(v => v % 2 === 1)` |
|
|
232
|
+
| `numericality: { even: true }` | `.refine(v => v % 2 === 0)` |
|
|
233
|
+
|
|
234
|
+
### Tier 2: Valuable Validations (v1)
|
|
235
|
+
|
|
236
|
+
| Rails Validation | Zod Equivalent |
|
|
237
|
+
|------------------|----------------|
|
|
238
|
+
| `format: { with: /regex/ }` | `.regex(/regex/)` |
|
|
239
|
+
| `exclusion: { in: [...] }` | `.refine(v => ![...].includes(v))` |
|
|
240
|
+
| `acceptance: true` | `z.literal(true)` or `z.boolean()` |
|
|
241
|
+
| `allow_nil: true` (on any validator) | Preserves `.nullable()` on type |
|
|
242
|
+
| `allow_blank: true` (on string) | Skips `.min(1)` from presence |
|
|
243
|
+
|
|
244
|
+
### Presence on Booleans (CAUTION)
|
|
245
|
+
|
|
246
|
+
Rails `presence: true` on booleans effectively means "must be truthy" because `false.blank? == true`. This is almost always a bug in Rails code.
|
|
247
|
+
|
|
248
|
+
**Our behavior**: Emit warning comment + map to `z.literal(true)`. Recommend user fix to `inclusion: { in: [true, false] }` if they want non-nil boolean.
|
|
249
|
+
|
|
250
|
+
### Validation Deduplication
|
|
251
|
+
|
|
252
|
+
When multiple validations apply to the same constraint, use the **stricter** value:
|
|
253
|
+
|
|
254
|
+
| Combined Validations | Deduped Output |
|
|
255
|
+
|---------------------|----------------|
|
|
256
|
+
| `presence: true` + `length: { minimum: 2 }` | `.min(2)` (not `.min(1).min(2)`) |
|
|
257
|
+
| `numericality: { greater_than: 0 }` + `numericality: { greater_than: 5 }` | `.gt(5)` |
|
|
258
|
+
|
|
259
|
+
The `ValidationMapper` must consolidate constraints before emitting Zod chains.
|
|
260
|
+
|
|
261
|
+
### Regex Conversion Strategy
|
|
262
|
+
|
|
263
|
+
Ruby regexes don't always translate cleanly to JavaScript. Use a **whitelist approach**:
|
|
264
|
+
|
|
265
|
+
| Rails Pattern | Detection | JavaScript Equivalent |
|
|
266
|
+
|--------------|-----------|----------------------|
|
|
267
|
+
| `URI::MailTo::EMAIL_REGEXP` | Check class/constant | `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` |
|
|
268
|
+
| `URI.regexp` | Check class/constant | Known URL regex |
|
|
269
|
+
| Simple patterns | Direct conversion | Escape as needed |
|
|
270
|
+
| Complex/unknown | Emit warning comment | Skip or use `.refine()` |
|
|
271
|
+
|
|
272
|
+
For unrecognized regexes, emit a TODO comment and optionally use `.refine()` with a runtime check.
|
|
273
|
+
|
|
274
|
+
### Zod 4 Built-in Email Regexes
|
|
275
|
+
|
|
276
|
+
Zod 4 provides several email regex options we can leverage:
|
|
277
|
+
|
|
278
|
+
| Rails Pattern | Zod 4 Equivalent |
|
|
279
|
+
|--------------|------------------|
|
|
280
|
+
| `URI::MailTo::EMAIL_REGEXP` | `z.email()` (default Gmail-style) |
|
|
281
|
+
| Loose validation | `z.email({ pattern: z.regexes.unicodeEmail })` |
|
|
282
|
+
| Browser-compatible | `z.email({ pattern: z.regexes.html5Email })` |
|
|
283
|
+
| RFC 5322 strict | `z.email({ pattern: z.regexes.rfc5322Email })` |
|
|
284
|
+
|
|
285
|
+
### Skipped Validations (Server-Side Only)
|
|
286
|
+
|
|
287
|
+
| Rails Validation | Reason |
|
|
288
|
+
|------------------|--------|
|
|
289
|
+
| `uniqueness` | Requires database query |
|
|
290
|
+
| `confirmation` | UI pattern - handled separately in InputSchema |
|
|
291
|
+
| Custom validators with `if:`/`unless:` | Conditional logic |
|
|
292
|
+
| `inclusion: { in: -> { ... } }` | Dynamic proc/lambda - cannot introspect statically |
|
|
293
|
+
|
|
294
|
+
**Dynamic Inclusions**: When `in:` is a proc/lambda, skip with comment. Allow config override to provide static list.
|
|
295
|
+
|
|
296
|
+
### Tier 3: Advanced Validations (v2 Roadmap)
|
|
297
|
+
|
|
298
|
+
| Rails Validation | Potential Zod Equivalent |
|
|
299
|
+
|------------------|--------------------------|
|
|
300
|
+
| Conditional validations (`if:`) | `.refine()` with runtime check |
|
|
301
|
+
| Custom validators | Extension point for user mapping |
|
|
302
|
+
| Associated validations | Nested schema validation |
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## 6. Prose-Driven RSpec Specifications
|
|
307
|
+
|
|
308
|
+
> **Philosophy**: Each `it` statement without a block is a pending spec—our implementation checklist. Write the behavior we want in plain English first, then implement.
|
|
309
|
+
|
|
310
|
+
### `spec/zod_rails/configuration_spec.rb`
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
RSpec.describe ZodRails::Configuration do
|
|
314
|
+
describe "output path" do
|
|
315
|
+
it "defaults to 'app/javascript/schemas/zod_schemas.ts'"
|
|
316
|
+
it "can be configured via initializer"
|
|
317
|
+
it "expands relative paths from Rails.root"
|
|
318
|
+
it "creates the output directory if it does not exist"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
describe "model selection" do
|
|
322
|
+
it "includes all ApplicationRecord descendants by default"
|
|
323
|
+
it "filters out abstract models (abstract_class = true)"
|
|
324
|
+
it "filters out models without tables (table_exists? = false)"
|
|
325
|
+
it "allows explicit model list via 'only' option"
|
|
326
|
+
it "allows exclusion list via 'except' option"
|
|
327
|
+
it "supports glob patterns for model selection"
|
|
328
|
+
it "eager loads Rails app before introspection"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
describe "association handling" do
|
|
332
|
+
it "defaults to :ids_only mode"
|
|
333
|
+
it "can be set to :nested mode globally"
|
|
334
|
+
it "can be overridden per-model"
|
|
335
|
+
it "supports :omit mode to exclude associations entirely"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
describe "enum format" do
|
|
339
|
+
it "defaults to string keys"
|
|
340
|
+
it "can be configured to use integer values"
|
|
341
|
+
it "documents the enum format choice in generated comments"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
describe "custom type mappings" do
|
|
345
|
+
it "allows overriding default type mappings"
|
|
346
|
+
it "allows registering custom column types"
|
|
347
|
+
it "applies custom mappings before defaults"
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
### `spec/zod_rails/introspection/model_inspector_spec.rb`
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
RSpec.describe ZodRails::Introspection::ModelInspector do
|
|
356
|
+
describe "column extraction" do
|
|
357
|
+
it "extracts all column names from an ActiveRecord model"
|
|
358
|
+
it "extracts column types (string, integer, datetime, etc.)"
|
|
359
|
+
it "detects nullability from column definition"
|
|
360
|
+
it "identifies primary key columns"
|
|
361
|
+
it "identifies foreign key columns"
|
|
362
|
+
it "handles PostgreSQL-specific types (uuid, jsonb, array)"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
describe "validation extraction" do
|
|
366
|
+
it "extracts presence validations"
|
|
367
|
+
it "extracts length validations with all options (min, max, is, in)"
|
|
368
|
+
it "extracts inclusion validations with array values"
|
|
369
|
+
it "extracts numericality validations with all constraints"
|
|
370
|
+
it "extracts format validations with regex patterns"
|
|
371
|
+
it "extracts allow_nil option from validators"
|
|
372
|
+
it "extracts allow_blank option from validators"
|
|
373
|
+
it "ignores validations with conditional :if/:unless options"
|
|
374
|
+
it "ignores uniqueness validations (server-side only)"
|
|
375
|
+
it "skips inclusion with proc/lambda :in (dynamic)"
|
|
376
|
+
it "handles multiple validations on same attribute"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
describe "enum extraction" do
|
|
380
|
+
it "extracts Rails enum definitions"
|
|
381
|
+
it "captures enum values as strings"
|
|
382
|
+
it "handles enums with custom database values"
|
|
383
|
+
it "handles enums with prefix/suffix options"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
describe "association extraction" do
|
|
387
|
+
it "extracts belongs_to associations"
|
|
388
|
+
it "extracts has_many associations"
|
|
389
|
+
it "extracts has_one associations"
|
|
390
|
+
it "identifies optional vs required belongs_to"
|
|
391
|
+
it "captures foreign key names"
|
|
392
|
+
it "handles polymorphic associations (marks as v2/unsupported)"
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
### `spec/zod_rails/mapping/type_mapper_spec.rb`
|
|
398
|
+
|
|
399
|
+
```ruby
|
|
400
|
+
RSpec.describe ZodRails::Mapping::TypeMapper do
|
|
401
|
+
describe "primitive type mapping" do
|
|
402
|
+
it "maps :string to z.string()"
|
|
403
|
+
it "maps :text to z.string()"
|
|
404
|
+
it "maps :integer to z.number().int()"
|
|
405
|
+
it "maps :bigint to z.number().int()"
|
|
406
|
+
it "maps :float to z.number()"
|
|
407
|
+
it "maps :decimal to z.number() by default"
|
|
408
|
+
it "maps :decimal to z.string() when precision mode enabled"
|
|
409
|
+
it "maps :boolean to z.boolean()"
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
describe "date and time mapping" do
|
|
413
|
+
it "maps :date to z.coerce.date()"
|
|
414
|
+
it "maps :datetime to z.coerce.date()"
|
|
415
|
+
it "maps :time to z.string() with time format note"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
describe "special type mapping" do
|
|
419
|
+
it "maps :uuid to z.string().uuid()"
|
|
420
|
+
it "maps :json to z.record(z.unknown())"
|
|
421
|
+
it "maps :jsonb to z.record(z.unknown())"
|
|
422
|
+
it "maps :binary to z.string() with base64 note"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
describe "PostgreSQL array types" do
|
|
426
|
+
it "maps string[] to z.array(z.string())"
|
|
427
|
+
it "maps integer[] to z.array(z.number().int())"
|
|
428
|
+
it "preserves nullability on array elements"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
describe "nullability" do
|
|
432
|
+
it "appends .nullable() when column allows null"
|
|
433
|
+
it "does not append .nullable() when null: false"
|
|
434
|
+
it "removes .nullable() when presence validation exists"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
describe "unknown types" do
|
|
438
|
+
it "falls back to z.unknown() for unrecognized types"
|
|
439
|
+
it "emits a warning comment for unknown types"
|
|
440
|
+
it "allows custom type handlers to be registered"
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### `spec/zod_rails/mapping/validation_mapper_spec.rb`
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
RSpec.describe ZodRails::Mapping::ValidationMapper do
|
|
449
|
+
describe "presence validation" do
|
|
450
|
+
it "adds .min(1) to string types"
|
|
451
|
+
it "removes .nullable() from the type"
|
|
452
|
+
it "has no effect on boolean types (false is valid)"
|
|
453
|
+
it "adds .min(1) to array types"
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
describe "length validation" do
|
|
457
|
+
it "maps minimum: N to .min(N)"
|
|
458
|
+
it "maps maximum: N to .max(N)"
|
|
459
|
+
it "maps is: N to .length(N)"
|
|
460
|
+
it "maps in: X..Y to .min(X).max(Y)"
|
|
461
|
+
it "handles length on string types"
|
|
462
|
+
it "handles length on array types"
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
describe "inclusion validation" do
|
|
466
|
+
it "converts to z.enum([...]) when all values are strings"
|
|
467
|
+
it "converts to z.union([z.literal()...]) for mixed types"
|
|
468
|
+
it "preserves the original type when inclusion has :in as Range"
|
|
469
|
+
it "uses .refine() for Range-based inclusion"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
describe "numericality validation" do
|
|
473
|
+
it "confirms base type is z.number()"
|
|
474
|
+
it "adds .int() for only_integer: true"
|
|
475
|
+
it "adds .positive() for greater_than: 0"
|
|
476
|
+
it "adds .nonnegative() for greater_than_or_equal_to: 0"
|
|
477
|
+
it "adds .gt(N) for greater_than: N"
|
|
478
|
+
it "adds .gte(N) for greater_than_or_equal_to: N"
|
|
479
|
+
it "adds .lt(N) for less_than: N"
|
|
480
|
+
it "adds .lte(N) for less_than_or_equal_to: N"
|
|
481
|
+
it "adds .multipleOf(N) for other: N (if supported)"
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
describe "format validation" do
|
|
485
|
+
it "maps format with regex to .regex()"
|
|
486
|
+
it "escapes regex special characters for TypeScript"
|
|
487
|
+
it "converts Ruby regex to JavaScript regex syntax"
|
|
488
|
+
it "handles common Rails regex patterns (email, url)"
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
describe "validation chaining" do
|
|
492
|
+
it "chains multiple validations in correct order"
|
|
493
|
+
it "applies presence before length (removes nullable first)"
|
|
494
|
+
it "applies type-changing validations (inclusion) before chainable ones"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
describe "conditional validations" do
|
|
498
|
+
it "skips validations with :if option"
|
|
499
|
+
it "skips validations with :unless option"
|
|
500
|
+
it "skips validations with :on option (create/update)"
|
|
501
|
+
it "documents skipped validations in comments"
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### `spec/zod_rails/mapping/enum_mapper_spec.rb`
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
RSpec.describe ZodRails::Mapping::EnumMapper do
|
|
510
|
+
describe "basic enum mapping" do
|
|
511
|
+
it "converts enum values to z.enum([...])"
|
|
512
|
+
it "uses string keys by default"
|
|
513
|
+
it "orders values as defined in the model"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
describe "enum with custom values" do
|
|
517
|
+
it "uses the defined keys, not database integers"
|
|
518
|
+
it "handles enums defined with hash syntax"
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
describe "enum with prefix/suffix" do
|
|
522
|
+
it "preserves the base enum values without prefix"
|
|
523
|
+
it "documents the prefix/suffix in comments"
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
describe "enum format configuration" do
|
|
527
|
+
it "can be configured to use integer values"
|
|
528
|
+
it "outputs z.union([z.literal(0), ...]) for integer mode"
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
### `spec/zod_rails/generation/schema_builder_spec.rb`
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
RSpec.describe ZodRails::Generation::SchemaBuilder do
|
|
537
|
+
describe "schema structure" do
|
|
538
|
+
it "generates z.object({...}) wrapper"
|
|
539
|
+
it "includes all mapped columns as properties"
|
|
540
|
+
it "uses snake_case property names by default"
|
|
541
|
+
it "can be configured to use camelCase property names"
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
describe "schema naming" do
|
|
545
|
+
it "names schema after model (UserSchema for User)"
|
|
546
|
+
it "flattens namespaced models (Admin::User → AdminUserSchema)"
|
|
547
|
+
it "generates both schema and inferred type export"
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
describe "dual schema generation" do
|
|
551
|
+
it "generates ModelSchema for response/DB shape"
|
|
552
|
+
it "generates ModelInputSchema omitting id, timestamps, readonly fields"
|
|
553
|
+
it "applies .optional() to fields with DB defaults in InputSchema"
|
|
554
|
+
it "handles confirmation fields in InputSchema when present"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
describe "generated output format" do
|
|
558
|
+
it "includes import statement for zod"
|
|
559
|
+
it "exports each schema as named export"
|
|
560
|
+
it "exports TypeScript type using z.infer<>"
|
|
561
|
+
it "adds JSDoc comments with model name"
|
|
562
|
+
it "sorts schemas alphabetically"
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
describe "association fields" do
|
|
566
|
+
it "adds foreign key fields for belongs_to (column exists)"
|
|
567
|
+
it "does NOT add _id fields for has_one (column on other table)"
|
|
568
|
+
it "does NOT add _ids arrays for has_many by default (virtual)"
|
|
569
|
+
it "adds _ids arrays for has_many when config.include_association_ids = true"
|
|
570
|
+
it "marks optional belongs_to as nullable"
|
|
571
|
+
it "adds nested schema reference in :nested mode"
|
|
572
|
+
it "adds array of nested schemas for has_many in :nested mode"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
describe "topological sorting" do
|
|
576
|
+
it "orders schemas to resolve dependencies (referenced before referencer)"
|
|
577
|
+
it "handles circular dependencies with getter syntax"
|
|
578
|
+
it "emits all leaf models (no outbound associations) first"
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
describe "special cases" do
|
|
582
|
+
it "handles models with no validations"
|
|
583
|
+
it "handles models with no associations"
|
|
584
|
+
it "handles STI models (uses base class columns)"
|
|
585
|
+
it "generates z.literal() for STI type discriminator column"
|
|
586
|
+
it "marks unsupported features with TODO comments"
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### `spec/zod_rails/generation/file_writer_spec.rb`
|
|
592
|
+
|
|
593
|
+
```ruby
|
|
594
|
+
RSpec.describe ZodRails::Generation::FileWriter do
|
|
595
|
+
describe "file output" do
|
|
596
|
+
it "writes to configured output path"
|
|
597
|
+
it "creates parent directories if needed"
|
|
598
|
+
it "overwrites existing file"
|
|
599
|
+
it "adds generation timestamp header"
|
|
600
|
+
it "adds 'do not edit' warning comment"
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
describe "file formatting" do
|
|
604
|
+
it "uses consistent indentation (2 spaces)"
|
|
605
|
+
it "adds trailing newline"
|
|
606
|
+
it "groups imports at top"
|
|
607
|
+
it "separates schemas with blank lines"
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
describe "error handling" do
|
|
611
|
+
it "raises descriptive error if directory not writable"
|
|
612
|
+
it "raises descriptive error if path is invalid"
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
### `spec/zod_rails/associations/association_resolver_spec.rb`
|
|
618
|
+
|
|
619
|
+
```ruby
|
|
620
|
+
RSpec.describe ZodRails::Associations::AssociationResolver do
|
|
621
|
+
describe "belongs_to resolution" do
|
|
622
|
+
it "returns foreign key field name (column exists on this model)"
|
|
623
|
+
it "determines if association is optional"
|
|
624
|
+
it "resolves the associated model class"
|
|
625
|
+
it "detects polymorphic and emits both _type and _id columns"
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
describe "has_many resolution" do
|
|
629
|
+
it "does NOT generate _ids by default (virtual attribute)"
|
|
630
|
+
it "generates _ids when include_association_ids config enabled"
|
|
631
|
+
it "resolves has_many :through to final target model"
|
|
632
|
+
it "detects polymorphic :through and marks unsupported"
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
describe "has_one resolution" do
|
|
636
|
+
it "does NOT generate _id by default (column on other table)"
|
|
637
|
+
it "resolves the associated model class for nested mode"
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
describe "nesting strategy" do
|
|
641
|
+
it "returns ID type for :ids_only mode (belongs_to FK only)"
|
|
642
|
+
it "returns schema reference for :nested mode"
|
|
643
|
+
it "handles circular references with getter syntax (Zod 4 pattern)"
|
|
644
|
+
it "limits nesting depth to configurable level (default: 1)"
|
|
645
|
+
it "switches to IDs at depth limit with comment"
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
describe "polymorphic associations" do
|
|
649
|
+
it "emits actual _type and _id columns with correct types"
|
|
650
|
+
it "uses z.string() for _type column"
|
|
651
|
+
it "infers _id type from column definition (int vs uuid)"
|
|
652
|
+
it "adds TODO comment noting polymorphic limitation"
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
### `spec/integration/full_generation_spec.rb`
|
|
658
|
+
|
|
659
|
+
```ruby
|
|
660
|
+
RSpec.describe "Full schema generation", type: :integration do
|
|
661
|
+
describe "generating from a complete Rails model" do
|
|
662
|
+
it "generates valid TypeScript that compiles without errors"
|
|
663
|
+
it "generates valid Zod that can be imported"
|
|
664
|
+
it "matches expected output for a User model with validations"
|
|
665
|
+
it "matches expected output for a Post model with associations"
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
describe "multi-model generation" do
|
|
669
|
+
it "generates all models in a single file"
|
|
670
|
+
it "orders schemas to resolve dependencies"
|
|
671
|
+
it "handles circular dependencies with z.lazy()"
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
describe "incremental generation" do
|
|
675
|
+
it "regenerates only changed models when configured"
|
|
676
|
+
it "preserves custom additions in separate file"
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
### `spec/integration/rails_integration_spec.rb`
|
|
682
|
+
|
|
683
|
+
```ruby
|
|
684
|
+
RSpec.describe "Rails integration", type: :integration do
|
|
685
|
+
describe "rake zod_rails:generate" do
|
|
686
|
+
it "is available after gem is loaded"
|
|
687
|
+
it "generates schema file to configured path"
|
|
688
|
+
it "outputs success message with file path"
|
|
689
|
+
it "outputs model count in success message"
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
describe "rake zod_rails:watch" do
|
|
693
|
+
it "requires listen gem to be available"
|
|
694
|
+
it "outputs helpful error if listen not installed"
|
|
695
|
+
it "watches configured model paths"
|
|
696
|
+
it "regenerates on .rb file changes"
|
|
697
|
+
it "can be stopped with Ctrl+C"
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
describe "Rails initializer" do
|
|
701
|
+
it "loads configuration from config/initializers/zod_rails.rb"
|
|
702
|
+
it "provides helpful error for invalid configuration"
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
---
|
|
708
|
+
|
|
709
|
+
## 7. Example Generated Output
|
|
710
|
+
|
|
711
|
+
### Input: Rails Model
|
|
712
|
+
|
|
713
|
+
```ruby
|
|
714
|
+
# app/models/user.rb
|
|
715
|
+
class User < ApplicationRecord
|
|
716
|
+
enum role: { member: 0, admin: 1, moderator: 2 }
|
|
717
|
+
|
|
718
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
719
|
+
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
|
|
720
|
+
validates :age, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
|
721
|
+
|
|
722
|
+
has_many :posts
|
|
723
|
+
belongs_to :organization, optional: true
|
|
724
|
+
end
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
### Output: Zod Schema
|
|
728
|
+
|
|
729
|
+
```typescript
|
|
730
|
+
// app/javascript/schemas/zod_schemas.ts
|
|
731
|
+
// Generated by ZodRails - DO NOT EDIT
|
|
732
|
+
// Generated at: 2025-01-13T12:00:00Z
|
|
733
|
+
// Zod version: ^4.0.0
|
|
734
|
+
|
|
735
|
+
import { z } from "zod";
|
|
736
|
+
|
|
737
|
+
// ============================================
|
|
738
|
+
// User model schemas
|
|
739
|
+
// ============================================
|
|
740
|
+
|
|
741
|
+
/** User response schema (database shape) */
|
|
742
|
+
export const UserSchema = z.object({
|
|
743
|
+
id: z.int(),
|
|
744
|
+
email: z.email(),
|
|
745
|
+
name: z.string(),
|
|
746
|
+
age: z.int().gte(0).nullable(),
|
|
747
|
+
role: z.enum(["member", "admin", "moderator"] as const),
|
|
748
|
+
organization_id: z.int().nullable(),
|
|
749
|
+
created_at: z.iso.datetime(),
|
|
750
|
+
updated_at: z.iso.datetime(),
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
export type User = z.infer<typeof UserSchema>;
|
|
754
|
+
|
|
755
|
+
/** User input schema (form submission) */
|
|
756
|
+
export const UserInputSchema = z.object({
|
|
757
|
+
email: z.email().min(1),
|
|
758
|
+
name: z.string().min(2).max(100),
|
|
759
|
+
age: z.int().gte(0).nullish(),
|
|
760
|
+
role: z.enum(["member", "admin", "moderator"] as const).optional(),
|
|
761
|
+
organization_id: z.int().nullish(),
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
export type UserInput = z.infer<typeof UserInputSchema>;
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
**Key differences between schemas:**
|
|
768
|
+
- `UserSchema` (Response): Matches DB shape, all fields present, uses `.nullable()` for NULL columns
|
|
769
|
+
- `UserInputSchema` (Input): Omits `id`/timestamps, uses `.nullish()` for optional fields, applies validation chains
|
|
770
|
+
|
|
771
|
+
---
|
|
772
|
+
|
|
773
|
+
## 8. Configuration DSL
|
|
774
|
+
|
|
775
|
+
```ruby
|
|
776
|
+
# config/initializers/zod_rails.rb
|
|
777
|
+
ZodRails.configure do |config|
|
|
778
|
+
# Output path (relative to Rails.root)
|
|
779
|
+
config.output_path = "app/javascript/schemas/zod_schemas.ts"
|
|
780
|
+
|
|
781
|
+
# Model selection
|
|
782
|
+
config.only = %w[User Post Comment] # nil = all models
|
|
783
|
+
config.except = %w[ActiveStorage::Blob]
|
|
784
|
+
|
|
785
|
+
# Schema generation modes
|
|
786
|
+
config.generate_input_schemas = true # Generate ModelInputSchema alongside ModelSchema
|
|
787
|
+
|
|
788
|
+
# Association handling
|
|
789
|
+
config.associations = :columns_only # :columns_only | :include_ids | :nested | :omit
|
|
790
|
+
# :columns_only = Only belongs_to foreign keys (actual columns)
|
|
791
|
+
# :include_ids = Add virtual _ids arrays for has_many (opt-in)
|
|
792
|
+
# :nested = Reference other schemas directly
|
|
793
|
+
# :omit = Exclude all association fields
|
|
794
|
+
|
|
795
|
+
config.nesting_depth = 1 # Max depth for :nested mode before switching to IDs
|
|
796
|
+
|
|
797
|
+
# Property naming (IMPORTANT: must match your API serializer)
|
|
798
|
+
config.property_case = :snake # :snake | :camel
|
|
799
|
+
# WARNING: Rails APIs default to snake_case. Only use :camel if your
|
|
800
|
+
# serializer transforms keys (e.g., ActiveModelSerializers key_transform)
|
|
801
|
+
|
|
802
|
+
# Enum format (string keys = modern Rails convention)
|
|
803
|
+
config.enum_format = :string # :string | :integer
|
|
804
|
+
|
|
805
|
+
# Timestamp handling
|
|
806
|
+
config.include_timestamps = true # true | false
|
|
807
|
+
|
|
808
|
+
# Primary key handling
|
|
809
|
+
config.include_primary_key = true # true | false
|
|
810
|
+
|
|
811
|
+
# Date/time format
|
|
812
|
+
config.datetime_format = :iso_string # :iso_string | :date_object
|
|
813
|
+
# :iso_string = z.iso.datetime() - keeps as string (recommended)
|
|
814
|
+
# :date_object = z.coerce.date() - parses to Date object
|
|
815
|
+
|
|
816
|
+
# BigInt handling
|
|
817
|
+
config.bigint_format = :string # :string | :number
|
|
818
|
+
# :string = Safe for all values (recommended)
|
|
819
|
+
# :number = z.int() - DANGER: overflows beyond Number.MAX_SAFE_INTEGER
|
|
820
|
+
|
|
821
|
+
# Custom type mappings (advanced)
|
|
822
|
+
config.type_mappings[:money] = "z.string()" # Or with chain: "z.string().regex(/^\\d+\\.\\d{2}$/)"
|
|
823
|
+
|
|
824
|
+
# Paths to watch (for file watcher)
|
|
825
|
+
config.watch_paths = ["app/models"]
|
|
826
|
+
end
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
---
|
|
830
|
+
|
|
831
|
+
## 9. Implementation Order
|
|
832
|
+
|
|
833
|
+
After prose specs are written and approved:
|
|
834
|
+
|
|
835
|
+
### Phase 1: Foundation
|
|
836
|
+
1. `version.rb` - Version constant
|
|
837
|
+
2. `configuration.rb` - Configuration DSL (all options)
|
|
838
|
+
3. Gem structure and gemspec
|
|
839
|
+
|
|
840
|
+
### Phase 2: Introspection
|
|
841
|
+
4. `ModelInspector` - Read columns, validators, enums from AR models
|
|
842
|
+
5. `ColumnInfo` - Value object for column metadata
|
|
843
|
+
6. `ValidationInfo` - Value object for validation metadata (including allow_nil/allow_blank)
|
|
844
|
+
|
|
845
|
+
### Phase 3: Associations (BEFORE Generation)
|
|
846
|
+
7. `AssociationResolver` - Determines how to represent associations
|
|
847
|
+
8. `NestingStrategy` - Columns-only vs nested, depth limits
|
|
848
|
+
|
|
849
|
+
### Phase 4: Mapping
|
|
850
|
+
9. `TypeMapper` - Rails type → Zod type
|
|
851
|
+
10. `ValidationMapper` - Rails validation → Zod chain (with deduplication)
|
|
852
|
+
11. `EnumMapper` - Rails enum → z.enum() with `as const`
|
|
853
|
+
|
|
854
|
+
### Phase 5: Generation
|
|
855
|
+
12. `SchemaBuilder` - Builds both Response and Input schemas per model
|
|
856
|
+
13. `DependencyResolver` - Topological sort for schema ordering
|
|
857
|
+
14. `TypescriptEmitter` - Formats TypeScript output
|
|
858
|
+
15. `FileWriter` - Writes the .ts file
|
|
859
|
+
|
|
860
|
+
### Phase 6: Rails Integration
|
|
861
|
+
16. `Railtie` - Rails integration with eager loading
|
|
862
|
+
17. Rake tasks (`generate`, `watch`)
|
|
863
|
+
|
|
864
|
+
### Phase 7: Polish
|
|
865
|
+
18. Error messages and edge cases
|
|
866
|
+
19. Documentation (README)
|
|
867
|
+
20. File watcher (optional `listen` integration)
|
|
868
|
+
|
|
869
|
+
---
|
|
870
|
+
|
|
871
|
+
## 10. Open Questions
|
|
872
|
+
|
|
873
|
+
These questions should be answered before implementation begins:
|
|
874
|
+
|
|
875
|
+
### Property Naming Default ✅ RESOLVED
|
|
876
|
+
- **Decision**: `snake_case` by default
|
|
877
|
+
- **Rationale**: Rails APIs send snake_case unless serializer transforms keys. Mismatched keys cause immediate validation failures.
|
|
878
|
+
- **Config**: `property_case: :camel` for apps with key transformation
|
|
879
|
+
|
|
880
|
+
### Timestamps ✅ RESOLVED
|
|
881
|
+
- **Decision**: Include by default, opt-out via config
|
|
882
|
+
- **Rationale**: Response schemas need them; Input schemas auto-exclude them
|
|
883
|
+
|
|
884
|
+
### ID Fields ✅ RESOLVED
|
|
885
|
+
- **Decision**: Include by default, opt-out via config
|
|
886
|
+
- **Rationale**: Response schemas need them; Input schemas auto-exclude them
|
|
887
|
+
|
|
888
|
+
### Dual Schema Generation ✅ RESOLVED
|
|
889
|
+
- **Decision**: Generate both `ModelSchema` (response) and `ModelInputSchema` (forms) by default
|
|
890
|
+
- **Rationale**: Solves Input vs Output conflation; `generate_input_schemas: false` to disable
|
|
891
|
+
|
|
892
|
+
### Association IDs ✅ RESOLVED
|
|
893
|
+
- **Decision**: `has_one`/`has_many` do NOT generate `_id`/`_ids` fields by default
|
|
894
|
+
- **Rationale**: These are virtual attributes, not columns. Only `belongs_to` FK columns exist on the model.
|
|
895
|
+
- **Config**: `associations: :include_ids` to opt-in to virtual `_ids` arrays
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
## 11. Research Findings
|
|
900
|
+
|
|
901
|
+
### Zod 4 Considerations
|
|
902
|
+
|
|
903
|
+
> **Target Version**: Zod 4.x (stable since May 2025, current v4.3.5)
|
|
904
|
+
|
|
905
|
+
#### Key Zod 4 Features We Leverage
|
|
906
|
+
|
|
907
|
+
| Feature | Usage in ZodRails |
|
|
908
|
+
|---------|-------------------|
|
|
909
|
+
| `z.int()`, `z.int32()` | Replace `z.number().int()` for integer columns |
|
|
910
|
+
| `z.email()`, `z.uuid()`, `z.url()` | Top-level formats instead of deprecated `.email()` methods |
|
|
911
|
+
| `z.iso.date()`, `z.iso.datetime()` | Default for date/time columns (keeps as ISO string) |
|
|
912
|
+
| Object getter syntax | Replace `z.lazy()` for circular/recursive associations |
|
|
913
|
+
| `.meta()` | Embed Rails metadata (column info, primary key, etc.) |
|
|
914
|
+
| `z.json()` | Native Zod 4 type for json/jsonb columns |
|
|
915
|
+
| Refinements inside schemas | `.refine()` chains with `.min()` etc. without ZodEffects wrapper |
|
|
916
|
+
|
|
917
|
+
#### Deprecations to Avoid
|
|
918
|
+
|
|
919
|
+
| Deprecated | Use Instead |
|
|
920
|
+
|------------|-------------|
|
|
921
|
+
| `z.string().email()` | `z.email()` |
|
|
922
|
+
| `z.string().uuid()` | `z.uuid()` |
|
|
923
|
+
| `z.object().merge()` | `.extend()` or spread syntax |
|
|
924
|
+
| `z.nativeEnum()` | `z.enum()` |
|
|
925
|
+
| `z.lazy()` for objects | Getter syntax: `get field() { return Schema }` |
|
|
926
|
+
|
|
927
|
+
#### Recursive Schema Pattern (Zod 4)
|
|
928
|
+
|
|
929
|
+
```typescript
|
|
930
|
+
// Old (Zod 3) - avoid
|
|
931
|
+
const Category = z.lazy(() => z.object({
|
|
932
|
+
subcategories: z.array(Category)
|
|
933
|
+
}));
|
|
934
|
+
|
|
935
|
+
// New (Zod 4) - preferred
|
|
936
|
+
const Category = z.object({
|
|
937
|
+
name: z.string(),
|
|
938
|
+
get subcategories() {
|
|
939
|
+
return z.array(Category)
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
### Competitive Landscape
|
|
945
|
+
|
|
946
|
+
| Existing Solution | Approach | Limitation |
|
|
947
|
+
|-------------------|----------|------------|
|
|
948
|
+
| `schema2type` | Parse `db/schema.rb` | Archived (2023), no validations |
|
|
949
|
+
| `camille` | Custom DSL | Not AR-aware, manual type definition |
|
|
950
|
+
| `easy_talk` | Ruby DSL → JSON Schema | Not AR-aware |
|
|
951
|
+
| `active_json_schema` | AR → JSON Schema | No Zod output, minimal adoption |
|
|
952
|
+
| `json-schema-to-zod` | npm converter | Two-step process, fidelity loss |
|
|
953
|
+
|
|
954
|
+
**Conclusion**: No direct Rails → Zod solution exists. Clear market opportunity.
|
|
955
|
+
|
|
956
|
+
### File Watcher Research
|
|
957
|
+
|
|
958
|
+
| Option | Stars | Status | Best For |
|
|
959
|
+
|--------|-------|--------|----------|
|
|
960
|
+
| `listen` | 1.9k | Active (Jan 2026) | Our use case |
|
|
961
|
+
| `guard` | 6.7k | Active | Complex multi-task workflows |
|
|
962
|
+
| `filewatcher` | 463 | Active | Zero-dependency environments |
|
|
963
|
+
|
|
964
|
+
**Decision**: Use `listen` as optional dependency. Most Rails apps already have it.
|
|
965
|
+
|
|
966
|
+
---
|
|
967
|
+
|
|
968
|
+
## 12. Known Challenges & Mitigations
|
|
969
|
+
|
|
970
|
+
| Challenge | Mitigation |
|
|
971
|
+
|-----------|------------|
|
|
972
|
+
| **Input vs Output conflation** | Generate both `ModelSchema` (response) and `ModelInputSchema` (forms) |
|
|
973
|
+
| **Optional vs Nullable confusion** | Use `.nullable()` for DB NULL, `.optional()` for missing keys, `.nullish()` for both |
|
|
974
|
+
| **Association IDs don't exist** | `has_one`/`has_many` don't create columns; only emit `belongs_to` FKs by default |
|
|
975
|
+
| **snake_case vs camelCase** | Default to `snake_case` matching Rails; camelCase opt-in with warning |
|
|
976
|
+
| **BigInt overflow** | Default to `z.string()` for `:bigint`; `z.int()` opt-in with warning |
|
|
977
|
+
| **Decimal precision loss** | Default to `z.string()`; `z.number()` opt-in |
|
|
978
|
+
| **json/jsonb type** | Use `z.json()` (Zod 4 native) not `z.record()` |
|
|
979
|
+
| **Ruby → JS regex** | Whitelist common patterns; emit TODO for complex regexes |
|
|
980
|
+
| **Validation deduplication** | `ValidationMapper` consolidates to strictest constraint |
|
|
981
|
+
| **Circular associations** | Getter syntax (Zod 4); topological sort for ordering |
|
|
982
|
+
| **Conditional validations** | Skip `:if/:unless/:on`; document in comments |
|
|
983
|
+
| **Dynamic inclusions** | Skip proc/lambda `:in`; allow config override |
|
|
984
|
+
| **STI discriminators** | Generate `z.literal("ChildClass")` for type column |
|
|
985
|
+
| **Polymorphic associations** | Emit actual `_type`/`_id` columns with correct types |
|
|
986
|
+
| **Abstract models** | Filter with `table_exists? && !abstract_class?` |
|
|
987
|
+
| **Namespaced models** | Flatten `Admin::User` → `AdminUserSchema` |
|
|
988
|
+
| **Presence on booleans** | Emit warning; map to `z.literal(true)` |
|
|
989
|
+
| **Enum type inference** | Use `as const` assertion for literal types |
|
|
990
|
+
| **Forward references** | Topological sort + getter syntax for cycles |
|
|
991
|
+
|
|
992
|
+
---
|
|
993
|
+
|
|
994
|
+
### Implementation Strategy
|
|
995
|
+
|
|
996
|
+
```ruby
|
|
997
|
+
# lib/tasks/zod_rails.rake
|
|
998
|
+
namespace :zod_rails do
|
|
999
|
+
desc "Watch for model changes and regenerate TypeScript"
|
|
1000
|
+
task :watch => :environment do
|
|
1001
|
+
begin
|
|
1002
|
+
require 'listen'
|
|
1003
|
+
rescue LoadError
|
|
1004
|
+
abort <<~ERROR
|
|
1005
|
+
The 'listen' gem is required for file watching.
|
|
1006
|
+
Add to your Gemfile:
|
|
1007
|
+
gem 'listen', group: :development
|
|
1008
|
+
Then run: bundle install
|
|
1009
|
+
ERROR
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
paths = ZodRails.configuration.watch_paths
|
|
1013
|
+
|
|
1014
|
+
puts "Watching #{paths.join(', ')} for changes..."
|
|
1015
|
+
puts "Press Ctrl+C to stop"
|
|
1016
|
+
|
|
1017
|
+
listener = Listen.to(*paths, only: /\.rb$/) do |modified, added, removed|
|
|
1018
|
+
puts "\n[#{Time.now.strftime('%H:%M:%S')}] Change detected"
|
|
1019
|
+
Rake::Task['zod_rails:generate'].reenable
|
|
1020
|
+
Rake::Task['zod_rails:generate'].invoke
|
|
1021
|
+
end
|
|
1022
|
+
|
|
1023
|
+
listener.start
|
|
1024
|
+
sleep
|
|
1025
|
+
rescue Interrupt
|
|
1026
|
+
puts "\nStopping watcher..."
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1029
|
+
```
|
|
1030
|
+
|
|
1031
|
+
---
|
|
1032
|
+
|
|
1033
|
+
## Next Steps
|
|
1034
|
+
|
|
1035
|
+
1. **Answer open questions** (property naming, timestamps, IDs)
|
|
1036
|
+
2. **Create ROADMAP.md** with completed/future sections
|
|
1037
|
+
3. **Scaffold gem structure** with Bundler
|
|
1038
|
+
4. **Write all prose specs** as pending RSpec examples
|
|
1039
|
+
5. **Review specs** for completeness
|
|
1040
|
+
6. **Begin implementation** by making specs pass one by one
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
1044
|
+
*This document serves as the single source of truth for the ZodRails gem architecture. Update it as decisions are made and implementation progresses.*
|
|
1045
|
+
# ZodRails Roadmap
|
|
1046
|
+
|
|
1047
|
+
> **Last Updated**: 2025-01-13
|
|
1048
|
+
|
|
1049
|
+
---
|
|
1050
|
+
|
|
1051
|
+
## Completed
|
|
1052
|
+
|
|
1053
|
+
*Nothing yet - project in planning phase*
|
|
1054
|
+
|
|
1055
|
+
---
|
|
1056
|
+
|
|
1057
|
+
## v1.0 Scope (In Progress)
|
|
1058
|
+
|
|
1059
|
+
### Core Features
|
|
1060
|
+
- [ ] Column type → Zod type mapping (all common types)
|
|
1061
|
+
- [ ] Nullability inference from column definition
|
|
1062
|
+
- [ ] Validation mapping (Tier 1 + Tier 2)
|
|
1063
|
+
- [ ] Rails enum support (string keys)
|
|
1064
|
+
- [ ] Rake task: `rails zod_rails:generate`
|
|
1065
|
+
- [ ] Configuration file (initializer DSL)
|
|
1066
|
+
- [ ] Customizable output path
|
|
1067
|
+
- [ ] Per-model include/exclude options
|
|
1068
|
+
|
|
1069
|
+
### Association Handling
|
|
1070
|
+
- [ ] Foreign key fields for `belongs_to`
|
|
1071
|
+
- [ ] ID arrays for `has_many`
|
|
1072
|
+
- [ ] Configurable: IDs-only vs nested schemas
|
|
1073
|
+
- [ ] Optional association support
|
|
1074
|
+
|
|
1075
|
+
### Developer Experience
|
|
1076
|
+
- [ ] File watcher: `rails zod_rails:watch` (requires `listen` gem)
|
|
1077
|
+
- [ ] Clear error messages for configuration issues
|
|
1078
|
+
- [ ] Generation timestamp in output
|
|
1079
|
+
- [ ] "Do not edit" warning in generated files
|
|
1080
|
+
|
|
1081
|
+
### Documentation
|
|
1082
|
+
- [ ] README with quick start guide
|
|
1083
|
+
- [ ] Configuration reference
|
|
1084
|
+
- [ ] Type mapping reference
|
|
1085
|
+
- [ ] Validation mapping reference
|
|
1086
|
+
|
|
1087
|
+
### Quality
|
|
1088
|
+
- [ ] Comprehensive RSpec test suite (prose-driven TDD)
|
|
1089
|
+
- [ ] CI/CD pipeline (GitHub Actions)
|
|
1090
|
+
- [ ] RuboCop configuration
|
|
1091
|
+
|
|
1092
|
+
---
|
|
1093
|
+
|
|
1094
|
+
## v1.1 (Post-Release Polish)
|
|
1095
|
+
|
|
1096
|
+
- [ ] Custom type mapping extension point
|
|
1097
|
+
- [ ] Per-model configuration overrides
|
|
1098
|
+
- [ ] Multiple output file support (split by namespace)
|
|
1099
|
+
- [ ] Better regex conversion (Ruby → JavaScript)
|
|
1100
|
+
- [ ] Common email/URL format detection
|
|
1101
|
+
|
|
1102
|
+
---
|
|
1103
|
+
|
|
1104
|
+
## v2.0 (Future)
|
|
1105
|
+
|
|
1106
|
+
### Serializer Integration
|
|
1107
|
+
- [ ] Alba adapter - generate from Alba serializers
|
|
1108
|
+
- [ ] Blueprinter adapter - generate from Blueprinter views
|
|
1109
|
+
- [ ] ActiveModelSerializers adapter
|
|
1110
|
+
- [ ] Custom serializer extension point
|
|
1111
|
+
|
|
1112
|
+
### Advanced Features
|
|
1113
|
+
- [ ] Polymorphic association support
|
|
1114
|
+
- [ ] STI (Single Table Inheritance) handling
|
|
1115
|
+
- [ ] Conditional validation mapping (Tier 3)
|
|
1116
|
+
- [ ] Custom validator extension API
|
|
1117
|
+
- [ ] Schema versioning support
|
|
1118
|
+
|
|
1119
|
+
### Performance
|
|
1120
|
+
- [ ] Incremental generation (only changed models)
|
|
1121
|
+
- [ ] Parallel model introspection
|
|
1122
|
+
- [ ] Caching layer for large codebases
|
|
1123
|
+
|
|
1124
|
+
### Ecosystem
|
|
1125
|
+
- [ ] VS Code extension for jump-to-definition
|
|
1126
|
+
- [ ] Integration with TypeScript Language Server
|
|
1127
|
+
- [ ] Rails generator: `rails g zod_rails:install`
|
|
1128
|
+
|
|
1129
|
+
---
|
|
1130
|
+
|
|
1131
|
+
## v3.0 (Vision)
|
|
1132
|
+
|
|
1133
|
+
### Bidirectional Sync
|
|
1134
|
+
- [ ] Detect drift between Rails and generated schemas
|
|
1135
|
+
- [ ] Generate Rails migrations from Zod schema changes
|
|
1136
|
+
- [ ] Schema diff tool
|
|
1137
|
+
|
|
1138
|
+
### API Documentation
|
|
1139
|
+
- [ ] OpenAPI spec generation alongside Zod
|
|
1140
|
+
- [ ] Swagger UI integration
|
|
1141
|
+
- [ ] API versioning support
|
|
1142
|
+
|
|
1143
|
+
### Runtime Features
|
|
1144
|
+
- [ ] Runtime API endpoint serving schema JSON
|
|
1145
|
+
- [ ] Schema hot-reloading in development
|
|
1146
|
+
- [ ] Client library generation (fetch/axios wrappers)
|
|
1147
|
+
|
|
1148
|
+
---
|
|
1149
|
+
|
|
1150
|
+
## Ideas Backlog
|
|
1151
|
+
|
|
1152
|
+
*Unscheduled ideas for future consideration*
|
|
1153
|
+
|
|
1154
|
+
- GraphQL type generation
|
|
1155
|
+
- Prisma schema generation
|
|
1156
|
+
- Database-level constraint extraction (beyond AR validations)
|
|
1157
|
+
- Multi-database support
|
|
1158
|
+
- Encrypted attribute handling
|
|
1159
|
+
- Action Text rich text field support
|
|
1160
|
+
- Active Storage attachment field support
|
|
1161
|
+
- I18n support for validation messages
|
|
1162
|
+
- JSON Schema output (alternative to Zod)
|
|
1163
|
+
- Yup schema output (alternative to Zod)
|
|
1164
|
+
|
|
1165
|
+
---
|
|
1166
|
+
|
|
1167
|
+
## Contributing
|
|
1168
|
+
|
|
1169
|
+
Have an idea? Open an issue or PR! We especially welcome:
|
|
1170
|
+
|
|
1171
|
+
1. New type mappings for database-specific types
|
|
1172
|
+
2. Validation mapping improvements
|
|
1173
|
+
3. Serializer adapter implementations
|
|
1174
|
+
4. Documentation improvements
|
|
1175
|
+
|
|
1176
|
+
---
|
|
1177
|
+
|
|
1178
|
+
*This roadmap is a living document. Priorities may shift based on community feedback.*
|