1pub use drizzle_types::{Casing, EnvOr, EnvOrError};
10use schemars::JsonSchema;
11use serde::Deserialize;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15pub const CONFIG_FILE: &str = "drizzle.config.toml";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Deserialize, JsonSchema)]
19pub enum IntrospectCasing {
20 #[default]
22 #[serde(rename = "camel")]
23 Camel,
24 #[serde(rename = "preserve")]
26 Preserve,
27}
28
29impl IntrospectCasing {
30 #[must_use]
31 pub const fn as_str(self) -> &'static str {
32 match self {
33 Self::Camel => "camel",
34 Self::Preserve => "preserve",
35 }
36 }
37}
38
39impl std::fmt::Display for IntrospectCasing {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.write_str(self.as_str())
42 }
43}
44
45impl std::str::FromStr for IntrospectCasing {
46 type Err = String;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 match s {
50 "camel" | "camelCase" => Ok(Self::Camel),
51 "preserve" => Ok(Self::Preserve),
52 _ => Err(format!(
53 "invalid introspect casing '{s}', expected 'camel' or 'preserve'"
54 )),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
61pub struct IntrospectConfig {
62 #[serde(default)]
64 pub casing: IntrospectCasing,
65}
66
67#[derive(Debug, Clone, Deserialize, JsonSchema)]
76#[serde(untagged)]
77pub enum RolesFilter {
78 Bool(bool),
80 Config {
82 #[serde(default)]
84 provider: Option<String>,
85 #[serde(default)]
87 include: Option<Vec<String>>,
88 #[serde(default)]
90 exclude: Option<Vec<String>>,
91 },
92}
93
94impl Default for RolesFilter {
95 fn default() -> Self {
96 Self::Bool(false)
97 }
98}
99
100impl RolesFilter {
101 #[must_use]
103 pub const fn is_enabled(&self) -> bool {
104 match self {
105 Self::Bool(b) => *b,
106 Self::Config { .. } => true,
107 }
108 }
109
110 #[must_use]
112 pub fn should_include(&self, role_name: &str) -> bool {
113 match self {
114 Self::Bool(b) => *b,
115 Self::Config {
116 provider,
117 include,
118 exclude,
119 } => {
120 if let Some(p) = provider
122 && is_provider_role(p, role_name)
123 {
124 return false;
125 }
126 if let Some(excl) = exclude
128 && excl.iter().any(|e| e == role_name)
129 {
130 return false;
131 }
132 if let Some(incl) = include {
134 return incl.iter().any(|i| i == role_name);
135 }
136 true
137 }
138 }
139 }
140}
141
142fn is_provider_role(provider: &str, role_name: &str) -> bool {
144 match provider {
145 "supabase" => matches!(
146 role_name,
147 "anon"
148 | "authenticated"
149 | "service_role"
150 | "supabase_admin"
151 | "supabase_auth_admin"
152 | "supabase_storage_admin"
153 | "dashboard_user"
154 | "supabase_replication_admin"
155 | "supabase_read_only_user"
156 | "supabase_realtime_admin"
157 | "supabase_functions_admin"
158 | "postgres"
159 | "pgbouncer"
160 | "pgsodium_keyholder"
161 | "pgsodium_keyiduser"
162 | "pgsodium_keymaker"
163 ),
164 "neon" => matches!(
165 role_name,
166 "neon_superuser" | "cloud_admin" | "authenticated" | "anonymous"
167 ),
168 _ => false,
169 }
170}
171
172#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
176pub struct EntitiesFilter {
177 #[serde(default)]
179 pub roles: RolesFilter,
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
188#[serde(rename_all = "lowercase")]
189pub enum Extension {
190 Postgis,
192}
193
194impl Extension {
195 #[must_use]
196 pub const fn as_str(self) -> &'static str {
197 match self {
198 Self::Postgis => "postgis",
199 }
200 }
201}
202
203impl std::fmt::Display for Extension {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 f.write_str(self.as_str())
206 }
207}
208
209#[derive(
215 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, Deserialize, JsonSchema,
216)]
217#[serde(rename_all = "lowercase")]
218pub enum Dialect {
219 #[default]
220 Sqlite,
221 #[serde(alias = "postgres")]
222 Postgresql,
223 Turso,
224}
225
226impl Dialect {
227 pub const ALL: &'static [&'static str] = &["sqlite", "postgresql", "turso"];
228
229 #[inline]
230 #[must_use]
231 pub const fn as_str(self) -> &'static str {
232 match self {
233 Self::Sqlite => "sqlite",
234 Self::Postgresql => "postgresql",
235 Self::Turso => "turso",
236 }
237 }
238
239 #[inline]
240 #[must_use]
241 pub const fn to_base(self) -> drizzle_types::Dialect {
242 match self {
243 Self::Sqlite | Self::Turso => drizzle_types::Dialect::SQLite,
244 Self::Postgresql => drizzle_types::Dialect::PostgreSQL,
245 }
246 }
247}
248
249impl std::fmt::Display for Dialect {
250 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
251 f.write_str(self.as_str())
252 }
253}
254
255impl std::str::FromStr for Dialect {
256 type Err = String;
257
258 fn from_str(s: &str) -> Result<Self, Self::Err> {
259 match s {
260 "sqlite" => Ok(Self::Sqlite),
261 "postgresql" | "postgres" => Ok(Self::Postgresql),
262 "turso" => Ok(Self::Turso),
263 _ => Err(format!(
264 "invalid dialect '{}', expected one of: {}",
265 s,
266 Self::ALL.join(", ")
267 )),
268 }
269 }
270}
271
272impl From<Dialect> for drizzle_types::Dialect {
273 #[inline]
274 fn from(d: Dialect) -> Self {
275 d.to_base()
276 }
277}
278
279#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, JsonSchema)]
285#[serde(rename_all = "kebab-case")]
286pub enum Driver {
287 Rusqlite,
289 Libsql,
291 Turso,
293 PostgresSync,
295 TokioPostgres,
297 D1Http,
305 DurableSqlite,
313 AwsDataApi,
327}
328
329impl Driver {
330 pub const ALL: &'static [&'static str] = &[
331 "rusqlite",
332 "libsql",
333 "turso",
334 "postgres-sync",
335 "tokio-postgres",
336 "d1-http",
337 "durable-sqlite",
338 "aws-data-api",
339 ];
340
341 #[inline]
342 #[must_use]
343 pub const fn as_str(self) -> &'static str {
344 match self {
345 Self::Rusqlite => "rusqlite",
346 Self::Libsql => "libsql",
347 Self::Turso => "turso",
348 Self::PostgresSync => "postgres-sync",
349 Self::TokioPostgres => "tokio-postgres",
350 Self::D1Http => "d1-http",
351 Self::DurableSqlite => "durable-sqlite",
352 Self::AwsDataApi => "aws-data-api",
353 }
354 }
355
356 #[must_use]
357 pub const fn valid_for(dialect: Dialect) -> &'static [Self] {
358 match dialect {
359 Dialect::Sqlite => &[Self::Rusqlite, Self::D1Http, Self::DurableSqlite],
363 Dialect::Turso => &[Self::Libsql, Self::Turso],
364 Dialect::Postgresql => &[Self::PostgresSync, Self::TokioPostgres, Self::AwsDataApi],
365 }
366 }
367
368 #[inline]
369 #[must_use]
370 pub const fn is_valid_for(self, dialect: Dialect) -> bool {
371 matches!(
372 (self, dialect),
373 (
374 Self::Rusqlite | Self::D1Http | Self::DurableSqlite,
375 Dialect::Sqlite
376 ) | (Self::Libsql | Self::Turso, Dialect::Turso)
377 | (
378 Self::PostgresSync | Self::TokioPostgres | Self::AwsDataApi,
379 Dialect::Postgresql
380 )
381 )
382 }
383
384 #[inline]
388 #[must_use]
389 pub const fn is_codegen_only(self) -> bool {
390 matches!(self, Self::DurableSqlite)
391 }
392}
393
394impl std::fmt::Display for Driver {
395 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396 f.write_str(self.as_str())
397 }
398}
399
400impl std::str::FromStr for Driver {
401 type Err = String;
402
403 fn from_str(s: &str) -> Result<Self, Self::Err> {
404 match s {
405 "rusqlite" => Ok(Self::Rusqlite),
406 "libsql" => Ok(Self::Libsql),
407 "turso" => Ok(Self::Turso),
408 "postgres-sync" => Ok(Self::PostgresSync),
409 "tokio-postgres" => Ok(Self::TokioPostgres),
410 "d1-http" => Ok(Self::D1Http),
411 "durable-sqlite" => Ok(Self::DurableSqlite),
412 "aws-data-api" => Ok(Self::AwsDataApi),
413 _ => Err(format!(
414 "invalid driver '{}', expected one of: {}",
415 s,
416 Self::ALL.join(", ")
417 )),
418 }
419 }
420}
421
422impl std::str::FromStr for Extension {
423 type Err = String;
424
425 fn from_str(s: &str) -> Result<Self, Self::Err> {
426 match s {
427 "postgis" => Ok(Self::Postgis),
428 _ => Err(format!(
429 "invalid extension filter '{s}', expected 'postgis'"
430 )),
431 }
432 }
433}
434
435#[derive(Clone)]
441pub enum Credentials {
442 Sqlite { path: Box<str> },
444
445 Turso {
447 url: Box<str>,
448 auth_token: Option<Box<str>>,
449 },
450
451 Postgres(PostgresCreds),
453
454 D1 {
460 account_id: Box<str>,
461 database_id: Box<str>,
462 token: Box<str>,
463 },
464
465 AwsDataApi {
471 database: Box<str>,
472 secret_arn: Box<str>,
473 resource_arn: Box<str>,
474 },
475}
476
477#[derive(Clone)]
479pub enum PostgresCreds {
480 Url(Box<str>),
481 Host {
482 host: Box<str>,
483 port: u16,
484 user: Option<Box<str>>,
485 password: Option<Box<str>>,
486 database: Box<str>,
487 ssl: PostgresSslMode,
488 },
489}
490
491impl std::fmt::Debug for Credentials {
492 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493 match self {
494 Self::Sqlite { path } => f.debug_struct("Sqlite").field("path", path).finish(),
495 Self::Turso { url, auth_token } => f
496 .debug_struct("Turso")
497 .field("url", url)
498 .field("auth_token", &auth_token.as_ref().map(|_| "[REDACTED]"))
499 .finish(),
500 Self::Postgres(credentials) => f.debug_tuple("Postgres").field(credentials).finish(),
501 Self::D1 {
502 account_id,
503 database_id,
504 token,
505 } => f
506 .debug_struct("D1")
507 .field("account_id", account_id)
508 .field("database_id", database_id)
509 .field("token", &(!token.is_empty()).then_some("[REDACTED]"))
510 .finish(),
511 Self::AwsDataApi {
512 database,
513 secret_arn,
514 resource_arn,
515 } => f
516 .debug_struct("AwsDataApi")
517 .field("database", database)
518 .field("secret_arn", secret_arn)
519 .field("resource_arn", resource_arn)
520 .finish(),
521 }
522 }
523}
524
525impl std::fmt::Debug for PostgresCreds {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 match self {
528 Self::Url(_) => f.debug_tuple("Url").field(&"[REDACTED]").finish(),
529 Self::Host {
530 host,
531 port,
532 user,
533 password,
534 database,
535 ssl,
536 } => f
537 .debug_struct("Host")
538 .field("host", host)
539 .field("port", port)
540 .field("user", user)
541 .field("password", &password.as_ref().map(|_| "[REDACTED]"))
542 .field("database", database)
543 .field("ssl", ssl)
544 .finish(),
545 }
546 }
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
550pub enum PostgresSslMode {
551 #[default]
552 Disable,
553 Allow,
554 Prefer,
555 Require,
556 VerifyCa,
557 VerifyFull,
558}
559
560impl PostgresSslMode {
561 pub(crate) fn parse(value: &str) -> Result<Self, String> {
562 match value.trim().to_ascii_lowercase().as_str() {
563 "false" | "0" | "no" | "off" | "disable" => Ok(Self::Disable),
564 "allow" => Ok(Self::Allow),
565 "prefer" => Ok(Self::Prefer),
566 "true" | "1" | "yes" | "on" | "require" => Ok(Self::Require),
567 "verify-ca" => Ok(Self::VerifyCa),
568 "verify-full" => Ok(Self::VerifyFull),
569 _ => Err(format!("invalid PostgreSQL SSL mode `{value}`")),
570 }
571 }
572}
573
574pub struct PostgresConnectionConfig {
575 pub config: tokio_postgres::Config,
576 pub ssl: PostgresSslMode,
577}
578
579impl PostgresCreds {
580 pub fn connection_config(&self) -> Result<PostgresConnectionConfig, String> {
581 match self {
582 Self::Url(url) => {
583 let config = url
584 .parse::<tokio_postgres::Config>()
585 .map_err(|error| error.to_string())?;
586 let ssl = match config.get_ssl_mode() {
587 tokio_postgres::config::SslMode::Disable => PostgresSslMode::Disable,
588 tokio_postgres::config::SslMode::Prefer => PostgresSslMode::Prefer,
589 tokio_postgres::config::SslMode::Require => PostgresSslMode::Require,
590 _ => PostgresSslMode::Require,
591 };
592 Ok(PostgresConnectionConfig { config, ssl })
593 }
594 Self::Host {
595 host,
596 port,
597 user,
598 password,
599 database,
600 ssl,
601 } => {
602 let mut config = tokio_postgres::Config::new();
603 config
604 .host(host.as_ref())
605 .port(*port)
606 .dbname(database.as_ref());
607 if let Some(user) = user {
608 config.user(user.as_ref());
609 }
610 if let Some(password) = password {
611 config.password(password.as_bytes());
612 }
613 config.ssl_mode(match ssl {
614 PostgresSslMode::Disable => tokio_postgres::config::SslMode::Disable,
615 PostgresSslMode::Allow | PostgresSslMode::Prefer => {
616 tokio_postgres::config::SslMode::Prefer
617 }
618 PostgresSslMode::Require
619 | PostgresSslMode::VerifyCa
620 | PostgresSslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
621 });
622 Ok(PostgresConnectionConfig { config, ssl: *ssl })
623 }
624 }
625 }
626}
627
628#[derive(Debug, Clone, Deserialize, JsonSchema)]
634#[serde(untagged)]
635pub enum Schema {
636 One(String),
637 Many(Vec<String>),
638}
639
640impl Default for Schema {
641 fn default() -> Self {
642 Self::One("src/schema.rs".into())
643 }
644}
645
646impl Schema {
647 pub fn iter(&self) -> impl Iterator<Item = &str> {
648 match self {
649 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
650 Self::Many(v) => v.iter().map(String::as_str),
651 }
652 }
653}
654
655#[derive(Debug, Clone, Deserialize, JsonSchema)]
657#[serde(untagged)]
658pub enum Filter {
659 One(String),
660 Many(Vec<String>),
661}
662
663impl Filter {
664 pub fn iter(&self) -> impl Iterator<Item = &str> {
665 match self {
666 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
667 Self::Many(v) => v.iter().map(String::as_str),
668 }
669 }
670}
671
672#[derive(Debug, Clone, Deserialize, JsonSchema)]
674pub struct MigrationsOpts {
675 pub table: Option<String>,
676 pub schema: Option<String>,
677 pub prefix: Option<MigrationPrefix>,
678 #[serde(default)]
685 pub bundle: Option<bool>,
686}
687
688#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
689#[serde(rename_all = "lowercase")]
690pub enum MigrationPrefix {
691 Index,
692 Timestamp,
693 Supabase,
694 Unix,
695 None,
696}
697
698#[derive(Debug, Clone, Deserialize, JsonSchema)]
703#[serde(untagged)]
704enum RawCreds {
705 D1 {
712 #[serde(rename = "accountId")]
713 account_id: EnvOr,
714 #[serde(rename = "databaseId")]
715 database_id: EnvOr,
716 token: EnvOr,
717 },
718 AwsDataApi {
725 database: EnvOr,
726 #[serde(rename = "secretArn")]
727 secret_arn: EnvOr,
728 #[serde(rename = "resourceArn")]
729 resource_arn: EnvOr,
730 },
731 Url {
732 url: EnvOr,
733 #[serde(default, rename = "authToken")]
734 auth_token: Option<EnvOr>,
735 },
736 Host {
737 host: EnvOr,
738 #[serde(default)]
739 port: Option<u16>,
740 #[serde(default)]
741 user: Option<EnvOr>,
742 #[serde(default)]
743 password: Option<EnvOr>,
744 database: EnvOr,
745 #[serde(default)]
746 ssl: Option<SslVal>,
747 },
748}
749
750#[derive(Debug, Clone, Deserialize, JsonSchema)]
751#[serde(untagged)]
752enum SslVal {
753 Bool(bool),
754 Str(String),
755}
756
757impl SslVal {
758 fn mode(&self) -> Result<PostgresSslMode, String> {
759 match self {
760 Self::Bool(false) => Ok(PostgresSslMode::Disable),
761 Self::Bool(true) => Ok(PostgresSslMode::Require),
762 Self::Str(value) => PostgresSslMode::parse(value),
763 }
764 }
765}
766
767#[derive(Debug, Clone, Deserialize, JsonSchema)]
775#[serde(rename_all = "camelCase")]
776pub struct DatabaseConfig {
777 pub dialect: Dialect,
779
780 #[serde(default)]
782 pub schema: Schema,
783
784 #[serde(default = "default_out")]
786 pub out: PathBuf,
787
788 #[serde(default = "yes")]
790 pub breakpoints: bool,
791
792 #[serde(default)]
794 pub driver: Option<Driver>,
795
796 #[serde(default)]
798 db_credentials: Option<RawCreds>,
799
800 #[serde(default)]
802 pub tables_filter: Option<Filter>,
803
804 #[serde(default)]
806 pub schema_filter: Option<Filter>,
807
808 #[serde(default)]
810 pub extensions_filters: Option<Vec<Extension>>,
811
812 #[serde(default)]
814 pub entities: Option<EntitiesFilter>,
815
816 #[serde(default)]
818 pub casing: Option<Casing>,
819
820 #[serde(default)]
822 pub introspect: Option<IntrospectConfig>,
823
824 #[serde(default)]
826 pub verbose: bool,
827
828 #[serde(default)]
830 pub migrations: Option<MigrationsOpts>,
831}
832
833fn default_out() -> PathBuf {
834 PathBuf::from("./drizzle")
835}
836
837const fn yes() -> bool {
838 true
839}
840
841impl DatabaseConfig {
842 fn normalize_paths(&mut self, base_dir: &Path) {
843 if self.out.is_relative() {
846 self.out = base_dir.join(&self.out);
847 }
848
849 let base = base_dir.to_string_lossy().replace('\\', "/");
853 let base = base.trim_end_matches('/').to_string();
854
855 let normalize_one = |p: &str| -> String {
856 let p_trim = p.trim();
857 let is_abs = Path::new(p_trim).is_absolute() || p_trim.starts_with("\\\\");
858 let joined = if is_abs || base.is_empty() || base == "." {
859 p_trim.to_string()
860 } else {
861 format!("{base}/{p_trim}")
862 };
863 joined.replace('\\', "/")
864 };
865
866 match &mut self.schema {
867 Schema::One(p) => *p = normalize_one(p),
868 Schema::Many(v) => {
869 for p in v.iter_mut() {
870 *p = normalize_one(p);
871 }
872 }
873 }
874 }
875
876 fn validate(&self, name: &str) -> Result<(), Error> {
877 if let Some(d) = self.driver
879 && !d.is_valid_for(self.dialect)
880 {
881 return Err(Error::InvalidDriver {
882 driver: d,
883 dialect: self.dialect,
884 });
885 }
886
887 if let Some(ref raw) = self.db_credentials {
889 self.validate_creds(raw, name)?;
890 }
891
892 if self.dialect != Dialect::Postgresql {
894 if self.schema_filter.is_some() {
895 return Err(Error::InvalidConfig(
896 "schemaFilter is only supported for dialect = \"postgresql\"".into(),
897 ));
898 }
899 if self.extensions_filters.is_some() {
900 return Err(Error::InvalidConfig(
901 "extensionsFilters is only supported for dialect = \"postgresql\"".into(),
902 ));
903 }
904 if self.entities.is_some() {
905 return Err(Error::InvalidConfig(
906 "entities filter is only supported for dialect = \"postgresql\"".into(),
907 ));
908 }
909 }
910
911 Ok(())
912 }
913
914 fn validate_creds(&self, raw: &RawCreds, _name: &str) -> Result<(), Error> {
915 let err = |msg: &str| Error::InvalidCredentials(msg.into());
916
917 match (self.dialect, raw) {
920 (Dialect::Postgresql, RawCreds::Host { .. } | RawCreds::Url { .. }) => {}
921 (_, RawCreds::Host { .. }) => {
922 return Err(err(
923 "host-based dbCredentials are only supported for dialect = \"postgresql\"",
924 ));
925 }
926 _ => {}
927 }
928
929 if let RawCreds::D1 { .. } = raw {
933 if self.dialect != Dialect::Sqlite {
934 return Err(err(
935 "D1 dbCredentials (accountId/databaseId/token) require dialect = \"sqlite\"",
936 ));
937 }
938 if self.driver != Some(Driver::D1Http) {
939 return Err(err(
940 "D1 dbCredentials (accountId/databaseId/token) require driver = \"d1-http\"",
941 ));
942 }
943 }
944
945 if self.driver == Some(Driver::D1Http) && !matches!(raw, RawCreds::D1 { .. }) {
949 return Err(err(
950 "driver = \"d1-http\" requires dbCredentials with accountId, databaseId, and token",
951 ));
952 }
953
954 if let RawCreds::AwsDataApi { .. } = raw {
958 if self.dialect != Dialect::Postgresql {
959 return Err(err(
960 "AWS Data API dbCredentials (database/secretArn/resourceArn) require dialect = \"postgresql\"",
961 ));
962 }
963 if self.driver != Some(Driver::AwsDataApi) {
964 return Err(err(
965 "AWS Data API dbCredentials (database/secretArn/resourceArn) require driver = \"aws-data-api\"",
966 ));
967 }
968 }
969
970 if self.driver == Some(Driver::AwsDataApi) && !matches!(raw, RawCreds::AwsDataApi { .. }) {
972 return Err(err(
973 "driver = \"aws-data-api\" requires dbCredentials with database, secretArn, and resourceArn",
974 ));
975 }
976
977 match (self.dialect, raw) {
979 (
980 Dialect::Sqlite,
981 RawCreds::Url {
982 auth_token: Some(_),
983 ..
984 },
985 ) => Err(err(
986 "SQLite doesn't support authToken (use dialect = \"turso\")",
987 )),
988 (
989 Dialect::Sqlite,
990 RawCreds::Url {
991 url: EnvOr::Value(url),
992 ..
993 },
994 ) if url.starts_with("libsql://") => Err(err(
995 "libsql:// URLs require dialect = \"turso\" (for local SQLite files, use ./path.db)",
996 )),
997 (
998 Dialect::Sqlite,
999 RawCreds::Url {
1000 url: EnvOr::Value(url),
1001 ..
1002 },
1003 ) if url.starts_with("http://")
1004 || url.starts_with("https://")
1005 || url.starts_with("postgres://")
1006 || url.starts_with("postgresql://") =>
1007 {
1008 Err(err(
1009 "SQLite dbCredentials.url must be a local file path (not an http(s)/postgres URL)",
1010 ))
1011 }
1012 (
1013 Dialect::Turso,
1014 RawCreds::Url {
1015 url: EnvOr::Value(url),
1016 ..
1017 },
1018 ) if !url.starts_with("libsql://") && !url.starts_with("http") => {
1019 Err(err("Turso URL must start with libsql:// or http(s)://"))
1020 }
1021 (
1022 Dialect::Postgresql,
1023 RawCreds::Url {
1024 url: EnvOr::Value(url),
1025 ..
1026 },
1027 ) if !url.starts_with("postgres") => {
1028 Err(err("PostgreSQL URL must start with postgres://"))
1029 }
1030 _ => Ok(()),
1031 }
1032 }
1033
1034 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1042 let Some(raw) = self.db_credentials.as_ref() else {
1043 return Ok(None);
1044 };
1045
1046 let resolve_opt = |opt: &Option<EnvOr>| -> Result<Option<Box<str>>, Error> {
1048 match opt.as_ref() {
1049 None => Ok(None),
1050 Some(e) => Ok(Some(e.resolve()?.into_boxed_str())),
1051 }
1052 };
1053
1054 let creds = match (self.dialect, raw) {
1055 (
1059 Dialect::Sqlite,
1060 RawCreds::D1 {
1061 account_id,
1062 database_id,
1063 token,
1064 },
1065 ) => Credentials::D1 {
1066 account_id: account_id.resolve()?.into_boxed_str(),
1067 database_id: database_id.resolve()?.into_boxed_str(),
1068 token: token.resolve()?.into_boxed_str(),
1069 },
1070 (
1073 Dialect::Postgresql,
1074 RawCreds::AwsDataApi {
1075 database,
1076 secret_arn,
1077 resource_arn,
1078 },
1079 ) => Credentials::AwsDataApi {
1080 database: database.resolve()?.into_boxed_str(),
1081 secret_arn: secret_arn.resolve()?.into_boxed_str(),
1082 resource_arn: resource_arn.resolve()?.into_boxed_str(),
1083 },
1084 (Dialect::Sqlite, RawCreds::Url { url, .. }) => Credentials::Sqlite {
1086 path: url.resolve()?.into_boxed_str(),
1087 },
1088 (Dialect::Turso, RawCreds::Url { url, auth_token }) => Credentials::Turso {
1090 url: url.resolve()?.into_boxed_str(),
1091 auth_token: resolve_opt(auth_token)?,
1092 },
1093 (Dialect::Postgresql, RawCreds::Url { url, .. }) => {
1095 Credentials::Postgres(PostgresCreds::Url(url.resolve()?.into_boxed_str()))
1096 }
1097 (
1099 Dialect::Postgresql,
1100 RawCreds::Host {
1101 host,
1102 port,
1103 user,
1104 password,
1105 database,
1106 ssl,
1107 },
1108 ) => Credentials::Postgres(PostgresCreds::Host {
1109 host: host.resolve()?.into_boxed_str(),
1110 port: port.unwrap_or(5432),
1111 user: resolve_opt(user)?,
1112 password: resolve_opt(password)?,
1113 database: database.resolve()?.into_boxed_str(),
1114 ssl: ssl
1115 .as_ref()
1116 .map_or(Ok(PostgresSslMode::Disable), SslVal::mode)
1117 .map_err(Error::InvalidCredentials)?,
1118 }),
1119 _ => return Ok(None),
1120 };
1121
1122 Ok(Some(creds))
1123 }
1124
1125 #[inline]
1127 #[must_use]
1128 pub fn migrations_dir(&self) -> &Path {
1129 &self.out
1130 }
1131
1132 #[inline]
1134 #[must_use]
1135 pub fn meta_dir(&self) -> PathBuf {
1136 self.out.join("meta")
1137 }
1138
1139 #[inline]
1141 #[must_use]
1142 pub fn journal_path(&self) -> PathBuf {
1143 self.meta_dir().join("_journal.json")
1144 }
1145
1146 #[must_use]
1148 pub fn schema_display(&self) -> String {
1149 match &self.schema {
1150 Schema::One(s) => s.clone(),
1151 Schema::Many(v) => v.join(", "),
1152 }
1153 }
1154
1155 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1162 let mut files = Vec::new();
1163
1164 for pattern in self.schema.iter() {
1165 let pat = pattern.trim();
1166
1167 let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
1169 if !is_glob {
1170 let p = PathBuf::from(pat);
1171 if p.exists() {
1172 files.push(p);
1173 continue;
1174 }
1175 }
1176
1177 let pat_norm = pat.replace('\\', "/");
1179 match glob::glob(&pat_norm) {
1180 Ok(paths) => {
1181 let matched: Vec<_> = paths.filter_map(Result::ok).collect();
1182 if matched.is_empty() && !is_glob {
1183 let p = PathBuf::from(&pat_norm);
1184 if p.exists() {
1185 files.push(p);
1186 }
1187 } else {
1188 files.extend(matched);
1189 }
1190 }
1191 Err(e) => return Err(Error::Glob(pat.into(), e)),
1192 }
1193 }
1194
1195 files.retain(|p| p.is_file());
1197 files.sort();
1198 files.dedup();
1199
1200 if files.is_empty() {
1201 return Err(Error::NoSchemaFiles(self.schema_display()));
1202 }
1203
1204 Ok(files)
1205 }
1206
1207 #[inline]
1209 #[must_use]
1210 pub fn effective_casing(&self) -> Casing {
1211 self.casing.unwrap_or_default()
1212 }
1213
1214 #[inline]
1216 #[must_use]
1217 pub fn effective_introspect_casing(&self) -> IntrospectCasing {
1218 self.introspect
1219 .as_ref()
1220 .map(|i| i.casing)
1221 .unwrap_or_default()
1222 }
1223
1224 #[inline]
1226 #[must_use]
1227 pub fn effective_entities(&self) -> EntitiesFilter {
1228 self.entities.clone().unwrap_or_default()
1229 }
1230
1231 #[must_use]
1233 pub fn should_include_role(&self, role_name: &str) -> bool {
1234 self.entities
1235 .as_ref()
1236 .is_some_and(|e| e.roles.should_include(role_name))
1237 }
1238
1239 #[must_use]
1241 pub fn roles_enabled(&self) -> bool {
1242 self.entities.as_ref().is_some_and(|e| e.roles.is_enabled())
1243 }
1244
1245 #[must_use]
1247 pub fn extensions(&self) -> &[Extension] {
1248 self.extensions_filters.as_deref().unwrap_or(&[])
1249 }
1250
1251 #[must_use]
1253 pub fn has_extension(&self, ext: Extension) -> bool {
1254 self.extensions_filters
1255 .as_ref()
1256 .is_some_and(|v| v.contains(&ext))
1257 }
1258
1259 #[must_use]
1261 pub fn migrations_table(&self) -> &str {
1262 self.migrations
1263 .as_ref()
1264 .and_then(|m| m.table.as_deref())
1265 .unwrap_or("__drizzle_migrations")
1266 }
1267
1268 #[must_use]
1270 pub fn migrations_schema(&self) -> &str {
1271 self.migrations
1272 .as_ref()
1273 .and_then(|m| m.schema.as_deref())
1274 .unwrap_or("drizzle")
1275 }
1276
1277 #[must_use]
1286 pub fn bundle_enabled(&self) -> bool {
1287 if let Some(explicit) = self.migrations.as_ref().and_then(|m| m.bundle) {
1288 return explicit;
1289 }
1290 matches!(self.driver, Some(Driver::DurableSqlite))
1291 }
1292}
1293
1294#[derive(Debug, Clone, Deserialize)]
1300struct MultiDbConfig {
1301 databases: HashMap<String, DatabaseConfig>,
1302}
1303
1304#[derive(Debug, Clone)]
1328pub struct Config {
1329 databases: HashMap<String, DatabaseConfig>,
1331 is_single: bool,
1333}
1334
1335pub const DEFAULT_DB: &str = "default";
1337
1338impl Config {
1339 pub fn load() -> Result<Self, Error> {
1346 Self::load_from(Path::new(CONFIG_FILE))
1347 }
1348
1349 pub fn load_from(path: &Path) -> Result<Self, Error> {
1357 let content = std::fs::read_to_string(path).map_err(|e| {
1358 if e.kind() == std::io::ErrorKind::NotFound {
1359 Error::NotFound(path.into())
1360 } else {
1361 Error::Io(path.into(), e)
1362 }
1363 })?;
1364
1365 Self::load_from_str(&content, path)
1366 }
1367
1368 fn load_from_str(content: &str, path: &Path) -> Result<Self, Error> {
1370 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
1371
1372 if let Ok(multi) = toml::from_str::<MultiDbConfig>(content)
1374 && !multi.databases.is_empty()
1375 {
1376 let mut config = Self {
1377 databases: multi.databases,
1378 is_single: false,
1379 };
1380 for db in config.databases.values_mut() {
1381 db.normalize_paths(base_dir);
1382 }
1383 config.validate()?;
1384 return Ok(config);
1385 }
1386
1387 let db_config: DatabaseConfig =
1389 toml::from_str(content).map_err(|e| Error::Parse(path.into(), e))?;
1390
1391 let mut databases = HashMap::new();
1392 databases.insert(DEFAULT_DB.to_string(), db_config);
1393
1394 let mut config = Self {
1395 databases,
1396 is_single: true,
1397 };
1398 for db in config.databases.values_mut() {
1399 db.normalize_paths(base_dir);
1400 }
1401 config.validate()?;
1402 Ok(config)
1403 }
1404
1405 fn validate(&self) -> Result<(), Error> {
1406 for (name, db) in &self.databases {
1407 db.validate(name)?;
1408 }
1409 Ok(())
1410 }
1411
1412 #[must_use]
1414 pub const fn is_single_database(&self) -> bool {
1415 self.is_single
1416 }
1417
1418 pub fn database_names(&self) -> impl Iterator<Item = &str> {
1420 self.databases.keys().map(String::as_str)
1421 }
1422
1423 pub fn database(&self, name: Option<&str>) -> Result<&DatabaseConfig, Error> {
1435 name.map_or_else(
1436 || {
1437 if self.is_single {
1439 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1440 } else if self.databases.len() == 1 {
1441 self.databases.values().next().ok_or(Error::NoDatabases)
1442 } else {
1443 Err(Error::DatabaseRequired(
1444 self.databases.keys().cloned().collect(),
1445 ))
1446 }
1447 },
1448 |name| {
1449 if self.is_single {
1450 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1452 } else {
1453 self.databases
1454 .get(name)
1455 .ok_or_else(|| Error::DatabaseNotFound(name.to_string()))
1456 }
1457 },
1458 )
1459 }
1460
1461 pub fn default_database(&self) -> Result<&DatabaseConfig, Error> {
1467 self.database(None)
1468 }
1469
1470 #[must_use]
1476 pub fn dialect(&self) -> Dialect {
1477 self.default_database()
1478 .map(|d| d.dialect)
1479 .unwrap_or_default()
1480 }
1481
1482 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1489 self.default_database()?.credentials()
1490 }
1491
1492 #[must_use]
1494 pub fn migrations_dir(&self) -> &Path {
1495 self.default_database()
1496 .map_or_else(|_| Path::new("./drizzle"), |d| d.migrations_dir())
1497 }
1498
1499 #[must_use]
1501 pub fn journal_path(&self) -> PathBuf {
1502 self.default_database().map_or_else(
1503 |_| PathBuf::from("./drizzle/meta/_journal.json"),
1504 DatabaseConfig::journal_path,
1505 )
1506 }
1507
1508 #[must_use]
1510 pub fn schema_display(&self) -> String {
1511 self.default_database()
1512 .map_or_else(|_| "src/schema.rs".into(), DatabaseConfig::schema_display)
1513 }
1514
1515 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1522 self.default_database()?.schema_files()
1523 }
1524
1525 #[must_use]
1527 pub fn base_dialect(&self) -> drizzle_types::Dialect {
1528 self.dialect().to_base()
1529 }
1530}
1531
1532#[derive(Debug, thiserror::Error)]
1537pub enum Error {
1538 #[error("config not found: {}", .0.display())]
1539 NotFound(PathBuf),
1540
1541 #[error("failed to read {}: {}", .0.display(), .1)]
1542 Io(PathBuf, #[source] std::io::Error),
1543
1544 #[error("failed to parse {}: {}", .0.display(), .1)]
1545 Parse(PathBuf, #[source] toml::de::Error),
1546
1547 #[error("driver '{driver}' invalid for {dialect} dialect")]
1548 InvalidDriver { driver: Driver, dialect: Dialect },
1549
1550 #[error("invalid credentials: {0}")]
1551 InvalidCredentials(String),
1552
1553 #[error("invalid config: {0}")]
1554 InvalidConfig(String),
1555
1556 #[error("invalid glob '{0}': {1}")]
1557 Glob(String, #[source] glob::PatternError),
1558
1559 #[error("no schema files found: {0}")]
1560 NoSchemaFiles(String),
1561
1562 #[error("environment variable '{0}' not found")]
1563 EnvNotFound(String),
1564
1565 #[error("environment variable '{0}' invalid: {1}")]
1566 EnvInvalid(String, String),
1567
1568 #[error("no databases configured")]
1569 NoDatabases,
1570
1571 #[error("database '{0}' not found")]
1572 DatabaseNotFound(String),
1573
1574 #[error("multiple databases configured, use --db to specify: {}", .0.join(", "))]
1575 DatabaseRequired(Vec<String>),
1576}
1577
1578impl From<EnvOrError> for Error {
1579 fn from(err: EnvOrError) -> Self {
1580 match err {
1581 EnvOrError::NotPresent(var) => Self::EnvNotFound(var),
1582 EnvOrError::NotUnicode(var) => Self::EnvInvalid(var, "contains invalid unicode".into()),
1583 }
1584 }
1585}
1586
1587#[cfg(test)]
1592mod tests {
1593 use super::*;
1594 use std::fs;
1595 use tempfile::TempDir;
1596
1597 #[test]
1598 fn sqlite() {
1599 let cfg = Config::load_from_str(
1600 r#"
1601 dialect = "sqlite"
1602 [dbCredentials]
1603 url = "./dev.db"
1604 "#,
1605 Path::new("test.toml"),
1606 )
1607 .unwrap();
1608 assert!(cfg.is_single_database());
1609 assert!(matches!(
1610 cfg.credentials().unwrap(),
1611 Some(Credentials::Sqlite { .. })
1612 ));
1613 }
1614
1615 #[test]
1616 fn postgres_url() {
1617 let cfg = Config::load_from_str(
1618 r#"
1619 dialect = "postgresql"
1620 [dbCredentials]
1621 url = "postgres://localhost/db"
1622 "#,
1623 Path::new("test.toml"),
1624 )
1625 .unwrap();
1626 assert!(matches!(
1627 cfg.credentials().unwrap(),
1628 Some(Credentials::Postgres(PostgresCreds::Url(_)))
1629 ));
1630 }
1631
1632 #[test]
1633 fn postgres_host_credentials_are_structured_without_url_encoding() {
1634 let credentials = PostgresCreds::Host {
1635 host: "db.example.com".into(),
1636 port: 5432,
1637 user: Some("user@tenant".into()),
1638 password: Some("p:a/ss% word".into()),
1639 database: "app/db name".into(),
1640 ssl: PostgresSslMode::VerifyFull,
1641 };
1642
1643 let connection = credentials.connection_config().expect("build config");
1644 assert_eq!(connection.config.get_user(), Some("user@tenant"));
1645 assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
1646 assert_eq!(connection.config.get_dbname(), Some("app/db name"));
1647 assert_eq!(
1648 connection.config.get_ssl_mode(),
1649 tokio_postgres::config::SslMode::Require
1650 );
1651 assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
1652 }
1653
1654 #[test]
1655 fn multi_database() {
1656 let cfg = Config::load_from_str(
1657 r#"
1658 [databases.dev]
1659 dialect = "sqlite"
1660 out = "./drizzle/sqlite"
1661 [databases.dev.dbCredentials]
1662 url = "./dev.db"
1663
1664 [databases.prod]
1665 dialect = "postgresql"
1666 out = "./drizzle/postgres"
1667 [databases.prod.dbCredentials]
1668 url = "postgres://localhost/db"
1669 "#,
1670 Path::new("test.toml"),
1671 )
1672 .unwrap();
1673
1674 assert!(!cfg.is_single_database());
1675 let names: Vec<_> = cfg.database_names().collect();
1676 assert!(names.contains(&"dev"));
1677 assert!(names.contains(&"prod"));
1678
1679 let dev = cfg.database(Some("dev")).unwrap();
1680 assert_eq!(dev.dialect, Dialect::Sqlite);
1681
1682 let prod = cfg.database(Some("prod")).unwrap();
1683 assert_eq!(prod.dialect, Dialect::Postgresql);
1684 }
1685
1686 #[test]
1687 fn multi_database_requires_selection() {
1688 let cfg = Config::load_from_str(
1689 r#"
1690 [databases.a]
1691 dialect = "sqlite"
1692 [databases.b]
1693 dialect = "postgresql"
1694 "#,
1695 Path::new("test.toml"),
1696 )
1697 .unwrap();
1698
1699 assert!(cfg.database(None).is_err());
1701 }
1702
1703 #[test]
1704 fn env_var_syntax() {
1705 let cfg = Config::load_from_str(
1706 r#"
1707 dialect = "postgresql"
1708 [dbCredentials]
1709 url = { env = "DATABASE_URL" }
1710 "#,
1711 Path::new("test.toml"),
1712 )
1713 .unwrap();
1714 assert!(cfg.is_single_database());
1715 }
1716
1717 #[test]
1718 fn casing_options() {
1719 let cfg = Config::load_from_str(
1720 r#"
1721 dialect = "postgresql"
1722 casing = "snake_case"
1723 [dbCredentials]
1724 url = "postgres://localhost/db"
1725 "#,
1726 Path::new("test.toml"),
1727 )
1728 .unwrap();
1729 let db = cfg.default_database().unwrap();
1730 assert_eq!(db.effective_casing(), Casing::SnakeCase);
1731
1732 let cfg2 = Config::load_from_str(
1734 r#"
1735 dialect = "postgresql"
1736 [dbCredentials]
1737 url = "postgres://localhost/db"
1738 "#,
1739 Path::new("test.toml"),
1740 )
1741 .unwrap();
1742 let db2 = cfg2.default_database().unwrap();
1743 assert_eq!(db2.effective_casing(), Casing::CamelCase);
1744 }
1745
1746 #[test]
1747 fn introspect_casing() {
1748 let cfg = Config::load_from_str(
1749 r#"
1750 dialect = "postgresql"
1751 [introspect]
1752 casing = "preserve"
1753 [dbCredentials]
1754 url = "postgres://localhost/db"
1755 "#,
1756 Path::new("test.toml"),
1757 )
1758 .unwrap();
1759 let db = cfg.default_database().unwrap();
1760 assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
1761 }
1762
1763 #[test]
1764 fn entities_roles_filter() {
1765 let cfg = Config::load_from_str(
1767 r#"
1768 dialect = "postgresql"
1769 [entities]
1770 roles = true
1771 [dbCredentials]
1772 url = "postgres://localhost/db"
1773 "#,
1774 Path::new("test.toml"),
1775 )
1776 .unwrap();
1777 let db = cfg.default_database().unwrap();
1778 assert!(db.roles_enabled());
1779 assert!(db.should_include_role("my_role"));
1780
1781 let cfg2 = Config::load_from_str(
1783 r#"
1784 dialect = "postgresql"
1785 [entities.roles]
1786 provider = "supabase"
1787 [dbCredentials]
1788 url = "postgres://localhost/db"
1789 "#,
1790 Path::new("test.toml"),
1791 )
1792 .unwrap();
1793 let db2 = cfg2.default_database().unwrap();
1794 assert!(db2.roles_enabled());
1795 assert!(!db2.should_include_role("anon")); assert!(db2.should_include_role("my_custom_role"));
1797 }
1798
1799 #[test]
1800 fn extensions_filter() {
1801 let cfg = Config::load_from_str(
1802 r#"
1803 dialect = "postgresql"
1804 extensionsFilters = ["postgis"]
1805 [dbCredentials]
1806 url = "postgres://localhost/db"
1807 "#,
1808 Path::new("test.toml"),
1809 )
1810 .unwrap();
1811 let db = cfg.default_database().unwrap();
1812 assert!(db.has_extension(Extension::Postgis));
1813 }
1814
1815 #[test]
1816 fn rejects_postgres_only_filters_for_sqlite() {
1817 let err = Config::load_from_str(
1818 r#"
1819 dialect = "sqlite"
1820 schemaFilter = ["public"]
1821 [dbCredentials]
1822 url = "./dev.db"
1823 "#,
1824 Path::new("test.toml"),
1825 )
1826 .expect_err("sqlite should reject schemaFilter");
1827 assert_eq!(
1828 err.to_string(),
1829 "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
1830 );
1831
1832 let err = Config::load_from_str(
1833 r#"
1834 dialect = "sqlite"
1835 extensionsFilters = ["postgis"]
1836 [dbCredentials]
1837 url = "./dev.db"
1838 "#,
1839 Path::new("test.toml"),
1840 )
1841 .expect_err("sqlite should reject extensionsFilters");
1842 assert_eq!(
1843 err.to_string(),
1844 "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
1845 );
1846 }
1847
1848 #[test]
1849 fn rejects_entities_filter_for_turso() {
1850 let err = Config::load_from_str(
1851 r#"
1852 dialect = "turso"
1853 [entities]
1854 roles = true
1855 [dbCredentials]
1856 url = "libsql://example.turso.io"
1857 "#,
1858 Path::new("test.toml"),
1859 )
1860 .expect_err("turso should reject entities filter");
1861 assert_eq!(
1862 err.to_string(),
1863 "invalid config: entities filter is only supported for dialect = \"postgresql\""
1864 );
1865 }
1866
1867 #[test]
1868 fn migrations_config() {
1869 let cfg = Config::load_from_str(
1870 r#"
1871 dialect = "postgresql"
1872 [migrations]
1873 table = "custom_migrations"
1874 schema = "custom_schema"
1875 [dbCredentials]
1876 url = "postgres://localhost/db"
1877 "#,
1878 Path::new("test.toml"),
1879 )
1880 .unwrap();
1881 let db = cfg.default_database().unwrap();
1882 assert_eq!(db.migrations_table(), "custom_migrations");
1883 assert_eq!(db.migrations_schema(), "custom_schema");
1884
1885 let cfg2 = Config::load_from_str(
1887 r#"
1888 dialect = "postgresql"
1889 [dbCredentials]
1890 url = "postgres://localhost/db"
1891 "#,
1892 Path::new("test.toml"),
1893 )
1894 .unwrap();
1895 let db2 = cfg2.default_database().unwrap();
1896 assert_eq!(db2.migrations_table(), "__drizzle_migrations");
1897 assert_eq!(db2.migrations_schema(), "drizzle");
1898 }
1899
1900 #[test]
1901 fn resolves_paths_relative_to_config_dir() {
1902 let tmp = TempDir::new().unwrap();
1903 let cfg_dir = tmp.path().join("cfg");
1904 fs::create_dir_all(&cfg_dir).unwrap();
1905
1906 let schema_path = cfg_dir.join("schema.rs");
1908 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
1909
1910 let cfg_path = cfg_dir.join("drizzle.config.toml");
1911 let cfg = Config::load_from_str(
1912 r#"
1913 dialect = "sqlite"
1914 schema = "schema.rs"
1915 out = "./drizzle"
1916 [dbCredentials]
1917 url = "./dev.db"
1918 "#,
1919 &cfg_path,
1920 )
1921 .unwrap();
1922
1923 let db = cfg.default_database().unwrap();
1924 assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
1925
1926 let files = db.schema_files().unwrap();
1927 assert_eq!(files.len(), 1);
1928 assert_eq!(files[0], schema_path);
1929 }
1930
1931 #[test]
1932 fn rejects_host_credentials_for_sqlite() {
1933 let err = Config::load_from_str(
1934 r#"
1935 dialect = "sqlite"
1936 [dbCredentials]
1937 host = "localhost"
1938 database = "db"
1939 "#,
1940 Path::new("test.toml"),
1941 )
1942 .unwrap_err();
1943
1944 assert_eq!(
1945 err.to_string(),
1946 "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\""
1947 );
1948 }
1949
1950 #[test]
1955 fn d1_http_credentials_parse() {
1956 let cfg = Config::load_from_str(
1957 r#"
1958 dialect = "sqlite"
1959 driver = "d1-http"
1960 [dbCredentials]
1961 accountId = "acc_abc"
1962 databaseId = "db_xyz"
1963 token = "tok_123"
1964 "#,
1965 Path::new("test.toml"),
1966 )
1967 .unwrap();
1968
1969 let db = cfg.default_database().unwrap();
1970 assert_eq!(db.driver, Some(Driver::D1Http));
1971 match db.credentials().unwrap() {
1972 Some(Credentials::D1 {
1973 account_id,
1974 database_id,
1975 token,
1976 }) => {
1977 assert_eq!(&*account_id, "acc_abc");
1978 assert_eq!(&*database_id, "db_xyz");
1979 assert_eq!(&*token, "tok_123");
1980 }
1981 other => panic!("expected Credentials::D1, got {other:?}"),
1982 }
1983 }
1984
1985 #[test]
1986 fn d1_http_credentials_resolve_from_env() {
1987 unsafe {
1989 std::env::set_var("TEST_D1_ACCT", "env_acct");
1990 std::env::set_var("TEST_D1_DB", "env_db");
1991 std::env::set_var("TEST_D1_TOKEN", "env_token");
1992 }
1993 let cfg = Config::load_from_str(
1994 r#"
1995 dialect = "sqlite"
1996 driver = "d1-http"
1997 [dbCredentials]
1998 accountId = { env = "TEST_D1_ACCT" }
1999 databaseId = { env = "TEST_D1_DB" }
2000 token = { env = "TEST_D1_TOKEN" }
2001 "#,
2002 Path::new("test.toml"),
2003 )
2004 .unwrap();
2005
2006 match cfg.default_database().unwrap().credentials().unwrap() {
2007 Some(Credentials::D1 {
2008 account_id,
2009 database_id,
2010 token,
2011 }) => {
2012 assert_eq!(&*account_id, "env_acct");
2013 assert_eq!(&*database_id, "env_db");
2014 assert_eq!(&*token, "env_token");
2015 }
2016 other => panic!("expected Credentials::D1, got {other:?}"),
2017 }
2018 }
2019
2020 #[test]
2021 fn d1_credentials_require_sqlite_dialect() {
2022 let err = Config::load_from_str(
2023 r#"
2024 dialect = "postgresql"
2025 [dbCredentials]
2026 accountId = "acc"
2027 databaseId = "db"
2028 token = "tok"
2029 "#,
2030 Path::new("test.toml"),
2031 )
2032 .unwrap_err();
2033 assert!(
2034 err.to_string().contains("D1 dbCredentials"),
2035 "expected D1-specific error, got: {err}"
2036 );
2037 }
2038
2039 #[test]
2040 fn d1_credentials_require_d1_http_driver() {
2041 let err = Config::load_from_str(
2043 r#"
2044 dialect = "sqlite"
2045 driver = "rusqlite"
2046 [dbCredentials]
2047 accountId = "acc"
2048 databaseId = "db"
2049 token = "tok"
2050 "#,
2051 Path::new("test.toml"),
2052 )
2053 .unwrap_err();
2054 assert!(
2055 err.to_string().contains("driver = \"d1-http\""),
2056 "expected d1-http driver error, got: {err}"
2057 );
2058 }
2059
2060 #[test]
2061 fn d1_http_driver_requires_d1_credentials() {
2062 let err = Config::load_from_str(
2064 r#"
2065 dialect = "sqlite"
2066 driver = "d1-http"
2067 [dbCredentials]
2068 url = "./dev.db"
2069 "#,
2070 Path::new("test.toml"),
2071 )
2072 .unwrap_err();
2073 assert!(
2074 err.to_string().contains("accountId, databaseId, and token"),
2075 "expected d1-http creds-shape error, got: {err}"
2076 );
2077 }
2078
2079 #[test]
2080 fn durable_sqlite_no_credentials_ok() {
2081 let cfg = Config::load_from_str(
2084 r#"
2085 dialect = "sqlite"
2086 driver = "durable-sqlite"
2087 "#,
2088 Path::new("test.toml"),
2089 )
2090 .unwrap();
2091
2092 let db = cfg.default_database().unwrap();
2093 assert_eq!(db.driver, Some(Driver::DurableSqlite));
2094 assert!(db.credentials().unwrap().is_none());
2095 assert!(
2097 db.bundle_enabled(),
2098 "durable-sqlite should auto-enable bundle"
2099 );
2100 }
2101
2102 #[test]
2103 fn durable_sqlite_explicit_bundle_false_respected() {
2104 let cfg = Config::load_from_str(
2106 r#"
2107 dialect = "sqlite"
2108 driver = "durable-sqlite"
2109 [migrations]
2110 bundle = false
2111 "#,
2112 Path::new("test.toml"),
2113 )
2114 .unwrap();
2115 assert!(!cfg.default_database().unwrap().bundle_enabled());
2116 }
2117
2118 #[test]
2119 fn durable_sqlite_rejects_non_sqlite_dialect() {
2120 let err = Config::load_from_str(
2121 r#"
2122 dialect = "postgresql"
2123 driver = "durable-sqlite"
2124 [dbCredentials]
2125 url = "postgres://localhost/db"
2126 "#,
2127 Path::new("test.toml"),
2128 )
2129 .unwrap_err();
2130 assert!(
2131 err.to_string().contains("invalid for postgresql"),
2132 "expected dialect/driver mismatch error, got: {err}"
2133 );
2134 }
2135
2136 #[test]
2137 fn driver_valid_for_sqlite_includes_cloudflare() {
2138 let drivers = Driver::valid_for(Dialect::Sqlite);
2139 assert!(drivers.contains(&Driver::Rusqlite));
2140 assert!(drivers.contains(&Driver::D1Http));
2141 assert!(drivers.contains(&Driver::DurableSqlite));
2142 for drv in [Driver::D1Http, Driver::DurableSqlite] {
2144 assert!(!drv.is_valid_for(Dialect::Postgresql));
2145 assert!(!drv.is_valid_for(Dialect::Turso));
2146 }
2147 }
2148
2149 #[test]
2150 fn driver_is_codegen_only_flag() {
2151 assert!(Driver::DurableSqlite.is_codegen_only());
2152 assert!(!Driver::D1Http.is_codegen_only());
2153 assert!(!Driver::Rusqlite.is_codegen_only());
2154 assert!(!Driver::AwsDataApi.is_codegen_only());
2155 }
2156
2157 #[test]
2162 fn aws_data_api_credentials_parse() {
2163 let cfg = Config::load_from_str(
2164 r#"
2165 dialect = "postgresql"
2166 driver = "aws-data-api"
2167 [dbCredentials]
2168 database = "mydb"
2169 secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2170 resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2171 "#,
2172 Path::new("test.toml"),
2173 )
2174 .unwrap();
2175
2176 let db = cfg.default_database().unwrap();
2177 assert_eq!(db.driver, Some(Driver::AwsDataApi));
2178 match db.credentials().unwrap() {
2179 Some(Credentials::AwsDataApi {
2180 database,
2181 secret_arn,
2182 resource_arn,
2183 }) => {
2184 assert_eq!(&*database, "mydb");
2185 assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2186 assert!(resource_arn.starts_with("arn:aws:rds"));
2187 }
2188 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2189 }
2190 }
2191
2192 #[test]
2193 fn aws_data_api_credentials_resolve_from_env() {
2194 unsafe {
2195 std::env::set_var("TEST_AWS_DB", "envdb");
2196 std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2197 std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2198 }
2199 let cfg = Config::load_from_str(
2200 r#"
2201 dialect = "postgresql"
2202 driver = "aws-data-api"
2203 [dbCredentials]
2204 database = { env = "TEST_AWS_DB" }
2205 secretArn = { env = "TEST_AWS_SECRET" }
2206 resourceArn = { env = "TEST_AWS_RESOURCE" }
2207 "#,
2208 Path::new("test.toml"),
2209 )
2210 .unwrap();
2211
2212 match cfg.default_database().unwrap().credentials().unwrap() {
2213 Some(Credentials::AwsDataApi {
2214 database,
2215 secret_arn,
2216 resource_arn,
2217 }) => {
2218 assert_eq!(&*database, "envdb");
2219 assert_eq!(&*secret_arn, "arn:env:secret");
2220 assert_eq!(&*resource_arn, "arn:env:resource");
2221 }
2222 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2223 }
2224 }
2225
2226 #[test]
2227 fn aws_data_api_requires_postgres_dialect() {
2228 let err = Config::load_from_str(
2229 r#"
2230 dialect = "sqlite"
2231 [dbCredentials]
2232 database = "mydb"
2233 secretArn = "arn:aws:secretsmanager:..."
2234 resourceArn = "arn:aws:rds:..."
2235 "#,
2236 Path::new("test.toml"),
2237 )
2238 .unwrap_err();
2239 assert!(
2240 err.to_string().contains("AWS Data API dbCredentials"),
2241 "expected AWS-specific error, got: {err}"
2242 );
2243 }
2244
2245 #[test]
2246 fn aws_data_api_requires_aws_data_api_driver() {
2247 let err = Config::load_from_str(
2249 r#"
2250 dialect = "postgresql"
2251 driver = "tokio-postgres"
2252 [dbCredentials]
2253 database = "mydb"
2254 secretArn = "arn:aws:secretsmanager:..."
2255 resourceArn = "arn:aws:rds:..."
2256 "#,
2257 Path::new("test.toml"),
2258 )
2259 .unwrap_err();
2260 assert!(
2261 err.to_string().contains("driver = \"aws-data-api\""),
2262 "expected aws-data-api driver error, got: {err}"
2263 );
2264 }
2265
2266 #[test]
2267 fn aws_data_api_driver_requires_aws_credentials() {
2268 let err = Config::load_from_str(
2270 r#"
2271 dialect = "postgresql"
2272 driver = "aws-data-api"
2273 [dbCredentials]
2274 url = "postgres://localhost/db"
2275 "#,
2276 Path::new("test.toml"),
2277 )
2278 .unwrap_err();
2279 assert!(
2280 err.to_string()
2281 .contains("database, secretArn, and resourceArn"),
2282 "expected aws-data-api creds-shape error, got: {err}"
2283 );
2284 }
2285
2286 #[test]
2287 fn aws_data_api_rejected_for_non_postgres_dialect() {
2288 let err = Config::load_from_str(
2289 r#"
2290 dialect = "sqlite"
2291 driver = "aws-data-api"
2292 "#,
2293 Path::new("test.toml"),
2294 )
2295 .unwrap_err();
2296 assert!(
2297 err.to_string().contains("invalid for sqlite"),
2298 "expected dialect/driver mismatch error, got: {err}"
2299 );
2300 }
2301
2302 #[test]
2303 fn driver_valid_for_postgres_includes_aws_data_api() {
2304 let drivers = Driver::valid_for(Dialect::Postgresql);
2305 assert!(drivers.contains(&Driver::PostgresSync));
2306 assert!(drivers.contains(&Driver::TokioPostgres));
2307 assert!(drivers.contains(&Driver::AwsDataApi));
2308 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2310 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2311 }
2312
2313 #[test]
2314 fn credentials_debug_redacts_secrets() {
2315 let postgres = Credentials::Postgres(PostgresCreds::Host {
2316 host: "localhost".into(),
2317 port: 5432,
2318 user: Some("alice".into()),
2319 password: Some("super-secret-password".into()),
2320 database: "app".into(),
2321 ssl: PostgresSslMode::Require,
2322 });
2323 let turso = Credentials::Turso {
2324 url: "libsql://example.turso.io".into(),
2325 auth_token: Some("super-secret-token".into()),
2326 };
2327 let d1 = Credentials::D1 {
2328 account_id: "account".into(),
2329 database_id: "database".into(),
2330 token: "super-secret-d1-token".into(),
2331 };
2332 let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2333 "postgres://alice:super-secret-url-password@localhost/app".into(),
2334 ));
2335
2336 let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2337 assert!(rendered.contains("[REDACTED]"));
2338 assert!(!rendered.contains("super-secret"));
2339 }
2340
2341 #[cfg(windows)]
2342 #[test]
2343 fn schema_files_accept_backslash_paths() {
2344 let tmp = TempDir::new().unwrap();
2345 let cfg_dir = tmp.path().join("cfg");
2346 fs::create_dir_all(&cfg_dir).unwrap();
2347
2348 let schema_path = cfg_dir.join("src").join("schema.rs");
2349 fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2350 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2351
2352 let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2354 let schema_toml = schema_str.replace('\\', "\\\\");
2356 let cfg_path = cfg_dir.join("drizzle.config.toml");
2357 let cfg = Config::load_from_str(
2358 &format!(
2359 r#"
2360 dialect = "sqlite"
2361 schema = "{}"
2362 "#,
2363 schema_toml
2364 ),
2365 &cfg_path,
2366 )
2367 .unwrap();
2368
2369 let db = cfg.default_database().unwrap();
2370 let files = db.schema_files().unwrap();
2371 assert_eq!(files, vec![schema_path]);
2372 }
2373}