Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
local.db
settings.local.json
135 changes: 135 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

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.

## Build and Test Commands

```bash
# Build the entire workspace
cargo build

# Run all tests
cargo test

# Run tests for a specific crate
cargo test -p vespertide-core
cargo test -p vespertide-planner

# Format code
cargo fmt

# Lint (important: use all targets and features)
cargo clippy --all-targets --all-features

# Regenerate JSON schemas
cargo run -p vespertide-schema-gen -- --out schemas

# Run CLI commands (use -p vespertide-cli)
cargo run -p vespertide-cli -- init
cargo run -p vespertide-cli -- new user
cargo run -p vespertide-cli -- diff
cargo run -p vespertide-cli -- sql
cargo run -p vespertide-cli -- revision -m "message"
cargo run -p vespertide-cli -- status
cargo run -p vespertide-cli -- log
```

## Architecture

### Core Data Flow

1. **Schema Definition**: Users define tables in JSON files (`TableDef`) in the `models/` directory
2. **Baseline Reconstruction**: Applied migrations are replayed to rebuild the baseline schema
3. **Diffing**: Current models are compared against the baseline to compute changes
4. **Planning**: Changes are converted into a `MigrationPlan` with versioned actions
5. **SQL Generation**: Migration actions are translated into PostgreSQL SQL statements

### Crate Responsibilities

- **vespertide-core**: Data structures (`TableDef`, `ColumnDef`, `MigrationAction`, `MigrationPlan`, constraints, indexes)
- **vespertide-planner**:
- `schema_from_plans()`: Replays applied migrations to reconstruct baseline schema
- `diff_schemas()`: Compares two schemas and generates migration actions
- `plan_next_migration()`: Combines baseline reconstruction + diffing to create the next migration
- `apply_action()`: Applies a single migration action to a schema (used during replay)
- `validate_*()`: Validates schemas and migration plans
- **vespertide-query**: Converts `MigrationAction` → PostgreSQL SQL with bind parameters
- **vespertide-config**: Manages `vespertide.json` (models/migrations directories, naming case preferences)
- **vespertide-cli**: Command-line interface implementation
- **vespertide-exporter**: Exports schemas to other formats (e.g., SeaORM entities)
- **vespertide-schema-gen**: Generates JSON Schema files for validation
- **vespertide-macro**: Placeholder for future runtime migration executor

### Key Architectural Patterns

**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.

**Declarative Diffing**: Users declare the desired end state in model files. The diff engine compares this against the reconstructed baseline to compute necessary changes.

**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.

## Important Implementation Details

### ColumnDef Structure
When creating `ColumnDef` instances in tests or code, you must initialize ALL fields including the newer inline constraint fields:

```rust
ColumnDef {
name: "id".into(),
r#type: ColumnType::Integer,
nullable: false,
default: None,
comment: None,
primary_key: None, // Inline PK declaration
unique: None, // Inline unique constraint
index: None, // Inline index creation
foreign_key: None, // Inline FK definition
}
```

These inline fields (added recently) allow constraints to be defined directly on columns in addition to table-level `TableConstraint` definitions.

### Foreign Key Definition
Foreign keys can be defined inline on columns via the `foreign_key` field:

```rust
pub struct ForeignKeyDef {
pub ref_table: TableName,
pub ref_columns: Vec<ColumnName>,
pub on_delete: Option<ReferenceAction>,
pub on_update: Option<ReferenceAction>,
}
```

### Migration Plan Validation
- Non-nullable columns added to existing tables require either a `default` value or a `fill_with` backfill expression
- Schemas are validated for constraint consistency before diffing
- The planner validates that column/table names follow the configured naming case

### SQL Generation Target
All SQL generation currently targets **PostgreSQL only**. When modifying the query builder, ensure PostgreSQL compatibility.

### JSON Schema Generation
The `vespertide-schema-gen` crate uses `schemars` to generate JSON Schemas from the Rust types. After modifying core data structures, regenerate schemas with:
```bash
cargo run -p vespertide-schema-gen -- --out schemas
```

Schema base URL can be overridden via `VESP_SCHEMA_BASE_URL` environment variable.

## Testing Patterns

- Tests use helper functions like `col()` and `table()` to reduce boilerplate
- Use `rstest` for parameterized tests (common in planner/query crates)
- Use `serial_test::serial` for tests that modify the filesystem or working directory
- Snapshot testing with `insta` is used in the exporter crate

## Limitations

- YAML loading is not implemented (templates can be generated but not parsed)
- Runtime migration executor (`run_migrations`) in `vespertide-macro` is not implemented
- Only PostgreSQL SQL generation is supported
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

148 changes: 148 additions & 0 deletions crates/vespertide-cli/src/commands/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,58 @@ fn format_action(action: &MigrationAction) -> String {
sql.bright_cyan()
)
}
MigrationAction::AddConstraint { table, constraint } => {
format!(
"{} {} {} {}",
"Add constraint:".bright_green(),
format_constraint_type(constraint).bright_cyan().bold(),
"on".bright_white(),
table.bright_cyan()
)
}
MigrationAction::RemoveConstraint { table, constraint } => {
format!(
"{} {} {} {}",
"Remove constraint:".bright_red(),
format_constraint_type(constraint).bright_cyan().bold(),
"from".bright_white(),
table.bright_cyan()
)
}
}
}

fn format_constraint_type(constraint: &vespertide_core::TableConstraint) -> String {
match constraint {
vespertide_core::TableConstraint::PrimaryKey { columns } => {
format!("PRIMARY KEY ({})", columns.join(", "))
}
vespertide_core::TableConstraint::Unique { name, columns } => {
if let Some(n) = name {
format!("{} UNIQUE ({})", n, columns.join(", "))
} else {
format!("UNIQUE ({})", columns.join(", "))
}
}
vespertide_core::TableConstraint::ForeignKey {
name,
columns,
ref_table,
..
} => {
if let Some(n) = name {
format!("{} FK ({}) -> {}", n, columns.join(", "), ref_table)
} else {
format!("FK ({}) -> {}", columns.join(", "), ref_table)
}
}
vespertide_core::TableConstraint::Check { name, expr } => {
if let Some(n) = name {
format!("{} CHECK ({})", n, expr)
} else {
format!("CHECK ({})", expr)
}
}
}
}

Expand Down Expand Up @@ -172,6 +224,11 @@ mod tests {
r#type: ColumnType::Integer,
nullable: false,
default: None,
comment: None,
primary_key: None,
unique: None,
index: None,
foreign_key: None,
}],
constraints: vec![],
indexes: vec![],
Expand All @@ -197,6 +254,11 @@ mod tests {
r#type: ColumnType::Text,
nullable: true,
default: None,
comment: None,
primary_key: None,
unique: None,
index: None,
foreign_key: None,
},
fill_with: None,
},
Expand Down Expand Up @@ -245,6 +307,92 @@ mod tests {
MigrationAction::RawSql { sql: "SELECT 1".into() },
format!("{} {}", "Execute raw SQL:".bright_yellow(), "SELECT 1".bright_cyan())
)]
#[case(
MigrationAction::AddConstraint {
table: "users".into(),
constraint: vespertide_core::TableConstraint::PrimaryKey {
columns: vec!["id".into()],
},
},
format!("{} {} {} {}", "Add constraint:".bright_green(), "PRIMARY KEY (id)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan())
)]
#[case(
MigrationAction::AddConstraint {
table: "users".into(),
constraint: vespertide_core::TableConstraint::Unique {
name: Some("unique_email".into()),
columns: vec!["email".into()],
},
},
format!("{} {} {} {}", "Add constraint:".bright_green(), "unique_email UNIQUE (email)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan())
)]
#[case(
MigrationAction::AddConstraint {
table: "posts".into(),
constraint: vespertide_core::TableConstraint::ForeignKey {
name: Some("fk_user".into()),
columns: vec!["user_id".into()],
ref_table: "users".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
},
},
format!("{} {} {} {}", "Add constraint:".bright_green(), "fk_user FK (user_id) -> users".bright_cyan().bold(), "on".bright_white(), "posts".bright_cyan())
)]
#[case(
MigrationAction::AddConstraint {
table: "users".into(),
constraint: vespertide_core::TableConstraint::Check {
name: Some("check_age".into()),
expr: "age > 0".into(),
},
},
format!("{} {} {} {}", "Add constraint:".bright_green(), "check_age CHECK (age > 0)".bright_cyan().bold(), "on".bright_white(), "users".bright_cyan())
)]
#[case(
MigrationAction::RemoveConstraint {
table: "users".into(),
constraint: vespertide_core::TableConstraint::PrimaryKey {
columns: vec!["id".into()],
},
},
format!("{} {} {} {}", "Remove constraint:".bright_red(), "PRIMARY KEY (id)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan())
)]
#[case(
MigrationAction::RemoveConstraint {
table: "users".into(),
constraint: vespertide_core::TableConstraint::Unique {
name: None,
columns: vec!["email".into()],
},
},
format!("{} {} {} {}", "Remove constraint:".bright_red(), "UNIQUE (email)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan())
)]
#[case(
MigrationAction::RemoveConstraint {
table: "posts".into(),
constraint: vespertide_core::TableConstraint::ForeignKey {
name: None,
columns: vec!["user_id".into()],
ref_table: "users".into(),
ref_columns: vec!["id".into()],
on_delete: None,
on_update: None,
},
},
format!("{} {} {} {}", "Remove constraint:".bright_red(), "FK (user_id) -> users".bright_cyan().bold(), "from".bright_white(), "posts".bright_cyan())
)]
#[case(
MigrationAction::RemoveConstraint {
table: "users".into(),
constraint: vespertide_core::TableConstraint::Check {
name: None,
expr: "age > 0".into(),
},
},
format!("{} {} {} {}", "Remove constraint:".bright_red(), "CHECK (age > 0)".bright_cyan().bold(), "from".bright_white(), "users".bright_cyan())
)]
#[serial]
fn format_action_cases(#[case] action: MigrationAction, #[case] expected: String) {
assert_eq!(format_action(&action), expected);
Expand Down
5 changes: 5 additions & 0 deletions crates/vespertide-cli/src/commands/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ mod tests {
r#type: ColumnType::Integer,
nullable: false,
default: None,
comment: None,
primary_key: None,
unique: None,
index: None,
foreign_key: None,
}],
constraints: vec![TableConstraint::PrimaryKey {
columns: vec!["id".into()],
Expand Down
Loading