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
574#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
575pub struct PostgresConnectionConfig {
576 pub config: tokio_postgres::Config,
577 pub ssl: PostgresSslMode,
578}
579
580#[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
581impl PostgresCreds {
582 pub fn connection_config(&self) -> Result<PostgresConnectionConfig, String> {
583 match self {
584 Self::Url(url) => {
585 let config = url
586 .parse::<tokio_postgres::Config>()
587 .map_err(|error| error.to_string())?;
588 let ssl = match config.get_ssl_mode() {
589 tokio_postgres::config::SslMode::Disable => PostgresSslMode::Disable,
590 tokio_postgres::config::SslMode::Prefer => PostgresSslMode::Prefer,
591 tokio_postgres::config::SslMode::Require => PostgresSslMode::Require,
592 _ => PostgresSslMode::Require,
593 };
594 Ok(PostgresConnectionConfig { config, ssl })
595 }
596 Self::Host {
597 host,
598 port,
599 user,
600 password,
601 database,
602 ssl,
603 } => {
604 let mut config = tokio_postgres::Config::new();
605 config
606 .host(host.as_ref())
607 .port(*port)
608 .dbname(database.as_ref());
609 if let Some(user) = user {
610 config.user(user.as_ref());
611 }
612 if let Some(password) = password {
613 config.password(password.as_bytes());
614 }
615 config.ssl_mode(match ssl {
616 PostgresSslMode::Disable => tokio_postgres::config::SslMode::Disable,
617 PostgresSslMode::Allow | PostgresSslMode::Prefer => {
618 tokio_postgres::config::SslMode::Prefer
619 }
620 PostgresSslMode::Require
621 | PostgresSslMode::VerifyCa
622 | PostgresSslMode::VerifyFull => tokio_postgres::config::SslMode::Require,
623 });
624 Ok(PostgresConnectionConfig { config, ssl: *ssl })
625 }
626 }
627 }
628}
629
630#[derive(Debug, Clone, Deserialize, JsonSchema)]
636#[serde(untagged)]
637pub enum Schema {
638 One(String),
639 Many(Vec<String>),
640}
641
642impl Default for Schema {
643 fn default() -> Self {
644 Self::One("src/schema.rs".into())
645 }
646}
647
648impl Schema {
649 pub fn iter(&self) -> impl Iterator<Item = &str> {
650 match self {
651 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
652 Self::Many(v) => v.iter().map(String::as_str),
653 }
654 }
655}
656
657#[derive(Debug, Clone, Deserialize, JsonSchema)]
659#[serde(untagged)]
660pub enum Filter {
661 One(String),
662 Many(Vec<String>),
663}
664
665impl Filter {
666 pub fn iter(&self) -> impl Iterator<Item = &str> {
667 match self {
668 Self::One(s) => std::slice::from_ref(s).iter().map(String::as_str),
669 Self::Many(v) => v.iter().map(String::as_str),
670 }
671 }
672}
673
674#[derive(Debug, Clone, Deserialize, JsonSchema)]
676pub struct MigrationsOpts {
677 pub table: Option<String>,
678 pub schema: Option<String>,
679 pub prefix: Option<MigrationPrefix>,
680 #[serde(default)]
687 pub bundle: Option<bool>,
688}
689
690#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
691#[serde(rename_all = "lowercase")]
692pub enum MigrationPrefix {
693 Index,
694 Timestamp,
695 Supabase,
696 Unix,
697 None,
698}
699
700#[derive(Debug, Clone, Deserialize, JsonSchema)]
705#[serde(untagged)]
706enum RawCreds {
707 D1 {
714 #[serde(rename = "accountId")]
715 account_id: EnvOr,
716 #[serde(rename = "databaseId")]
717 database_id: EnvOr,
718 token: EnvOr,
719 },
720 AwsDataApi {
727 database: EnvOr,
728 #[serde(rename = "secretArn")]
729 secret_arn: EnvOr,
730 #[serde(rename = "resourceArn")]
731 resource_arn: EnvOr,
732 },
733 Url {
734 url: EnvOr,
735 #[serde(default, rename = "authToken")]
736 auth_token: Option<EnvOr>,
737 },
738 Host {
739 host: EnvOr,
740 #[serde(default)]
741 port: Option<u16>,
742 #[serde(default)]
743 user: Option<EnvOr>,
744 #[serde(default)]
745 password: Option<EnvOr>,
746 database: EnvOr,
747 #[serde(default)]
748 ssl: Option<SslVal>,
749 },
750}
751
752#[derive(Debug, Clone, Deserialize, JsonSchema)]
753#[serde(untagged)]
754enum SslVal {
755 Bool(bool),
756 Str(String),
757}
758
759impl SslVal {
760 fn mode(&self) -> Result<PostgresSslMode, String> {
761 match self {
762 Self::Bool(false) => Ok(PostgresSslMode::Disable),
763 Self::Bool(true) => Ok(PostgresSslMode::Require),
764 Self::Str(value) => PostgresSslMode::parse(value),
765 }
766 }
767}
768
769#[derive(Debug, Clone, Deserialize, JsonSchema)]
777#[serde(rename_all = "camelCase")]
778pub struct DatabaseConfig {
779 pub dialect: Dialect,
781
782 #[serde(default)]
784 pub schema: Schema,
785
786 #[serde(default = "default_out")]
788 pub out: PathBuf,
789
790 #[serde(default = "yes")]
792 pub breakpoints: bool,
793
794 #[serde(default)]
796 pub driver: Option<Driver>,
797
798 #[serde(default)]
800 db_credentials: Option<RawCreds>,
801
802 #[serde(default)]
804 pub tables_filter: Option<Filter>,
805
806 #[serde(default)]
808 pub schema_filter: Option<Filter>,
809
810 #[serde(default)]
812 pub extensions_filters: Option<Vec<Extension>>,
813
814 #[serde(default)]
816 pub entities: Option<EntitiesFilter>,
817
818 #[serde(default)]
820 pub casing: Option<Casing>,
821
822 #[serde(default)]
824 pub introspect: Option<IntrospectConfig>,
825
826 #[serde(default)]
828 pub verbose: bool,
829
830 #[serde(default)]
832 pub migrations: Option<MigrationsOpts>,
833}
834
835fn default_out() -> PathBuf {
836 PathBuf::from("./drizzle")
837}
838
839const fn yes() -> bool {
840 true
841}
842
843impl DatabaseConfig {
844 fn normalize_paths(&mut self, base_dir: &Path) {
845 if self.out.is_relative() {
848 self.out = base_dir.join(&self.out);
849 }
850
851 let base = base_dir.to_string_lossy().replace('\\', "/");
855 let base = base.trim_end_matches('/').to_string();
856
857 let normalize_one = |p: &str| -> String {
858 let p_trim = p.trim();
859 let is_abs = Path::new(p_trim).is_absolute() || p_trim.starts_with("\\\\");
860 let joined = if is_abs || base.is_empty() || base == "." {
861 p_trim.to_string()
862 } else {
863 format!("{base}/{p_trim}")
864 };
865 joined.replace('\\', "/")
866 };
867
868 match &mut self.schema {
869 Schema::One(p) => *p = normalize_one(p),
870 Schema::Many(v) => {
871 for p in v.iter_mut() {
872 *p = normalize_one(p);
873 }
874 }
875 }
876 }
877
878 fn validate(&self, name: &str) -> Result<(), Error> {
879 if let Some(d) = self.driver
881 && !d.is_valid_for(self.dialect)
882 {
883 return Err(Error::InvalidDriver {
884 driver: d,
885 dialect: self.dialect,
886 });
887 }
888
889 if let Some(ref raw) = self.db_credentials {
891 self.validate_creds(raw, name)?;
892 }
893
894 if self.dialect != Dialect::Postgresql {
896 if self.schema_filter.is_some() {
897 return Err(Error::InvalidConfig(
898 "schemaFilter is only supported for dialect = \"postgresql\"".into(),
899 ));
900 }
901 if self.extensions_filters.is_some() {
902 return Err(Error::InvalidConfig(
903 "extensionsFilters is only supported for dialect = \"postgresql\"".into(),
904 ));
905 }
906 if self.entities.is_some() {
907 return Err(Error::InvalidConfig(
908 "entities filter is only supported for dialect = \"postgresql\"".into(),
909 ));
910 }
911 }
912
913 Ok(())
914 }
915
916 fn validate_creds(&self, raw: &RawCreds, _name: &str) -> Result<(), Error> {
917 let err = |msg: &str| Error::InvalidCredentials(msg.into());
918
919 match (self.dialect, raw) {
922 (Dialect::Postgresql, RawCreds::Host { .. } | RawCreds::Url { .. }) => {}
923 (_, RawCreds::Host { .. }) => {
924 return Err(err(
925 "host-based dbCredentials are only supported for dialect = \"postgresql\"",
926 ));
927 }
928 _ => {}
929 }
930
931 if let RawCreds::D1 { .. } = raw {
935 if self.dialect != Dialect::Sqlite {
936 return Err(err(
937 "D1 dbCredentials (accountId/databaseId/token) require dialect = \"sqlite\"",
938 ));
939 }
940 if self.driver != Some(Driver::D1Http) {
941 return Err(err(
942 "D1 dbCredentials (accountId/databaseId/token) require driver = \"d1-http\"",
943 ));
944 }
945 }
946
947 if self.driver == Some(Driver::D1Http) && !matches!(raw, RawCreds::D1 { .. }) {
951 return Err(err(
952 "driver = \"d1-http\" requires dbCredentials with accountId, databaseId, and token",
953 ));
954 }
955
956 if let RawCreds::AwsDataApi { .. } = raw {
960 if self.dialect != Dialect::Postgresql {
961 return Err(err(
962 "AWS Data API dbCredentials (database/secretArn/resourceArn) require dialect = \"postgresql\"",
963 ));
964 }
965 if self.driver != Some(Driver::AwsDataApi) {
966 return Err(err(
967 "AWS Data API dbCredentials (database/secretArn/resourceArn) require driver = \"aws-data-api\"",
968 ));
969 }
970 }
971
972 if self.driver == Some(Driver::AwsDataApi) && !matches!(raw, RawCreds::AwsDataApi { .. }) {
974 return Err(err(
975 "driver = \"aws-data-api\" requires dbCredentials with database, secretArn, and resourceArn",
976 ));
977 }
978
979 match (self.dialect, raw) {
981 (
982 Dialect::Sqlite,
983 RawCreds::Url {
984 auth_token: Some(_),
985 ..
986 },
987 ) => Err(err(
988 "SQLite doesn't support authToken (use dialect = \"turso\")",
989 )),
990 (
991 Dialect::Sqlite,
992 RawCreds::Url {
993 url: EnvOr::Value(url),
994 ..
995 },
996 ) if url.starts_with("libsql://") => Err(err(
997 "libsql:// URLs require dialect = \"turso\" (for local SQLite files, use ./path.db)",
998 )),
999 (
1000 Dialect::Sqlite,
1001 RawCreds::Url {
1002 url: EnvOr::Value(url),
1003 ..
1004 },
1005 ) if url.starts_with("http://")
1006 || url.starts_with("https://")
1007 || url.starts_with("postgres://")
1008 || url.starts_with("postgresql://") =>
1009 {
1010 Err(err(
1011 "SQLite dbCredentials.url must be a local file path (not an http(s)/postgres URL)",
1012 ))
1013 }
1014 (
1015 Dialect::Turso,
1016 RawCreds::Url {
1017 url: EnvOr::Value(url),
1018 ..
1019 },
1020 ) if !url.starts_with("libsql://") && !url.starts_with("http") => {
1021 Err(err("Turso URL must start with libsql:// or http(s)://"))
1022 }
1023 (
1024 Dialect::Postgresql,
1025 RawCreds::Url {
1026 url: EnvOr::Value(url),
1027 ..
1028 },
1029 ) if !url.starts_with("postgres") => {
1030 Err(err("PostgreSQL URL must start with postgres://"))
1031 }
1032 _ => Ok(()),
1033 }
1034 }
1035
1036 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1044 let Some(raw) = self.db_credentials.as_ref() else {
1045 return Ok(None);
1046 };
1047
1048 let resolve_opt = |opt: &Option<EnvOr>| -> Result<Option<Box<str>>, Error> {
1050 match opt.as_ref() {
1051 None => Ok(None),
1052 Some(e) => Ok(Some(e.resolve()?.into_boxed_str())),
1053 }
1054 };
1055
1056 let creds = match (self.dialect, raw) {
1057 (
1061 Dialect::Sqlite,
1062 RawCreds::D1 {
1063 account_id,
1064 database_id,
1065 token,
1066 },
1067 ) => Credentials::D1 {
1068 account_id: account_id.resolve()?.into_boxed_str(),
1069 database_id: database_id.resolve()?.into_boxed_str(),
1070 token: token.resolve()?.into_boxed_str(),
1071 },
1072 (
1075 Dialect::Postgresql,
1076 RawCreds::AwsDataApi {
1077 database,
1078 secret_arn,
1079 resource_arn,
1080 },
1081 ) => Credentials::AwsDataApi {
1082 database: database.resolve()?.into_boxed_str(),
1083 secret_arn: secret_arn.resolve()?.into_boxed_str(),
1084 resource_arn: resource_arn.resolve()?.into_boxed_str(),
1085 },
1086 (Dialect::Sqlite, RawCreds::Url { url, .. }) => Credentials::Sqlite {
1088 path: url.resolve()?.into_boxed_str(),
1089 },
1090 (Dialect::Turso, RawCreds::Url { url, auth_token }) => Credentials::Turso {
1092 url: url.resolve()?.into_boxed_str(),
1093 auth_token: resolve_opt(auth_token)?,
1094 },
1095 (Dialect::Postgresql, RawCreds::Url { url, .. }) => {
1097 Credentials::Postgres(PostgresCreds::Url(url.resolve()?.into_boxed_str()))
1098 }
1099 (
1101 Dialect::Postgresql,
1102 RawCreds::Host {
1103 host,
1104 port,
1105 user,
1106 password,
1107 database,
1108 ssl,
1109 },
1110 ) => Credentials::Postgres(PostgresCreds::Host {
1111 host: host.resolve()?.into_boxed_str(),
1112 port: port.unwrap_or(5432),
1113 user: resolve_opt(user)?,
1114 password: resolve_opt(password)?,
1115 database: database.resolve()?.into_boxed_str(),
1116 ssl: ssl
1117 .as_ref()
1118 .map_or(Ok(PostgresSslMode::Disable), SslVal::mode)
1119 .map_err(Error::InvalidCredentials)?,
1120 }),
1121 _ => return Ok(None),
1122 };
1123
1124 Ok(Some(creds))
1125 }
1126
1127 #[inline]
1129 #[must_use]
1130 pub fn migrations_dir(&self) -> &Path {
1131 &self.out
1132 }
1133
1134 #[inline]
1136 #[must_use]
1137 pub fn meta_dir(&self) -> PathBuf {
1138 self.out.join("meta")
1139 }
1140
1141 #[inline]
1143 #[must_use]
1144 pub fn journal_path(&self) -> PathBuf {
1145 self.meta_dir().join("_journal.json")
1146 }
1147
1148 #[must_use]
1150 pub fn schema_display(&self) -> String {
1151 match &self.schema {
1152 Schema::One(s) => s.clone(),
1153 Schema::Many(v) => v.join(", "),
1154 }
1155 }
1156
1157 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1164 let mut files = Vec::new();
1165
1166 for pattern in self.schema.iter() {
1167 let pat = pattern.trim();
1168
1169 let is_glob = pat.contains('*') || pat.contains('?') || pat.contains('[');
1171 if !is_glob {
1172 let p = PathBuf::from(pat);
1173 if p.exists() {
1174 files.push(p);
1175 continue;
1176 }
1177 }
1178
1179 let pat_norm = pat.replace('\\', "/");
1181 match glob::glob(&pat_norm) {
1182 Ok(paths) => {
1183 let matched: Vec<_> = paths.filter_map(Result::ok).collect();
1184 if matched.is_empty() && !is_glob {
1185 let p = PathBuf::from(&pat_norm);
1186 if p.exists() {
1187 files.push(p);
1188 }
1189 } else {
1190 files.extend(matched);
1191 }
1192 }
1193 Err(e) => return Err(Error::Glob(pat.into(), e)),
1194 }
1195 }
1196
1197 files.retain(|p| p.is_file());
1199 files.sort();
1200 files.dedup();
1201
1202 if files.is_empty() {
1203 return Err(Error::NoSchemaFiles(self.schema_display()));
1204 }
1205
1206 Ok(files)
1207 }
1208
1209 #[inline]
1211 #[must_use]
1212 pub fn effective_casing(&self) -> Casing {
1213 self.casing.unwrap_or_default()
1214 }
1215
1216 #[inline]
1218 #[must_use]
1219 pub fn effective_introspect_casing(&self) -> IntrospectCasing {
1220 self.introspect
1221 .as_ref()
1222 .map(|i| i.casing)
1223 .unwrap_or_default()
1224 }
1225
1226 #[inline]
1228 #[must_use]
1229 pub fn effective_entities(&self) -> EntitiesFilter {
1230 self.entities.clone().unwrap_or_default()
1231 }
1232
1233 #[must_use]
1235 pub fn should_include_role(&self, role_name: &str) -> bool {
1236 self.entities
1237 .as_ref()
1238 .is_some_and(|e| e.roles.should_include(role_name))
1239 }
1240
1241 #[must_use]
1243 pub fn roles_enabled(&self) -> bool {
1244 self.entities.as_ref().is_some_and(|e| e.roles.is_enabled())
1245 }
1246
1247 #[must_use]
1249 pub fn extensions(&self) -> &[Extension] {
1250 self.extensions_filters.as_deref().unwrap_or(&[])
1251 }
1252
1253 #[must_use]
1255 pub fn has_extension(&self, ext: Extension) -> bool {
1256 self.extensions_filters
1257 .as_ref()
1258 .is_some_and(|v| v.contains(&ext))
1259 }
1260
1261 #[must_use]
1263 pub fn migrations_table(&self) -> &str {
1264 self.migrations
1265 .as_ref()
1266 .and_then(|m| m.table.as_deref())
1267 .unwrap_or("__drizzle_migrations")
1268 }
1269
1270 #[must_use]
1272 pub fn migrations_schema(&self) -> &str {
1273 self.migrations
1274 .as_ref()
1275 .and_then(|m| m.schema.as_deref())
1276 .unwrap_or("drizzle")
1277 }
1278
1279 #[must_use]
1288 pub fn bundle_enabled(&self) -> bool {
1289 if let Some(explicit) = self.migrations.as_ref().and_then(|m| m.bundle) {
1290 return explicit;
1291 }
1292 matches!(self.driver, Some(Driver::DurableSqlite))
1293 }
1294}
1295
1296#[derive(Debug, Clone, Deserialize)]
1302struct MultiDbConfig {
1303 databases: HashMap<String, DatabaseConfig>,
1304}
1305
1306#[derive(Debug, Clone)]
1330pub struct Config {
1331 databases: HashMap<String, DatabaseConfig>,
1333 is_single: bool,
1335}
1336
1337pub const DEFAULT_DB: &str = "default";
1339
1340impl Config {
1341 pub fn load() -> Result<Self, Error> {
1348 Self::load_from(Path::new(CONFIG_FILE))
1349 }
1350
1351 pub fn load_from(path: &Path) -> Result<Self, Error> {
1359 let content = std::fs::read_to_string(path).map_err(|e| {
1360 if e.kind() == std::io::ErrorKind::NotFound {
1361 Error::NotFound(path.into())
1362 } else {
1363 Error::Io(path.into(), e)
1364 }
1365 })?;
1366
1367 Self::load_from_str(&content, path)
1368 }
1369
1370 fn load_from_str(content: &str, path: &Path) -> Result<Self, Error> {
1372 let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
1373
1374 if let Ok(multi) = toml::from_str::<MultiDbConfig>(content)
1376 && !multi.databases.is_empty()
1377 {
1378 let mut config = Self {
1379 databases: multi.databases,
1380 is_single: false,
1381 };
1382 for db in config.databases.values_mut() {
1383 db.normalize_paths(base_dir);
1384 }
1385 config.validate()?;
1386 return Ok(config);
1387 }
1388
1389 let db_config: DatabaseConfig =
1391 toml::from_str(content).map_err(|e| Error::Parse(path.into(), e))?;
1392
1393 let mut databases = HashMap::new();
1394 databases.insert(DEFAULT_DB.to_string(), db_config);
1395
1396 let mut config = Self {
1397 databases,
1398 is_single: true,
1399 };
1400 for db in config.databases.values_mut() {
1401 db.normalize_paths(base_dir);
1402 }
1403 config.validate()?;
1404 Ok(config)
1405 }
1406
1407 fn validate(&self) -> Result<(), Error> {
1408 for (name, db) in &self.databases {
1409 db.validate(name)?;
1410 }
1411 Ok(())
1412 }
1413
1414 #[must_use]
1416 pub const fn is_single_database(&self) -> bool {
1417 self.is_single
1418 }
1419
1420 pub fn database_names(&self) -> impl Iterator<Item = &str> {
1422 self.databases.keys().map(String::as_str)
1423 }
1424
1425 pub fn database(&self, name: Option<&str>) -> Result<&DatabaseConfig, Error> {
1437 name.map_or_else(
1438 || {
1439 if self.is_single {
1441 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1442 } else if self.databases.len() == 1 {
1443 self.databases.values().next().ok_or(Error::NoDatabases)
1444 } else {
1445 Err(Error::DatabaseRequired(
1446 self.databases.keys().cloned().collect(),
1447 ))
1448 }
1449 },
1450 |name| {
1451 if self.is_single {
1452 self.databases.get(DEFAULT_DB).ok_or(Error::NoDatabases)
1454 } else {
1455 self.databases
1456 .get(name)
1457 .ok_or_else(|| Error::DatabaseNotFound(name.to_string()))
1458 }
1459 },
1460 )
1461 }
1462
1463 pub fn default_database(&self) -> Result<&DatabaseConfig, Error> {
1469 self.database(None)
1470 }
1471
1472 #[must_use]
1478 pub fn dialect(&self) -> Dialect {
1479 self.default_database()
1480 .map(|d| d.dialect)
1481 .unwrap_or_default()
1482 }
1483
1484 pub fn credentials(&self) -> Result<Option<Credentials>, Error> {
1491 self.default_database()?.credentials()
1492 }
1493
1494 #[must_use]
1496 pub fn migrations_dir(&self) -> &Path {
1497 self.default_database()
1498 .map_or_else(|_| Path::new("./drizzle"), |d| d.migrations_dir())
1499 }
1500
1501 #[must_use]
1503 pub fn journal_path(&self) -> PathBuf {
1504 self.default_database().map_or_else(
1505 |_| PathBuf::from("./drizzle/meta/_journal.json"),
1506 DatabaseConfig::journal_path,
1507 )
1508 }
1509
1510 #[must_use]
1512 pub fn schema_display(&self) -> String {
1513 self.default_database()
1514 .map_or_else(|_| "src/schema.rs".into(), DatabaseConfig::schema_display)
1515 }
1516
1517 pub fn schema_files(&self) -> Result<Vec<PathBuf>, Error> {
1524 self.default_database()?.schema_files()
1525 }
1526
1527 #[must_use]
1529 pub fn base_dialect(&self) -> drizzle_types::Dialect {
1530 self.dialect().to_base()
1531 }
1532}
1533
1534#[derive(Debug, thiserror::Error)]
1539pub enum Error {
1540 #[error("config not found: {}", .0.display())]
1541 NotFound(PathBuf),
1542
1543 #[error("failed to read {}: {}", .0.display(), .1)]
1544 Io(PathBuf, #[source] std::io::Error),
1545
1546 #[error("failed to parse {}: {}", .0.display(), .1)]
1547 Parse(PathBuf, #[source] toml::de::Error),
1548
1549 #[error("driver '{driver}' invalid for {dialect} dialect")]
1550 InvalidDriver { driver: Driver, dialect: Dialect },
1551
1552 #[error("invalid credentials: {0}")]
1553 InvalidCredentials(String),
1554
1555 #[error("invalid config: {0}")]
1556 InvalidConfig(String),
1557
1558 #[error("invalid glob '{0}': {1}")]
1559 Glob(String, #[source] glob::PatternError),
1560
1561 #[error("no schema files found: {0}")]
1562 NoSchemaFiles(String),
1563
1564 #[error("environment variable '{0}' not found")]
1565 EnvNotFound(String),
1566
1567 #[error("environment variable '{0}' invalid: {1}")]
1568 EnvInvalid(String, String),
1569
1570 #[error("no databases configured")]
1571 NoDatabases,
1572
1573 #[error("database '{0}' not found")]
1574 DatabaseNotFound(String),
1575
1576 #[error("multiple databases configured, use --db to specify: {}", .0.join(", "))]
1577 DatabaseRequired(Vec<String>),
1578}
1579
1580impl From<EnvOrError> for Error {
1581 fn from(err: EnvOrError) -> Self {
1582 match err {
1583 EnvOrError::NotPresent(var) => Self::EnvNotFound(var),
1584 EnvOrError::NotUnicode(var) => Self::EnvInvalid(var, "contains invalid unicode".into()),
1585 }
1586 }
1587}
1588
1589#[cfg(test)]
1594mod tests {
1595 use super::*;
1596 use std::fs;
1597 use tempfile::TempDir;
1598
1599 #[test]
1600 fn sqlite() {
1601 let cfg = Config::load_from_str(
1602 r#"
1603 dialect = "sqlite"
1604 [dbCredentials]
1605 url = "./dev.db"
1606 "#,
1607 Path::new("test.toml"),
1608 )
1609 .unwrap();
1610 assert!(cfg.is_single_database());
1611 assert!(matches!(
1612 cfg.credentials().unwrap(),
1613 Some(Credentials::Sqlite { .. })
1614 ));
1615 }
1616
1617 #[test]
1618 fn postgres_url() {
1619 let cfg = Config::load_from_str(
1620 r#"
1621 dialect = "postgresql"
1622 [dbCredentials]
1623 url = "postgres://localhost/db"
1624 "#,
1625 Path::new("test.toml"),
1626 )
1627 .unwrap();
1628 assert!(matches!(
1629 cfg.credentials().unwrap(),
1630 Some(Credentials::Postgres(PostgresCreds::Url(_)))
1631 ));
1632 }
1633
1634 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1635 #[test]
1636 fn postgres_host_credentials_are_structured_without_url_encoding() {
1637 let credentials = PostgresCreds::Host {
1638 host: "db.example.com".into(),
1639 port: 5432,
1640 user: Some("user@tenant".into()),
1641 password: Some("p:a/ss% word".into()),
1642 database: "app/db name".into(),
1643 ssl: PostgresSslMode::VerifyFull,
1644 };
1645
1646 let connection = credentials.connection_config().expect("build config");
1647 assert_eq!(connection.config.get_user(), Some("user@tenant"));
1648 assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
1649 assert_eq!(connection.config.get_dbname(), Some("app/db name"));
1650 assert_eq!(
1651 connection.config.get_ssl_mode(),
1652 tokio_postgres::config::SslMode::Require
1653 );
1654 assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
1655 }
1656
1657 #[test]
1658 fn multi_database() {
1659 let cfg = Config::load_from_str(
1660 r#"
1661 [databases.dev]
1662 dialect = "sqlite"
1663 out = "./drizzle/sqlite"
1664 [databases.dev.dbCredentials]
1665 url = "./dev.db"
1666
1667 [databases.prod]
1668 dialect = "postgresql"
1669 out = "./drizzle/postgres"
1670 [databases.prod.dbCredentials]
1671 url = "postgres://localhost/db"
1672 "#,
1673 Path::new("test.toml"),
1674 )
1675 .unwrap();
1676
1677 assert!(!cfg.is_single_database());
1678 let names: Vec<_> = cfg.database_names().collect();
1679 assert!(names.contains(&"dev"));
1680 assert!(names.contains(&"prod"));
1681
1682 let dev = cfg.database(Some("dev")).unwrap();
1683 assert_eq!(dev.dialect, Dialect::Sqlite);
1684
1685 let prod = cfg.database(Some("prod")).unwrap();
1686 assert_eq!(prod.dialect, Dialect::Postgresql);
1687 }
1688
1689 #[test]
1690 fn multi_database_requires_selection() {
1691 let cfg = Config::load_from_str(
1692 r#"
1693 [databases.a]
1694 dialect = "sqlite"
1695 [databases.b]
1696 dialect = "postgresql"
1697 "#,
1698 Path::new("test.toml"),
1699 )
1700 .unwrap();
1701
1702 assert!(cfg.database(None).is_err());
1704 }
1705
1706 #[test]
1707 fn env_var_syntax() {
1708 let cfg = Config::load_from_str(
1709 r#"
1710 dialect = "postgresql"
1711 [dbCredentials]
1712 url = { env = "DATABASE_URL" }
1713 "#,
1714 Path::new("test.toml"),
1715 )
1716 .unwrap();
1717 assert!(cfg.is_single_database());
1718 }
1719
1720 #[test]
1721 fn casing_options() {
1722 let cfg = Config::load_from_str(
1723 r#"
1724 dialect = "postgresql"
1725 casing = "snake_case"
1726 [dbCredentials]
1727 url = "postgres://localhost/db"
1728 "#,
1729 Path::new("test.toml"),
1730 )
1731 .unwrap();
1732 let db = cfg.default_database().unwrap();
1733 assert_eq!(db.effective_casing(), Casing::SnakeCase);
1734
1735 let cfg2 = Config::load_from_str(
1737 r#"
1738 dialect = "postgresql"
1739 [dbCredentials]
1740 url = "postgres://localhost/db"
1741 "#,
1742 Path::new("test.toml"),
1743 )
1744 .unwrap();
1745 let db2 = cfg2.default_database().unwrap();
1746 assert_eq!(db2.effective_casing(), Casing::CamelCase);
1747 }
1748
1749 #[test]
1750 fn introspect_casing() {
1751 let cfg = Config::load_from_str(
1752 r#"
1753 dialect = "postgresql"
1754 [introspect]
1755 casing = "preserve"
1756 [dbCredentials]
1757 url = "postgres://localhost/db"
1758 "#,
1759 Path::new("test.toml"),
1760 )
1761 .unwrap();
1762 let db = cfg.default_database().unwrap();
1763 assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
1764 }
1765
1766 #[test]
1767 fn entities_roles_filter() {
1768 let cfg = Config::load_from_str(
1770 r#"
1771 dialect = "postgresql"
1772 [entities]
1773 roles = true
1774 [dbCredentials]
1775 url = "postgres://localhost/db"
1776 "#,
1777 Path::new("test.toml"),
1778 )
1779 .unwrap();
1780 let db = cfg.default_database().unwrap();
1781 assert!(db.roles_enabled());
1782 assert!(db.should_include_role("my_role"));
1783
1784 let cfg2 = Config::load_from_str(
1786 r#"
1787 dialect = "postgresql"
1788 [entities.roles]
1789 provider = "supabase"
1790 [dbCredentials]
1791 url = "postgres://localhost/db"
1792 "#,
1793 Path::new("test.toml"),
1794 )
1795 .unwrap();
1796 let db2 = cfg2.default_database().unwrap();
1797 assert!(db2.roles_enabled());
1798 assert!(!db2.should_include_role("anon")); assert!(db2.should_include_role("my_custom_role"));
1800 }
1801
1802 #[test]
1803 fn extensions_filter() {
1804 let cfg = Config::load_from_str(
1805 r#"
1806 dialect = "postgresql"
1807 extensionsFilters = ["postgis"]
1808 [dbCredentials]
1809 url = "postgres://localhost/db"
1810 "#,
1811 Path::new("test.toml"),
1812 )
1813 .unwrap();
1814 let db = cfg.default_database().unwrap();
1815 assert!(db.has_extension(Extension::Postgis));
1816 }
1817
1818 #[test]
1819 fn rejects_postgres_only_filters_for_sqlite() {
1820 let err = Config::load_from_str(
1821 r#"
1822 dialect = "sqlite"
1823 schemaFilter = ["public"]
1824 [dbCredentials]
1825 url = "./dev.db"
1826 "#,
1827 Path::new("test.toml"),
1828 )
1829 .expect_err("sqlite should reject schemaFilter");
1830 assert_eq!(
1831 err.to_string(),
1832 "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
1833 );
1834
1835 let err = Config::load_from_str(
1836 r#"
1837 dialect = "sqlite"
1838 extensionsFilters = ["postgis"]
1839 [dbCredentials]
1840 url = "./dev.db"
1841 "#,
1842 Path::new("test.toml"),
1843 )
1844 .expect_err("sqlite should reject extensionsFilters");
1845 assert_eq!(
1846 err.to_string(),
1847 "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
1848 );
1849 }
1850
1851 #[test]
1852 fn rejects_entities_filter_for_turso() {
1853 let err = Config::load_from_str(
1854 r#"
1855 dialect = "turso"
1856 [entities]
1857 roles = true
1858 [dbCredentials]
1859 url = "libsql://example.turso.io"
1860 "#,
1861 Path::new("test.toml"),
1862 )
1863 .expect_err("turso should reject entities filter");
1864 assert_eq!(
1865 err.to_string(),
1866 "invalid config: entities filter is only supported for dialect = \"postgresql\""
1867 );
1868 }
1869
1870 #[test]
1871 fn migrations_config() {
1872 let cfg = Config::load_from_str(
1873 r#"
1874 dialect = "postgresql"
1875 [migrations]
1876 table = "custom_migrations"
1877 schema = "custom_schema"
1878 [dbCredentials]
1879 url = "postgres://localhost/db"
1880 "#,
1881 Path::new("test.toml"),
1882 )
1883 .unwrap();
1884 let db = cfg.default_database().unwrap();
1885 assert_eq!(db.migrations_table(), "custom_migrations");
1886 assert_eq!(db.migrations_schema(), "custom_schema");
1887
1888 let cfg2 = Config::load_from_str(
1890 r#"
1891 dialect = "postgresql"
1892 [dbCredentials]
1893 url = "postgres://localhost/db"
1894 "#,
1895 Path::new("test.toml"),
1896 )
1897 .unwrap();
1898 let db2 = cfg2.default_database().unwrap();
1899 assert_eq!(db2.migrations_table(), "__drizzle_migrations");
1900 assert_eq!(db2.migrations_schema(), "drizzle");
1901 }
1902
1903 #[test]
1904 fn resolves_paths_relative_to_config_dir() {
1905 let tmp = TempDir::new().unwrap();
1906 let cfg_dir = tmp.path().join("cfg");
1907 fs::create_dir_all(&cfg_dir).unwrap();
1908
1909 let schema_path = cfg_dir.join("schema.rs");
1911 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
1912
1913 let cfg_path = cfg_dir.join("drizzle.config.toml");
1914 let cfg = Config::load_from_str(
1915 r#"
1916 dialect = "sqlite"
1917 schema = "schema.rs"
1918 out = "./drizzle"
1919 [dbCredentials]
1920 url = "./dev.db"
1921 "#,
1922 &cfg_path,
1923 )
1924 .unwrap();
1925
1926 let db = cfg.default_database().unwrap();
1927 assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
1928
1929 let files = db.schema_files().unwrap();
1930 assert_eq!(files.len(), 1);
1931 assert_eq!(files[0], schema_path);
1932 }
1933
1934 #[test]
1935 fn rejects_host_credentials_for_sqlite() {
1936 let err = Config::load_from_str(
1937 r#"
1938 dialect = "sqlite"
1939 [dbCredentials]
1940 host = "localhost"
1941 database = "db"
1942 "#,
1943 Path::new("test.toml"),
1944 )
1945 .unwrap_err();
1946
1947 assert_eq!(
1948 err.to_string(),
1949 "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\""
1950 );
1951 }
1952
1953 #[test]
1958 fn d1_http_credentials_parse() {
1959 let cfg = Config::load_from_str(
1960 r#"
1961 dialect = "sqlite"
1962 driver = "d1-http"
1963 [dbCredentials]
1964 accountId = "acc_abc"
1965 databaseId = "db_xyz"
1966 token = "tok_123"
1967 "#,
1968 Path::new("test.toml"),
1969 )
1970 .unwrap();
1971
1972 let db = cfg.default_database().unwrap();
1973 assert_eq!(db.driver, Some(Driver::D1Http));
1974 match db.credentials().unwrap() {
1975 Some(Credentials::D1 {
1976 account_id,
1977 database_id,
1978 token,
1979 }) => {
1980 assert_eq!(&*account_id, "acc_abc");
1981 assert_eq!(&*database_id, "db_xyz");
1982 assert_eq!(&*token, "tok_123");
1983 }
1984 other => panic!("expected Credentials::D1, got {other:?}"),
1985 }
1986 }
1987
1988 #[test]
1989 fn d1_http_credentials_resolve_from_env() {
1990 unsafe {
1992 std::env::set_var("TEST_D1_ACCT", "env_acct");
1993 std::env::set_var("TEST_D1_DB", "env_db");
1994 std::env::set_var("TEST_D1_TOKEN", "env_token");
1995 }
1996 let cfg = Config::load_from_str(
1997 r#"
1998 dialect = "sqlite"
1999 driver = "d1-http"
2000 [dbCredentials]
2001 accountId = { env = "TEST_D1_ACCT" }
2002 databaseId = { env = "TEST_D1_DB" }
2003 token = { env = "TEST_D1_TOKEN" }
2004 "#,
2005 Path::new("test.toml"),
2006 )
2007 .unwrap();
2008
2009 match cfg.default_database().unwrap().credentials().unwrap() {
2010 Some(Credentials::D1 {
2011 account_id,
2012 database_id,
2013 token,
2014 }) => {
2015 assert_eq!(&*account_id, "env_acct");
2016 assert_eq!(&*database_id, "env_db");
2017 assert_eq!(&*token, "env_token");
2018 }
2019 other => panic!("expected Credentials::D1, got {other:?}"),
2020 }
2021 }
2022
2023 #[test]
2024 fn d1_credentials_require_sqlite_dialect() {
2025 let err = Config::load_from_str(
2026 r#"
2027 dialect = "postgresql"
2028 [dbCredentials]
2029 accountId = "acc"
2030 databaseId = "db"
2031 token = "tok"
2032 "#,
2033 Path::new("test.toml"),
2034 )
2035 .unwrap_err();
2036 assert!(
2037 err.to_string().contains("D1 dbCredentials"),
2038 "expected D1-specific error, got: {err}"
2039 );
2040 }
2041
2042 #[test]
2043 fn d1_credentials_require_d1_http_driver() {
2044 let err = Config::load_from_str(
2046 r#"
2047 dialect = "sqlite"
2048 driver = "rusqlite"
2049 [dbCredentials]
2050 accountId = "acc"
2051 databaseId = "db"
2052 token = "tok"
2053 "#,
2054 Path::new("test.toml"),
2055 )
2056 .unwrap_err();
2057 assert!(
2058 err.to_string().contains("driver = \"d1-http\""),
2059 "expected d1-http driver error, got: {err}"
2060 );
2061 }
2062
2063 #[test]
2064 fn d1_http_driver_requires_d1_credentials() {
2065 let err = Config::load_from_str(
2067 r#"
2068 dialect = "sqlite"
2069 driver = "d1-http"
2070 [dbCredentials]
2071 url = "./dev.db"
2072 "#,
2073 Path::new("test.toml"),
2074 )
2075 .unwrap_err();
2076 assert!(
2077 err.to_string().contains("accountId, databaseId, and token"),
2078 "expected d1-http creds-shape error, got: {err}"
2079 );
2080 }
2081
2082 #[test]
2083 fn durable_sqlite_no_credentials_ok() {
2084 let cfg = Config::load_from_str(
2087 r#"
2088 dialect = "sqlite"
2089 driver = "durable-sqlite"
2090 "#,
2091 Path::new("test.toml"),
2092 )
2093 .unwrap();
2094
2095 let db = cfg.default_database().unwrap();
2096 assert_eq!(db.driver, Some(Driver::DurableSqlite));
2097 assert!(db.credentials().unwrap().is_none());
2098 assert!(
2100 db.bundle_enabled(),
2101 "durable-sqlite should auto-enable bundle"
2102 );
2103 }
2104
2105 #[test]
2106 fn durable_sqlite_explicit_bundle_false_respected() {
2107 let cfg = Config::load_from_str(
2109 r#"
2110 dialect = "sqlite"
2111 driver = "durable-sqlite"
2112 [migrations]
2113 bundle = false
2114 "#,
2115 Path::new("test.toml"),
2116 )
2117 .unwrap();
2118 assert!(!cfg.default_database().unwrap().bundle_enabled());
2119 }
2120
2121 #[test]
2122 fn durable_sqlite_rejects_non_sqlite_dialect() {
2123 let err = Config::load_from_str(
2124 r#"
2125 dialect = "postgresql"
2126 driver = "durable-sqlite"
2127 [dbCredentials]
2128 url = "postgres://localhost/db"
2129 "#,
2130 Path::new("test.toml"),
2131 )
2132 .unwrap_err();
2133 assert!(
2134 err.to_string().contains("invalid for postgresql"),
2135 "expected dialect/driver mismatch error, got: {err}"
2136 );
2137 }
2138
2139 #[test]
2140 fn driver_valid_for_sqlite_includes_cloudflare() {
2141 let drivers = Driver::valid_for(Dialect::Sqlite);
2142 assert!(drivers.contains(&Driver::Rusqlite));
2143 assert!(drivers.contains(&Driver::D1Http));
2144 assert!(drivers.contains(&Driver::DurableSqlite));
2145 for drv in [Driver::D1Http, Driver::DurableSqlite] {
2147 assert!(!drv.is_valid_for(Dialect::Postgresql));
2148 assert!(!drv.is_valid_for(Dialect::Turso));
2149 }
2150 }
2151
2152 #[test]
2153 fn driver_is_codegen_only_flag() {
2154 assert!(Driver::DurableSqlite.is_codegen_only());
2155 assert!(!Driver::D1Http.is_codegen_only());
2156 assert!(!Driver::Rusqlite.is_codegen_only());
2157 assert!(!Driver::AwsDataApi.is_codegen_only());
2158 }
2159
2160 #[test]
2165 fn aws_data_api_credentials_parse() {
2166 let cfg = Config::load_from_str(
2167 r#"
2168 dialect = "postgresql"
2169 driver = "aws-data-api"
2170 [dbCredentials]
2171 database = "mydb"
2172 secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2173 resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2174 "#,
2175 Path::new("test.toml"),
2176 )
2177 .unwrap();
2178
2179 let db = cfg.default_database().unwrap();
2180 assert_eq!(db.driver, Some(Driver::AwsDataApi));
2181 match db.credentials().unwrap() {
2182 Some(Credentials::AwsDataApi {
2183 database,
2184 secret_arn,
2185 resource_arn,
2186 }) => {
2187 assert_eq!(&*database, "mydb");
2188 assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2189 assert!(resource_arn.starts_with("arn:aws:rds"));
2190 }
2191 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2192 }
2193 }
2194
2195 #[test]
2196 fn aws_data_api_credentials_resolve_from_env() {
2197 unsafe {
2198 std::env::set_var("TEST_AWS_DB", "envdb");
2199 std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2200 std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2201 }
2202 let cfg = Config::load_from_str(
2203 r#"
2204 dialect = "postgresql"
2205 driver = "aws-data-api"
2206 [dbCredentials]
2207 database = { env = "TEST_AWS_DB" }
2208 secretArn = { env = "TEST_AWS_SECRET" }
2209 resourceArn = { env = "TEST_AWS_RESOURCE" }
2210 "#,
2211 Path::new("test.toml"),
2212 )
2213 .unwrap();
2214
2215 match cfg.default_database().unwrap().credentials().unwrap() {
2216 Some(Credentials::AwsDataApi {
2217 database,
2218 secret_arn,
2219 resource_arn,
2220 }) => {
2221 assert_eq!(&*database, "envdb");
2222 assert_eq!(&*secret_arn, "arn:env:secret");
2223 assert_eq!(&*resource_arn, "arn:env:resource");
2224 }
2225 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2226 }
2227 }
2228
2229 #[test]
2230 fn aws_data_api_requires_postgres_dialect() {
2231 let err = Config::load_from_str(
2232 r#"
2233 dialect = "sqlite"
2234 [dbCredentials]
2235 database = "mydb"
2236 secretArn = "arn:aws:secretsmanager:..."
2237 resourceArn = "arn:aws:rds:..."
2238 "#,
2239 Path::new("test.toml"),
2240 )
2241 .unwrap_err();
2242 assert!(
2243 err.to_string().contains("AWS Data API dbCredentials"),
2244 "expected AWS-specific error, got: {err}"
2245 );
2246 }
2247
2248 #[test]
2249 fn aws_data_api_requires_aws_data_api_driver() {
2250 let err = Config::load_from_str(
2252 r#"
2253 dialect = "postgresql"
2254 driver = "tokio-postgres"
2255 [dbCredentials]
2256 database = "mydb"
2257 secretArn = "arn:aws:secretsmanager:..."
2258 resourceArn = "arn:aws:rds:..."
2259 "#,
2260 Path::new("test.toml"),
2261 )
2262 .unwrap_err();
2263 assert!(
2264 err.to_string().contains("driver = \"aws-data-api\""),
2265 "expected aws-data-api driver error, got: {err}"
2266 );
2267 }
2268
2269 #[test]
2270 fn aws_data_api_driver_requires_aws_credentials() {
2271 let err = Config::load_from_str(
2273 r#"
2274 dialect = "postgresql"
2275 driver = "aws-data-api"
2276 [dbCredentials]
2277 url = "postgres://localhost/db"
2278 "#,
2279 Path::new("test.toml"),
2280 )
2281 .unwrap_err();
2282 assert!(
2283 err.to_string()
2284 .contains("database, secretArn, and resourceArn"),
2285 "expected aws-data-api creds-shape error, got: {err}"
2286 );
2287 }
2288
2289 #[test]
2290 fn aws_data_api_rejected_for_non_postgres_dialect() {
2291 let err = Config::load_from_str(
2292 r#"
2293 dialect = "sqlite"
2294 driver = "aws-data-api"
2295 "#,
2296 Path::new("test.toml"),
2297 )
2298 .unwrap_err();
2299 assert!(
2300 err.to_string().contains("invalid for sqlite"),
2301 "expected dialect/driver mismatch error, got: {err}"
2302 );
2303 }
2304
2305 #[test]
2306 fn driver_valid_for_postgres_includes_aws_data_api() {
2307 let drivers = Driver::valid_for(Dialect::Postgresql);
2308 assert!(drivers.contains(&Driver::PostgresSync));
2309 assert!(drivers.contains(&Driver::TokioPostgres));
2310 assert!(drivers.contains(&Driver::AwsDataApi));
2311 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2313 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2314 }
2315
2316 #[test]
2317 fn credentials_debug_redacts_secrets() {
2318 let postgres = Credentials::Postgres(PostgresCreds::Host {
2319 host: "localhost".into(),
2320 port: 5432,
2321 user: Some("alice".into()),
2322 password: Some("super-secret-password".into()),
2323 database: "app".into(),
2324 ssl: PostgresSslMode::Require,
2325 });
2326 let turso = Credentials::Turso {
2327 url: "libsql://example.turso.io".into(),
2328 auth_token: Some("super-secret-token".into()),
2329 };
2330 let d1 = Credentials::D1 {
2331 account_id: "account".into(),
2332 database_id: "database".into(),
2333 token: "super-secret-d1-token".into(),
2334 };
2335 let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2336 "postgres://alice:super-secret-url-password@localhost/app".into(),
2337 ));
2338
2339 let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2340 assert!(rendered.contains("[REDACTED]"));
2341 assert!(!rendered.contains("super-secret"));
2342 }
2343
2344 #[cfg(windows)]
2345 #[test]
2346 fn schema_files_accept_backslash_paths() {
2347 let tmp = TempDir::new().unwrap();
2348 let cfg_dir = tmp.path().join("cfg");
2349 fs::create_dir_all(&cfg_dir).unwrap();
2350
2351 let schema_path = cfg_dir.join("src").join("schema.rs");
2352 fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2353 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2354
2355 let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2357 let schema_toml = schema_str.replace('\\', "\\\\");
2359 let cfg_path = cfg_dir.join("drizzle.config.toml");
2360 let cfg = Config::load_from_str(
2361 &format!(
2362 r#"
2363 dialect = "sqlite"
2364 schema = "{}"
2365 "#,
2366 schema_toml
2367 ),
2368 &cfg_path,
2369 )
2370 .unwrap();
2371
2372 let db = cfg.default_database().unwrap();
2373 let files = db.schema_files().unwrap();
2374 assert_eq!(files, vec![schema_path]);
2375 }
2376}