Skip to content

Commit 4a13efe

Browse files
authored
Merge pull request #11 from dev-five-git/add-inline-syntax
Add inline syntax
2 parents 01387e4 + afde6d0 commit 4a13efe

File tree

32 files changed

+2717
-98
lines changed

32 files changed

+2717
-98
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
/target
22
local.db
3+
settings.local.json

CLAUDE.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
Vespertide is a Rust workspace for defining database schemas in JSON/YAML and generating migration plans and SQL from model diffs. It enables declarative schema management by comparing the current model state against a baseline reconstructed from applied migrations.
8+
9+
## Build and Test Commands
10+
11+
```bash
12+
# Build the entire workspace
13+
cargo build
14+
15+
# Run all tests
16+
cargo test
17+
18+
# Run tests for a specific crate
19+
cargo test -p vespertide-core
20+
cargo test -p vespertide-planner
21+
22+
# Format code
23+
cargo fmt
24+
25+
# Lint (important: use all targets and features)
26+
cargo clippy --all-targets --all-features
27+
28+
# Regenerate JSON schemas
29+
cargo run -p vespertide-schema-gen -- --out schemas
30+
31+
# Run CLI commands (use -p vespertide-cli)
32+
cargo run -p vespertide-cli -- init
33+
cargo run -p vespertide-cli -- new user
34+
cargo run -p vespertide-cli -- diff
35+
cargo run -p vespertide-cli -- sql
36+
cargo run -p vespertide-cli -- revision -m "message"
37+
cargo run -p vespertide-cli -- status
38+
cargo run -p vespertide-cli -- log
39+
```
40+
41+
## Architecture
42+
43+
### Core Data Flow
44+
45+
1. **Schema Definition**: Users define tables in JSON files (`TableDef`) in the `models/` directory
46+
2. **Baseline Reconstruction**: Applied migrations are replayed to rebuild the baseline schema
47+
3. **Diffing**: Current models are compared against the baseline to compute changes
48+
4. **Planning**: Changes are converted into a `MigrationPlan` with versioned actions
49+
5. **SQL Generation**: Migration actions are translated into PostgreSQL SQL statements
50+
51+
### Crate Responsibilities
52+
53+
- **vespertide-core**: Data structures (`TableDef`, `ColumnDef`, `MigrationAction`, `MigrationPlan`, constraints, indexes)
54+
- **vespertide-planner**:
55+
- `schema_from_plans()`: Replays applied migrations to reconstruct baseline schema
56+
- `diff_schemas()`: Compares two schemas and generates migration actions
57+
- `plan_next_migration()`: Combines baseline reconstruction + diffing to create the next migration
58+
- `apply_action()`: Applies a single migration action to a schema (used during replay)
59+
- `validate_*()`: Validates schemas and migration plans
60+
- **vespertide-query**: Converts `MigrationAction` → PostgreSQL SQL with bind parameters
61+
- **vespertide-config**: Manages `vespertide.json` (models/migrations directories, naming case preferences)
62+
- **vespertide-cli**: Command-line interface implementation
63+
- **vespertide-exporter**: Exports schemas to other formats (e.g., SeaORM entities)
64+
- **vespertide-schema-gen**: Generates JSON Schema files for validation
65+
- **vespertide-macro**: Placeholder for future runtime migration executor
66+
67+
### Key Architectural Patterns
68+
69+
**Migration Replay Pattern**: The planner doesn't store a "current database state" - it reconstructs it by replaying all applied migrations in order. This ensures the baseline is always derivable from the migration history.
70+
71+
**Declarative Diffing**: Users declare the desired end state in model files. The diff engine compares this against the reconstructed baseline to compute necessary changes.
72+
73+
**Action-Based Migrations**: All changes are expressed as typed `MigrationAction` enums (CreateTable, AddColumn, ModifyColumnType, etc.) rather than raw SQL. SQL generation happens in a separate layer.
74+
75+
## Important Implementation Details
76+
77+
### ColumnDef Structure
78+
When creating `ColumnDef` instances in tests or code, you must initialize ALL fields including the newer inline constraint fields:
79+
80+
```rust
81+
ColumnDef {
82+
name: "id".into(),
83+
r#type: ColumnType::Integer,
84+
nullable: false,
85+
default: None,
86+
comment: None,
87+
primary_key: None, // Inline PK declaration
88+
unique: None, // Inline unique constraint
89+
index: None, // Inline index creation
90+
foreign_key: None, // Inline FK definition
91+
}
92+
```
93+
94+
These inline fields (added recently) allow constraints to be defined directly on columns in addition to table-level `TableConstraint` definitions.
95+
96+
### Foreign Key Definition
97+
Foreign keys can be defined inline on columns via the `foreign_key` field:
98+
99+
```rust
100+
pub struct ForeignKeyDef {
101+
pub ref_table: TableName,
102+
pub ref_columns: Vec<ColumnName>,
103+
pub on_delete: Option<ReferenceAction>,
104+
pub on_update: Option<ReferenceAction>,
105+
}
106+
```
107+
108+
### Migration Plan Validation
109+
- Non-nullable columns added to existing tables require either a `default` value or a `fill_with` backfill expression
110+
- Schemas are validated for constraint consistency before diffing
111+
- The planner validates that column/table names follow the configured naming case
112+
113+
### SQL Generation Target
114+
All SQL generation currently targets **PostgreSQL only**. When modifying the query builder, ensure PostgreSQL compatibility.
115+
116+
### JSON Schema Generation
117+
The `vespertide-schema-gen` crate uses `schemars` to generate JSON Schemas from the Rust types. After modifying core data structures, regenerate schemas with:
118+
```bash
119+
cargo run -p vespertide-schema-gen -- --out schemas
120+
```
121+
122+
Schema base URL can be overridden via `VESP_SCHEMA_BASE_URL` environment variable.
123+
124+
## Testing Patterns
125+
126+
- Tests use helper functions like `col()` and `table()` to reduce boilerplate
127+
- Use `rstest` for parameterized tests (common in planner/query crates)
128+
- Use `serial_test::serial` for tests that modify the filesystem or working directory
129+
- Snapshot testing with `insta` is used in the exporter crate
130+
131+
## Limitations
132+
133+
- YAML loading is not implemented (templates can be generated but not parsed)
134+
- Runtime migration executor (`run_migrations`) in `vespertide-macro` is not implemented
135+
- Only PostgreSQL SQL generation is supported

Cargo.lock

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vespertide-cli/src/commands/diff.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,58 @@ fn format_action(action: &MigrationAction) -> String {
123123
sql.bright_cyan()
124124
)
125125
}
126+
MigrationAction::AddConstraint { table, constraint } => {
127+
format!(
128+
"{} {} {} {}",
129+
"Add constraint:".bright_green(),
130+
format_constraint_type(constraint).bright_cyan().bold(),
131+
"on".bright_white(),
132+
table.bright_cyan()
133+
)
134+
}
135+
MigrationAction::RemoveConstraint { table, constraint } => {
136+
format!(
137+
"{} {} {} {}",
138+
"Remove constraint:".bright_red(),
139+
format_constraint_type(constraint).bright_cyan().bold(),
140+
"from".bright_white(),
141+
table.bright_cyan()
142+
)
143+
}
144+
}
145+
}
146+
147+
fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> String {
148+
match constraint {
149+
vespertide_core::TableConstraint::PrimaryKey { columns } => {
150+
format!("PRIMARY KEY ({})", columns.join(", "))
151+
}
152+
vespertide_core::TableConstraint::Unique { name, columns } => {
153+
if let Some(n) = name {
154+
format!("{} UNIQUE ({})", n, columns.join(", "))
155+
} else {
156+
format!("UNIQUE ({})", columns.join(", "))
157+
}
158+
}
159+
vespertide_core::TableConstraint::ForeignKey {
160+
name,
161+
columns,
162+
ref_table,
163+
..
164+
} => {
165+
if let Some(n) = name {
166+
format!("{} FK ({}) -> {}", n, columns.join(", "), ref_table)
167+
} else {
168+
format!("FK ({}) -> {}", columns.join(", "), ref_table)
169+
}
170+
}
171+
vespertide_core::TableConstraint::Check { name, expr } => {
172+
if let Some(n) = name {
173+
format!("{} CHECK ({})", n, expr)
174+
} else {
175+
format!("CHECK ({})", expr)
176+
}
177+
}
126178
}
127179
}
128180

@@ -172,6 +224,11 @@ mod tests {
172224
r#type: ColumnType::Integer,
173225
nullable: false,
174226
default: None,
227+
comment: None,
228+
primary_key: None,
229+
unique: None,
230+
index: None,
231+
foreign_key: None,
175232
}],
176233
constraints: vec![],
177234
indexes: vec![],
@@ -197,6 +254,11 @@ mod tests {
197254
r#type: ColumnType::Text,
198255
nullable: true,
199256
default: None,
257+
comment: None,
258+
primary_key: None,
259+
unique: None,
260+
index: None,
261+
foreign_key: None,
200262
},
201263
fill_with: None,
202264
},
@@ -245,6 +307,92 @@ mod tests {
245307
MigrationAction::RawSql { sql: "SELECT 1".into() },
246308
format!("{} {}", "Execute raw SQL:".bright_yellow(), "SELECT 1".bright_cyan())
247309
)]
310+
#[case(
311+
MigrationAction::AddConstraint {
312+
table: "users".into(),
313+
constraint: vespertide_core::TableConstraint::PrimaryKey {
314+
columns: vec!["id".into()],
315+
},
316+
},
317+
format!("{} {} {} {}", "Add constraint:".bright_green(), "PRIMARY KEY (id)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan())
318+
)]
319+
#[case(
320+
MigrationAction::AddConstraint {
321+
table: "users".into(),
322+
constraint: vespertide_core::TableConstraint::Unique {
323+
name: Some("unique_email".into()),
324+
columns: vec!["email".into()],
325+
},
326+
},
327+
format!("{} {} {} {}", "Add constraint:".bright_green(), "unique_email UNIQUE (email)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan())
328+
)]
329+
#[case(
330+
MigrationAction::AddConstraint {
331+
table: "posts".into(),
332+
constraint: vespertide_core::TableConstraint::ForeignKey {
333+
name: Some("fk_user".into()),
334+
columns: vec!["user_id".into()],
335+
ref_table: "users".into(),
336+
ref_columns: vec!["id".into()],
337+
on_delete: None,
338+
on_update: None,
339+
},
340+
},
341+
format!("{} {} {} {}", "Add constraint:".bright_green(), "fk_user FK (user_id) -> users".bright_cyan().bold(), "on".bright_white(), "posts".bright_cyan())
342+
)]
343+
#[case(
344+
MigrationAction::AddConstraint {
345+
table: "users".into(),
346+
constraint: vespertide_core::TableConstraint::Check {
347+
name: Some("check_age".into()),
348+
expr: "age > 0".into(),
349+
},
350+
},
351+
format!("{} {} {} {}", "Add constraint:".bright_green(), "check_age CHECK (age > 0)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan())
352+
)]
353+
#[case(
354+
MigrationAction::RemoveConstraint {
355+
table: "users".into(),
356+
constraint: vespertide_core::TableConstraint::PrimaryKey {
357+
columns: vec!["id".into()],
358+
},
359+
},
360+
format!("{} {} {} {}", "Remove constraint:".bright_red(), "PRIMARY KEY (id)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan())
361+
)]
362+
#[case(
363+
MigrationAction::RemoveConstraint {
364+
table: "users".into(),
365+
constraint: vespertide_core::TableConstraint::Unique {
366+
name: None,
367+
columns: vec!["email".into()],
368+
},
369+
},
370+
format!("{} {} {} {}", "Remove constraint:".bright_red(), "UNIQUE (email)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan())
371+
)]
372+
#[case(
373+
MigrationAction::RemoveConstraint {
374+
table: "posts".into(),
375+
constraint: vespertide_core::TableConstraint::ForeignKey {
376+
name: None,
377+
columns: vec!["user_id".into()],
378+
ref_table: "users".into(),
379+
ref_columns: vec!["id".into()],
380+
on_delete: None,
381+
on_update: None,
382+
},
383+
},
384+
format!("{} {} {} {}", "Remove constraint:".bright_red(), "FK (user_id) -> users".bright_cyan().bold(), "from".bright_white(), "posts".bright_cyan())
385+
)]
386+
#[case(
387+
MigrationAction::RemoveConstraint {
388+
table: "users".into(),
389+
constraint: vespertide_core::TableConstraint::Check {
390+
name: None,
391+
expr: "age > 0".into(),
392+
},
393+
},
394+
format!("{} {} {} {}", "Remove constraint:".bright_red(), "CHECK (age > 0)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan())
395+
)]
248396
#[serial]
249397
fn format_action_cases(#[case] action: MigrationAction, #[case] expected: String) {
250398
assert_eq!(format_action(&action), expected);

crates/vespertide-cli/src/commands/export.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ mod tests {
197197
r#type: ColumnType::Integer,
198198
nullable: false,
199199
default: None,
200+
comment: None,
201+
primary_key: None,
202+
unique: None,
203+
index: None,
204+
foreign_key: None,
200205
}],
201206
constraints: vec![TableConstraint::PrimaryKey {
202207
columns: vec!["id".into()],

0 commit comments

Comments
 (0)