1use crate::config::Tracking;
33use crate::generate::{DiffOptions, diff_with};
34use crate::naming::{PrefixMode, generate_migration_tag_with_mode};
35use crate::parser::SchemaParser;
36use crate::schema::Snapshot;
37pub use drizzle_types::Casing;
38use drizzle_types::{ConfigValue, ConfigValueError, Dialect};
39use serde::Deserialize;
40use std::path::{Path, PathBuf};
41
42#[derive(Debug, Clone)]
44pub struct Config {
45 files: Vec<PathBuf>,
46 out_dir: PathBuf,
47 dialect: Dialect,
48 casing: Option<Casing>,
49 breakpoints: bool,
50 prefix_mode: PrefixMode,
51 custom_name: Option<String>,
52 url: Option<ConfigValue>,
53 tracking: Tracking,
54 config_path: Option<PathBuf>,
57 watched_env_vars: Vec<String>,
60 transform: Option<StatementTransform>,
62 sqlite_rebuild_data: Option<SqliteRebuildDataSource>,
63}
64
65#[derive(Clone)]
70struct StatementTransform(std::sync::Arc<dyn Fn(Vec<String>) -> Vec<String> + Send + Sync>);
71
72#[derive(Clone, Debug)]
73enum SqliteRebuildDataSource {
74 Inline(crate::sqlite::SqliteRebuildDataPlanRegistry),
75 File(PathBuf),
76}
77
78impl std::fmt::Debug for StatementTransform {
79 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 formatter.write_str("<statement transform>")
81 }
82}
83
84impl Config {
85 #[must_use]
91 pub fn new(dialect: Dialect) -> Self {
92 Self {
93 files: Vec::new(),
94 out_dir: PathBuf::from("./drizzle"),
95 dialect,
96 casing: None,
97 breakpoints: true,
98 prefix_mode: PrefixMode::Timestamp,
99 custom_name: None,
100 url: None,
101 tracking: default_tracking(dialect),
102 config_path: None,
103 watched_env_vars: Vec::new(),
104 transform: None,
105 sqlite_rebuild_data: None,
106 }
107 }
108
109 pub fn from_toml(path: impl AsRef<Path>) -> Result<Self, BuildError> {
126 let path = path.as_ref();
127 let content = std::fs::read_to_string(path).map_err(|source| {
128 if source.kind() == std::io::ErrorKind::NotFound {
129 BuildError::ConfigNotFound(path.to_path_buf())
130 } else {
131 BuildError::Io(source)
132 }
133 })?;
134 let raw: RawConfig = toml::from_str(&content).map_err(|source| BuildError::Toml {
135 path: path.to_path_buf(),
136 source,
137 })?;
138
139 let dialect = raw.dialect;
140 let mut cfg = Self::new(dialect);
141 cfg.config_path = Some(path.to_path_buf());
142
143 if let Some(out) = raw.out {
144 cfg.out_dir = out;
145 }
146 cfg.files = match raw.schema {
147 Some(SchemaPaths::One(s)) => vec![PathBuf::from(s)],
148 Some(SchemaPaths::Many(v)) => v.into_iter().map(PathBuf::from).collect(),
149 None => Vec::new(),
150 };
151 if let Some(b) = raw.breakpoints {
152 cfg.breakpoints = b;
153 }
154 if let Some(c) = raw.casing {
155 cfg.casing = Some(c);
156 }
157 if let Some(creds) = raw.db_credentials {
158 if let ConfigValue::Env(ref var) = creds.url {
159 cfg.watched_env_vars.push(var.clone());
160 }
161 cfg.url = Some(creds.url);
162 }
163 if let Some(m) = raw.migrations {
164 if let Some(t) = m.table {
165 cfg.tracking = cfg.tracking.table(t);
166 }
167 if let Some(s) = m.schema {
168 cfg.tracking = cfg.tracking.schema(s);
169 }
170 if let Some(plan) = m.sqlite_rebuild_data_plan {
171 let base = path.parent().unwrap_or_else(|| Path::new("."));
172 cfg.sqlite_rebuild_data = Some(SqliteRebuildDataSource::File(base.join(plan)));
173 }
174 }
175
176 Ok(cfg)
177 }
178
179 #[must_use]
181 pub fn file(self, path: impl Into<PathBuf>) -> Self {
182 let mut this = self;
183 this.files.push(path.into());
184 this
185 }
186
187 #[must_use]
189 pub fn out(mut self, out_dir: impl Into<PathBuf>) -> Self {
190 self.out_dir = out_dir.into();
191 self
192 }
193
194 #[must_use]
196 pub const fn casing(mut self, casing: Casing) -> Self {
197 self.casing = Some(casing);
198 self
199 }
200
201 #[must_use]
203 pub const fn breakpoints(mut self, enabled: bool) -> Self {
204 self.breakpoints = enabled;
205 self
206 }
207
208 #[must_use]
210 pub const fn prefix_mode(mut self, mode: PrefixMode) -> Self {
211 self.prefix_mode = mode;
212 self
213 }
214
215 #[must_use]
217 pub fn name(mut self, name: impl Into<String>) -> Self {
218 self.custom_name = Some(name.into());
219 self
220 }
221
222 #[must_use]
264 pub fn transform_statements(
265 mut self,
266 transform: impl Fn(Vec<String>) -> Vec<String> + Send + Sync + 'static,
267 ) -> Self {
268 self.transform = Some(StatementTransform(std::sync::Arc::new(transform)));
269 self
270 }
271
272 #[must_use]
277 pub fn sqlite_rebuild_data_plan(mut self, plan: crate::sqlite::SqliteRebuildDataPlan) -> Self {
278 self.sqlite_rebuild_data = Some(SqliteRebuildDataSource::Inline(
279 crate::sqlite::SqliteRebuildDataPlanRegistry::single(plan),
280 ));
281 self
282 }
283
284 #[must_use]
286 pub fn sqlite_rebuild_data_plan_registry(
287 mut self,
288 registry: crate::sqlite::SqliteRebuildDataPlanRegistry,
289 ) -> Self {
290 self.sqlite_rebuild_data = Some(SqliteRebuildDataSource::Inline(registry));
291 self
292 }
293
294 #[must_use]
297 pub fn sqlite_rebuild_data_plan_file(mut self, path: impl Into<PathBuf>) -> Self {
298 self.sqlite_rebuild_data = Some(SqliteRebuildDataSource::File(path.into()));
299 self
300 }
301
302 fn apply_transform(&self, statements: Vec<String>) -> Vec<String> {
304 match &self.transform {
305 Some(StatementTransform(transform)) => transform(statements),
306 None => statements,
307 }
308 }
309
310 fn watch_targets(&self) -> Vec<PathBuf> {
316 let mut targets = self.files.clone();
317 if let Some(cfg_path) = &self.config_path {
318 targets.push(cfg_path.clone());
319 }
320 targets.push(self.out_dir.clone());
321 if let Some(SqliteRebuildDataSource::File(path)) = &self.sqlite_rebuild_data {
322 targets.push(path.clone());
323 }
324 targets
325 }
326
327 pub fn watch(&self) {
343 for path in self.watch_targets() {
344 println!("cargo:rerun-if-changed={}", path.display());
345 }
346 for var in &self.watched_env_vars {
347 println!("cargo:rerun-if-env-changed={var}");
348 }
349 }
350
351 #[inline]
353 #[must_use]
354 pub const fn dialect(&self) -> Dialect {
355 self.dialect
356 }
357
358 #[inline]
361 #[must_use]
362 pub fn out_dir(&self) -> &Path {
363 &self.out_dir
364 }
365
366 pub fn url(&self) -> Result<String, BuildError> {
375 let cred = self.url.as_ref().ok_or(BuildError::MissingUrl)?;
376 cred.resolve().map_err(|e| match e {
377 ConfigValueError::NotPresent(var) => BuildError::EnvVarNotSet(var),
378 ConfigValueError::NotUnicode(var) => BuildError::EnvVarNotUnicode(var),
379 })
380 }
381
382 #[inline]
388 #[must_use]
389 pub fn tracking(&self) -> Tracking {
390 self.tracking.clone()
391 }
392}
393
394#[inline]
395fn default_tracking(dialect: Dialect) -> Tracking {
396 match dialect {
397 Dialect::PostgreSQL => Tracking::POSTGRES,
398 _ => Tracking::SQLITE,
399 }
400}
401
402#[derive(Debug, Deserialize)]
411#[serde(rename_all = "camelCase")]
412struct RawConfig {
413 dialect: Dialect,
414 #[serde(default)]
415 schema: Option<SchemaPaths>,
416 #[serde(default)]
417 out: Option<PathBuf>,
418 #[serde(default)]
419 breakpoints: Option<bool>,
420 #[serde(default)]
421 casing: Option<Casing>,
422 #[serde(default)]
423 db_credentials: Option<RawCreds>,
424 #[serde(default)]
425 migrations: Option<RawMigrations>,
426}
427
428#[derive(Debug, Deserialize)]
429#[serde(untagged)]
430enum SchemaPaths {
431 One(String),
432 Many(Vec<String>),
433}
434
435#[derive(Debug, Deserialize)]
436struct RawCreds {
437 url: ConfigValue,
438}
439
440#[derive(Debug, Deserialize)]
441#[serde(rename_all = "camelCase")]
442struct RawMigrations {
443 #[serde(default)]
444 table: Option<String>,
445 #[serde(default)]
446 schema: Option<String>,
447 #[serde(default)]
448 sqlite_rebuild_data_plan: Option<PathBuf>,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum Output {
454 NoChanges,
456 Generated {
458 tag: String,
460 path: PathBuf,
462 statement_count: usize,
464 },
465}
466
467impl Output {
468 #[must_use]
469 pub const fn is_generated(&self) -> bool {
470 matches!(self, Self::Generated { .. })
471 }
472}
473
474#[derive(Debug, thiserror::Error)]
476pub enum BuildError {
477 #[error("unsupported dialect for build generation: {0:?}")]
478 UnsupportedDialect(Dialect),
479
480 #[error("no schema files configured")]
481 MissingSchemaFiles,
482
483 #[error(
484 "SQLite rebuild-data plans cannot be combined with statement transforms; typed plan validation must remain the final migration authority"
485 )]
486 SqliteRebuildDataTransformConflict,
487
488 #[error("failed to read schema file `{path:?}`: {source}")]
489 ReadSchema {
490 path: PathBuf,
491 #[source]
492 source: std::io::Error,
493 },
494
495 #[error("schema source failed to parse:\n{0}")]
496 SchemaParse(String),
497
498 #[error("failed to parse or write migration metadata: {0}")]
499 Io(#[from] std::io::Error),
500
501 #[error("failed to generate migration diff: {0}")]
502 Migration(#[from] crate::writer::MigrationError),
503
504 #[error("config file not found: {}", .0.display())]
505 ConfigNotFound(PathBuf),
506
507 #[error("failed to parse config `{}`: {source}", path.display())]
508 Toml {
509 path: PathBuf,
510 #[source]
511 source: toml::de::Error,
512 },
513
514 #[error("failed to read SQLite rebuild-data plan `{}`: {source}", path.display())]
515 ReadSqliteRebuildDataPlan {
516 path: PathBuf,
517 #[source]
518 source: std::io::Error,
519 },
520
521 #[error("failed to parse SQLite rebuild-data plan `{}`: {source}", path.display())]
522 ParseSqliteRebuildDataPlan {
523 path: PathBuf,
524 #[source]
525 source: serde_json::Error,
526 },
527
528 #[error("no database URL configured (set `dbCredentials.url` in TOML)")]
529 MissingUrl,
530
531 #[error("env var `{0}` not set")]
532 EnvVarNotSet(String),
533
534 #[error("env var `{0}` contains invalid unicode")]
535 EnvVarNotUnicode(String),
536}
537
538fn load_sqlite_rebuild_data_plan(
539 source: &SqliteRebuildDataSource,
540) -> Result<crate::sqlite::SqliteRebuildDataPlanRegistry, BuildError> {
541 match source {
542 SqliteRebuildDataSource::Inline(plan) => Ok(plan.clone()),
543 SqliteRebuildDataSource::File(path) => {
544 let bytes =
545 std::fs::read(path).map_err(|source| BuildError::ReadSqliteRebuildDataPlan {
546 path: path.clone(),
547 source,
548 })?;
549 serde_json::from_slice(&bytes).map_err(|source| {
550 BuildError::ParseSqliteRebuildDataPlan {
551 path: path.clone(),
552 source,
553 }
554 })
555 }
556 }
557}
558
559pub fn run(config: &Config) -> Result<Output, BuildError> {
590 if config.files.is_empty() {
591 return Err(BuildError::MissingSchemaFiles);
592 }
593
594 if config.sqlite_rebuild_data.is_some() && config.transform.is_some() {
595 return Err(BuildError::SqliteRebuildDataTransformConflict);
596 }
597
598 if !matches!(config.dialect, Dialect::SQLite | Dialect::PostgreSQL) {
599 return Err(BuildError::UnsupportedDialect(config.dialect));
600 }
601
602 let parse_result = parse_files(&config.files)?;
603 for warning in &parse_result.warnings {
604 println!("cargo:warning=schema parse: {warning}");
605 }
606 if !parse_result.errors.is_empty() {
610 return Err(BuildError::SchemaParse(parse_result.errors.join("\n")));
611 }
612 if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
613 return Ok(Output::NoChanges);
614 }
615
616 let current_snapshot =
617 Snapshot::from_parse_result(&parse_result, config.dialect, config.casing);
618 let previous_snapshot = load_previous_snapshot(&config.out_dir, config.dialect)?;
619 let sqlite_rebuild_data = config
620 .sqlite_rebuild_data
621 .as_ref()
622 .map(load_sqlite_rebuild_data_plan)
623 .transpose()?;
624 let options = match sqlite_rebuild_data {
625 Some(registry) => DiffOptions::new().sqlite_rebuild_data_registry(registry),
626 None => DiffOptions::new(),
627 };
628 let mut generated = diff_with(&previous_snapshot, ¤t_snapshot, &options)?;
629
630 generated.statements = config.apply_transform(generated.statements);
634
635 if generated.is_empty() {
636 return Ok(Output::NoChanges);
637 }
638
639 for warning in &generated.warnings {
640 println!("cargo:warning={warning}");
641 }
642
643 let next_idx = next_migration_index(&config.out_dir)?;
644 let tag = generate_migration_tag_with_mode(
645 config.prefix_mode,
646 next_idx,
647 config.custom_name.as_deref(),
648 );
649
650 let sql = if config.breakpoints {
651 generated.to_sql()
652 } else {
653 generated.statements.join("\n\n")
654 };
655
656 let migration_dir =
659 crate::writer::publish_migration_directory(&config.out_dir, &tag, |staging| {
660 std::fs::write(staging.join("migration.sql"), &sql)
661 .map_err(|error| crate::writer::MigrationError::IoError(error.to_string()))?;
662 generated
663 .snapshot
664 .save(&staging.join("snapshot.json"))
665 .map_err(|error| crate::writer::MigrationError::SnapshotError(error.to_string()))?;
666 Ok(())
667 })?;
668
669 Ok(Output::Generated {
670 tag,
671 path: migration_dir,
672 statement_count: generated.statements.len(),
673 })
674}
675
676fn parse_files(files: &[PathBuf]) -> Result<crate::parser::ParseResult, BuildError> {
677 let mut combined = String::new();
678 for path in files {
679 let code = std::fs::read_to_string(path).map_err(|source| BuildError::ReadSchema {
680 path: path.clone(),
681 source,
682 })?;
683 combined.push_str(&code);
684 combined.push('\n');
685 }
686 Ok(SchemaParser::parse(&combined))
687}
688
689fn load_previous_snapshot(out_dir: &Path, dialect: Dialect) -> Result<Snapshot, BuildError> {
690 let v3_entries = collect_v3_migration_dirs(out_dir)?;
691 for (_, migration_dir) in v3_entries.iter().rev() {
695 let snapshot_path = migration_dir.join("snapshot.json");
696 if snapshot_path.exists() {
697 return Snapshot::load(&snapshot_path, dialect).map_err(BuildError::from);
698 }
699 }
700
701 Ok(Snapshot::empty(dialect))
702}
703
704fn next_migration_index(out_dir: &Path) -> Result<u32, BuildError> {
705 let entries = collect_v3_migration_dirs(out_dir)?;
706 let mut max_index: Option<u32> = None;
707
708 for (tag, _) in &entries {
709 let Some(prefix) = tag.split('_').next() else {
710 continue;
711 };
712
713 if prefix.len() > 5 || !prefix.chars().all(|c| c.is_ascii_digit()) {
716 continue;
717 }
718
719 if let Ok(idx) = prefix.parse::<u32>() {
720 max_index = Some(max_index.map_or(idx, |curr| curr.max(idx)));
721 }
722 }
723
724 Ok(max_index.map_or_else(
725 || u32::try_from(entries.len()).unwrap_or(u32::MAX),
726 |idx| idx.saturating_add(1),
727 ))
728}
729
730fn collect_v3_migration_dirs(out_dir: &Path) -> Result<Vec<(String, PathBuf)>, BuildError> {
731 if !out_dir.exists() {
732 return Ok(Vec::new());
733 }
734
735 let mut entries = Vec::new();
736 for entry in std::fs::read_dir(out_dir)? {
737 let entry = entry?;
738 if !entry.file_type()?.is_dir() {
739 continue;
740 }
741
742 let tag = entry.file_name().to_string_lossy().to_string();
743 if tag == "meta" {
744 continue;
745 }
746
747 let path = entry.path();
748 if path.join("migration.sql").exists() {
749 entries.push((tag, path));
750 }
751 }
752
753 entries.sort_by(|a, b| a.0.cmp(&b.0));
754 Ok(entries)
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use crate::sqlite::{
761 SQLITE_REBUILD_DATA_PLAN_VERSION, SqliteColumnCopy, SqliteCopyExpression,
762 SqliteDataValidation, SqliteIntegerMapping, SqliteRebuildDataPlan,
763 SqliteRebuildDataPlanRegistry, SqliteTableRebuildPlan,
764 };
765
766 #[test]
767 fn run_creates_then_stabilizes() {
768 let dir = tempfile::tempdir().expect("tempdir");
769 let schema_path = dir.path().join("schema.rs");
770 let out_dir = dir.path().join("drizzle");
771
772 std::fs::write(
773 &schema_path,
774 r#"
775#[SQLiteTable]
776pub struct Users {
777 #[column(primary)]
778 pub id: i64,
779}
780"#,
781 )
782 .expect("write schema");
783
784 let cfg = Config::new(Dialect::SQLite)
785 .file(&schema_path)
786 .out(&out_dir);
787
788 let first = run(&cfg).expect("first generation should succeed");
789 assert!(matches!(first, Output::Generated { .. }));
790 assert!(
791 !out_dir.join("meta").join("_journal.json").exists(),
792 "v3 generation should not create legacy journal metadata"
793 );
794
795 let second = run(&cfg).expect("second generation should succeed");
796 assert_eq!(second, Output::NoChanges);
797 }
798
799 #[test]
800 fn run_applies_snapshot_bound_typed_sqlite_rebuild_data() {
801 let dir = tempfile::tempdir().expect("tempdir");
802 let schema_path = dir.path().join("schema.rs");
803 let out_dir = dir.path().join("drizzle");
804 std::fs::write(
805 &schema_path,
806 r#"
807#[SQLiteTable]
808pub struct Assets {
809 #[column(primary)]
810 pub id: i64,
811 pub digest: Option<String>,
812 pub relation: i64,
813 pub metadata: String,
814}
815"#,
816 )
817 .expect("write predecessor schema");
818 let base = Config::new(Dialect::SQLite)
819 .file(&schema_path)
820 .out(&out_dir)
821 .prefix_mode(PrefixMode::Index);
822 let Output::Generated { path, .. } = run(&base).expect("generate predecessor") else {
823 panic!("expected predecessor migration");
824 };
825 let predecessor = Snapshot::load(&path.join("snapshot.json"), Dialect::SQLite)
826 .expect("load predecessor snapshot");
827
828 std::fs::write(
829 &schema_path,
830 r#"
831#[SQLiteTable(STRICT)]
832pub struct Assets {
833 #[column(primary)]
834 pub id: i64,
835 #[column(blob)]
836 pub digest: Option<Vec<u8>>,
837 pub relation: i64,
838 pub metadata: String,
839}
840"#,
841 )
842 .expect("write current schema");
843 let error = run(&base).expect_err("affinity change without a rebuild plan must fail");
844 let BuildError::Migration(crate::writer::MigrationError::ConfigError(message)) = error
845 else {
846 panic!("unexpected error: {error:?}");
847 };
848 assert!(
849 message.contains("storage affinity without a rebuild-data plan")
850 && message.contains("assets.digest"),
851 "{message}"
852 );
853 let plan = SqliteRebuildDataPlan {
854 predecessor_snapshot_id: uuid::Uuid::parse_str(predecessor.id())
855 .expect("generated predecessor ID is a UUID"),
856 tables: vec![SqliteTableRebuildPlan {
857 table: "assets".to_string(),
858 columns: vec![
859 SqliteColumnCopy {
860 target: "digest".to_string(),
861 expression: SqliteCopyExpression::HexTextToBlob {
862 source: "digest".to_string(),
863 bytes: 32,
864 },
865 },
866 SqliteColumnCopy {
867 target: "relation".to_string(),
868 expression: SqliteCopyExpression::IntegerMap {
869 source: "relation".to_string(),
870 cases: vec![
871 SqliteIntegerMapping { from: 1, to: 0 },
872 SqliteIntegerMapping { from: 2, to: 1 },
873 SqliteIntegerMapping { from: 4, to: 2 },
874 ],
875 },
876 },
877 ],
878 validations: vec![SqliteDataValidation::JsonValid {
879 column: "metadata".to_string(),
880 }],
881 }],
882 };
883 let registry = SqliteRebuildDataPlanRegistry {
884 version: SQLITE_REBUILD_DATA_PLAN_VERSION,
885 plans: vec![plan],
886 };
887 let plan_path = dir.path().join("rebuild-data.json");
888 std::fs::write(
889 &plan_path,
890 serde_json::to_vec_pretty(®istry).expect("serialize rebuild-data registry"),
891 )
892 .expect("write rebuild-data plan");
893 let configured = Config::new(Dialect::SQLite)
894 .file(&schema_path)
895 .out(&out_dir)
896 .prefix_mode(PrefixMode::Index)
897 .sqlite_rebuild_data_plan_file(&plan_path);
898 let Output::Generated { tag, path, .. } = run(&configured).expect("generate typed rebuild")
899 else {
900 panic!("expected typed rebuild migration");
901 };
902 let sql = std::fs::read_to_string(path.join("migration.sql"))
903 .expect("read generated rebuild migration");
904 assert!(
905 sql.contains("coalesce(length(unhex(`digest`)), -1) <> 32"),
906 "{sql}"
907 );
908 assert!(sql.contains("unhex(`digest`)"), "{sql}");
909 assert!(
910 sql.contains("CASE `relation` WHEN 1 THEN 0 WHEN 2 THEN 1 WHEN 4 THEN 2 ELSE NULL END"),
911 "{sql}"
912 );
913 assert!(!sql.contains("IF EXISTS"), "{sql}");
914 assert!(!sql.contains("IF NOT EXISTS"), "{sql}");
915
916 let connection = rusqlite::Connection::open_in_memory().expect("open SQLite");
917 connection
918 .execute_batch(
919 "CREATE TABLE assets (id INTEGER PRIMARY KEY, digest TEXT, relation INTEGER NOT NULL, metadata TEXT NOT NULL);\
920 INSERT INTO assets VALUES (1, '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 1, '[]');\
921 INSERT INTO assets VALUES (2, NULL, 4, '{}');",
922 )
923 .expect("seed valid predecessor rows");
924 apply_generated_sql(&connection, &sql).expect("apply generated rebuild");
925 let rows = connection
926 .prepare("SELECT id, typeof(digest), length(digest), relation, metadata FROM assets ORDER BY id")
927 .expect("prepare migrated read")
928 .query_map([], |row| {
929 Ok((
930 row.get::<_, i64>(0)?,
931 row.get::<_, String>(1)?,
932 row.get::<_, Option<i64>>(2)?,
933 row.get::<_, i64>(3)?,
934 row.get::<_, String>(4)?,
935 ))
936 })
937 .expect("query migrated rows")
938 .collect::<Result<Vec<_>, _>>()
939 .expect("decode migrated rows");
940 assert_eq!(
941 rows,
942 vec![
943 (1, "blob".to_string(), Some(32), 0, "[]".to_string()),
944 (2, "null".to_string(), None, 2, "{}".to_string()),
945 ]
946 );
947 let guard_count: i64 = connection
948 .query_row(
949 "SELECT count(*) FROM sqlite_temp_master WHERE name LIKE '__drizzle_rebuild_guard_%'",
950 [],
951 |row| row.get(0),
952 )
953 .expect("query temp guards");
954 assert_eq!(guard_count, 0, "successful migration must drop its guard");
955
956 let migration = crate::Migration::new(&tag, &sql);
957 let migrations = crate::Migrations::new(vec![migration], Dialect::SQLite);
958 assert_eq!(migrations.pending::<String>(&[]).count(), 1);
959 assert_eq!(migrations.pending(&[tag]).count(), 0);
960 assert!(
961 apply_generated_sql(&connection, &sql).is_err(),
962 "forced replay must fail loudly"
963 );
964
965 assert_invalid_rebuild_row(
966 &sql,
967 "'zz0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'",
968 "1",
969 "'[]'",
970 );
971 assert_invalid_rebuild_row(&sql, "NULL", "3", "'[]'");
972 assert_invalid_rebuild_row(&sql, "NULL", "1", "'{' ");
973
974 assert_eq!(
975 run(&configured).expect("reopen generation with historical registry"),
976 Output::NoChanges
977 );
978 }
979
980 fn apply_generated_sql(connection: &rusqlite::Connection, sql: &str) -> rusqlite::Result<()> {
981 for statement in sql.split("\n--> statement-breakpoint\n") {
982 connection.execute_batch(statement)?;
983 }
984 Ok(())
985 }
986
987 fn assert_invalid_rebuild_row(sql: &str, digest: &str, relation: &str, metadata: &str) {
988 let connection = rusqlite::Connection::open_in_memory().expect("open SQLite");
989 connection
990 .execute_batch(&format!(
991 "CREATE TABLE assets (id INTEGER PRIMARY KEY, digest TEXT, relation INTEGER NOT NULL, metadata TEXT NOT NULL);\
992 INSERT INTO assets VALUES (1, {digest}, {relation}, {metadata});"
993 ))
994 .expect("seed invalid predecessor row");
995 assert!(
996 apply_generated_sql(&connection, sql).is_err(),
997 "invalid predecessor row unexpectedly migrated: digest={digest}, relation={relation}, metadata={metadata}"
998 );
999 let table_sql: String = connection
1000 .query_row(
1001 "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'assets'",
1002 [],
1003 |row| row.get(0),
1004 )
1005 .expect("predecessor table remains");
1006 assert!(!table_sql.contains("STRICT"), "copy began before preflight");
1007 }
1008
1009 #[test]
1010 fn run_fails_loudly_on_parse_errors() {
1011 let dir = tempfile::tempdir().expect("tempdir");
1012 let schema_path = dir.path().join("schema.rs");
1013 let out_dir = dir.path().join("drizzle");
1014
1015 std::fs::write(
1018 &schema_path,
1019 r#"
1020#[SQLiteTable]
1021pub struct Users {
1022 #[column(primary)]
1023 pub id: i64,
1024"#,
1025 )
1026 .expect("write schema");
1027
1028 let cfg = Config::new(Dialect::SQLite)
1029 .file(&schema_path)
1030 .out(&out_dir);
1031
1032 let error = run(&cfg).expect_err("parse failure must not be silent");
1033 assert!(
1034 matches!(error, BuildError::SchemaParse(_)),
1035 "expected SchemaParse, got {error:?}"
1036 );
1037 assert!(
1038 !out_dir.exists(),
1039 "no migration output should be written on parse failure"
1040 );
1041 }
1042
1043 #[test]
1044 fn run_accepts_multiple_files() {
1045 let dir = tempfile::tempdir().expect("tempdir");
1046 let users_path = dir.path().join("users.rs");
1047 let posts_path = dir.path().join("posts.rs");
1048 let schema_path = dir.path().join("schema.rs");
1049 let out_dir = dir.path().join("drizzle");
1050
1051 std::fs::write(
1052 &users_path,
1053 r#"
1054#[SQLiteTable]
1055pub struct Users {
1056 #[column(primary)]
1057 pub id: i64,
1058 pub name: String,
1059}
1060"#,
1061 )
1062 .expect("write users schema");
1063
1064 std::fs::write(
1065 &posts_path,
1066 r#"
1067#[SQLiteTable]
1068pub struct Posts {
1069 #[column(primary)]
1070 pub id: i64,
1071 #[column(references = Users::id)]
1072 pub author_id: i64,
1073}
1074"#,
1075 )
1076 .expect("write posts schema");
1077
1078 std::fs::write(
1079 &schema_path,
1080 r#"
1081#[derive(SQLiteSchema)]
1082pub struct Schema {
1083 pub users: Users,
1084 pub posts: Posts,
1085}
1086"#,
1087 )
1088 .expect("write root schema");
1089
1090 let cfg = Config::new(Dialect::SQLite)
1091 .file(&users_path)
1092 .file(&posts_path)
1093 .file(&schema_path)
1094 .out(&out_dir);
1095
1096 let outcome = run(&cfg).expect("generation should succeed");
1097 let Output::Generated { path, .. } = outcome else {
1098 panic!("expected a migration to be generated");
1099 };
1100
1101 let migration_sql_path = path.join("migration.sql");
1102 assert!(migration_sql_path.exists(), "migration.sql should exist");
1103 assert!(
1104 path.join("snapshot.json").exists(),
1105 "snapshot.json should exist"
1106 );
1107
1108 let migration_sql =
1109 std::fs::read_to_string(&migration_sql_path).expect("read generated migration.sql");
1110 let mut statements: Vec<_> = migration_sql
1111 .split("\n--> statement-breakpoint\n")
1112 .map(str::to_string)
1113 .collect();
1114 statements.sort();
1115
1116 let mut expected = vec![
1117 "CREATE TABLE `posts` (\n\t`id` INTEGER PRIMARY KEY,\n\t`author_id` INTEGER NOT NULL,\n\tCONSTRAINT `fk_posts_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`)\n);".to_string(),
1118 "CREATE TABLE `users` (\n\t`id` INTEGER PRIMARY KEY,\n\t`name` TEXT NOT NULL\n);".to_string(),
1119 ];
1120 expected.sort();
1121
1122 assert_eq!(statements, expected, "unexpected generated migration SQL");
1123 }
1124
1125 fn two_table_schema(dir: &Path) -> (PathBuf, PathBuf) {
1128 let schema_path = dir.join("schema.rs");
1129 std::fs::write(
1130 &schema_path,
1131 r#"
1132#[SQLiteTable]
1133pub struct Users {
1134 #[column(primary)]
1135 pub id: i64,
1136 pub name: String,
1137}
1138
1139#[SQLiteTable]
1140pub struct ScratchCache {
1141 #[column(primary)]
1142 pub id: i64,
1143}
1144
1145#[derive(SQLiteSchema)]
1146pub struct Schema {
1147 pub users: Users,
1148 pub scratch_cache: ScratchCache,
1149}
1150"#,
1151 )
1152 .expect("write schema");
1153 (schema_path, dir.join("drizzle"))
1154 }
1155
1156 #[test]
1157 fn transform_statements_rewrites_generated_sql() {
1158 let dir = tempfile::tempdir().expect("tempdir");
1159 let (schema_path, out_dir) = two_table_schema(dir.path());
1160
1161 let cfg = Config::new(Dialect::SQLite)
1162 .file(&schema_path)
1163 .out(&out_dir)
1164 .transform_statements(|statements| {
1167 statements
1168 .into_iter()
1169 .filter(|sql| !sql.contains("scratch_cache"))
1170 .collect()
1171 });
1172
1173 let Output::Generated {
1174 path,
1175 statement_count,
1176 ..
1177 } = run(&cfg).expect("generation should succeed")
1178 else {
1179 panic!("expected a migration to be generated");
1180 };
1181
1182 assert_eq!(statement_count, 1, "transform dropped one statement");
1183 let sql = std::fs::read_to_string(path.join("migration.sql")).expect("read migration.sql");
1184 assert!(sql.contains("`users`"), "{sql}");
1185 assert!(
1186 !sql.contains("scratch_cache"),
1187 "filtered statement leaked into migration.sql: {sql}"
1188 );
1189 assert!(
1190 path.join("snapshot.json").exists(),
1191 "the snapshot still records the untransformed schema"
1192 );
1193 }
1194
1195 #[test]
1196 fn transform_statements_can_append_statements() {
1197 let dir = tempfile::tempdir().expect("tempdir");
1198 let (schema_path, out_dir) = two_table_schema(dir.path());
1199
1200 let cfg = Config::new(Dialect::SQLite)
1201 .file(&schema_path)
1202 .out(&out_dir)
1203 .transform_statements(|mut statements| {
1204 statements.push("CREATE INDEX `users_name_idx` ON `users` (`name`);".to_string());
1205 statements
1206 });
1207
1208 let Output::Generated {
1209 path,
1210 statement_count,
1211 ..
1212 } = run(&cfg).expect("generation should succeed")
1213 else {
1214 panic!("expected a migration to be generated");
1215 };
1216
1217 assert_eq!(statement_count, 3);
1218 let sql = std::fs::read_to_string(path.join("migration.sql")).expect("read migration.sql");
1219 assert!(sql.contains("users_name_idx"), "{sql}");
1220 }
1221
1222 #[test]
1223 fn transform_statements_emptying_the_plan_reports_no_changes() {
1224 let dir = tempfile::tempdir().expect("tempdir");
1225 let (schema_path, out_dir) = two_table_schema(dir.path());
1226
1227 let cfg = Config::new(Dialect::SQLite)
1228 .file(&schema_path)
1229 .out(&out_dir)
1230 .transform_statements(|_| Vec::new());
1231
1232 assert_eq!(run(&cfg).expect("run"), Output::NoChanges);
1233 assert!(
1234 !out_dir.exists(),
1235 "an emptied plan must not write a migration folder"
1236 );
1237 }
1238
1239 #[test]
1240 fn config_without_transform_is_unchanged() {
1241 let cfg = Config::new(Dialect::SQLite);
1242 assert_eq!(
1243 cfg.apply_transform(vec!["SELECT 1".to_string()]),
1244 vec!["SELECT 1".to_string()]
1245 );
1246 let cloned = cfg.clone().transform_statements(|s| s);
1248 assert!(format!("{cloned:?}").contains("statement transform"));
1249 }
1250
1251 #[test]
1252 fn typed_rebuild_plan_rejects_statement_transform() {
1253 let plan = SqliteRebuildDataPlan {
1254 predecessor_snapshot_id: uuid::Uuid::new_v4(),
1255 tables: Vec::new(),
1256 };
1257 let cfg = Config::new(Dialect::SQLite)
1258 .file("schema.rs")
1259 .sqlite_rebuild_data_plan(plan)
1260 .transform_statements(|statements| statements);
1261
1262 assert!(matches!(
1263 run(&cfg),
1264 Err(BuildError::SqliteRebuildDataTransformConflict)
1265 ));
1266 }
1267
1268 #[test]
1269 fn from_toml_loads_minimal_sqlite() {
1270 let dir = tempfile::tempdir().expect("tempdir");
1271 let cfg_path = dir.path().join("drizzle.config.toml");
1272 std::fs::write(
1273 &cfg_path,
1274 r#"
1275dialect = "sqlite"
1276schema = "src/schema.rs"
1277out = "./drizzle"
1278
1279[migrations]
1280sqliteRebuildDataPlan = "plans/rebuild-data.json"
1281
1282[dbCredentials]
1283url = "./dev.db"
1284"#,
1285 )
1286 .expect("write config");
1287
1288 let cfg = Config::from_toml(&cfg_path).expect("load toml");
1289
1290 assert_eq!(cfg.dialect(), Dialect::SQLite);
1291 assert_eq!(cfg.out_dir(), Path::new("./drizzle"));
1292 assert_eq!(cfg.url().expect("resolve url"), "./dev.db");
1293 assert_eq!(cfg.tracking(), Tracking::SQLITE);
1294 assert!(
1295 cfg.watch_targets()
1296 .contains(&dir.path().join("plans/rebuild-data.json")),
1297 "checked-in rebuild plan must be resolved relative to and watched with its config"
1298 );
1299 }
1300
1301 #[test]
1302 fn from_toml_handles_env_url_and_multiple_schemas() {
1303 let dir = tempfile::tempdir().expect("tempdir");
1304 let cfg_path = dir.path().join("drizzle.config.toml");
1305 std::fs::write(
1306 &cfg_path,
1307 r#"
1308dialect = "postgresql"
1309schema = ["src/users.rs", "src/posts.rs"]
1310
1311[dbCredentials]
1312url = { env = "DRIZZLE_BUILD_TEST_URL" }
1313
1314[migrations]
1315table = "my_migrations"
1316schema = "drizzle_meta"
1317"#,
1318 )
1319 .expect("write config");
1320
1321 let cfg = Config::from_toml(&cfg_path).expect("load toml");
1322 assert_eq!(cfg.dialect(), Dialect::PostgreSQL);
1323
1324 let tracking = cfg.tracking();
1325 assert_eq!(tracking.table, "my_migrations");
1326 assert_eq!(tracking.schema.as_deref(), Some("drizzle_meta"));
1327
1328 unsafe { std::env::set_var("DRIZZLE_BUILD_TEST_URL", "postgres://x") };
1330 assert_eq!(cfg.url().expect("resolve env"), "postgres://x");
1331 unsafe { std::env::remove_var("DRIZZLE_BUILD_TEST_URL") };
1332
1333 let err = cfg.url().expect_err("missing env var should error");
1334 assert!(
1335 matches!(err, BuildError::EnvVarNotSet(ref v) if v == "DRIZZLE_BUILD_TEST_URL"),
1336 "unexpected error: {err:?}"
1337 );
1338 }
1339
1340 #[test]
1341 fn from_toml_missing_url_errors_lazily() {
1342 let dir = tempfile::tempdir().expect("tempdir");
1343 let cfg_path = dir.path().join("drizzle.config.toml");
1344 std::fs::write(
1345 &cfg_path,
1346 r#"
1347dialect = "sqlite"
1348schema = "src/schema.rs"
1349"#,
1350 )
1351 .expect("write config");
1352
1353 let cfg = Config::from_toml(&cfg_path).expect("load toml");
1355 assert!(matches!(cfg.url(), Err(BuildError::MissingUrl)));
1356 }
1357
1358 #[test]
1359 fn watch_targets_include_out_dir_and_schema_files() {
1360 let cfg = Config::new(Dialect::SQLite)
1361 .file("src/schema.rs")
1362 .file("src/posts.rs")
1363 .out("./drizzle");
1364
1365 let targets = cfg.watch_targets();
1366
1367 assert!(
1368 targets.contains(&PathBuf::from("src/schema.rs"))
1369 && targets.contains(&PathBuf::from("src/posts.rs")),
1370 "schema files must be watched: {targets:?}"
1371 );
1372 assert!(
1376 targets.contains(&PathBuf::from("./drizzle")),
1377 "out_dir must be watched: {targets:?}"
1378 );
1379 }
1380
1381 #[test]
1382 fn watch_targets_include_the_toml_config_path() {
1383 let dir = tempfile::tempdir().expect("tempdir");
1384 let cfg_path = dir.path().join("drizzle.config.toml");
1385 std::fs::write(
1386 &cfg_path,
1387 r#"
1388dialect = "sqlite"
1389schema = "src/schema.rs"
1390out = "./migrations-out"
1391"#,
1392 )
1393 .expect("write config");
1394
1395 let cfg = Config::from_toml(&cfg_path).expect("load toml");
1396 let targets = cfg.watch_targets();
1397
1398 assert!(
1399 targets.contains(&cfg_path),
1400 "config path must be watched: {targets:?}"
1401 );
1402 assert!(
1403 targets.contains(&PathBuf::from("src/schema.rs")),
1404 "schema files must be watched: {targets:?}"
1405 );
1406 assert!(
1407 targets.contains(&PathBuf::from("./migrations-out")),
1408 "out_dir must be watched: {targets:?}"
1409 );
1410 }
1411}