From cffe7f459284cca81c0d1911055a63f1a34e1131 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 16:31:30 +0900 Subject: [PATCH 1/7] Optimize --- Cargo.lock | 20 +- Cargo.toml | 6 +- crates/vespertide-cli/Cargo.toml | 6 +- crates/vespertide-config/Cargo.toml | 9 +- crates/vespertide-config/src/config.rs | 7 +- crates/vespertide-config/src/file_format.rs | 6 +- crates/vespertide-config/src/name_case.rs | 4 +- crates/vespertide-core/Cargo.toml | 6 +- crates/vespertide-core/src/action.rs | 7 +- crates/vespertide-core/src/schema/column.rs | 19 +- .../vespertide-core/src/schema/constraint.rs | 4 +- .../vespertide-core/src/schema/foreign_key.rs | 10 +- crates/vespertide-core/src/schema/index.rs | 4 +- .../vespertide-core/src/schema/primary_key.rs | 7 +- .../vespertide-core/src/schema/reference.rs | 4 +- .../vespertide-core/src/schema/str_or_bool.rs | 7 +- crates/vespertide-core/src/schema/table.rs | 5 +- crates/vespertide-loader/Cargo.toml | 6 +- crates/vespertide-loader/src/migrations.rs | 33 +- crates/vespertide-loader/src/models.rs | 34 +- crates/vespertide-macro/Cargo.toml | 2 +- crates/vespertide-macro/src/lib.rs | 409 ++++++++---------- crates/vespertide-schema-gen/Cargo.toml | 4 +- 23 files changed, 328 insertions(+), 291 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 77d333bf..599386f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3761,7 +3761,7 @@ dependencies = [ [[package]] name = "vespertide" -version = "0.1.50" +version = "0.1.51" dependencies = [ "vespertide-core", "vespertide-macro", @@ -3769,7 +3769,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.1.50" +version = "0.1.51" dependencies = [ "anyhow", "assert_cmd", @@ -3798,7 +3798,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.1.50" +version = "0.1.51" dependencies = [ "clap", "schemars", @@ -3808,7 +3808,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.1.50" +version = "0.1.51" dependencies = [ "rstest", "schemars", @@ -3820,7 +3820,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.1.50" +version = "0.1.51" dependencies = [ "insta", "rstest", @@ -3832,7 +3832,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.1.50" +version = "0.1.51" dependencies = [ "anyhow", "rstest", @@ -3847,7 +3847,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.1.50" +version = "0.1.51" dependencies = [ "proc-macro2", "quote", @@ -3864,11 +3864,11 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.1.50" +version = "0.1.51" [[package]] name = "vespertide-planner" -version = "0.1.50" +version = "0.1.51" dependencies = [ "insta", "rstest", @@ -3879,7 +3879,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.1.50" +version = "0.1.51" dependencies = [ "insta", "rstest", diff --git a/Cargo.toml b/Cargo.toml index 5055eea5..b5a2ca91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,9 @@ documentation = "https://docs.rs/vespertide" unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } [workspace.dependencies] -vespertide-core = { path = "crates/vespertide-core", version = "0.1.51" } -vespertide-config = { path = "crates/vespertide-config", version = "0.1.51" } -vespertide-loader = { path = "crates/vespertide-loader", version = "0.1.51" } +vespertide-core = { path = "crates/vespertide-core", version = "0.1.51", default-features = false } +vespertide-config = { path = "crates/vespertide-config", version = "0.1.51", default-features = false } +vespertide-loader = { path = "crates/vespertide-loader", version = "0.1.51", default-features = false } vespertide-macro = { path = "crates/vespertide-macro", version = "0.1.51" } vespertide-naming = { path = "crates/vespertide-naming", version = "0.1.51" } vespertide-planner = { path = "crates/vespertide-planner", version = "0.1.51" } diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 938a5534..36858026 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -22,9 +22,9 @@ schemars = "1.2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs"] } futures = "0.3" async-recursion = "1" -vespertide-config = { workspace = true } -vespertide-core = { workspace = true } -vespertide-loader = { workspace = true } +vespertide-config = { workspace = true, features = ["cli", "schema"] } +vespertide-core = { workspace = true, features = ["schema"] } +vespertide-loader = { workspace = true, features = ["yaml"] } vespertide-planner = { workspace = true } vespertide-query = { workspace = true } vespertide-exporter = { workspace = true } diff --git a/crates/vespertide-config/Cargo.toml b/crates/vespertide-config/Cargo.toml index 511e4602..128ad2c2 100644 --- a/crates/vespertide-config/Cargo.toml +++ b/crates/vespertide-config/Cargo.toml @@ -10,8 +10,13 @@ description = "Manages models/migrations directories and naming-case preferences [dependencies] serde = { version = "1", features = ["derive"] } -clap = { version = "4", features = ["derive"] } -schemars = "1.2" +clap = { version = "4", features = ["derive"], optional = true } +schemars = { version = "1.2", optional = true } + +[features] +default = ["cli", "schema"] +cli = ["dep:clap"] +schema = ["dep:schemars"] [dev-dependencies] serde_json = "1" diff --git a/crates/vespertide-config/src/config.rs b/crates/vespertide-config/src/config.rs index e1ecbbb5..0b7a4822 100644 --- a/crates/vespertide-config/src/config.rs +++ b/crates/vespertide-config/src/config.rs @@ -1,6 +1,5 @@ use std::path::{Path, PathBuf}; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::file_format::FileFormat; @@ -12,7 +11,8 @@ pub fn default_migration_filename_pattern() -> String { } /// SeaORM-specific export configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct SeaOrmConfig { /// Additional derive macros to add to generated enum types. @@ -78,7 +78,8 @@ impl SeaOrmConfig { } /// Top-level vespertide configuration. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "camelCase")] pub struct VespertideConfig { pub models_dir: PathBuf, diff --git a/crates/vespertide-config/src/file_format.rs b/crates/vespertide-config/src/file_format.rs index be9dec6d..76ba3317 100644 --- a/crates/vespertide-config/src/file_format.rs +++ b/crates/vespertide-config/src/file_format.rs @@ -1,9 +1,9 @@ -use clap::ValueEnum; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Supported file formats for generated artifacts. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum, JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "cli", derive(clap::ValueEnum))] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "lowercase")] #[derive(Default)] pub enum FileFormat { diff --git a/crates/vespertide-config/src/name_case.rs b/crates/vespertide-config/src/name_case.rs index a47dd134..edd01448 100644 --- a/crates/vespertide-config/src/name_case.rs +++ b/crates/vespertide-config/src/name_case.rs @@ -1,8 +1,8 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; /// Supported naming cases. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum NameCase { Snake, diff --git a/crates/vespertide-core/Cargo.toml b/crates/vespertide-core/Cargo.toml index 127e6711..1ddb0eb8 100644 --- a/crates/vespertide-core/Cargo.toml +++ b/crates/vespertide-core/Cargo.toml @@ -10,10 +10,14 @@ description = "Data models for tables, columns, constraints, indexes, and migrat [dependencies] serde = { version = "1", features = ["derive"] } -schemars = { version = "1.2" } +schemars = { version = "1.2", optional = true } thiserror = "2" vespertide-naming = { workspace = true } +[features] +default = ["schema"] +schema = ["dep:schemars"] + [dev-dependencies] rstest = "0.26" serde_json = "1" diff --git a/crates/vespertide-core/src/action.rs b/crates/vespertide-core/src/action.rs index bcd1db09..50d39384 100644 --- a/crates/vespertide-core/src/action.rs +++ b/crates/vespertide-core/src/action.rs @@ -1,10 +1,10 @@ use crate::schema::{ColumnDef, ColumnName, ColumnType, TableConstraint, TableName}; -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fmt; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub struct MigrationPlan { /// Unique identifier for this migration (UUID format). @@ -18,7 +18,8 @@ pub struct MigrationPlan { pub actions: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(tag = "type", rename_all = "snake_case")] pub enum MigrationAction { CreateTable { diff --git a/crates/vespertide-core/src/schema/column.rs b/crates/vespertide-core/src/schema/column.rs index 5900eff0..ee838724 100644 --- a/crates/vespertide-core/src/schema/column.rs +++ b/crates/vespertide-core/src/schema/column.rs @@ -1,4 +1,3 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::schema::{ @@ -8,7 +7,8 @@ use crate::schema::{ str_or_bool::{StrOrBoolOrArray, StringOrBool}, }; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub struct ColumnDef { pub name: ColumnName, @@ -28,7 +28,8 @@ pub struct ColumnDef { pub foreign_key: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", untagged)] pub enum ColumnType { Simple(SimpleColumnType), @@ -146,7 +147,8 @@ impl ColumnType { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum SimpleColumnType { SmallInt, @@ -246,14 +248,16 @@ impl SimpleColumnType { } /// Integer enum variant with name and numeric value -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] pub struct NumValue { pub name: String, pub value: i32, } /// Enum values definition - either all string or all integer -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(untagged)] pub enum EnumValues { String(Vec), @@ -317,7 +321,8 @@ impl From> for EnumValues { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", tag = "kind")] pub enum ComplexColumnType { Varchar { length: u32 }, diff --git a/crates/vespertide-core/src/schema/constraint.rs b/crates/vespertide-core/src/schema/constraint.rs index e825e8a7..5bdc5878 100644 --- a/crates/vespertide-core/src/schema/constraint.rs +++ b/crates/vespertide-core/src/schema/constraint.rs @@ -1,4 +1,3 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::schema::{ @@ -6,7 +5,8 @@ use crate::schema::{ names::{ColumnName, TableName}, }; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", tag = "type")] pub enum TableConstraint { PrimaryKey { diff --git a/crates/vespertide-core/src/schema/foreign_key.rs b/crates/vespertide-core/src/schema/foreign_key.rs index 5586cd73..d59305fc 100644 --- a/crates/vespertide-core/src/schema/foreign_key.rs +++ b/crates/vespertide-core/src/schema/foreign_key.rs @@ -1,9 +1,9 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::schema::{names::ColumnName, names::TableName, reference::ReferenceAction}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub struct ForeignKeyDef { pub ref_table: TableName, @@ -13,7 +13,8 @@ pub struct ForeignKeyDef { } /// Shorthand syntax for foreign key: { "references": "table.column", "on_delete": "cascade" } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub struct ReferenceSyntaxDef { /// Reference in "table.column" format @@ -24,7 +25,8 @@ pub struct ReferenceSyntaxDef { pub on_update: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", untagged)] pub enum ForeignKeySyntax { /// table.column diff --git a/crates/vespertide-core/src/schema/index.rs b/crates/vespertide-core/src/schema/index.rs index b550a557..0f592b92 100644 --- a/crates/vespertide-core/src/schema/index.rs +++ b/crates/vespertide-core/src/schema/index.rs @@ -1,9 +1,9 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::schema::names::{ColumnName, IndexName}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub struct IndexDef { pub name: IndexName, diff --git a/crates/vespertide-core/src/schema/primary_key.rs b/crates/vespertide-core/src/schema/primary_key.rs index f23dad44..366d0b73 100644 --- a/crates/vespertide-core/src/schema/primary_key.rs +++ b/crates/vespertide-core/src/schema/primary_key.rs @@ -1,14 +1,15 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub struct PrimaryKeyDef { #[serde(default)] pub auto_increment: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", untagged)] pub enum PrimaryKeySyntax { Bool(bool), diff --git a/crates/vespertide-core/src/schema/reference.rs b/crates/vespertide-core/src/schema/reference.rs index c1f51c28..ae895155 100644 --- a/crates/vespertide-core/src/schema/reference.rs +++ b/crates/vespertide-core/src/schema/reference.rs @@ -1,7 +1,7 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub enum ReferenceAction { Cascade, diff --git a/crates/vespertide-core/src/schema/str_or_bool.rs b/crates/vespertide-core/src/schema/str_or_bool.rs index 9e20633b..61bef6e3 100644 --- a/crates/vespertide-core/src/schema/str_or_bool.rs +++ b/crates/vespertide-core/src/schema/str_or_bool.rs @@ -1,7 +1,7 @@ -use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case", untagged)] pub enum StrOrBoolOrArray { Str(String), @@ -11,7 +11,8 @@ pub enum StrOrBoolOrArray { /// A value that can be a string, boolean, or number. /// This is used for default values where columns can use literal values directly. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(untagged)] pub enum DefaultValue { Bool(bool), diff --git a/crates/vespertide-core/src/schema/table.rs b/crates/vespertide-core/src/schema/table.rs index dd9a492e..56b4859f 100644 --- a/crates/vespertide-core/src/schema/table.rs +++ b/crates/vespertide-core/src/schema/table.rs @@ -1,5 +1,3 @@ -use schemars::JsonSchema; - use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -46,7 +44,8 @@ impl std::fmt::Display for TableValidationError { impl std::error::Error for TableValidationError {} -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "snake_case")] pub struct TableDef { pub name: TableName, diff --git a/crates/vespertide-loader/Cargo.toml b/crates/vespertide-loader/Cargo.toml index 7bcd9b0a..03e68c8e 100644 --- a/crates/vespertide-loader/Cargo.toml +++ b/crates/vespertide-loader/Cargo.toml @@ -14,7 +14,11 @@ vespertide-config = { workspace = true } vespertide-planner = { workspace = true } anyhow = "1" serde_json = "1.0" -serde_yaml = "0.9" +serde_yaml = { version = "0.9", optional = true } + +[features] +default = ["yaml"] +yaml = ["dep:serde_yaml"] [dev-dependencies] tempfile = "3" diff --git a/crates/vespertide-loader/src/migrations.rs b/crates/vespertide-loader/src/migrations.rs index 762bee94..fc20d56f 100644 --- a/crates/vespertide-loader/src/migrations.rs +++ b/crates/vespertide-loader/src/migrations.rs @@ -30,8 +30,18 @@ pub fn load_migrations(config: &VespertideConfig) -> Result> serde_json::from_str(&content) .with_context(|| format!("parse migration: {}", path.display()))? } else { - serde_yaml::from_str(&content) - .with_context(|| format!("parse migration: {}", path.display()))? + #[cfg(feature = "yaml")] + { + serde_yaml::from_str(&content) + .with_context(|| format!("parse migration: {}", path.display()))? + } + #[cfg(not(feature = "yaml"))] + { + anyhow::bail!( + "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", + path.display() + ); + } }; // Validate the migration plan @@ -89,9 +99,19 @@ pub fn load_migrations_from_dir( format!("Failed to parse JSON migration {}: {}", path.display(), e) })? } else { - serde_yaml::from_str(&content).map_err(|e| { - format!("Failed to parse YAML migration {}: {}", path.display(), e) - })? + #[cfg(feature = "yaml")] + { + serde_yaml::from_str(&content).map_err(|e| { + format!("Failed to parse YAML migration {}: {}", path.display(), e) + })? + } + #[cfg(not(feature = "yaml"))] + { + return Err(format!( + "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", + path.display() + ).into()); + } }; plans.push(plan); @@ -191,6 +211,7 @@ mod tests { assert_eq!(plans[2].version, 3); } + #[cfg(feature = "yaml")] #[test] fn test_load_migrations_from_dir_with_yaml_migration() { let temp_dir = TempDir::new().unwrap(); @@ -218,6 +239,7 @@ actions: assert_eq!(plans[0].version, 1); } + #[cfg(feature = "yaml")] #[test] fn test_load_migrations_from_dir_with_yml_migration() { let temp_dir = TempDir::new().unwrap(); @@ -260,6 +282,7 @@ actions: assert!(err_msg.contains("Failed to parse JSON migration")); } + #[cfg(feature = "yaml")] #[test] fn test_load_migrations_from_dir_with_invalid_yaml() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vespertide-loader/src/models.rs b/crates/vespertide-loader/src/models.rs index d1421742..75bab8f3 100644 --- a/crates/vespertide-loader/src/models.rs +++ b/crates/vespertide-loader/src/models.rs @@ -59,8 +59,18 @@ fn load_models_recursive(dir: &Path, tables: &mut Vec) -> Result<()> { serde_json::from_str(&content) .with_context(|| format!("parse JSON model: {}", path.display()))? } else { - serde_yaml::from_str(&content) - .with_context(|| format!("parse YAML model: {}", path.display()))? + #[cfg(feature = "yaml")] + { + serde_yaml::from_str(&content) + .with_context(|| format!("parse YAML model: {}", path.display()))? + } + #[cfg(not(feature = "yaml"))] + { + anyhow::bail!( + "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", + path.display() + ); + } }; tables.push(table); @@ -145,9 +155,19 @@ fn load_models_recursive_internal( format!("Failed to parse JSON model {}: {}", path.display(), e) })? } else { - serde_yaml::from_str(&content).map_err(|e| { - format!("Failed to parse YAML model {}: {}", path.display(), e) - })? + #[cfg(feature = "yaml")] + { + serde_yaml::from_str(&content).map_err(|e| { + format!("Failed to parse YAML model {}: {}", path.display(), e) + })? + } + #[cfg(not(feature = "yaml"))] + { + return Err(format!( + "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", + path.display() + ).into()); + } }; tables.push(table); @@ -210,6 +230,7 @@ mod tests { assert_eq!(models.len(), 0); } + #[cfg(feature = "yaml")] #[test] #[serial] fn load_models_reads_yaml_and_validates() { @@ -394,6 +415,7 @@ mod tests { assert_eq!(models.len(), 0); } + #[cfg(feature = "yaml")] #[test] #[serial] fn test_load_models_from_dir_with_yaml() { @@ -430,6 +452,7 @@ mod tests { assert_eq!(models[0].name, "users"); } + #[cfg(feature = "yaml")] #[test] #[serial] fn test_load_models_from_dir_with_yml() { @@ -518,6 +541,7 @@ mod tests { assert!(err_msg.contains("Failed to parse JSON model")); } + #[cfg(feature = "yaml")] #[test] #[serial] fn test_load_models_from_dir_with_invalid_yaml() { diff --git a/crates/vespertide-macro/Cargo.toml b/crates/vespertide-macro/Cargo.toml index 518f06d4..3b9cb925 100644 --- a/crates/vespertide-macro/Cargo.toml +++ b/crates/vespertide-macro/Cargo.toml @@ -18,7 +18,7 @@ vespertide-loader = { workspace = true } vespertide-query = { workspace = true } vespertide-planner = { workspace = true } thiserror = "2" -syn = { version = "2.0", features = ["full"] } +syn = { version = "2.0", features = ["parsing", "proc-macro"] } quote = "1.0" proc-macro2 = "1.0" diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 8c596ade..06996044 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -4,9 +4,9 @@ use std::env; use std::path::PathBuf; use proc_macro::TokenStream; -use quote::quote; +use quote::{format_ident, quote}; use syn::parse::{Parse, ParseStream}; -use syn::{Expr, Ident, Token}; +use syn::{Ident, Token}; use vespertide_loader::{ load_config_or_default, load_migrations_at_compile_time, load_models_at_compile_time, }; @@ -14,14 +14,18 @@ use vespertide_planner::apply_action; use vespertide_query::{DatabaseBackend, build_plan_queries}; struct MacroInput { - pool: Expr, + pool: proc_macro2::TokenStream, version_table: Option, verbose: bool, } impl Parse for MacroInput { fn parse(input: ParseStream) -> syn::Result { - let pool = input.parse()?; + let mut pool_tokens = Vec::new(); + while !input.is_empty() && !input.peek(Token![,]) { + pool_tokens.push(input.parse::()?); + } + let pool: proc_macro2::TokenStream = pool_tokens.into_iter().collect(); let mut version_table = None; let mut verbose = false; @@ -54,11 +58,20 @@ impl Parse for MacroInput { } } +/// Generated migration block with static SQL arrays and execution code. +#[derive(Debug)] +pub(crate) struct MigrationBlock { + /// Static array declarations (placed outside async block) + pub statics: proc_macro2::TokenStream, + /// Execution block (placed inside async block) + pub execute: proc_macro2::TokenStream, +} + pub(crate) fn build_migration_block( migration: &vespertide_core::MigrationPlan, baseline_schema: &mut Vec, verbose: bool, -) -> Result { +) -> Result { let version = migration.version; let migration_id = &migration.id; @@ -75,168 +88,112 @@ pub(crate) fn build_migration_block( let _ = apply_action(baseline_schema, action); } - // Generate version guard and SQL execution block - let version_str = format!("v{}", version); - let comment_str = migration.comment.as_deref().unwrap_or("").to_string(); - - let block = if verbose { - // Verbose mode: preserve per-action grouping with action descriptions - let total_sql_count: usize = queries - .iter() - .map(|q| q.postgres.len().max(q.mysql.len()).max(q.sqlite.len())) - .sum(); - let total_sql_count_lit = total_sql_count; - - let mut action_blocks = Vec::new(); - let mut global_idx: usize = 0; - - for (action_idx, q) in queries.iter().enumerate() { - let action_desc = format!("{}", q.action); - let action_num = action_idx + 1; - let total_actions = queries.len(); - - let pg: Vec = q - .postgres - .iter() - .map(|s| s.build(DatabaseBackend::Postgres)) - .collect(); - let mysql: Vec = q - .mysql - .iter() - .map(|s| s.build(DatabaseBackend::MySql)) - .collect(); - let sqlite: Vec = q - .sqlite - .iter() - .map(|s| s.build(DatabaseBackend::Sqlite)) - .collect(); - - // Build per-SQL execution with global index - let sql_count = pg.len().max(mysql.len()).max(sqlite.len()); - let mut sql_exec_blocks = Vec::new(); - - for i in 0..sql_count { - let idx = global_idx + i + 1; - let pg_sql = pg.get(i).cloned().unwrap_or_default(); - let mysql_sql = mysql.get(i).cloned().unwrap_or_default(); - let sqlite_sql = sqlite.get(i).cloned().unwrap_or_default(); - - sql_exec_blocks.push(quote! { - { - let sql: &str = match backend { - sea_orm::DatabaseBackend::Postgres => #pg_sql, - sea_orm::DatabaseBackend::MySql => #mysql_sql, - sea_orm::DatabaseBackend::Sqlite => #sqlite_sql, - _ => #pg_sql, - }; - if !sql.is_empty() { - eprintln!("[vespertide] [{}/{}] {}", #idx, #total_sql_count_lit, sql); - let stmt = sea_orm::Statement::from_string(backend, sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e)) - })?; - } - } - }); - } - global_idx += sql_count; + // Flatten all SQL into per-backend arrays + let mut pg_sqls = Vec::new(); + let mut mysql_sqls = Vec::new(); + let mut sqlite_sqls = Vec::new(); - action_blocks.push(quote! { - eprintln!("[vespertide] Action {}/{}: {}", #action_num, #total_actions, #action_desc); - #(#sql_exec_blocks)* - }); + for q in &queries { + for stmt in &q.postgres { + pg_sqls.push(stmt.build(DatabaseBackend::Postgres)); + } + for stmt in &q.mysql { + mysql_sqls.push(stmt.build(DatabaseBackend::MySql)); } + for stmt in &q.sqlite { + sqlite_sqls.push(stmt.build(DatabaseBackend::Sqlite)); + } + } - quote! { - if __version < #version { - // Validate migration id against database if version already tracked - if let Some(db_id) = __version_ids.get(&#version) { - let expected_id: &str = #migration_id; - if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { - return Err(::vespertide::MigrationError::IdMismatch { - version: #version, - expected: expected_id.to_string(), - found: db_id.clone(), - }); - } - } + // Hoist SQL into static arrays outside the async block + let pg_ident = format_ident!("__V{}_PG", version); + let mysql_ident = format_ident!("__V{}_MYSQL", version); + let sqlite_ident = format_ident!("__V{}_SQLITE", version); - eprintln!("[vespertide] Applying migration {} ({})", #version_str, #comment_str); - #(#action_blocks)* + let statics = quote! { + static #pg_ident: &[&str] = &[#(#pg_sqls),*]; + static #mysql_ident: &[&str] = &[#(#mysql_sqls),*]; + static #sqlite_ident: &[&str] = &[#(#sqlite_sqls),*]; + }; - let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id); - let stmt = sea_orm::Statement::from_string(backend, insert_sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) - })?; + // Generate version guard and SQL execution block + let version_str = format!("v{}", version); + let comment_str = migration.comment.as_deref().unwrap_or("").to_string(); - eprintln!("[vespertide] Migration {} applied successfully", #version_str); - } + let verbose_start = if verbose { + quote! { + eprintln!("[vespertide] Applying migration {} ({})", #version_str, #comment_str); } } else { - // Non-verbose: flatten all SQL into one array (minimal overhead) - let mut pg_sqls = Vec::new(); - let mut mysql_sqls = Vec::new(); - let mut sqlite_sqls = Vec::new(); - - for q in &queries { - for stmt in &q.postgres { - pg_sqls.push(stmt.build(DatabaseBackend::Postgres)); - } - for stmt in &q.mysql { - mysql_sqls.push(stmt.build(DatabaseBackend::MySql)); - } - for stmt in &q.sqlite { - sqlite_sqls.push(stmt.build(DatabaseBackend::Sqlite)); - } + quote! {} + }; + + let verbose_sql_log = if verbose { + quote! { + eprintln!("[vespertide] [{}/{}] {}", __sql_idx + 1, __sqls.len(), __sql); } + } else { + quote! {} + }; + let verbose_end = if verbose { quote! { - if __version < #version { - // Validate migration id against database if version already tracked - if let Some(db_id) = __version_ids.get(&#version) { - let expected_id: &str = #migration_id; - if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { - return Err(::vespertide::MigrationError::IdMismatch { - version: #version, - expected: expected_id.to_string(), - found: db_id.clone(), - }); - } - } + eprintln!("[vespertide] Migration {} applied successfully", #version_str); + } + } else { + quote! {} + }; - let sqls: &[&str] = match backend { - sea_orm::DatabaseBackend::Postgres => &[#(#pg_sqls),*], - sea_orm::DatabaseBackend::MySql => &[#(#mysql_sqls),*], - sea_orm::DatabaseBackend::Sqlite => &[#(#sqlite_sqls),*], - _ => &[#(#pg_sqls),*], - }; - - for sql in sqls { - if !sql.is_empty() { - let stmt = sea_orm::Statement::from_string(backend, *sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e)) - })?; - } + let execute = quote! { + if __version < #version { + // Validate migration id against database if version already tracked + if let Some(db_id) = __version_ids.get(&#version) { + let expected_id: &str = #migration_id; + if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { + return Err(::vespertide::MigrationError::IdMismatch { + version: #version, + expected: expected_id.to_string(), + found: db_id.clone(), + }); } + } - let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id); - let stmt = sea_orm::Statement::from_string(backend, insert_sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) - })?; + #verbose_start + let __sqls: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => #pg_ident, + sea_orm::DatabaseBackend::MySql => #mysql_ident, + sea_orm::DatabaseBackend::Sqlite => #sqlite_ident, + _ => #pg_ident, + }; + for (__sql_idx, __sql) in __sqls.iter().enumerate() { + if !__sql.is_empty() { + #verbose_sql_log + let stmt = sea_orm::Statement::from_string(backend, *__sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError( + format!("Failed to execute SQL '{}': {}", __sql, e) + ) + })?; + } } + + let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id); + let stmt = sea_orm::Statement::from_string(backend, insert_sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) + })?; + + #verbose_end } }; - Ok(block) + Ok(MigrationBlock { statics, execute }) } fn generate_migration_code( - pool: &Expr, + pool: &proc_macro2::TokenStream, version_table: &str, - migration_blocks: Vec, + migration_blocks: Vec, verbose: bool, ) -> proc_macro2::TokenStream { let verbose_current_version = if verbose { @@ -247,78 +204,84 @@ fn generate_migration_code( quote! {} }; + let all_statics: Vec<_> = migration_blocks.iter().map(|b| &b.statics).collect(); + let all_executes: Vec<_> = migration_blocks.iter().map(|b| &b.execute).collect(); + quote! { - async { - use sea_orm::{ConnectionTrait, TransactionTrait}; - let __pool = &#pool; - let __version_table = #version_table; - let backend = __pool.get_database_backend(); - let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '"' }; - - // Create version table if it does not exist (outside transaction) - let create_table_sql = format!( - "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", - __version_table - ); - let stmt = sea_orm::Statement::from_string(backend, create_table_sql); - __pool.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to create version table: {}", e)) - })?; + { + #(#all_statics)* + async { + use sea_orm::{ConnectionTrait, TransactionTrait}; + let __pool = &#pool; + let __version_table = #version_table; + let backend = __pool.get_database_backend(); + let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '"' }; + + // Create version table if it does not exist (outside transaction) + let create_table_sql = format!( + "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", + __version_table + ); + let stmt = sea_orm::Statement::from_string(backend, create_table_sql); + __pool.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to create version table: {}", e)) + })?; - // Add id column for existing tables that don't have it yet (backward compatibility). - // We use a try-and-ignore approach: if the column already exists, the ALTER will fail - // and we simply ignore the error. - let alter_sql = format!( - "ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''", - __version_table - ); - let stmt = sea_orm::Statement::from_string(backend, alter_sql); - let _ = __pool.execute_raw(stmt).await; - - // Single transaction for the entire migration process. - // This prevents race conditions when multiple connections exist - // (e.g. SQLite with max_connections > 1). - let __txn = __pool.begin().await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to begin transaction: {}", e)) - })?; + // Add id column for existing tables that don't have it yet (backward compatibility). + // We use a try-and-ignore approach: if the column already exists, the ALTER will fail + // and we simply ignore the error. + let alter_sql = format!( + "ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''", + __version_table + ); + let stmt = sea_orm::Statement::from_string(backend, alter_sql); + let _ = __pool.execute_raw(stmt).await; + + // Single transaction for the entire migration process. + // This prevents race conditions when multiple connections exist + // (e.g. SQLite with max_connections > 1). + let __txn = __pool.begin().await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to begin transaction: {}", e)) + })?; - // Read current maximum version inside the transaction (holds lock) - let select_sql = format!("SELECT MAX(version) as version FROM {q}{}{q}", __version_table); - let stmt = sea_orm::Statement::from_string(backend, select_sql); - let version_result = __txn.query_one_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to read version: {}", e)) - })?; + // Read current maximum version inside the transaction (holds lock) + let select_sql = format!("SELECT MAX(version) as version FROM {q}{}{q}", __version_table); + let stmt = sea_orm::Statement::from_string(backend, select_sql); + let version_result = __txn.query_one_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to read version: {}", e)) + })?; - let __version = version_result - .and_then(|row| row.try_get::("", "version").ok()) - .unwrap_or(0) as u32; + let __version = version_result + .and_then(|row| row.try_get::("", "version").ok()) + .unwrap_or(0) as u32; - // Load all existing (version, id) pairs for id mismatch validation - let select_ids_sql = format!("SELECT version, id FROM {q}{}{q}", __version_table); - let stmt = sea_orm::Statement::from_string(backend, select_ids_sql); - let id_rows = __txn.query_all_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to read version ids: {}", e)) - })?; + // Load all existing (version, id) pairs for id mismatch validation + let select_ids_sql = format!("SELECT version, id FROM {q}{}{q}", __version_table); + let stmt = sea_orm::Statement::from_string(backend, select_ids_sql); + let id_rows = __txn.query_all_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to read version ids: {}", e)) + })?; - let mut __version_ids = std::collections::HashMap::::new(); - for row in &id_rows { - if let Ok(v) = row.try_get::("", "version") { - let id = row.try_get::("", "id").unwrap_or_default(); - __version_ids.insert(v as u32, id); + let mut __version_ids = std::collections::HashMap::::new(); + for row in &id_rows { + if let Ok(v) = row.try_get::("", "version") { + let id = row.try_get::("", "id").unwrap_or_default(); + __version_ids.insert(v as u32, id); + } } - } - #verbose_current_version + #verbose_current_version - // Execute each migration block within the same transaction - #(#migration_blocks)* + // Execute each migration block within the same transaction + #(#all_executes)* - // Commit the entire migration - __txn.commit().await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to commit transaction: {}", e)) - })?; + // Commit the entire migration + __txn.commit().await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to commit transaction: {}", e)) + })?; - Ok::<(), ::vespertide::MigrationError>(()) + Ok::<(), ::vespertide::MigrationError>(()) + } } } } @@ -522,6 +485,10 @@ mod tests { } } + fn block_to_string(block: &MigrationBlock) -> String { + format!("{} {}", block.statics, block.execute) + } + #[test] fn test_build_migration_block_create_table() { let migration = MigrationPlan { @@ -541,7 +508,7 @@ mod tests { assert!(result.is_ok()); let block = result.unwrap(); - let block_str = block.to_string(); + let block_str = block_to_string(&block); // Verify the generated block contains expected elements assert!(block_str.contains("version < 1u32")); @@ -596,7 +563,7 @@ mod tests { let result = build_migration_block(&add_column_migration, &mut baseline, false); assert!(result.is_ok()); let block = result.unwrap(); - let block_str = block.to_string(); + let block_str = block_to_string(&block); assert!(block_str.contains("version < 2u32")); assert!(block_str.contains("ALTER TABLE")); @@ -633,7 +600,7 @@ mod tests { #[test] fn test_generate_migration_code() { - let pool: Expr = syn::parse_str("db_pool").unwrap(); + let pool: proc_macro2::TokenStream = "db_pool".parse().unwrap(); let version_table = "test_versions"; // Create a simple migration block @@ -665,7 +632,7 @@ mod tests { #[test] fn test_generate_migration_code_empty_migrations() { - let pool: Expr = syn::parse_str("pool").unwrap(); + let pool: proc_macro2::TokenStream = "pool".parse().unwrap(); let version_table = "vespertide_version"; let generated = generate_migration_code(&pool, version_table, vec![], false); @@ -678,7 +645,7 @@ mod tests { #[test] fn test_generate_migration_code_multiple_blocks() { - let pool: Expr = syn::parse_str("connection").unwrap(); + let pool: proc_macro2::TokenStream = "connection".parse().unwrap(); let mut baseline = Vec::new(); @@ -734,7 +701,7 @@ mod tests { let result = build_migration_block(&migration, &mut baseline, false); assert!(result.is_ok()); - let block_str = result.unwrap().to_string(); + let block_str = block_to_string(&result.unwrap()); // The generated block should have backend matching assert!(block_str.contains("DatabaseBackend :: Postgres")); @@ -774,7 +741,7 @@ mod tests { let result = build_migration_block(&delete_migration, &mut baseline, false); assert!(result.is_ok()); - let block_str = result.unwrap().to_string(); + let block_str = block_to_string(&result.unwrap()); assert!(block_str.contains("DROP TABLE")); // Baseline should be empty after delete @@ -944,11 +911,11 @@ mod tests { let result = build_migration_block(&migration, &mut baseline, true); assert!(result.is_ok()); - let block_str = result.unwrap().to_string(); + let block_str = block_to_string(&result.unwrap()); - // Verbose mode should contain eprintln statements with action descriptions + // Verbose mode should contain eprintln statements with migration info assert!(block_str.contains("vespertide")); - assert!(block_str.contains("Action")); + assert!(block_str.contains("Applying migration")); assert!(block_str.contains("version < 1u32")); } @@ -977,10 +944,10 @@ mod tests { let result = build_migration_block(&migration, &mut baseline, true); assert!(result.is_ok()); - let block_str = result.unwrap().to_string(); + let block_str = block_to_string(&result.unwrap()); - // Should have action numbering for both actions - assert!(block_str.contains("Action")); + // Should have migration-level logging + assert!(block_str.contains("Applying migration")); assert_eq!(baseline.len(), 2); } @@ -1026,14 +993,14 @@ mod tests { let result = build_migration_block(&add_col, &mut baseline, true); assert!(result.is_ok()); - let block_str = result.unwrap().to_string(); + let block_str = block_to_string(&result.unwrap()); assert!(block_str.contains("vespertide")); assert!(block_str.contains("version < 2u32")); } #[test] fn test_generate_migration_code_verbose() { - let pool: Expr = syn::parse_str("db_pool").unwrap(); + let pool: proc_macro2::TokenStream = "db_pool".parse().unwrap(); let version_table = "test_versions"; let migration = MigrationPlan { diff --git a/crates/vespertide-schema-gen/Cargo.toml b/crates/vespertide-schema-gen/Cargo.toml index e747181e..f89ffa27 100644 --- a/crates/vespertide-schema-gen/Cargo.toml +++ b/crates/vespertide-schema-gen/Cargo.toml @@ -14,8 +14,8 @@ anyhow = "1" clap = { version = "4", features = ["derive"] } schemars = "1.2" serde_json = "1" -vespertide-core = { workspace = true } -vespertide-config = { workspace = true } +vespertide-core = { workspace = true, features = ["schema"] } +vespertide-config = { workspace = true, features = ["schema"] } [dev-dependencies] tempfile = "3" From 35e90332d0f4bdb50ef6ccccc3d7c97d0cd07a5f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 17:06:12 +0900 Subject: [PATCH 2/7] Add macro --- crates/vespertide-macro/src/lib.rs | 271 +++++++++++++++++------------ 1 file changed, 161 insertions(+), 110 deletions(-) diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 06996044..d4563d25 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -58,22 +58,30 @@ impl Parse for MacroInput { } } -/// Generated migration block with static SQL arrays and execution code. +/// Generated migration block with static SQL arrays and metadata for the data-driven loop. #[derive(Debug)] pub(crate) struct MigrationBlock { /// Static array declarations (placed outside async block) pub statics: proc_macro2::TokenStream, - /// Execution block (placed inside async block) - pub execute: proc_macro2::TokenStream, + /// Migration version number + pub version: u32, + /// Migration ID for validation + pub migration_id: String, + /// Migration comment (for verbose logging) + pub comment: String, + /// Identifier for PostgreSQL static array + pub pg_ident: proc_macro2::Ident, + /// Identifier for MySQL static array + pub mysql_ident: proc_macro2::Ident, + /// Identifier for SQLite static array + pub sqlite_ident: proc_macro2::Ident, } pub(crate) fn build_migration_block( migration: &vespertide_core::MigrationPlan, baseline_schema: &mut Vec, - verbose: bool, ) -> Result { let version = migration.version; - let migration_id = &migration.id; // Use the current baseline schema (from all previous migrations) let queries = build_plan_queries(migration, baseline_schema).map_err(|e| { @@ -116,13 +124,36 @@ pub(crate) fn build_migration_block( static #sqlite_ident: &[&str] = &[#(#sqlite_sqls),*]; }; - // Generate version guard and SQL execution block - let version_str = format!("v{}", version); - let comment_str = migration.comment.as_deref().unwrap_or("").to_string(); + let comment = migration.comment.as_deref().unwrap_or("").to_string(); + + Ok(MigrationBlock { + statics, + version, + migration_id: migration.id.clone(), + comment, + pg_ident, + mysql_ident, + sqlite_ident, + }) +} + +fn generate_migration_code( + pool: &proc_macro2::TokenStream, + version_table: &str, + migration_blocks: Vec, + verbose: bool, +) -> proc_macro2::TokenStream { + let verbose_current_version = if verbose { + quote! { + eprintln!("[vespertide] Current database version: {}", __version); + } + } else { + quote! {} + }; let verbose_start = if verbose { quote! { - eprintln!("[vespertide] Applying migration {} ({})", #version_str, #comment_str); + eprintln!("[vespertide] Applying migration v{} ({})", __v, __comment); } } else { quote! {} @@ -138,75 +169,82 @@ pub(crate) fn build_migration_block( let verbose_end = if verbose { quote! { - eprintln!("[vespertide] Migration {} applied successfully", #version_str); + eprintln!("[vespertide] Migration v{} applied successfully", __v); } } else { quote! {} }; - let execute = quote! { - if __version < #version { - // Validate migration id against database if version already tracked - if let Some(db_id) = __version_ids.get(&#version) { - let expected_id: &str = #migration_id; - if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { - return Err(::vespertide::MigrationError::IdMismatch { - version: #version, - expected: expected_id.to_string(), - found: db_id.clone(), - }); - } - } + let all_statics: Vec<_> = migration_blocks.iter().map(|b| &b.statics).collect(); - #verbose_start - let __sqls: &[&str] = match backend { - sea_orm::DatabaseBackend::Postgres => #pg_ident, - sea_orm::DatabaseBackend::MySql => #mysql_ident, - sea_orm::DatabaseBackend::Sqlite => #sqlite_ident, - _ => #pg_ident, - }; - for (__sql_idx, __sql) in __sqls.iter().enumerate() { - if !__sql.is_empty() { - #verbose_sql_log - let stmt = sea_orm::Statement::from_string(backend, *__sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError( - format!("Failed to execute SQL '{}': {}", __sql, e) - ) - })?; - } + // Build metadata entries for the data-driven loop + let entries: Vec<_> = migration_blocks + .iter() + .map(|b| { + let version = b.version; + let id = &b.migration_id; + let comment = &b.comment; + let pg = &b.pg_ident; + let mysql = &b.mysql_ident; + let sqlite = &b.sqlite_ident; + quote! { + (#version, #id, #comment, #pg, #mysql, #sqlite) } + }) + .collect(); - let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, #version, #migration_id); - let stmt = sea_orm::Statement::from_string(backend, insert_sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) - })?; + // Generate the migration loop (or nothing if no migrations) + let migration_loop = if entries.is_empty() { + quote! {} + } else { + quote! { + for (__v, __mid, __comment, __pg_sqls, __mysql_sqls, __sqlite_sqls) in [ + #(#entries),* + ] { + if __version < __v { + // Validate migration id against database if version already tracked + if let Some(db_id) = __version_ids.get(&__v) { + let expected_id: &str = __mid; + if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { + return Err(::vespertide::MigrationError::IdMismatch { + version: __v, + expected: expected_id.to_string(), + found: db_id.clone(), + }); + } + } - #verbose_end - } - }; + #verbose_start + let __sqls: &[&str] = match backend { + sea_orm::DatabaseBackend::Postgres => __pg_sqls, + sea_orm::DatabaseBackend::MySql => __mysql_sqls, + sea_orm::DatabaseBackend::Sqlite => __sqlite_sqls, + _ => __pg_sqls, + }; + for (__sql_idx, __sql) in __sqls.iter().enumerate() { + if !__sql.is_empty() { + #verbose_sql_log + let stmt = sea_orm::Statement::from_string(backend, *__sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError( + format!("Failed to execute SQL '{}': {}", __sql, e) + ) + })?; + } + } - Ok(MigrationBlock { statics, execute }) -} + let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, __v, __mid); + let stmt = sea_orm::Statement::from_string(backend, insert_sql); + __txn.execute_raw(stmt).await.map_err(|e| { + ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) + })?; -fn generate_migration_code( - pool: &proc_macro2::TokenStream, - version_table: &str, - migration_blocks: Vec, - verbose: bool, -) -> proc_macro2::TokenStream { - let verbose_current_version = if verbose { - quote! { - eprintln!("[vespertide] Current database version: {}", __version); + #verbose_end + } + } } - } else { - quote! {} }; - let all_statics: Vec<_> = migration_blocks.iter().map(|b| &b.statics).collect(); - let all_executes: Vec<_> = migration_blocks.iter().map(|b| &b.execute).collect(); - quote! { { #(#all_statics)* @@ -272,8 +310,8 @@ fn generate_migration_code( #verbose_current_version - // Execute each migration block within the same transaction - #(#all_executes)* + // Execute migrations via data-driven loop + #migration_loop // Commit the entire migration __txn.commit().await.map_err(|e| { @@ -354,7 +392,7 @@ pub(crate) fn vespertide_migration_impl( for migration in &migrations { // Apply prefix to migration table names let prefixed_migration = migration.clone().with_prefix(prefix); - match build_migration_block(&prefixed_migration, &mut baseline_schema, verbose) { + match build_migration_block(&prefixed_migration, &mut baseline_schema) { Ok(block) => migration_blocks.push(block), Err(e) => { return syn::Error::new(proc_macro2::Span::call_site(), e).to_compile_error(); @@ -486,7 +524,7 @@ mod tests { } fn block_to_string(block: &MigrationBlock) -> String { - format!("{} {}", block.statics, block.execute) + block.statics.to_string() } #[test] @@ -504,15 +542,15 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline, false); + let result = build_migration_block(&migration, &mut baseline); assert!(result.is_ok()); let block = result.unwrap(); let block_str = block_to_string(&block); - // Verify the generated block contains expected elements - assert!(block_str.contains("version < 1u32")); + // Verify statics contain SQL and metadata is correct assert!(block_str.contains("CREATE TABLE")); + assert_eq!(block.version, 1); // Verify baseline schema was updated assert_eq!(baseline.len(), 1); @@ -535,7 +573,7 @@ mod tests { }; let mut baseline = Vec::new(); - let _ = build_migration_block(&create_migration, &mut baseline, false); + let _ = build_migration_block(&create_migration, &mut baseline); // Now add a column let add_column_migration = MigrationPlan { @@ -560,12 +598,12 @@ mod tests { }], }; - let result = build_migration_block(&add_column_migration, &mut baseline, false); + let result = build_migration_block(&add_column_migration, &mut baseline); assert!(result.is_ok()); let block = result.unwrap(); let block_str = block_to_string(&block); - assert!(block_str.contains("version < 2u32")); + assert_eq!(block.version, 2); assert!(block_str.contains("ALTER TABLE")); assert!(block_str.contains("ADD COLUMN")); } @@ -592,7 +630,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline, false); + let result = build_migration_block(&migration, &mut baseline); assert!(result.is_ok()); assert_eq!(baseline.len(), 2); @@ -617,7 +655,7 @@ mod tests { }; let mut baseline = Vec::new(); - let block = build_migration_block(&migration, &mut baseline, false).unwrap(); + let block = build_migration_block(&migration, &mut baseline).unwrap(); let generated = generate_migration_code(&pool, version_table, vec![block], false); let generated_str = generated.to_string(); @@ -628,6 +666,8 @@ mod tests { assert!(generated_str.contains("test_versions")); assert!(generated_str.contains("CREATE TABLE IF NOT EXISTS")); assert!(generated_str.contains("SELECT MAX")); + // Verify data-driven loop structure + assert!(generated_str.contains("1u32")); } #[test] @@ -660,7 +700,7 @@ mod tests { constraints: vec![], }], }; - let block1 = build_migration_block(&migration1, &mut baseline, false).unwrap(); + let block1 = build_migration_block(&migration1, &mut baseline).unwrap(); let migration2 = MigrationPlan { id: String::new(), @@ -673,14 +713,16 @@ mod tests { constraints: vec![], }], }; - let block2 = build_migration_block(&migration2, &mut baseline, false).unwrap(); + let block2 = build_migration_block(&migration2, &mut baseline).unwrap(); let generated = generate_migration_code(&pool, "migrations", vec![block1, block2], false); let generated_str = generated.to_string(); - // Both version checks should be present - assert!(generated_str.contains("version < 1u32")); - assert!(generated_str.contains("version < 2u32")); + // Both migration versions should be present in the metadata array + assert!(generated_str.contains("1u32")); + assert!(generated_str.contains("2u32")); + // Data-driven loop structure + assert!(generated_str.contains("__version < __v")); } #[test] @@ -698,15 +740,21 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline, false); + let result = build_migration_block(&migration, &mut baseline); assert!(result.is_ok()); - let block_str = block_to_string(&result.unwrap()); + let block = result.unwrap(); + let block_str = block_to_string(&block); + + // Statics should have all three backend arrays + assert!(block_str.contains("__V1_PG")); + assert!(block_str.contains("__V1_MYSQL")); + assert!(block_str.contains("__V1_SQLITE")); - // The generated block should have backend matching - assert!(block_str.contains("DatabaseBackend :: Postgres")); - assert!(block_str.contains("DatabaseBackend :: MySql")); - assert!(block_str.contains("DatabaseBackend :: Sqlite")); + // Verify ident names match + assert_eq!(block.pg_ident.to_string(), "__V1_PG"); + assert_eq!(block.mysql_ident.to_string(), "__V1_MYSQL"); + assert_eq!(block.sqlite_ident.to_string(), "__V1_SQLITE"); } #[test] @@ -725,7 +773,7 @@ mod tests { }; let mut baseline = Vec::new(); - let _ = build_migration_block(&create_migration, &mut baseline, false); + let _ = build_migration_block(&create_migration, &mut baseline); assert_eq!(baseline.len(), 1); // Now delete it @@ -739,7 +787,7 @@ mod tests { }], }; - let result = build_migration_block(&delete_migration, &mut baseline, false); + let result = build_migration_block(&delete_migration, &mut baseline); assert!(result.is_ok()); let block_str = block_to_string(&result.unwrap()); assert!(block_str.contains("DROP TABLE")); @@ -776,7 +824,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline, false); + let result = build_migration_block(&migration, &mut baseline); assert!(result.is_ok()); // Table should be normalized with index @@ -801,7 +849,7 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline, false); + let result = build_migration_block(&migration, &mut baseline); assert!(result.is_err()); let err = result.unwrap_err(); @@ -908,15 +956,17 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline, true); + let result = build_migration_block(&migration, &mut baseline); assert!(result.is_ok()); - let block_str = block_to_string(&result.unwrap()); + let block = result.unwrap(); - // Verbose mode should contain eprintln statements with migration info - assert!(block_str.contains("vespertide")); - assert!(block_str.contains("Applying migration")); - assert!(block_str.contains("version < 1u32")); + // Metadata should capture comment for verbose logging in generate_migration_code + assert_eq!(block.version, 1); + assert_eq!(block.comment, "initial setup"); + // SQL statics should still contain the SQL + let block_str = block_to_string(&block); + assert!(block_str.contains("CREATE TABLE")); } #[test] @@ -941,14 +991,12 @@ mod tests { }; let mut baseline = Vec::new(); - let result = build_migration_block(&migration, &mut baseline, true); + let result = build_migration_block(&migration, &mut baseline); assert!(result.is_ok()); - let block_str = block_to_string(&result.unwrap()); - - // Should have migration-level logging - assert!(block_str.contains("Applying migration")); assert_eq!(baseline.len(), 2); + // Metadata should be set even with multiple actions + assert_eq!(result.as_ref().unwrap().version, 1); } #[test] @@ -966,9 +1014,9 @@ mod tests { }], }; let mut baseline = Vec::new(); - let _ = build_migration_block(&create, &mut baseline, true); + let _ = build_migration_block(&create, &mut baseline); - // Add column in verbose mode + // Add column let add_col = MigrationPlan { id: String::new(), version: 2, @@ -991,11 +1039,13 @@ mod tests { }], }; - let result = build_migration_block(&add_col, &mut baseline, true); + let result = build_migration_block(&add_col, &mut baseline); assert!(result.is_ok()); - let block_str = block_to_string(&result.unwrap()); - assert!(block_str.contains("vespertide")); - assert!(block_str.contains("version < 2u32")); + let block = result.unwrap(); + assert_eq!(block.version, 2); + assert_eq!(block.comment, "add email"); + let block_str = block_to_string(&block); + assert!(block_str.contains("__V2_PG")); } #[test] @@ -1016,13 +1066,14 @@ mod tests { }; let mut baseline = Vec::new(); - let block = build_migration_block(&migration, &mut baseline, true).unwrap(); + let block = build_migration_block(&migration, &mut baseline).unwrap(); let generated = generate_migration_code(&pool, version_table, vec![block], true); let generated_str = generated.to_string(); - // Verbose mode should include current version eprintln + // Verbose mode should include logging in the data-driven loop assert!(generated_str.contains("Current database version")); + assert!(generated_str.contains("Applying migration")); assert!(generated_str.contains("async")); } From 3ef1218046ec7c4072695ea3f3015bfac0916701 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 17:30:45 +0900 Subject: [PATCH 3/7] Optimize --- Cargo.lock | 1 - Cargo.toml | 3 + crates/vespertide-macro/Cargo.toml | 1 - crates/vespertide-macro/src/lib.rs | 455 ++++++++++++++++------------- 4 files changed, 248 insertions(+), 212 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 599386f7..9f7d8445 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3850,7 +3850,6 @@ name = "vespertide-macro" version = "0.1.51" dependencies = [ "proc-macro2", - "quote", "runtime-macros", "syn 2.0.117", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index b5a2ca91..ee3de817 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,6 @@ vespertide-naming = { path = "crates/vespertide-naming", version = "0.1.51" } vespertide-planner = { path = "crates/vespertide-planner", version = "0.1.51" } vespertide-query = { path = "crates/vespertide-query", version = "0.1.51" } vespertide-exporter = { path = "crates/vespertide-exporter", version = "0.1.51" } + +[profile.dev] +debug = 1 # Line tables only — faster DWARF generation for large codegen output diff --git a/crates/vespertide-macro/Cargo.toml b/crates/vespertide-macro/Cargo.toml index 3b9cb925..a29d1227 100644 --- a/crates/vespertide-macro/Cargo.toml +++ b/crates/vespertide-macro/Cargo.toml @@ -19,7 +19,6 @@ vespertide-query = { workspace = true } vespertide-planner = { workspace = true } thiserror = "2" syn = { version = "2.0", features = ["parsing", "proc-macro"] } -quote = "1.0" proc-macro2 = "1.0" [dev-dependencies] diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index d4563d25..745ce53a 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -1,17 +1,17 @@ // MigrationOptions and MigrationError are now in vespertide-core use std::env; +use std::fmt::Write; use std::path::PathBuf; use proc_macro::TokenStream; -use quote::{format_ident, quote}; use syn::parse::{Parse, ParseStream}; use syn::{Ident, Token}; use vespertide_loader::{ load_config_or_default, load_migrations_at_compile_time, load_models_at_compile_time, }; use vespertide_planner::apply_action; -use vespertide_query::{DatabaseBackend, build_plan_queries}; +use vespertide_query::{build_plan_queries, DatabaseBackend}; struct MacroInput { pool: proc_macro2::TokenStream, @@ -58,23 +58,21 @@ impl Parse for MacroInput { } } -/// Generated migration block with static SQL arrays and metadata for the data-driven loop. +/// Generated migration block with raw SQL strings for string-based codegen. #[derive(Debug)] pub(crate) struct MigrationBlock { - /// Static array declarations (placed outside async block) - pub statics: proc_macro2::TokenStream, /// Migration version number pub version: u32, /// Migration ID for validation pub migration_id: String, /// Migration comment (for verbose logging) pub comment: String, - /// Identifier for PostgreSQL static array - pub pg_ident: proc_macro2::Ident, - /// Identifier for MySQL static array - pub mysql_ident: proc_macro2::Ident, - /// Identifier for SQLite static array - pub sqlite_ident: proc_macro2::Ident, + /// PostgreSQL SQL statements + pub pg_sqls: Vec, + /// MySQL SQL statements + pub mysql_sqls: Vec, + /// SQLite SQL statements + pub sqlite_sqls: Vec, } pub(crate) fn build_migration_block( @@ -96,7 +94,7 @@ pub(crate) fn build_migration_block( let _ = apply_action(baseline_schema, action); } - // Flatten all SQL into per-backend arrays + // Flatten all SQL into per-backend string arrays let mut pg_sqls = Vec::new(); let mut mysql_sqls = Vec::new(); let mut sqlite_sqls = Vec::new(); @@ -113,27 +111,15 @@ pub(crate) fn build_migration_block( } } - // Hoist SQL into static arrays outside the async block - let pg_ident = format_ident!("__V{}_PG", version); - let mysql_ident = format_ident!("__V{}_MYSQL", version); - let sqlite_ident = format_ident!("__V{}_SQLITE", version); - - let statics = quote! { - static #pg_ident: &[&str] = &[#(#pg_sqls),*]; - static #mysql_ident: &[&str] = &[#(#mysql_sqls),*]; - static #sqlite_ident: &[&str] = &[#(#sqlite_sqls),*]; - }; - let comment = migration.comment.as_deref().unwrap_or("").to_string(); Ok(MigrationBlock { - statics, version, migration_id: migration.id.clone(), comment, - pg_ident, - mysql_ident, - sqlite_ident, + pg_sqls, + mysql_sqls, + sqlite_sqls, }) } @@ -143,185 +129,213 @@ fn generate_migration_code( migration_blocks: Vec, verbose: bool, ) -> proc_macro2::TokenStream { - let verbose_current_version = if verbose { - quote! { - eprintln!("[vespertide] Current database version: {}", __version); - } - } else { - quote! {} - }; + // Build entire output as a String, parse once at the end. + // This avoids per-token IPC overhead from quote! (see rust-lang/rust#65080). + let pool_str = pool.to_string(); + let mut code = String::with_capacity(2_097_152); // 2MB pre-allocation + + code.push_str("{\n"); + + // Emit static SQL arrays (outside async block for zero runtime cost) + for b in &migration_blocks { + write_static_array(&mut code, &format!("__V{}_PG", b.version), &b.pg_sqls); + write_static_array(&mut code, &format!("__V{}_MYSQL", b.version), &b.mysql_sqls); + write_static_array( + &mut code, + &format!("__V{}_SQLITE", b.version), + &b.sqlite_sqls, + ); + } - let verbose_start = if verbose { - quote! { - eprintln!("[vespertide] Applying migration v{} ({})", __v, __comment); - } - } else { - quote! {} - }; + // Begin async block + code.push_str("async {\n"); + code.push_str("use sea_orm::{ConnectionTrait, TransactionTrait};\n"); + writeln!(code, "let __pool = &{pool_str};").unwrap(); + writeln!(code, "let __version_table = {version_table:?};").unwrap(); + code.push_str("let backend = __pool.get_database_backend();\n"); + code.push_str( + "let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '\"' };\n", + ); + + // Create version table (outside transaction) + code.push_str(concat!( + "let create_table_sql = format!(", + "\"CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, ", + "id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)\", ", + "__version_table);\n", + )); + code.push_str("let stmt = sea_orm::Statement::from_string(backend, create_table_sql);\n"); + code.push_str(concat!( + "__pool.execute_raw(stmt).await.map_err(|e| ", + "::vespertide::MigrationError::DatabaseError(", + "format!(\"Failed to create version table: {}\", e)))?;\n", + )); + + // Add id column for backward compatibility + code.push_str(concat!( + "let alter_sql = format!(\"ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''\", ", + "__version_table);\n", + )); + code.push_str("let stmt = sea_orm::Statement::from_string(backend, alter_sql);\n"); + code.push_str("let _ = __pool.execute_raw(stmt).await;\n"); + + // Begin transaction + code.push_str(concat!( + "let __txn = __pool.begin().await.map_err(|e| ", + "::vespertide::MigrationError::DatabaseError(", + "format!(\"Failed to begin transaction: {}\", e)))?;\n", + )); + + // Read current version + code.push_str(concat!( + "let select_sql = format!(\"SELECT MAX(version) as version FROM {q}{}{q}\", ", + "__version_table);\n", + )); + code.push_str("let stmt = sea_orm::Statement::from_string(backend, select_sql);\n"); + code.push_str(concat!( + "let version_result = __txn.query_one_raw(stmt).await.map_err(|e| ", + "::vespertide::MigrationError::DatabaseError(", + "format!(\"Failed to read version: {}\", e)))?;\n", + )); + code.push_str(concat!( + "let __version = version_result", + ".and_then(|row| row.try_get::(\"\", \"version\").ok())", + ".unwrap_or(0) as u32;\n", + )); + + // Load version ids for mismatch validation + code.push_str(concat!( + "let select_ids_sql = format!(\"SELECT version, id FROM {q}{}{q}\", ", + "__version_table);\n", + )); + code.push_str("let stmt = sea_orm::Statement::from_string(backend, select_ids_sql);\n"); + code.push_str(concat!( + "let id_rows = __txn.query_all_raw(stmt).await.map_err(|e| ", + "::vespertide::MigrationError::DatabaseError(", + "format!(\"Failed to read version ids: {}\", e)))?;\n", + )); + code.push_str("let mut __version_ids = std::collections::HashMap::::new();\n"); + code.push_str(concat!( + "for row in &id_rows { ", + "if let Ok(v) = row.try_get::(\"\", \"version\") { ", + "let id = row.try_get::(\"\", \"id\").unwrap_or_default(); ", + "__version_ids.insert(v as u32, id); ", + "} }\n", + )); + + // Verbose: current version + if verbose { + code.push_str("eprintln!(\"[vespertide] Current database version: {}\", __version);\n"); + } - let verbose_sql_log = if verbose { - quote! { - eprintln!("[vespertide] [{}/{}] {}", __sql_idx + 1, __sqls.len(), __sql); + // Data-driven migration loop + if !migration_blocks.is_empty() { + code.push_str("for (__v, __mid, __comment, __pg_sqls, __mysql_sqls, __sqlite_sqls) in [\n"); + for b in &migration_blocks { + writeln!( + code, + "({}u32, {:?}, {:?}, __V{}_PG, __V{}_MYSQL, __V{}_SQLITE),", + b.version, b.migration_id, b.comment, b.version, b.version, b.version + ) + .unwrap(); } - } else { - quote! {} - }; - - let verbose_end = if verbose { - quote! { - eprintln!("[vespertide] Migration v{} applied successfully", __v); + code.push_str("] {\n"); + + // Loop body: validation + execution (written ONCE) + code.push_str("if __version < __v {\n"); + + // Id mismatch validation + code.push_str(concat!( + "if let Some(db_id) = __version_ids.get(&__v) { ", + "let expected_id: &str = __mid; ", + "if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { ", + "return Err(::vespertide::MigrationError::IdMismatch { ", + "version: __v, expected: expected_id.to_string(), found: db_id.clone() }); ", + "} }\n", + )); + + // Verbose: applying + if verbose { + code.push_str( + "eprintln!(\"[vespertide] Applying migration v{} ({})\", __v, __comment);\n", + ); } - } else { - quote! {} - }; - let all_statics: Vec<_> = migration_blocks.iter().map(|b| &b.statics).collect(); - - // Build metadata entries for the data-driven loop - let entries: Vec<_> = migration_blocks - .iter() - .map(|b| { - let version = b.version; - let id = &b.migration_id; - let comment = &b.comment; - let pg = &b.pg_ident; - let mysql = &b.mysql_ident; - let sqlite = &b.sqlite_ident; - quote! { - (#version, #id, #comment, #pg, #mysql, #sqlite) - } - }) - .collect(); - - // Generate the migration loop (or nothing if no migrations) - let migration_loop = if entries.is_empty() { - quote! {} - } else { - quote! { - for (__v, __mid, __comment, __pg_sqls, __mysql_sqls, __sqlite_sqls) in [ - #(#entries),* - ] { - if __version < __v { - // Validate migration id against database if version already tracked - if let Some(db_id) = __version_ids.get(&__v) { - let expected_id: &str = __mid; - if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { - return Err(::vespertide::MigrationError::IdMismatch { - version: __v, - expected: expected_id.to_string(), - found: db_id.clone(), - }); - } - } - - #verbose_start - let __sqls: &[&str] = match backend { - sea_orm::DatabaseBackend::Postgres => __pg_sqls, - sea_orm::DatabaseBackend::MySql => __mysql_sqls, - sea_orm::DatabaseBackend::Sqlite => __sqlite_sqls, - _ => __pg_sqls, - }; - for (__sql_idx, __sql) in __sqls.iter().enumerate() { - if !__sql.is_empty() { - #verbose_sql_log - let stmt = sea_orm::Statement::from_string(backend, *__sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError( - format!("Failed to execute SQL '{}': {}", __sql, e) - ) - })?; - } - } - - let insert_sql = format!("INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", __version_table, __v, __mid); - let stmt = sea_orm::Statement::from_string(backend, insert_sql); - __txn.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) - })?; - - #verbose_end - } - } + // Backend match + code.push_str(concat!( + "let __sqls: &[&str] = match backend { ", + "sea_orm::DatabaseBackend::Postgres => __pg_sqls, ", + "sea_orm::DatabaseBackend::MySql => __mysql_sqls, ", + "sea_orm::DatabaseBackend::Sqlite => __sqlite_sqls, ", + "_ => __pg_sqls };\n", + )); + + // SQL execution loop + code.push_str("for (__sql_idx, __sql) in __sqls.iter().enumerate() {\n"); + code.push_str("if !__sql.is_empty() {\n"); + if verbose { + code.push_str(concat!( + "eprintln!(\"[vespertide] [{}/{}] {}\", ", + "__sql_idx + 1, __sqls.len(), __sql);\n", + )); + } + code.push_str(concat!( + "let stmt = sea_orm::Statement::from_string(backend, *__sql);\n", + "__txn.execute_raw(stmt).await.map_err(|e| ", + "::vespertide::MigrationError::DatabaseError(", + "format!(\"Failed to execute SQL '{}': {}\", __sql, e)))?;\n", + )); + code.push_str("}\n"); // if !empty + code.push_str("}\n"); // for __sql + + // Insert version record + code.push_str(concat!( + "let insert_sql = format!(\"INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')\", ", + "__version_table, __v, __mid);\n", + )); + code.push_str("let stmt = sea_orm::Statement::from_string(backend, insert_sql);\n"); + code.push_str(concat!( + "__txn.execute_raw(stmt).await.map_err(|e| ", + "::vespertide::MigrationError::DatabaseError(", + "format!(\"Failed to insert version: {}\", e)))?;\n", + )); + + // Verbose: done + if verbose { + code.push_str("eprintln!(\"[vespertide] Migration v{} applied successfully\", __v);\n"); } - }; - quote! { - { - #(#all_statics)* - async { - use sea_orm::{ConnectionTrait, TransactionTrait}; - let __pool = &#pool; - let __version_table = #version_table; - let backend = __pool.get_database_backend(); - let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '"' }; - - // Create version table if it does not exist (outside transaction) - let create_table_sql = format!( - "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", - __version_table - ); - let stmt = sea_orm::Statement::from_string(backend, create_table_sql); - __pool.execute_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to create version table: {}", e)) - })?; - - // Add id column for existing tables that don't have it yet (backward compatibility). - // We use a try-and-ignore approach: if the column already exists, the ALTER will fail - // and we simply ignore the error. - let alter_sql = format!( - "ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''", - __version_table - ); - let stmt = sea_orm::Statement::from_string(backend, alter_sql); - let _ = __pool.execute_raw(stmt).await; - - // Single transaction for the entire migration process. - // This prevents race conditions when multiple connections exist - // (e.g. SQLite with max_connections > 1). - let __txn = __pool.begin().await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to begin transaction: {}", e)) - })?; - - // Read current maximum version inside the transaction (holds lock) - let select_sql = format!("SELECT MAX(version) as version FROM {q}{}{q}", __version_table); - let stmt = sea_orm::Statement::from_string(backend, select_sql); - let version_result = __txn.query_one_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to read version: {}", e)) - })?; - - let __version = version_result - .and_then(|row| row.try_get::("", "version").ok()) - .unwrap_or(0) as u32; - - // Load all existing (version, id) pairs for id mismatch validation - let select_ids_sql = format!("SELECT version, id FROM {q}{}{q}", __version_table); - let stmt = sea_orm::Statement::from_string(backend, select_ids_sql); - let id_rows = __txn.query_all_raw(stmt).await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to read version ids: {}", e)) - })?; - - let mut __version_ids = std::collections::HashMap::::new(); - for row in &id_rows { - if let Ok(v) = row.try_get::("", "version") { - let id = row.try_get::("", "id").unwrap_or_default(); - __version_ids.insert(v as u32, id); - } - } + code.push_str("}\n"); // if __version < __v + code.push_str("}\n"); // for loop + } - #verbose_current_version + // Commit transaction + code.push_str(concat!( + "__txn.commit().await.map_err(|e| ", + "::vespertide::MigrationError::DatabaseError(", + "format!(\"Failed to commit transaction: {}\", e)))?;\n", + )); - // Execute migrations via data-driven loop - #migration_loop + code.push_str("Ok::<(), ::vespertide::MigrationError>(())\n"); + code.push_str("}\n"); // async block + code.push_str("}\n"); // outer block - // Commit the entire migration - __txn.commit().await.map_err(|e| { - ::vespertide::MigrationError::DatabaseError(format!("Failed to commit transaction: {}", e)) - })?; + // Single parse — ONE IPC call to rustc tokenizer + code.parse().unwrap_or_else(|e| { + panic!("vespertide_migration codegen produced invalid Rust:\n{e}\n\nGenerated code (first 2000 chars):\n{}", &code[..code.len().min(2000)]) + }) +} - Ok::<(), ::vespertide::MigrationError>(()) - } +/// Write a static `&[&str]` array declaration into the code string. +fn write_static_array(code: &mut String, ident: &str, sqls: &[String]) { + write!(code, "static {ident}: &[&str] = &[").unwrap(); + for (i, sql) in sqls.iter().enumerate() { + if i > 0 { + code.push_str(", "); } + write!(code, "{sql:?}").unwrap(); } + code.push_str("];\n"); } /// Inner implementation that works with proc_macro2::TokenStream for testability. @@ -523,8 +537,22 @@ mod tests { } } + /// Concatenate all SQL strings from a block for assertion testing. fn block_to_string(block: &MigrationBlock) -> String { - block.statics.to_string() + let mut result = String::new(); + for sql in &block.pg_sqls { + result.push_str(sql); + result.push(' '); + } + for sql in &block.mysql_sqls { + result.push_str(sql); + result.push(' '); + } + for sql in &block.sqlite_sqls { + result.push_str(sql); + result.push(' '); + } + result } #[test] @@ -744,17 +772,25 @@ mod tests { assert!(result.is_ok()); let block = result.unwrap(); - let block_str = block_to_string(&block); - // Statics should have all three backend arrays - assert!(block_str.contains("__V1_PG")); - assert!(block_str.contains("__V1_MYSQL")); - assert!(block_str.contains("__V1_SQLITE")); + // All three backend SQL arrays should be populated + assert!( + !block.pg_sqls.is_empty(), + "PostgreSQL SQL should not be empty" + ); + assert!( + !block.mysql_sqls.is_empty(), + "MySQL SQL should not be empty" + ); + assert!( + !block.sqlite_sqls.is_empty(), + "SQLite SQL should not be empty" + ); - // Verify ident names match - assert_eq!(block.pg_ident.to_string(), "__V1_PG"); - assert_eq!(block.mysql_ident.to_string(), "__V1_MYSQL"); - assert_eq!(block.sqlite_ident.to_string(), "__V1_SQLITE"); + // Each should contain CREATE TABLE SQL + assert!(block.pg_sqls.iter().any(|s| s.contains("CREATE TABLE"))); + assert!(block.mysql_sqls.iter().any(|s| s.contains("CREATE TABLE"))); + assert!(block.sqlite_sqls.iter().any(|s| s.contains("CREATE TABLE"))); } #[test] @@ -789,8 +825,8 @@ mod tests { let result = build_migration_block(&delete_migration, &mut baseline); assert!(result.is_ok()); - let block_str = block_to_string(&result.unwrap()); - assert!(block_str.contains("DROP TABLE")); + let block = result.unwrap(); + assert!(block.pg_sqls.iter().any(|s| s.contains("DROP TABLE"))); // Baseline should be empty after delete assert_eq!(baseline.len(), 0); @@ -964,9 +1000,8 @@ mod tests { // Metadata should capture comment for verbose logging in generate_migration_code assert_eq!(block.version, 1); assert_eq!(block.comment, "initial setup"); - // SQL statics should still contain the SQL - let block_str = block_to_string(&block); - assert!(block_str.contains("CREATE TABLE")); + // SQL should contain CREATE TABLE + assert!(block.pg_sqls.iter().any(|s| s.contains("CREATE TABLE"))); } #[test] @@ -1044,8 +1079,8 @@ mod tests { let block = result.unwrap(); assert_eq!(block.version, 2); assert_eq!(block.comment, "add email"); - let block_str = block_to_string(&block); - assert!(block_str.contains("__V2_PG")); + // SQL should contain ALTER TABLE for the add column action + assert!(block.pg_sqls.iter().any(|s| s.contains("ALTER TABLE"))); } #[test] From a2438802f2b49ef6ac7627e8f94523f417916c69 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 18:56:53 +0900 Subject: [PATCH 4/7] Split runtime --- crates/vespertide-macro/src/lib.rs | 255 ++++++++--------------------- crates/vespertide/Cargo.toml | 1 + crates/vespertide/src/lib.rs | 2 + crates/vespertide/src/runtime.rs | 196 ++++++++++++++++++++++ 4 files changed, 263 insertions(+), 191 deletions(-) create mode 100644 crates/vespertide/src/runtime.rs diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 745ce53a..d7d89f3e 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -11,7 +11,7 @@ use vespertide_loader::{ load_config_or_default, load_migrations_at_compile_time, load_models_at_compile_time, }; use vespertide_planner::apply_action; -use vespertide_query::{build_plan_queries, DatabaseBackend}; +use vespertide_query::{DatabaseBackend, build_plan_queries}; struct MacroInput { pool: proc_macro2::TokenStream, @@ -132,191 +132,41 @@ fn generate_migration_code( // Build entire output as a String, parse once at the end. // This avoids per-token IPC overhead from quote! (see rust-lang/rust#65080). let pool_str = pool.to_string(); - let mut code = String::with_capacity(2_097_152); // 2MB pre-allocation + let mut code = String::with_capacity(1_048_576); code.push_str("{\n"); - // Emit static SQL arrays (outside async block for zero runtime cost) + // Emit compact SQL blobs (outside async block for zero runtime cost). for b in &migration_blocks { - write_static_array(&mut code, &format!("__V{}_PG", b.version), &b.pg_sqls); - write_static_array(&mut code, &format!("__V{}_MYSQL", b.version), &b.mysql_sqls); - write_static_array( + write_sql_blob(&mut code, &format!("__V{}_PG", b.version), &b.pg_sqls); + write_sql_blob(&mut code, &format!("__V{}_MYSQL", b.version), &b.mysql_sqls); + write_sql_blob( &mut code, &format!("__V{}_SQLITE", b.version), &b.sqlite_sqls, ); } - // Begin async block - code.push_str("async {\n"); - code.push_str("use sea_orm::{ConnectionTrait, TransactionTrait};\n"); - writeln!(code, "let __pool = &{pool_str};").unwrap(); - writeln!(code, "let __version_table = {version_table:?};").unwrap(); - code.push_str("let backend = __pool.get_database_backend();\n"); code.push_str( - "let q = if matches!(backend, sea_orm::DatabaseBackend::MySql) { '`' } else { '\"' };\n", + "static __VESPERTIDE_MIGRATIONS: &[::vespertide::runtime::EmbeddedMigration] = &[\n", ); - - // Create version table (outside transaction) - code.push_str(concat!( - "let create_table_sql = format!(", - "\"CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, ", - "id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)\", ", - "__version_table);\n", - )); - code.push_str("let stmt = sea_orm::Statement::from_string(backend, create_table_sql);\n"); - code.push_str(concat!( - "__pool.execute_raw(stmt).await.map_err(|e| ", - "::vespertide::MigrationError::DatabaseError(", - "format!(\"Failed to create version table: {}\", e)))?;\n", - )); - - // Add id column for backward compatibility - code.push_str(concat!( - "let alter_sql = format!(\"ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''\", ", - "__version_table);\n", - )); - code.push_str("let stmt = sea_orm::Statement::from_string(backend, alter_sql);\n"); - code.push_str("let _ = __pool.execute_raw(stmt).await;\n"); - - // Begin transaction - code.push_str(concat!( - "let __txn = __pool.begin().await.map_err(|e| ", - "::vespertide::MigrationError::DatabaseError(", - "format!(\"Failed to begin transaction: {}\", e)))?;\n", - )); - - // Read current version - code.push_str(concat!( - "let select_sql = format!(\"SELECT MAX(version) as version FROM {q}{}{q}\", ", - "__version_table);\n", - )); - code.push_str("let stmt = sea_orm::Statement::from_string(backend, select_sql);\n"); - code.push_str(concat!( - "let version_result = __txn.query_one_raw(stmt).await.map_err(|e| ", - "::vespertide::MigrationError::DatabaseError(", - "format!(\"Failed to read version: {}\", e)))?;\n", - )); - code.push_str(concat!( - "let __version = version_result", - ".and_then(|row| row.try_get::(\"\", \"version\").ok())", - ".unwrap_or(0) as u32;\n", - )); - - // Load version ids for mismatch validation - code.push_str(concat!( - "let select_ids_sql = format!(\"SELECT version, id FROM {q}{}{q}\", ", - "__version_table);\n", - )); - code.push_str("let stmt = sea_orm::Statement::from_string(backend, select_ids_sql);\n"); - code.push_str(concat!( - "let id_rows = __txn.query_all_raw(stmt).await.map_err(|e| ", - "::vespertide::MigrationError::DatabaseError(", - "format!(\"Failed to read version ids: {}\", e)))?;\n", - )); - code.push_str("let mut __version_ids = std::collections::HashMap::::new();\n"); - code.push_str(concat!( - "for row in &id_rows { ", - "if let Ok(v) = row.try_get::(\"\", \"version\") { ", - "let id = row.try_get::(\"\", \"id\").unwrap_or_default(); ", - "__version_ids.insert(v as u32, id); ", - "} }\n", - )); - - // Verbose: current version - if verbose { - code.push_str("eprintln!(\"[vespertide] Current database version: {}\", __version);\n"); - } - - // Data-driven migration loop - if !migration_blocks.is_empty() { - code.push_str("for (__v, __mid, __comment, __pg_sqls, __mysql_sqls, __sqlite_sqls) in [\n"); - for b in &migration_blocks { - writeln!( - code, - "({}u32, {:?}, {:?}, __V{}_PG, __V{}_MYSQL, __V{}_SQLITE),", - b.version, b.migration_id, b.comment, b.version, b.version, b.version - ) - .unwrap(); - } - code.push_str("] {\n"); - - // Loop body: validation + execution (written ONCE) - code.push_str("if __version < __v {\n"); - - // Id mismatch validation - code.push_str(concat!( - "if let Some(db_id) = __version_ids.get(&__v) { ", - "let expected_id: &str = __mid; ", - "if !expected_id.is_empty() && !db_id.is_empty() && db_id != expected_id { ", - "return Err(::vespertide::MigrationError::IdMismatch { ", - "version: __v, expected: expected_id.to_string(), found: db_id.clone() }); ", - "} }\n", - )); - - // Verbose: applying - if verbose { - code.push_str( - "eprintln!(\"[vespertide] Applying migration v{} ({})\", __v, __comment);\n", - ); - } - - // Backend match - code.push_str(concat!( - "let __sqls: &[&str] = match backend { ", - "sea_orm::DatabaseBackend::Postgres => __pg_sqls, ", - "sea_orm::DatabaseBackend::MySql => __mysql_sqls, ", - "sea_orm::DatabaseBackend::Sqlite => __sqlite_sqls, ", - "_ => __pg_sqls };\n", - )); - - // SQL execution loop - code.push_str("for (__sql_idx, __sql) in __sqls.iter().enumerate() {\n"); - code.push_str("if !__sql.is_empty() {\n"); - if verbose { - code.push_str(concat!( - "eprintln!(\"[vespertide] [{}/{}] {}\", ", - "__sql_idx + 1, __sqls.len(), __sql);\n", - )); - } - code.push_str(concat!( - "let stmt = sea_orm::Statement::from_string(backend, *__sql);\n", - "__txn.execute_raw(stmt).await.map_err(|e| ", - "::vespertide::MigrationError::DatabaseError(", - "format!(\"Failed to execute SQL '{}': {}\", __sql, e)))?;\n", - )); - code.push_str("}\n"); // if !empty - code.push_str("}\n"); // for __sql - - // Insert version record - code.push_str(concat!( - "let insert_sql = format!(\"INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')\", ", - "__version_table, __v, __mid);\n", - )); - code.push_str("let stmt = sea_orm::Statement::from_string(backend, insert_sql);\n"); - code.push_str(concat!( - "__txn.execute_raw(stmt).await.map_err(|e| ", - "::vespertide::MigrationError::DatabaseError(", - "format!(\"Failed to insert version: {}\", e)))?;\n", - )); - - // Verbose: done - if verbose { - code.push_str("eprintln!(\"[vespertide] Migration v{} applied successfully\", __v);\n"); - } - - code.push_str("}\n"); // if __version < __v - code.push_str("}\n"); // for loop + for b in &migration_blocks { + writeln!( + code, + "::vespertide::runtime::EmbeddedMigration::new({}u32, {:?}, {:?}, __V{}_PG, __V{}_MYSQL, __V{}_SQLITE),", + b.version, b.migration_id, b.comment, b.version, b.version, b.version + ) + .unwrap(); } + code.push_str("];\n"); - // Commit transaction - code.push_str(concat!( - "__txn.commit().await.map_err(|e| ", - "::vespertide::MigrationError::DatabaseError(", - "format!(\"Failed to commit transaction: {}\", e)))?;\n", - )); - - code.push_str("Ok::<(), ::vespertide::MigrationError>(())\n"); + code.push_str("async {\n"); + writeln!(code, "let __pool = &{pool_str};").unwrap(); + writeln!( + code, + "::vespertide::runtime::run_embedded_migrations(__pool, {version_table:?}, {verbose}, __VESPERTIDE_MIGRATIONS).await" + ) + .unwrap(); code.push_str("}\n"); // async block code.push_str("}\n"); // outer block @@ -326,16 +176,15 @@ fn generate_migration_code( }) } -/// Write a static `&[&str]` array declaration into the code string. -fn write_static_array(code: &mut String, ident: &str, sqls: &[String]) { - write!(code, "static {ident}: &[&str] = &[").unwrap(); - for (i, sql) in sqls.iter().enumerate() { - if i > 0 { - code.push_str(", "); - } - write!(code, "{sql:?}").unwrap(); +/// Write a compact SQL blob declaration into the code string. +fn write_sql_blob(code: &mut String, ident: &str, sqls: &[String]) { + let mut blob = String::new(); + for sql in sqls { + blob.push_str(sql); + blob.push('\0'); } - code.push_str("];\n"); + + writeln!(code, "static {ident}: &str = {blob:?};").unwrap(); } /// Inner implementation that works with proc_macro2::TokenStream for testability. @@ -692,9 +541,8 @@ mod tests { assert!(generated_str.contains("async")); assert!(generated_str.contains("db_pool")); assert!(generated_str.contains("test_versions")); - assert!(generated_str.contains("CREATE TABLE IF NOT EXISTS")); - assert!(generated_str.contains("SELECT MAX")); - // Verify data-driven loop structure + assert!(generated_str.contains("run_embedded_migrations")); + assert!(generated_str.contains("EmbeddedMigration")); assert!(generated_str.contains("1u32")); } @@ -749,8 +597,35 @@ mod tests { // Both migration versions should be present in the metadata array assert!(generated_str.contains("1u32")); assert!(generated_str.contains("2u32")); - // Data-driven loop structure - assert!(generated_str.contains("__version < __v")); + assert!(generated_str.contains("__VESPERTIDE_MIGRATIONS")); + } + + #[test] + fn test_generate_migration_code_delegates_runtime_execution() { + let pool: proc_macro2::TokenStream = "db_pool".parse().unwrap(); + + let migration = MigrationPlan { + id: String::new(), + version: 1, + comment: Some("initial".into()), + created_at: None, + actions: vec![MigrationAction::CreateTable { + table: "users".into(), + columns: vec![test_column("id")], + constraints: vec![], + }], + }; + + let mut baseline = Vec::new(); + let block = build_migration_block(&migration, &mut baseline).unwrap(); + + let generated = generate_migration_code(&pool, "vespertide_version", vec![block], false); + let generated_str = generated.to_string(); + + assert!(generated_str.contains("run_embedded_migrations")); + assert!(generated_str.contains("EmbeddedMigration")); + assert!(!generated_str.contains("SELECT MAX")); + assert!(!generated_str.contains("execute_raw")); } #[test] @@ -960,8 +835,8 @@ mod tests { output_str ); assert!( - output_str.contains("CREATE TABLE IF NOT EXISTS"), - "Expected version table creation, got: {}", + output_str.contains("run_embedded_migrations"), + "Expected runtime helper delegation, got: {}", output_str ); @@ -1106,9 +981,7 @@ mod tests { let generated = generate_migration_code(&pool, version_table, vec![block], true); let generated_str = generated.to_string(); - // Verbose mode should include logging in the data-driven loop - assert!(generated_str.contains("Current database version")); - assert!(generated_str.contains("Applying migration")); + assert!(generated_str.contains("run_embedded_migrations")); assert!(generated_str.contains("async")); } diff --git a/crates/vespertide/Cargo.toml b/crates/vespertide/Cargo.toml index ce75867e..8a235819 100644 --- a/crates/vespertide/Cargo.toml +++ b/crates/vespertide/Cargo.toml @@ -11,3 +11,4 @@ description = "Rust workspace for defining database schemas in JSON and generati [dependencies] vespertide-macro = { workspace = true } vespertide-core = { workspace = true } +sea-orm = { version = "2.0.0-rc.36", default-features = false } diff --git a/crates/vespertide/src/lib.rs b/crates/vespertide/src/lib.rs index 70001e74..a74e2c59 100644 --- a/crates/vespertide/src/lib.rs +++ b/crates/vespertide/src/lib.rs @@ -1,3 +1,5 @@ +pub mod runtime; + // Re-export macro for convenient usage #[doc(inline)] pub use vespertide_macro::vespertide_migration; diff --git a/crates/vespertide/src/runtime.rs b/crates/vespertide/src/runtime.rs new file mode 100644 index 00000000..4222d792 --- /dev/null +++ b/crates/vespertide/src/runtime.rs @@ -0,0 +1,196 @@ +use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement, TransactionTrait}; + +use crate::MigrationError; + +#[derive(Debug, Clone, Copy)] +pub struct EmbeddedMigration { + pub version: u32, + pub migration_id: &'static str, + pub comment: &'static str, + pub postgres_sql_blob: &'static str, + pub mysql_sql_blob: &'static str, + pub sqlite_sql_blob: &'static str, +} + +impl EmbeddedMigration { + pub const fn new( + version: u32, + migration_id: &'static str, + comment: &'static str, + postgres_sql_blob: &'static str, + mysql_sql_blob: &'static str, + sqlite_sql_blob: &'static str, + ) -> Self { + Self { + version, + migration_id, + comment, + postgres_sql_blob, + mysql_sql_blob, + sqlite_sql_blob, + } + } + + pub const fn sql_blob(self, backend: DatabaseBackend) -> &'static str { + match backend { + DatabaseBackend::Postgres => self.postgres_sql_blob, + DatabaseBackend::MySql => self.mysql_sql_blob, + DatabaseBackend::Sqlite => self.sqlite_sql_blob, + _ => self.postgres_sql_blob, + } + } +} + +pub fn split_sql_blob(blob: &str) -> impl Iterator { + blob.split_terminator('\0').filter(|sql| !sql.is_empty()) +} + +pub async fn run_embedded_migrations( + pool: &DatabaseConnection, + version_table: &str, + verbose: bool, + migrations: &[EmbeddedMigration], +) -> Result<(), MigrationError> { + let backend = pool.get_database_backend(); + let q = if matches!(backend, DatabaseBackend::MySql) { + '`' + } else { + '"' + }; + + let create_table_sql = format!( + "CREATE TABLE IF NOT EXISTS {q}{}{q} (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", + version_table + ); + let stmt = Statement::from_string(backend, create_table_sql); + pool.execute_raw(stmt).await.map_err(|e| { + MigrationError::DatabaseError(format!("Failed to create version table: {}", e)) + })?; + + let alter_sql = format!( + "ALTER TABLE {q}{}{q} ADD COLUMN id TEXT DEFAULT ''", + version_table + ); + let stmt = Statement::from_string(backend, alter_sql); + let _ = pool.execute_raw(stmt).await; + + let txn = pool.begin().await.map_err(|e| { + MigrationError::DatabaseError(format!("Failed to begin transaction: {}", e)) + })?; + + let select_sql = format!( + "SELECT MAX(version) as version FROM {q}{}{q}", + version_table + ); + let stmt = Statement::from_string(backend, select_sql); + let version_result = txn + .query_one_raw(stmt) + .await + .map_err(|e| MigrationError::DatabaseError(format!("Failed to read version: {}", e)))?; + let version = version_result + .and_then(|row| row.try_get::("", "version").ok()) + .unwrap_or(0) as u32; + + let select_ids_sql = format!("SELECT version, id FROM {q}{}{q}", version_table); + let stmt = Statement::from_string(backend, select_ids_sql); + let id_rows = txn + .query_all_raw(stmt) + .await + .map_err(|e| MigrationError::DatabaseError(format!("Failed to read version ids: {}", e)))?; + let mut version_ids = std::collections::HashMap::::new(); + for row in &id_rows { + if let Ok(found_version) = row.try_get::("", "version") { + let id = row.try_get::("", "id").unwrap_or_default(); + version_ids.insert(found_version as u32, id); + } + } + + if verbose { + eprintln!("[vespertide] Current database version: {}", version); + } + + for migration in migrations { + if version >= migration.version { + continue; + } + + if let Some(db_id) = version_ids.get(&migration.version) + && !migration.migration_id.is_empty() + && !db_id.is_empty() + && db_id != migration.migration_id + { + return Err(MigrationError::IdMismatch { + version: migration.version, + expected: migration.migration_id.to_string(), + found: db_id.clone(), + }); + } + + if verbose { + eprintln!( + "[vespertide] Applying migration v{} ({})", + migration.version, migration.comment + ); + } + + let sql_blob = migration.sql_blob(backend); + let sqls: Vec<_> = split_sql_blob(sql_blob).collect(); + + for (sql_idx, sql) in sqls.iter().enumerate() { + if verbose { + eprintln!("[vespertide] [{}/{}] {}", sql_idx + 1, sqls.len(), sql); + } + + let stmt = Statement::from_string(backend, (*sql).to_owned()); + txn.execute_raw(stmt).await.map_err(|e| { + MigrationError::DatabaseError(format!("Failed to execute SQL '{}': {}", sql, e)) + })?; + } + + let insert_sql = format!( + "INSERT INTO {q}{}{q} (version, id) VALUES ({}, '{}')", + version_table, migration.version, migration.migration_id + ); + let stmt = Statement::from_string(backend, insert_sql); + txn.execute_raw(stmt).await.map_err(|e| { + MigrationError::DatabaseError(format!("Failed to insert version: {}", e)) + })?; + + if verbose { + eprintln!( + "[vespertide] Migration v{} applied successfully", + migration.version + ); + } + } + + txn.commit().await.map_err(|e| { + MigrationError::DatabaseError(format!("Failed to commit transaction: {}", e)) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use sea_orm::DatabaseBackend; + + use super::{EmbeddedMigration, split_sql_blob}; + + #[test] + fn split_sql_blob_ignores_empty_segments() { + let sqls: Vec<_> = + split_sql_blob("CREATE TABLE users ();\0\0ALTER TABLE users;\0").collect(); + + assert_eq!(sqls, vec!["CREATE TABLE users ();", "ALTER TABLE users;"]); + } + + #[test] + fn embedded_migration_selects_backend_blob() { + let migration = EmbeddedMigration::new(1, "id", "comment", "pg\0", "mysql\0", "sqlite\0"); + + assert_eq!(migration.sql_blob(DatabaseBackend::Postgres), "pg\0"); + assert_eq!(migration.sql_blob(DatabaseBackend::MySql), "mysql\0"); + assert_eq!(migration.sql_blob(DatabaseBackend::Sqlite), "sqlite\0"); + } +} From 50f1daa8884476b9a04644e2c953be95c85a312c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 19:42:12 +0900 Subject: [PATCH 5/7] Optimize compile time --- .../changepack_log_Y7ehoHZ4WG9uPKetaanjN.json | 1 + crates/vespertide-macro/Cargo.toml | 1 - crates/vespertide-macro/src/lib.rs | 91 ++++++++++++++++--- 3 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 .changepacks/changepack_log_Y7ehoHZ4WG9uPKetaanjN.json diff --git a/.changepacks/changepack_log_Y7ehoHZ4WG9uPKetaanjN.json b/.changepacks/changepack_log_Y7ehoHZ4WG9uPKetaanjN.json new file mode 100644 index 00000000..21008719 --- /dev/null +++ b/.changepacks/changepack_log_Y7ehoHZ4WG9uPKetaanjN.json @@ -0,0 +1 @@ +{"changes":{"crates/vespertide-query/Cargo.toml":"Patch","crates/vespertide/Cargo.toml":"Patch","crates/vespertide-macro/Cargo.toml":"Patch","crates/vespertide-naming/Cargo.toml":"Patch","crates/vespertide-planner/Cargo.toml":"Patch","crates/vespertide-loader/Cargo.toml":"Patch","crates/vespertide-config/Cargo.toml":"Patch","crates/vespertide-cli/Cargo.toml":"Patch","crates/vespertide-core/Cargo.toml":"Patch","crates/vespertide-exporter/Cargo.toml":"Patch"},"note":"Optimize compile time","date":"2026-03-12T10:42:01.647467800Z"} \ No newline at end of file diff --git a/crates/vespertide-macro/Cargo.toml b/crates/vespertide-macro/Cargo.toml index a29d1227..45406d32 100644 --- a/crates/vespertide-macro/Cargo.toml +++ b/crates/vespertide-macro/Cargo.toml @@ -17,7 +17,6 @@ vespertide-config = { workspace = true } vespertide-loader = { workspace = true } vespertide-query = { workspace = true } vespertide-planner = { workspace = true } -thiserror = "2" syn = { version = "2.0", features = ["parsing", "proc-macro"] } proc-macro2 = "1.0" diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index d7d89f3e..746fd5fb 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -7,9 +7,7 @@ use std::path::PathBuf; use proc_macro::TokenStream; use syn::parse::{Parse, ParseStream}; use syn::{Ident, Token}; -use vespertide_loader::{ - load_config_or_default, load_migrations_at_compile_time, load_models_at_compile_time, -}; +use vespertide_loader::{load_config_or_default, load_migrations_at_compile_time}; use vespertide_planner::apply_action; use vespertide_query::{DatabaseBackend, build_plan_queries}; @@ -235,18 +233,6 @@ pub(crate) fn vespertide_migration_impl( .to_compile_error(); } }; - let _models = match load_models_at_compile_time() { - Ok(models) => models, - #[cfg(not(tarpaulin_include))] - Err(e) => { - return syn::Error::new( - proc_macro2::Span::call_site(), - format!("Failed to load models at compile time: {}", e), - ) - .to_compile_error(); - } - }; - // Apply prefix to migrations and build SQL using incremental baseline schema let mut baseline_schema = Vec::new(); let mut migration_blocks = Vec::new(); @@ -1065,4 +1051,79 @@ mod tests { } } } + + #[test] + fn test_vespertide_migration_impl_ignores_invalid_models() { + use std::fs; + + let dir = tempdir().unwrap(); + let project_dir = dir.path(); + + let config_content = r#"{ + "modelsDir": "models", + "migrationsDir": "migrations", + "tableNamingCase": "snake", + "columnNamingCase": "snake", + "modelFormat": "json" + }"#; + fs::write(project_dir.join("vespertide.json"), config_content).unwrap(); + + fs::create_dir_all(project_dir.join("models")).unwrap(); + fs::create_dir_all(project_dir.join("migrations")).unwrap(); + + fs::write( + project_dir.join("models").join("broken.json"), + r#"{ + "name": "broken", + "columns": [ + {"name": "user_id", "type": "integer", "nullable": false, "foreign_key": "invalid_format"} + ], + "constraints": [] + }"#, + ) + .unwrap(); + + fs::write( + project_dir.join("migrations").join("0001_initial.json"), + r#"{ + "version": 1, + "actions": [ + { + "type": "create_table", + "table": "users", + "columns": [ + {"name": "id", "type": "integer", "nullable": false} + ], + "constraints": [] + } + ] + }"#, + ) + .unwrap(); + + let original = std::env::var("CARGO_MANIFEST_DIR").ok(); + unsafe { + std::env::set_var("CARGO_MANIFEST_DIR", project_dir); + } + + let input: proc_macro2::TokenStream = "pool".parse().unwrap(); + let output = vespertide_migration_impl(input); + let output_str = output.to_string(); + + assert!( + output_str.contains("async"), + "Expected migration code generation to ignore invalid models, got: {}", + output_str + ); + + if let Some(val) = original { + unsafe { + std::env::set_var("CARGO_MANIFEST_DIR", val); + } + } else { + unsafe { + std::env::remove_var("CARGO_MANIFEST_DIR"); + } + } + } } From 3d2aa607d31b05710aaef5b255580d451cbaff37 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 20:58:22 +0900 Subject: [PATCH 6/7] Add testcase --- Cargo.lock | 328 ++++++++------------- crates/vespertide-loader/src/migrations.rs | 102 ++++++- crates/vespertide-loader/src/models.rs | 47 ++- crates/vespertide-macro/src/lib.rs | 17 +- crates/vespertide/Cargo.toml | 6 +- crates/vespertide/src/runtime.rs | 220 +++++++++++++- examples/app/Cargo.toml | 4 +- 7 files changed, 485 insertions(+), 239 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f7d8445..ad1f0409 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,41 +452,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum_typed_multipart" -version = "0.16.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c8b2ee396b35396ec27f5b9aa101f77000ba842dc82549a381b74c3ae2db7e" -dependencies = [ - "anyhow", - "async-trait", - "axum", - "axum_typed_multipart_macros", - "bytes", - "chrono", - "futures-core", - "futures-util", - "rust_decimal", - "tempfile", - "thiserror", - "tokio", - "uuid", -] - -[[package]] -name = "axum_typed_multipart_macros" -version = "0.16.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a27cefbd055910a29c4a3710016559cece5bdb4fb78ec055a1c2e9f8c61e3aa9" -dependencies = [ - "darling 0.23.0", - "heck 0.5.0", - "proc-macro-error2", - "quote", - "syn 2.0.117", - "ubyte", -] - [[package]] name = "base64" version = "0.22.1" @@ -781,9 +746,9 @@ dependencies = [ [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -856,18 +821,8 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core", + "darling_macro", ] [[package]] @@ -883,37 +838,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -1701,9 +1632,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.90" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1783,9 +1714,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" @@ -1795,13 +1726,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ "bitflags", "libc", - "redox_syscall 0.7.0", + "plain", + "redox_syscall 0.7.3", ] [[package]] @@ -1842,6 +1774,17 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "serde", + "winapi", +] + [[package]] name = "matchit" version = "0.8.4" @@ -1864,6 +1807,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1900,9 +1852,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -1915,6 +1867,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -2032,9 +1997,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" @@ -2173,6 +2138,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "pluralizer" version = "0.5.0" @@ -2388,9 +2359,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ "bitflags", ] @@ -2668,9 +2639,9 @@ dependencies = [ [[package]] name = "sea-orm" -version = "2.0.0-rc.36" +version = "2.0.0-rc.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "844d97615ecd2661fe4aba04df28b80a91ad34cc6c476e683f3103381b0c33bd" +checksum = "4b846dc1c7fefbea372c03765ff08307d68894bbad8c73b66176dcd53a3ee131" dependencies = [ "async-stream", "async-trait", @@ -2680,6 +2651,7 @@ dependencies = [ "futures-util", "itertools", "log", + "mac_address", "ouroboros", "pgvector", "rust_decimal", @@ -2712,9 +2684,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "2.0.0-rc.36" +version = "2.0.0-rc.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b22cd368ed2053ee7b9ccae5ef33621a55c4499dd3b64cdb518e69661b83e4" +checksum = "b449fe660e4d365f335222025df97ae01e670ef7ad788b3c67db9183b6cb0474" dependencies = [ "heck 0.5.0", "itertools", @@ -2758,7 +2730,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" dependencies = [ - "darling 0.20.11", + "darling", "heck 0.4.1", "proc-macro2", "quote", @@ -2772,7 +2744,7 @@ version = "1.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d88ad44b6ad9788c8b9476b6b91f94c7461d1e19d39cd8ea37838b1e6ff5aa8" dependencies = [ - "darling 0.20.11", + "darling", "heck 0.4.1", "proc-macro2", "quote", @@ -2823,9 +2795,9 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation", @@ -2836,9 +2808,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3062,12 +3034,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3533,9 +3505,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.3+spec-1.1.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0a07913e63758bc95142d9863a5a45173b71515e68b690cad70cf99c3255ce1" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", "toml_datetime", @@ -3618,12 +3590,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "ubyte" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -3695,9 +3661,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3719,13 +3685,12 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vespera" -version = "0.1.40" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a5811dd7c65c1b092a814c2c141b2fdef884c1c77d67725b2080ee460c379d4" +checksum = "2379c3d45c2af97ca9cfc6a2c2f6656d97314ca9b2a701e8d73705a83d637c32" dependencies = [ "axum", "axum-extra", - "axum_typed_multipart", "chrono", "serde_json", "tempfile", @@ -3737,9 +3702,9 @@ dependencies = [ [[package]] name = "vespera_core" -version = "0.1.40" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab2e8f2a0ac1ef3f3992791a52a320eeb6ac1d4f7b8c9bd0bd98317e7f8bd97" +checksum = "48f6782ea90a8c9acd3817529f0e5e1805357f1e29d2adb4276a59564813f670" dependencies = [ "serde", "serde_json", @@ -3747,9 +3712,9 @@ dependencies = [ [[package]] name = "vespera_macro" -version = "0.1.40" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc52ff53d752b1aa43b7a1d6ee9c2a23af34c334f136fb614364e0f4364d0d" +checksum = "eb90670fca1976f73dcf0adf5cdbea21df41030ada05dd4224a5297b25a06c24" dependencies = [ "proc-macro2", "quote", @@ -3763,6 +3728,8 @@ dependencies = [ name = "vespertide" version = "0.1.51" dependencies = [ + "sea-orm", + "tokio", "vespertide-core", "vespertide-macro", ] @@ -3853,7 +3820,6 @@ dependencies = [ "runtime-macros", "syn 2.0.117", "tempfile", - "thiserror", "vespertide-config", "vespertide-core", "vespertide-loader", @@ -3945,9 +3911,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -3958,9 +3924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3968,9 +3934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -3981,9 +3947,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.113" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] @@ -4032,6 +3998,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -4109,15 +4097,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4151,30 +4130,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4187,12 +4149,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4205,12 +4161,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4223,24 +4173,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4253,12 +4191,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4271,12 +4203,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4289,12 +4215,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4307,17 +4227,11 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -4456,18 +4370,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c" dependencies = [ "proc-macro2", "quote", diff --git a/crates/vespertide-loader/src/migrations.rs b/crates/vespertide-loader/src/migrations.rs index fc20d56f..13190d07 100644 --- a/crates/vespertide-loader/src/migrations.rs +++ b/crates/vespertide-loader/src/migrations.rs @@ -107,10 +107,7 @@ pub fn load_migrations_from_dir( } #[cfg(not(feature = "yaml"))] { - return Err(format!( - "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", - path.display() - ).into()); + return Err(format!("YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", path.display()).into()); } }; @@ -133,9 +130,70 @@ pub fn load_migrations_at_compile_time() -> Result, Box Self { + let original = env::current_dir().unwrap(); + env::set_current_dir(dir).unwrap(); + Self { original } + } + } + + impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = env::set_current_dir(&self.original); + } + } + + fn write_config(dir: &std::path::Path) { + let cfg = VespertideConfig::default(); + let text = serde_json::to_string_pretty(&cfg).unwrap(); + fs::write(dir.join("vespertide.json"), text).unwrap(); + } + + #[test] + #[serial] + fn test_load_migrations_returns_empty_when_no_migrations_dir() { + let temp_dir = TempDir::new().unwrap(); + let _guard = CwdGuard::new(&temp_dir.path().to_path_buf()); + write_config(temp_dir.path()); + + let result = load_migrations(&VespertideConfig::default()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + #[serial] + fn test_load_migrations_reads_json_and_sorts_versions() { + let temp_dir = TempDir::new().unwrap(); + let _guard = CwdGuard::new(&temp_dir.path().to_path_buf()); + write_config(temp_dir.path()); + fs::create_dir_all("migrations").unwrap(); + fs::write( + "migrations/0002_second.json", + r#"{"version": 2, "actions": []}"#, + ) + .unwrap(); + fs::write( + "migrations/0001_first.json", + r#"{"version": 1, "actions": []}"#, + ) + .unwrap(); + + let plans = load_migrations(&VespertideConfig::default()).unwrap(); + assert_eq!( + plans.iter().map(|plan| plan.version).collect::>(), + vec![1, 2] + ); + } + #[test] fn test_load_migrations_from_dir_with_no_migrations_dir() { let temp_dir = TempDir::new().unwrap(); @@ -282,6 +340,42 @@ actions: assert!(err_msg.contains("Failed to parse JSON migration")); } + #[cfg(not(feature = "yaml"))] + #[test] + #[serial] + fn test_load_migrations_reports_yaml_disabled_for_runtime_loader() { + let temp_dir = TempDir::new().unwrap(); + let _guard = CwdGuard::new(&temp_dir.path().to_path_buf()); + write_config(temp_dir.path()); + fs::create_dir_all("migrations").unwrap(); + fs::write("migrations/0001_test.yaml", "version: 1\nactions: []\n").unwrap(); + + let result = load_migrations(&VespertideConfig::default()); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("YAML support not enabled")); + assert!(err_msg.contains("0001_test.yaml")); + } + + #[cfg(not(feature = "yaml"))] + #[test] + fn test_load_migrations_from_dir_reports_yaml_disabled() { + let temp_dir = TempDir::new().unwrap(); + let migrations_dir = temp_dir.path().join("migrations"); + fs::create_dir_all(&migrations_dir).unwrap(); + fs::write( + migrations_dir.join("0001_test.yaml"), + "version: 1\nactions: []\n", + ) + .unwrap(); + + let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf())); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("YAML support not enabled")); + assert!(err_msg.contains("0001_test.yaml")); + } + #[cfg(feature = "yaml")] #[test] fn test_load_migrations_from_dir_with_invalid_yaml() { diff --git a/crates/vespertide-loader/src/models.rs b/crates/vespertide-loader/src/models.rs index 75bab8f3..652c0d01 100644 --- a/crates/vespertide-loader/src/models.rs +++ b/crates/vespertide-loader/src/models.rs @@ -163,10 +163,7 @@ fn load_models_recursive_internal( } #[cfg(not(feature = "yaml"))] { - return Err(format!( - "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", - path.display() - ).into()); + return Err(format!("YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", path.display()).into()); } }; @@ -341,6 +338,28 @@ mod tests { assert!(err_msg.contains("Failed to normalize table 'orders'")); } + #[cfg(not(feature = "yaml"))] + #[test] + #[serial] + fn load_models_reports_yaml_disabled_for_runtime_loader() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + + fs::create_dir_all("models").unwrap(); + fs::write( + "models/users.yaml", + "name: users\ncolumns: []\nconstraints: []\n", + ) + .unwrap(); + + let result = load_models(&VespertideConfig::default()); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("YAML support not enabled")); + assert!(err_msg.contains("users.yaml")); + } + #[test] #[serial] fn test_load_models_from_dir_with_root() { @@ -415,6 +434,26 @@ mod tests { assert_eq!(models.len(), 0); } + #[cfg(not(feature = "yaml"))] + #[test] + #[serial] + fn test_load_models_from_dir_reports_yaml_disabled() { + let temp_dir = tempdir().unwrap(); + let models_dir = temp_dir.path().join("models"); + fs::create_dir_all(&models_dir).unwrap(); + fs::write( + models_dir.join("users.yaml"), + "name: users\ncolumns: []\nconstraints: []\n", + ) + .unwrap(); + + let result = load_models_from_dir(Some(temp_dir.path().to_path_buf())); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("YAML support not enabled")); + assert!(err_msg.contains("users.yaml")); + } + #[cfg(feature = "yaml")] #[test] #[serial] diff --git a/crates/vespertide-macro/src/lib.rs b/crates/vespertide-macro/src/lib.rs index 746fd5fb..dbc65d6a 100644 --- a/crates/vespertide-macro/src/lib.rs +++ b/crates/vespertide-macro/src/lib.rs @@ -149,29 +149,18 @@ fn generate_migration_code( "static __VESPERTIDE_MIGRATIONS: &[::vespertide::runtime::EmbeddedMigration] = &[\n", ); for b in &migration_blocks { - writeln!( - code, - "::vespertide::runtime::EmbeddedMigration::new({}u32, {:?}, {:?}, __V{}_PG, __V{}_MYSQL, __V{}_SQLITE),", - b.version, b.migration_id, b.comment, b.version, b.version, b.version - ) - .unwrap(); + writeln!(code, "::vespertide::runtime::EmbeddedMigration::new({}u32, {:?}, {:?}, __V{}_PG, __V{}_MYSQL, __V{}_SQLITE),", b.version, b.migration_id, b.comment, b.version, b.version, b.version).unwrap(); } code.push_str("];\n"); code.push_str("async {\n"); writeln!(code, "let __pool = &{pool_str};").unwrap(); - writeln!( - code, - "::vespertide::runtime::run_embedded_migrations(__pool, {version_table:?}, {verbose}, __VESPERTIDE_MIGRATIONS).await" - ) - .unwrap(); + writeln!(code, "::vespertide::runtime::run_embedded_migrations(__pool, {version_table:?}, {verbose}, __VESPERTIDE_MIGRATIONS).await").unwrap(); code.push_str("}\n"); // async block code.push_str("}\n"); // outer block // Single parse — ONE IPC call to rustc tokenizer - code.parse().unwrap_or_else(|e| { - panic!("vespertide_migration codegen produced invalid Rust:\n{e}\n\nGenerated code (first 2000 chars):\n{}", &code[..code.len().min(2000)]) - }) + code.parse().unwrap_or_else(|e| panic!("vespertide_migration codegen produced invalid Rust:\n{e}\n\nGenerated code (first 2000 chars):\n{}", &code[..code.len().min(2000)])) } /// Write a compact SQL blob declaration into the code string. diff --git a/crates/vespertide/Cargo.toml b/crates/vespertide/Cargo.toml index 8a235819..13e65b53 100644 --- a/crates/vespertide/Cargo.toml +++ b/crates/vespertide/Cargo.toml @@ -11,4 +11,8 @@ description = "Rust workspace for defining database schemas in JSON and generati [dependencies] vespertide-macro = { workspace = true } vespertide-core = { workspace = true } -sea-orm = { version = "2.0.0-rc.36", default-features = false } +sea-orm = { version = "2.0.0-rc.37", default-features = false } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +sea-orm = { version = "2.0.0-rc.37", default-features = false, features = ["runtime-tokio-native-tls", "sqlx-sqlite"] } diff --git a/crates/vespertide/src/runtime.rs b/crates/vespertide/src/runtime.rs index 4222d792..a250493b 100644 --- a/crates/vespertide/src/runtime.rs +++ b/crates/vespertide/src/runtime.rs @@ -32,11 +32,12 @@ impl EmbeddedMigration { } pub const fn sql_blob(self, backend: DatabaseBackend) -> &'static str { - match backend { - DatabaseBackend::Postgres => self.postgres_sql_blob, - DatabaseBackend::MySql => self.mysql_sql_blob, - DatabaseBackend::Sqlite => self.sqlite_sql_blob, - _ => self.postgres_sql_blob, + if matches!(backend, DatabaseBackend::MySql) { + self.mysql_sql_blob + } else if matches!(backend, DatabaseBackend::Sqlite) { + self.sqlite_sql_blob + } else { + self.postgres_sql_blob } } } @@ -173,9 +174,31 @@ pub async fn run_embedded_migrations( #[cfg(test)] mod tests { - use sea_orm::DatabaseBackend; + use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement}; - use super::{EmbeddedMigration, split_sql_blob}; + use crate::MigrationError; + + use super::{EmbeddedMigration, run_embedded_migrations, split_sql_blob}; + + async fn sqlite_memory_db() -> sea_orm::DatabaseConnection { + Database::connect("sqlite::memory:").await.unwrap() + } + + async fn read_versions(db: &sea_orm::DatabaseConnection) -> Vec<(i32, String)> { + let stmt = Statement::from_string( + DatabaseBackend::Sqlite, + "SELECT version, id FROM \"vespertide_migrations\" ORDER BY version".to_owned(), + ); + let rows = db.query_all_raw(stmt).await.unwrap(); + rows.into_iter() + .map(|row| { + ( + row.try_get::("", "version").unwrap(), + row.try_get::("", "id").unwrap(), + ) + }) + .collect() + } #[test] fn split_sql_blob_ignores_empty_segments() { @@ -193,4 +216,187 @@ mod tests { assert_eq!(migration.sql_blob(DatabaseBackend::MySql), "mysql\0"); assert_eq!(migration.sql_blob(DatabaseBackend::Sqlite), "sqlite\0"); } + + #[tokio::test] + async fn run_embedded_migrations_applies_pending_versions_and_records_ids() { + let db = sqlite_memory_db().await; + let migrations = [ + EmbeddedMigration::new( + 1, + "init", + "create users", + "CREATE TABLE users (id INTEGER PRIMARY KEY);\0", + "CREATE TABLE users (id INTEGER PRIMARY KEY);\0", + "CREATE TABLE users (id INTEGER PRIMARY KEY);\0", + ), + EmbeddedMigration::new( + 2, + "add_name", + "add name column", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + ), + ]; + + run_embedded_migrations(&db, "vespertide_migrations", true, &migrations) + .await + .unwrap(); + + let versions = read_versions(&db).await; + assert_eq!( + versions, + vec![(1, "init".to_string()), (2, "add_name".to_string())] + ); + + let stmt = Statement::from_string( + DatabaseBackend::Sqlite, + "PRAGMA table_info('users')".to_owned(), + ); + let rows = db.query_all_raw(stmt).await.unwrap(); + let names: Vec<_> = rows + .into_iter() + .map(|row| row.try_get::("", "name").unwrap()) + .collect(); + assert_eq!(names, vec!["id".to_string(), "name".to_string()]); + } + + #[tokio::test] + async fn run_embedded_migrations_skips_versions_that_are_already_applied() { + let db = sqlite_memory_db().await; + run_embedded_migrations( + &db, + "vespertide_migrations", + false, + &[EmbeddedMigration::new( + 1, + "init", + "create users", + "CREATE TABLE users (id INTEGER PRIMARY KEY);\0", + "CREATE TABLE users (id INTEGER PRIMARY KEY);\0", + "CREATE TABLE users (id INTEGER PRIMARY KEY);\0", + )], + ) + .await + .unwrap(); + + run_embedded_migrations( + &db, + "vespertide_migrations", + true, + &[ + EmbeddedMigration::new( + 1, + "init", + "should skip existing", + "ALTER TABLE users ADD COLUMN skipped TEXT;\0", + "ALTER TABLE users ADD COLUMN skipped TEXT;\0", + "ALTER TABLE users ADD COLUMN skipped TEXT;\0", + ), + EmbeddedMigration::new( + 2, + "add_name", + "apply only new version", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + ), + ], + ) + .await + .unwrap(); + + let versions = read_versions(&db).await; + assert_eq!( + versions, + vec![(1, "init".to_string()), (2, "add_name".to_string())] + ); + + let stmt = Statement::from_string( + DatabaseBackend::Sqlite, + "PRAGMA table_info('users')".to_owned(), + ); + let rows = db.query_all_raw(stmt).await.unwrap(); + let names: Vec<_> = rows + .into_iter() + .map(|row| row.try_get::("", "name").unwrap()) + .collect(); + assert!(!names.iter().any(|name| name == "skipped")); + assert!(names.iter().any(|name| name == "name")); + } + + #[tokio::test] + async fn run_embedded_migrations_surfaces_sql_errors() { + let db = sqlite_memory_db().await; + let stmt = Statement::from_string(DatabaseBackend::Sqlite, "CREATE TABLE \"vespertide_migrations\" (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)".to_owned()); + db.execute_raw(stmt).await.unwrap(); + let stmt = Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO \"vespertide_migrations\" (version, id) VALUES (2, 'different')" + .to_owned(), + ); + db.execute_raw(stmt).await.unwrap(); + + let result = run_embedded_migrations( + &db, + "vespertide_migrations", + true, + &[EmbeddedMigration::new( + 3, + "broken", + "invalid sql", + "THIS IS NOT SQL;\0", + "THIS IS NOT SQL;\0", + "THIS IS NOT SQL;\0", + )], + ) + .await; + + assert!( + matches!(result, Err(MigrationError::DatabaseError(message)) if message.contains("Failed to execute SQL 'THIS IS NOT SQL;'")) + ); + } + + #[tokio::test] + async fn run_embedded_migrations_detects_existing_version_id_mismatch() { + let db = sqlite_memory_db().await; + let stmt = Statement::from_string(DatabaseBackend::Sqlite, "CREATE TABLE \"vespertide_migrations\" (version INTEGER PRIMARY KEY, id TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)".to_owned()); + db.execute_raw(stmt).await.unwrap(); + let stmt = Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO \"vespertide_migrations\" (version, id) VALUES (2, 'different')" + .to_owned(), + ); + db.execute_raw(stmt).await.unwrap(); + let stmt = Statement::from_string( + DatabaseBackend::Sqlite, + "INSERT INTO \"vespertide_migrations\" (version, id) VALUES (2147483648, 'overflow')" + .to_owned(), + ); + db.execute_raw(stmt).await.unwrap(); + + let result = run_embedded_migrations( + &db, + "vespertide_migrations", + true, + &[EmbeddedMigration::new( + 2, + "expected", + "mismatch", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + "ALTER TABLE users ADD COLUMN name TEXT;\0", + )], + ) + .await; + + assert!(matches!( + result, + Err(MigrationError::IdMismatch { + version: 2, + expected, + found, + }) if expected == "expected" && found == "different" + )); + } } diff --git a/examples/app/Cargo.toml b/examples/app/Cargo.toml index ca799f90..3bf60141 100644 --- a/examples/app/Cargo.toml +++ b/examples/app/Cargo.toml @@ -7,7 +7,7 @@ publish = false [dependencies] vespertide = { path = "../../crates/vespertide" } tokio = { version = "1", features = ["full"] } -sea-orm = { version = "2.0.0-rc.36", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-native-tls", "macros"] } +sea-orm = { version = "2.0.0-rc.37", features = ["sqlx-sqlite", "sqlx-postgres", "runtime-tokio-native-tls", "macros"] } anyhow = "1" serde = { version = "1", features = ["derive"] } -vespera = "0.1.40" +vespera = "0.1.43" From 44c2f8bfabf7859d3739e6b2731a4b8caf6969ff Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 12 Mar 2026 21:16:31 +0900 Subject: [PATCH 7/7] Rm yaml feature --- crates/vespertide-cli/Cargo.toml | 2 +- crates/vespertide-loader/Cargo.toml | 6 +- crates/vespertide-loader/src/migrations.rs | 84 +++++++--------------- crates/vespertide-loader/src/models.rs | 73 ++----------------- 4 files changed, 34 insertions(+), 131 deletions(-) diff --git a/crates/vespertide-cli/Cargo.toml b/crates/vespertide-cli/Cargo.toml index 36858026..0eedd23c 100644 --- a/crates/vespertide-cli/Cargo.toml +++ b/crates/vespertide-cli/Cargo.toml @@ -24,7 +24,7 @@ futures = "0.3" async-recursion = "1" vespertide-config = { workspace = true, features = ["cli", "schema"] } vespertide-core = { workspace = true, features = ["schema"] } -vespertide-loader = { workspace = true, features = ["yaml"] } +vespertide-loader = { workspace = true } vespertide-planner = { workspace = true } vespertide-query = { workspace = true } vespertide-exporter = { workspace = true } diff --git a/crates/vespertide-loader/Cargo.toml b/crates/vespertide-loader/Cargo.toml index 03e68c8e..7bcd9b0a 100644 --- a/crates/vespertide-loader/Cargo.toml +++ b/crates/vespertide-loader/Cargo.toml @@ -14,11 +14,7 @@ vespertide-config = { workspace = true } vespertide-planner = { workspace = true } anyhow = "1" serde_json = "1.0" -serde_yaml = { version = "0.9", optional = true } - -[features] -default = ["yaml"] -yaml = ["dep:serde_yaml"] +serde_yaml = "0.9" [dev-dependencies] tempfile = "3" diff --git a/crates/vespertide-loader/src/migrations.rs b/crates/vespertide-loader/src/migrations.rs index 13190d07..1fa928ce 100644 --- a/crates/vespertide-loader/src/migrations.rs +++ b/crates/vespertide-loader/src/migrations.rs @@ -30,18 +30,8 @@ pub fn load_migrations(config: &VespertideConfig) -> Result> serde_json::from_str(&content) .with_context(|| format!("parse migration: {}", path.display()))? } else { - #[cfg(feature = "yaml")] - { - serde_yaml::from_str(&content) - .with_context(|| format!("parse migration: {}", path.display()))? - } - #[cfg(not(feature = "yaml"))] - { - anyhow::bail!( - "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", - path.display() - ); - } + serde_yaml::from_str(&content) + .with_context(|| format!("parse migration: {}", path.display()))? }; // Validate the migration plan @@ -99,16 +89,9 @@ pub fn load_migrations_from_dir( format!("Failed to parse JSON migration {}: {}", path.display(), e) })? } else { - #[cfg(feature = "yaml")] - { - serde_yaml::from_str(&content).map_err(|e| { - format!("Failed to parse YAML migration {}: {}", path.display(), e) - })? - } - #[cfg(not(feature = "yaml"))] - { - return Err(format!("YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", path.display()).into()); - } + serde_yaml::from_str(&content).map_err(|e| { + format!("Failed to parse YAML migration {}: {}", path.display(), e) + })? }; plans.push(plan); @@ -269,7 +252,6 @@ mod tests { assert_eq!(plans[2].version, 3); } - #[cfg(feature = "yaml")] #[test] fn test_load_migrations_from_dir_with_yaml_migration() { let temp_dir = TempDir::new().unwrap(); @@ -297,7 +279,6 @@ actions: assert_eq!(plans[0].version, 1); } - #[cfg(feature = "yaml")] #[test] fn test_load_migrations_from_dir_with_yml_migration() { let temp_dir = TempDir::new().unwrap(); @@ -325,58 +306,47 @@ actions: assert_eq!(plans[0].version, 1); } - #[test] - fn test_load_migrations_from_dir_with_invalid_json() { - let temp_dir = TempDir::new().unwrap(); - let migrations_dir = temp_dir.path().join("migrations"); - fs::create_dir_all(&migrations_dir).unwrap(); - - let invalid_json = r#"{"version": 1, "actions": [invalid]}"#; - fs::write(migrations_dir.join("0001_invalid.json"), invalid_json).unwrap(); - - let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf())); - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("Failed to parse JSON migration")); - } - - #[cfg(not(feature = "yaml"))] #[test] #[serial] - fn test_load_migrations_reports_yaml_disabled_for_runtime_loader() { + fn test_load_migrations_reads_yaml_for_runtime_loader() { let temp_dir = TempDir::new().unwrap(); let _guard = CwdGuard::new(&temp_dir.path().to_path_buf()); write_config(temp_dir.path()); fs::create_dir_all("migrations").unwrap(); - fs::write("migrations/0001_test.yaml", "version: 1\nactions: []\n").unwrap(); - let result = load_migrations(&VespertideConfig::default()); - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("YAML support not enabled")); - assert!(err_msg.contains("0001_test.yaml")); + let migration_content = r#"--- +version: 1 +actions: + - type: create_table + table: users + columns: + - name: id + type: integer + nullable: false + constraints: [] +"#; + fs::write("migrations/0001_test.yaml", migration_content).unwrap(); + + let plans = load_migrations(&VespertideConfig::default()).unwrap(); + assert_eq!(plans.len(), 1); + assert_eq!(plans[0].version, 1); } - #[cfg(not(feature = "yaml"))] #[test] - fn test_load_migrations_from_dir_reports_yaml_disabled() { + fn test_load_migrations_from_dir_with_invalid_json() { let temp_dir = TempDir::new().unwrap(); let migrations_dir = temp_dir.path().join("migrations"); fs::create_dir_all(&migrations_dir).unwrap(); - fs::write( - migrations_dir.join("0001_test.yaml"), - "version: 1\nactions: []\n", - ) - .unwrap(); + + let invalid_json = r#"{"version": 1, "actions": [invalid]}"#; + fs::write(migrations_dir.join("0001_invalid.json"), invalid_json).unwrap(); let result = load_migrations_from_dir(Some(temp_dir.path().to_path_buf())); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("YAML support not enabled")); - assert!(err_msg.contains("0001_test.yaml")); + assert!(err_msg.contains("Failed to parse JSON migration")); } - #[cfg(feature = "yaml")] #[test] fn test_load_migrations_from_dir_with_invalid_yaml() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/vespertide-loader/src/models.rs b/crates/vespertide-loader/src/models.rs index 652c0d01..d1421742 100644 --- a/crates/vespertide-loader/src/models.rs +++ b/crates/vespertide-loader/src/models.rs @@ -59,18 +59,8 @@ fn load_models_recursive(dir: &Path, tables: &mut Vec) -> Result<()> { serde_json::from_str(&content) .with_context(|| format!("parse JSON model: {}", path.display()))? } else { - #[cfg(feature = "yaml")] - { - serde_yaml::from_str(&content) - .with_context(|| format!("parse YAML model: {}", path.display()))? - } - #[cfg(not(feature = "yaml"))] - { - anyhow::bail!( - "YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", - path.display() - ); - } + serde_yaml::from_str(&content) + .with_context(|| format!("parse YAML model: {}", path.display()))? }; tables.push(table); @@ -155,16 +145,9 @@ fn load_models_recursive_internal( format!("Failed to parse JSON model {}: {}", path.display(), e) })? } else { - #[cfg(feature = "yaml")] - { - serde_yaml::from_str(&content).map_err(|e| { - format!("Failed to parse YAML model {}: {}", path.display(), e) - })? - } - #[cfg(not(feature = "yaml"))] - { - return Err(format!("YAML support not enabled. Enable the 'yaml' feature or use JSON format: {}", path.display()).into()); - } + serde_yaml::from_str(&content).map_err(|e| { + format!("Failed to parse YAML model {}: {}", path.display(), e) + })? }; tables.push(table); @@ -227,7 +210,6 @@ mod tests { assert_eq!(models.len(), 0); } - #[cfg(feature = "yaml")] #[test] #[serial] fn load_models_reads_yaml_and_validates() { @@ -338,28 +320,6 @@ mod tests { assert!(err_msg.contains("Failed to normalize table 'orders'")); } - #[cfg(not(feature = "yaml"))] - #[test] - #[serial] - fn load_models_reports_yaml_disabled_for_runtime_loader() { - let tmp = tempdir().unwrap(); - let _guard = CwdGuard::new(&tmp.path().to_path_buf()); - write_config(); - - fs::create_dir_all("models").unwrap(); - fs::write( - "models/users.yaml", - "name: users\ncolumns: []\nconstraints: []\n", - ) - .unwrap(); - - let result = load_models(&VespertideConfig::default()); - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("YAML support not enabled")); - assert!(err_msg.contains("users.yaml")); - } - #[test] #[serial] fn test_load_models_from_dir_with_root() { @@ -434,27 +394,6 @@ mod tests { assert_eq!(models.len(), 0); } - #[cfg(not(feature = "yaml"))] - #[test] - #[serial] - fn test_load_models_from_dir_reports_yaml_disabled() { - let temp_dir = tempdir().unwrap(); - let models_dir = temp_dir.path().join("models"); - fs::create_dir_all(&models_dir).unwrap(); - fs::write( - models_dir.join("users.yaml"), - "name: users\ncolumns: []\nconstraints: []\n", - ) - .unwrap(); - - let result = load_models_from_dir(Some(temp_dir.path().to_path_buf())); - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!(err_msg.contains("YAML support not enabled")); - assert!(err_msg.contains("users.yaml")); - } - - #[cfg(feature = "yaml")] #[test] #[serial] fn test_load_models_from_dir_with_yaml() { @@ -491,7 +430,6 @@ mod tests { assert_eq!(models[0].name, "users"); } - #[cfg(feature = "yaml")] #[test] #[serial] fn test_load_models_from_dir_with_yml() { @@ -580,7 +518,6 @@ mod tests { assert!(err_msg.contains("Failed to parse JSON model")); } - #[cfg(feature = "yaml")] #[test] #[serial] fn test_load_models_from_dir_with_invalid_yaml() {