Skip to content

Commit 88f09c8

Browse files
committed
Add Drizzle ORM code generation module
FEATURE: Add TypeScript Drizzle ORM schema generation from PostgreSQL tables Implements a new code generator in crates/codegen/src/drizzle/ that produces idiomatic Drizzle ORM schema definitions from PostgreSQL table snapshots. Follows the same architecture as the existing Python (SQLModel) and Rust (SeaORM) generators. Supports: - pgTable definitions with all common PG types mapped to Drizzle builders - Single-file (schema.ts) and multi-file (per-table + index.ts) output - camelCase column names with explicit DB column name passthrough - Composite primary keys and unique constraints via pgTable third arg - Foreign key references via .references() chain - Optional relations() generation for one-to-many/many-to-one - Non-public schema support via pgSchema - JavaScript reserved word handling Includes comprehensive unit tests and insta snapshot tests covering simple tables, blog schemas with relations, composite PKs, all PG types, reserved words, multi-file output, self-referential FKs, snake_case mode, and non-public schemas. https://claude.ai/code/session_01EqR9j4R6jsTQbgCVLoFBZX
1 parent 3924baa commit 88f09c8

23 files changed

Lines changed: 3342 additions & 316 deletions

crates/codegen/src/drizzle/column.rs

Lines changed: 445 additions & 0 deletions
Large diffs are not rendered by default.

crates/codegen/src/drizzle/generator.rs

Lines changed: 451 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
//! Import management for Drizzle ORM code generation.
2+
//!
3+
//! This module handles collecting and organizing TypeScript imports for generated code.
4+
//! All column builder imports come from `drizzle-orm/pg-core`, while relation helpers
5+
//! come from `drizzle-orm`.
6+
7+
use std::collections::BTreeSet;
8+
9+
/// Collects and organizes TypeScript imports for Drizzle code generation.
10+
#[derive(Debug, Default, Clone)]
11+
pub struct ImportCollector {
12+
/// Imports from "drizzle-orm/pg-core" (pgTable, serial, text, etc.).
13+
pg_core: BTreeSet<String>,
14+
/// Imports from "drizzle-orm" (relations, etc.).
15+
drizzle_orm: BTreeSet<String>,
16+
}
17+
18+
impl ImportCollector {
19+
/// Creates a new empty import collector.
20+
pub fn new() -> Self {
21+
Self::default()
22+
}
23+
24+
/// Adds a "drizzle-orm/pg-core" import.
25+
pub fn add_pg_core(&mut self, name: &str) {
26+
self.pg_core.insert(name.to_string());
27+
}
28+
29+
/// Adds multiple "drizzle-orm/pg-core" imports.
30+
pub fn add_pg_core_all(&mut self, names: &[String]) {
31+
for name in names {
32+
self.pg_core.insert(name.clone());
33+
}
34+
}
35+
36+
/// Adds a "drizzle-orm" import.
37+
pub fn add_drizzle_orm(&mut self, name: &str) {
38+
self.drizzle_orm.insert(name.to_string());
39+
}
40+
41+
/// Adds the pgTable import.
42+
pub fn add_pg_table(&mut self) {
43+
self.add_pg_core("pgTable");
44+
}
45+
46+
/// Adds the pgSchema import.
47+
pub fn add_pg_schema(&mut self) {
48+
self.add_pg_core("pgSchema");
49+
}
50+
51+
/// Adds the primaryKey import (for composite PKs).
52+
pub fn add_primary_key(&mut self) {
53+
self.add_pg_core("primaryKey");
54+
}
55+
56+
/// Adds the unique import (for composite unique constraints).
57+
pub fn add_unique(&mut self) {
58+
self.add_pg_core("unique");
59+
}
60+
61+
/// Adds the index import.
62+
pub fn add_index(&mut self) {
63+
self.add_pg_core("index");
64+
}
65+
66+
/// Adds the relations import.
67+
pub fn add_relations(&mut self) {
68+
self.add_drizzle_orm("relations");
69+
}
70+
71+
/// Merges another ImportCollector into this one.
72+
pub fn merge(&mut self, other: &ImportCollector) {
73+
self.pg_core.extend(other.pg_core.iter().cloned());
74+
self.drizzle_orm.extend(other.drizzle_orm.iter().cloned());
75+
}
76+
77+
/// Generates the import statements as formatted TypeScript code.
78+
pub fn generate(&self) -> String {
79+
let mut lines = Vec::new();
80+
81+
if !self.pg_core.is_empty() {
82+
let names: Vec<&str> = self.pg_core.iter().map(|s| s.as_str()).collect();
83+
lines.push(format_import_line(&names, "drizzle-orm/pg-core"));
84+
}
85+
86+
if !self.drizzle_orm.is_empty() {
87+
let names: Vec<&str> = self.drizzle_orm.iter().map(|s| s.as_str()).collect();
88+
lines.push(format_import_line(&names, "drizzle-orm"));
89+
}
90+
91+
lines.join("\n")
92+
}
93+
94+
/// Returns true if there are no imports.
95+
pub fn is_empty(&self) -> bool {
96+
self.pg_core.is_empty() && self.drizzle_orm.is_empty()
97+
}
98+
}
99+
100+
/// Formats a single import line.
101+
fn format_import_line(names: &[&str], module: &str) -> String {
102+
let joined = names.join(", ");
103+
let single_line = format!("import {{ {joined} }} from \"{module}\";");
104+
105+
if single_line.len() <= 100 {
106+
single_line
107+
} else {
108+
// Multi-line format
109+
let mut lines = vec![format!("import {{")];
110+
for name in names {
111+
lines.push(format!(" {name},"));
112+
}
113+
lines.push(format!("}} from \"{module}\";"));
114+
lines.join("\n")
115+
}
116+
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::*;
121+
122+
#[test]
123+
fn test_empty_collector() {
124+
let collector = ImportCollector::new();
125+
assert!(collector.is_empty());
126+
assert_eq!(collector.generate(), "");
127+
}
128+
129+
#[test]
130+
fn test_pg_core_imports() {
131+
let mut collector = ImportCollector::new();
132+
collector.add_pg_table();
133+
collector.add_pg_core("serial");
134+
collector.add_pg_core("text");
135+
136+
let output = collector.generate();
137+
assert!(output.contains("import {"));
138+
assert!(output.contains("pgTable"));
139+
assert!(output.contains("serial"));
140+
assert!(output.contains("text"));
141+
assert!(output.contains("drizzle-orm/pg-core"));
142+
}
143+
144+
#[test]
145+
fn test_drizzle_orm_imports() {
146+
let mut collector = ImportCollector::new();
147+
collector.add_relations();
148+
149+
let output = collector.generate();
150+
assert!(output.contains("relations"));
151+
assert!(output.contains("drizzle-orm"));
152+
assert!(!output.contains("pg-core"));
153+
}
154+
155+
#[test]
156+
fn test_both_import_sources() {
157+
let mut collector = ImportCollector::new();
158+
collector.add_pg_table();
159+
collector.add_pg_core("text");
160+
collector.add_relations();
161+
162+
let output = collector.generate();
163+
// pg-core line should come first (alphabetical order of module path)
164+
let pg_core_pos = output.find("drizzle-orm/pg-core").unwrap();
165+
let drizzle_pos = output
166+
.find("\"drizzle-orm\"")
167+
.or_else(|| output.find("from \"drizzle-orm\""))
168+
.unwrap();
169+
assert!(pg_core_pos < drizzle_pos);
170+
}
171+
172+
#[test]
173+
fn test_deterministic_ordering() {
174+
for _ in 0..5 {
175+
let mut collector = ImportCollector::new();
176+
collector.add_pg_core("varchar");
177+
collector.add_pg_core("text");
178+
collector.add_pg_core("serial");
179+
collector.add_pg_core("integer");
180+
collector.add_pg_table();
181+
182+
let output = collector.generate();
183+
// BTreeSet ensures alphabetical order
184+
let integer_pos = output.find("integer").unwrap();
185+
let pg_table_pos = output.find("pgTable").unwrap();
186+
let serial_pos = output.find("serial").unwrap();
187+
let text_pos = output.find("text").unwrap();
188+
let varchar_pos = output.find("varchar").unwrap();
189+
190+
assert!(integer_pos < pg_table_pos);
191+
assert!(pg_table_pos < serial_pos);
192+
assert!(serial_pos < text_pos);
193+
assert!(text_pos < varchar_pos);
194+
}
195+
}
196+
197+
#[test]
198+
fn test_merge_collectors() {
199+
let mut c1 = ImportCollector::new();
200+
c1.add_pg_core("text");
201+
c1.add_pg_table();
202+
203+
let mut c2 = ImportCollector::new();
204+
c2.add_pg_core("integer");
205+
c2.add_relations();
206+
207+
c1.merge(&c2);
208+
209+
let output = c1.generate();
210+
assert!(output.contains("text"));
211+
assert!(output.contains("integer"));
212+
assert!(output.contains("pgTable"));
213+
assert!(output.contains("relations"));
214+
}
215+
216+
#[test]
217+
fn test_deduplication() {
218+
let mut collector = ImportCollector::new();
219+
collector.add_pg_core("text");
220+
collector.add_pg_core("text");
221+
collector.add_pg_core("text");
222+
223+
let output = collector.generate();
224+
// "text" should appear only once in the import
225+
let count = output.matches("text").count();
226+
assert_eq!(count, 1);
227+
}
228+
229+
#[test]
230+
fn test_add_pg_core_all() {
231+
let mut collector = ImportCollector::new();
232+
collector.add_pg_core_all(&["text".to_string(), "integer".to_string()]);
233+
234+
assert!(!collector.is_empty());
235+
let output = collector.generate();
236+
assert!(output.contains("text"));
237+
assert!(output.contains("integer"));
238+
}
239+
}

crates/codegen/src/drizzle/mod.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
//! Drizzle ORM code generation module.
2+
//!
3+
//! This module provides code generation for TypeScript Drizzle ORM schema
4+
//! definitions from PostgreSQL table definitions. It supports:
5+
//!
6+
//! - Idiomatic Drizzle `pgTable` definitions with proper type mappings
7+
//! - Primary keys (single and composite), foreign keys, unique constraints
8+
//! - Optional relation generation via `relations()` calls
9+
//! - camelCase or snake_case column naming with explicit DB column names
10+
//! - Non-public schema support via `pgSchema`
11+
//!
12+
//! # Example
13+
//!
14+
//! ```ignore
15+
//! use tern_codegen::{Codegen, drizzle::DrizzleCodegen};
16+
//!
17+
//! let codegen = DrizzleCodegen::new(DrizzleCodegenConfig::default());
18+
//! let output = codegen.generate(tables);
19+
//! // output contains "schema.ts" with Drizzle table definitions
20+
//! ```
21+
22+
mod column;
23+
mod generator;
24+
mod imports;
25+
mod naming;
26+
mod table;
27+
mod type_mapping;
28+
29+
#[cfg(test)]
30+
mod tests;
31+
32+
pub use generator::DrizzleCodegen;
33+
34+
use std::fmt;
35+
36+
/// Configuration for Drizzle ORM code generation.
37+
#[derive(Debug, Clone)]
38+
pub struct DrizzleCodegenConfig {
39+
/// Output mode: single file or multiple files.
40+
pub output_mode: OutputMode,
41+
42+
/// Whether to generate Drizzle `relations()` calls for foreign keys.
43+
pub generate_relations: bool,
44+
45+
/// Whether to use camelCase for column variable names.
46+
///
47+
/// When true (default), column names like `user_id` become `userId` in the
48+
/// generated code, with the original DB name passed to the builder:
49+
/// `integer("user_id")`.
50+
///
51+
/// When false, column names are kept as-is: `user_id: integer("user_id")`.
52+
pub camel_case_columns: bool,
53+
54+
/// Optional schema name for non-public schemas.
55+
///
56+
/// When set, uses `pgSchema` instead of `pgTable`:
57+
/// ```ignore
58+
/// const mySchema = pgSchema("myschema");
59+
/// export const users = mySchema.table("users", { ... });
60+
/// ```
61+
pub schema_name: Option<String>,
62+
}
63+
64+
impl Default for DrizzleCodegenConfig {
65+
fn default() -> Self {
66+
Self {
67+
output_mode: OutputMode::default(),
68+
generate_relations: false,
69+
camel_case_columns: true,
70+
schema_name: None,
71+
}
72+
}
73+
}
74+
75+
/// Output mode for generated code.
76+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
77+
pub enum OutputMode {
78+
/// Generate all table definitions in a single `schema.ts` file.
79+
#[default]
80+
SingleFile,
81+
/// Generate separate files per table with an `index.ts` barrel export.
82+
MultiFile,
83+
}
84+
85+
impl fmt::Display for OutputMode {
86+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87+
match self {
88+
Self::SingleFile => write!(f, "single_file"),
89+
Self::MultiFile => write!(f, "multi_file"),
90+
}
91+
}
92+
}
93+
94+
/// Errors that can occur during Drizzle code generation.
95+
#[derive(Debug, thiserror::Error)]
96+
pub enum DrizzleCodegenError {
97+
/// An unsupported PostgreSQL type was encountered.
98+
#[error("unsupported PostgreSQL type: {0}")]
99+
UnsupportedType(String),
100+
101+
/// A table has no columns.
102+
#[error("table has no columns: {0}")]
103+
EmptyTable(String),
104+
105+
/// Code generation failed.
106+
#[error("code generation failed: {0}")]
107+
GenerationError(String),
108+
}

0 commit comments

Comments
 (0)