1pub use drizzle_types::{Casing, ConfigValue, ConfigValueError};
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: ConfigValue,
716 #[serde(rename = "databaseId")]
717 database_id: ConfigValue,
718 token: ConfigValue,
719 },
720 AwsDataApi {
727 database: ConfigValue,
728 #[serde(rename = "secretArn")]
729 secret_arn: ConfigValue,
730 #[serde(rename = "resourceArn")]
731 resource_arn: ConfigValue,
732 },
733 Url {
734 url: ConfigValue,
735 #[serde(default, rename = "authToken")]
736 auth_token: Option<ConfigValue>,
737 },
738 Host {
739 host: ConfigValue,
740 #[serde(default)]
741 port: Option<u16>,
742 #[serde(default)]
743 user: Option<ConfigValue>,
744 #[serde(default)]
745 password: Option<ConfigValue>,
746 database: ConfigValue,
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: ConfigValue::Inline(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: ConfigValue::Inline(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: ConfigValue::Inline(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: ConfigValue::Inline(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<ConfigValue>| -> 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<ConfigValueError> for Error {
1581 fn from(err: ConfigValueError) -> Self {
1582 match err {
1583 ConfigValueError::NotPresent(var) => Self::EnvNotFound(var),
1584 ConfigValueError::NotUnicode(var) => {
1585 Self::EnvInvalid(var, "contains invalid unicode".into())
1586 }
1587 }
1588 }
1589}
1590
1591#[cfg(test)]
1596mod tests {
1597 use super::*;
1598 use std::fs;
1599 use tempfile::TempDir;
1600
1601 #[test]
1602 fn sqlite() {
1603 let cfg = Config::load_from_str(
1604 r#"
1605 dialect = "sqlite"
1606 [dbCredentials]
1607 url = "./dev.db"
1608 "#,
1609 Path::new("test.toml"),
1610 )
1611 .unwrap();
1612 assert!(cfg.is_single_database());
1613 assert!(matches!(
1614 cfg.credentials().unwrap(),
1615 Some(Credentials::Sqlite { .. })
1616 ));
1617 }
1618
1619 #[test]
1620 fn postgres_url() {
1621 let cfg = Config::load_from_str(
1622 r#"
1623 dialect = "postgresql"
1624 [dbCredentials]
1625 url = "postgres://localhost/db"
1626 "#,
1627 Path::new("test.toml"),
1628 )
1629 .unwrap();
1630 assert!(matches!(
1631 cfg.credentials().unwrap(),
1632 Some(Credentials::Postgres(PostgresCreds::Url(_)))
1633 ));
1634 }
1635
1636 #[cfg(any(feature = "postgres-sync", feature = "tokio-postgres"))]
1637 #[test]
1638 fn postgres_host_credentials_are_structured_without_url_encoding() {
1639 let credentials = PostgresCreds::Host {
1640 host: "db.example.com".into(),
1641 port: 5432,
1642 user: Some("user@tenant".into()),
1643 password: Some("p:a/ss% word".into()),
1644 database: "app/db name".into(),
1645 ssl: PostgresSslMode::VerifyFull,
1646 };
1647
1648 let connection = credentials.connection_config().expect("build config");
1649 assert_eq!(connection.config.get_user(), Some("user@tenant"));
1650 assert_eq!(connection.config.get_password(), Some(&b"p:a/ss% word"[..]));
1651 assert_eq!(connection.config.get_dbname(), Some("app/db name"));
1652 assert_eq!(
1653 connection.config.get_ssl_mode(),
1654 tokio_postgres::config::SslMode::Require
1655 );
1656 assert_eq!(connection.ssl, PostgresSslMode::VerifyFull);
1657 }
1658
1659 #[test]
1660 fn multi_database() {
1661 let cfg = Config::load_from_str(
1662 r#"
1663 [databases.dev]
1664 dialect = "sqlite"
1665 out = "./drizzle/sqlite"
1666 [databases.dev.dbCredentials]
1667 url = "./dev.db"
1668
1669 [databases.prod]
1670 dialect = "postgresql"
1671 out = "./drizzle/postgres"
1672 [databases.prod.dbCredentials]
1673 url = "postgres://localhost/db"
1674 "#,
1675 Path::new("test.toml"),
1676 )
1677 .unwrap();
1678
1679 assert!(!cfg.is_single_database());
1680 let names: Vec<_> = cfg.database_names().collect();
1681 assert!(names.contains(&"dev"));
1682 assert!(names.contains(&"prod"));
1683
1684 let dev = cfg.database(Some("dev")).unwrap();
1685 assert_eq!(dev.dialect, Dialect::Sqlite);
1686
1687 let prod = cfg.database(Some("prod")).unwrap();
1688 assert_eq!(prod.dialect, Dialect::Postgresql);
1689 }
1690
1691 #[test]
1692 fn multi_database_requires_selection() {
1693 let cfg = Config::load_from_str(
1694 r#"
1695 [databases.a]
1696 dialect = "sqlite"
1697 [databases.b]
1698 dialect = "postgresql"
1699 "#,
1700 Path::new("test.toml"),
1701 )
1702 .unwrap();
1703
1704 assert!(cfg.database(None).is_err());
1706 }
1707
1708 #[test]
1709 fn env_var_syntax() {
1710 let cfg = Config::load_from_str(
1711 r#"
1712 dialect = "postgresql"
1713 [dbCredentials]
1714 url = { env = "DATABASE_URL" }
1715 "#,
1716 Path::new("test.toml"),
1717 )
1718 .unwrap();
1719 assert!(cfg.is_single_database());
1720 }
1721
1722 #[test]
1723 fn casing_options() {
1724 let cfg = Config::load_from_str(
1725 r#"
1726 dialect = "postgresql"
1727 casing = "snake_case"
1728 [dbCredentials]
1729 url = "postgres://localhost/db"
1730 "#,
1731 Path::new("test.toml"),
1732 )
1733 .unwrap();
1734 let db = cfg.default_database().unwrap();
1735 assert_eq!(db.effective_casing(), Casing::SnakeCase);
1736
1737 let cfg2 = Config::load_from_str(
1739 r#"
1740 dialect = "postgresql"
1741 [dbCredentials]
1742 url = "postgres://localhost/db"
1743 "#,
1744 Path::new("test.toml"),
1745 )
1746 .unwrap();
1747 let db2 = cfg2.default_database().unwrap();
1748 assert_eq!(db2.effective_casing(), Casing::CamelCase);
1749 }
1750
1751 #[test]
1752 fn introspect_casing() {
1753 let cfg = Config::load_from_str(
1754 r#"
1755 dialect = "postgresql"
1756 [introspect]
1757 casing = "preserve"
1758 [dbCredentials]
1759 url = "postgres://localhost/db"
1760 "#,
1761 Path::new("test.toml"),
1762 )
1763 .unwrap();
1764 let db = cfg.default_database().unwrap();
1765 assert_eq!(db.effective_introspect_casing(), IntrospectCasing::Preserve);
1766 }
1767
1768 #[test]
1769 fn entities_roles_filter() {
1770 let cfg = Config::load_from_str(
1772 r#"
1773 dialect = "postgresql"
1774 [entities]
1775 roles = true
1776 [dbCredentials]
1777 url = "postgres://localhost/db"
1778 "#,
1779 Path::new("test.toml"),
1780 )
1781 .unwrap();
1782 let db = cfg.default_database().unwrap();
1783 assert!(db.roles_enabled());
1784 assert!(db.should_include_role("my_role"));
1785
1786 let cfg2 = Config::load_from_str(
1788 r#"
1789 dialect = "postgresql"
1790 [entities.roles]
1791 provider = "supabase"
1792 [dbCredentials]
1793 url = "postgres://localhost/db"
1794 "#,
1795 Path::new("test.toml"),
1796 )
1797 .unwrap();
1798 let db2 = cfg2.default_database().unwrap();
1799 assert!(db2.roles_enabled());
1800 assert!(!db2.should_include_role("anon")); assert!(db2.should_include_role("my_custom_role"));
1802 }
1803
1804 #[test]
1805 fn extensions_filter() {
1806 let cfg = Config::load_from_str(
1807 r#"
1808 dialect = "postgresql"
1809 extensionsFilters = ["postgis"]
1810 [dbCredentials]
1811 url = "postgres://localhost/db"
1812 "#,
1813 Path::new("test.toml"),
1814 )
1815 .unwrap();
1816 let db = cfg.default_database().unwrap();
1817 assert!(db.has_extension(Extension::Postgis));
1818 }
1819
1820 #[test]
1821 fn rejects_postgres_only_filters_for_sqlite() {
1822 let err = Config::load_from_str(
1823 r#"
1824 dialect = "sqlite"
1825 schemaFilter = ["public"]
1826 [dbCredentials]
1827 url = "./dev.db"
1828 "#,
1829 Path::new("test.toml"),
1830 )
1831 .expect_err("sqlite should reject schemaFilter");
1832 assert_eq!(
1833 err.to_string(),
1834 "invalid config: schemaFilter is only supported for dialect = \"postgresql\""
1835 );
1836
1837 let err = Config::load_from_str(
1838 r#"
1839 dialect = "sqlite"
1840 extensionsFilters = ["postgis"]
1841 [dbCredentials]
1842 url = "./dev.db"
1843 "#,
1844 Path::new("test.toml"),
1845 )
1846 .expect_err("sqlite should reject extensionsFilters");
1847 assert_eq!(
1848 err.to_string(),
1849 "invalid config: extensionsFilters is only supported for dialect = \"postgresql\""
1850 );
1851 }
1852
1853 #[test]
1854 fn rejects_entities_filter_for_turso() {
1855 let err = Config::load_from_str(
1856 r#"
1857 dialect = "turso"
1858 [entities]
1859 roles = true
1860 [dbCredentials]
1861 url = "libsql://example.turso.io"
1862 "#,
1863 Path::new("test.toml"),
1864 )
1865 .expect_err("turso should reject entities filter");
1866 assert_eq!(
1867 err.to_string(),
1868 "invalid config: entities filter is only supported for dialect = \"postgresql\""
1869 );
1870 }
1871
1872 #[test]
1873 fn migrations_config() {
1874 let cfg = Config::load_from_str(
1875 r#"
1876 dialect = "postgresql"
1877 [migrations]
1878 table = "custom_migrations"
1879 schema = "custom_schema"
1880 [dbCredentials]
1881 url = "postgres://localhost/db"
1882 "#,
1883 Path::new("test.toml"),
1884 )
1885 .unwrap();
1886 let db = cfg.default_database().unwrap();
1887 assert_eq!(db.migrations_table(), "custom_migrations");
1888 assert_eq!(db.migrations_schema(), "custom_schema");
1889
1890 let cfg2 = Config::load_from_str(
1892 r#"
1893 dialect = "postgresql"
1894 [dbCredentials]
1895 url = "postgres://localhost/db"
1896 "#,
1897 Path::new("test.toml"),
1898 )
1899 .unwrap();
1900 let db2 = cfg2.default_database().unwrap();
1901 assert_eq!(db2.migrations_table(), "__drizzle_migrations");
1902 assert_eq!(db2.migrations_schema(), "drizzle");
1903 }
1904
1905 #[test]
1906 fn resolves_paths_relative_to_config_dir() {
1907 let tmp = TempDir::new().unwrap();
1908 let cfg_dir = tmp.path().join("cfg");
1909 fs::create_dir_all(&cfg_dir).unwrap();
1910
1911 let schema_path = cfg_dir.join("schema.rs");
1913 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
1914
1915 let cfg_path = cfg_dir.join("drizzle.config.toml");
1916 let cfg = Config::load_from_str(
1917 r#"
1918 dialect = "sqlite"
1919 schema = "schema.rs"
1920 out = "./drizzle"
1921 [dbCredentials]
1922 url = "./dev.db"
1923 "#,
1924 &cfg_path,
1925 )
1926 .unwrap();
1927
1928 let db = cfg.default_database().unwrap();
1929 assert_eq!(db.migrations_dir(), cfg_dir.join("./drizzle").as_path());
1930
1931 let files = db.schema_files().unwrap();
1932 assert_eq!(files.len(), 1);
1933 assert_eq!(files[0], schema_path);
1934 }
1935
1936 #[test]
1937 fn rejects_host_credentials_for_sqlite() {
1938 let err = Config::load_from_str(
1939 r#"
1940 dialect = "sqlite"
1941 [dbCredentials]
1942 host = "localhost"
1943 database = "db"
1944 "#,
1945 Path::new("test.toml"),
1946 )
1947 .unwrap_err();
1948
1949 assert_eq!(
1950 err.to_string(),
1951 "invalid credentials: host-based dbCredentials are only supported for dialect = \"postgresql\""
1952 );
1953 }
1954
1955 #[test]
1960 fn d1_http_credentials_parse() {
1961 let cfg = Config::load_from_str(
1962 r#"
1963 dialect = "sqlite"
1964 driver = "d1-http"
1965 [dbCredentials]
1966 accountId = "acc_abc"
1967 databaseId = "db_xyz"
1968 token = "tok_123"
1969 "#,
1970 Path::new("test.toml"),
1971 )
1972 .unwrap();
1973
1974 let db = cfg.default_database().unwrap();
1975 assert_eq!(db.driver, Some(Driver::D1Http));
1976 match db.credentials().unwrap() {
1977 Some(Credentials::D1 {
1978 account_id,
1979 database_id,
1980 token,
1981 }) => {
1982 assert_eq!(&*account_id, "acc_abc");
1983 assert_eq!(&*database_id, "db_xyz");
1984 assert_eq!(&*token, "tok_123");
1985 }
1986 other => panic!("expected Credentials::D1, got {other:?}"),
1987 }
1988 }
1989
1990 #[test]
1991 fn d1_http_credentials_resolve_from_env() {
1992 unsafe {
1994 std::env::set_var("TEST_D1_ACCT", "env_acct");
1995 std::env::set_var("TEST_D1_DB", "env_db");
1996 std::env::set_var("TEST_D1_TOKEN", "env_token");
1997 }
1998 let cfg = Config::load_from_str(
1999 r#"
2000 dialect = "sqlite"
2001 driver = "d1-http"
2002 [dbCredentials]
2003 accountId = { env = "TEST_D1_ACCT" }
2004 databaseId = { env = "TEST_D1_DB" }
2005 token = { env = "TEST_D1_TOKEN" }
2006 "#,
2007 Path::new("test.toml"),
2008 )
2009 .unwrap();
2010
2011 match cfg.default_database().unwrap().credentials().unwrap() {
2012 Some(Credentials::D1 {
2013 account_id,
2014 database_id,
2015 token,
2016 }) => {
2017 assert_eq!(&*account_id, "env_acct");
2018 assert_eq!(&*database_id, "env_db");
2019 assert_eq!(&*token, "env_token");
2020 }
2021 other => panic!("expected Credentials::D1, got {other:?}"),
2022 }
2023 }
2024
2025 #[test]
2026 fn d1_credentials_require_sqlite_dialect() {
2027 let err = Config::load_from_str(
2028 r#"
2029 dialect = "postgresql"
2030 [dbCredentials]
2031 accountId = "acc"
2032 databaseId = "db"
2033 token = "tok"
2034 "#,
2035 Path::new("test.toml"),
2036 )
2037 .unwrap_err();
2038 assert!(
2039 err.to_string().contains("D1 dbCredentials"),
2040 "expected D1-specific error, got: {err}"
2041 );
2042 }
2043
2044 #[test]
2045 fn d1_credentials_require_d1_http_driver() {
2046 let err = Config::load_from_str(
2048 r#"
2049 dialect = "sqlite"
2050 driver = "rusqlite"
2051 [dbCredentials]
2052 accountId = "acc"
2053 databaseId = "db"
2054 token = "tok"
2055 "#,
2056 Path::new("test.toml"),
2057 )
2058 .unwrap_err();
2059 assert!(
2060 err.to_string().contains("driver = \"d1-http\""),
2061 "expected d1-http driver error, got: {err}"
2062 );
2063 }
2064
2065 #[test]
2066 fn d1_http_driver_requires_d1_credentials() {
2067 let err = Config::load_from_str(
2069 r#"
2070 dialect = "sqlite"
2071 driver = "d1-http"
2072 [dbCredentials]
2073 url = "./dev.db"
2074 "#,
2075 Path::new("test.toml"),
2076 )
2077 .unwrap_err();
2078 assert!(
2079 err.to_string().contains("accountId, databaseId, and token"),
2080 "expected d1-http creds-shape error, got: {err}"
2081 );
2082 }
2083
2084 #[test]
2085 fn durable_sqlite_no_credentials_ok() {
2086 let cfg = Config::load_from_str(
2089 r#"
2090 dialect = "sqlite"
2091 driver = "durable-sqlite"
2092 "#,
2093 Path::new("test.toml"),
2094 )
2095 .unwrap();
2096
2097 let db = cfg.default_database().unwrap();
2098 assert_eq!(db.driver, Some(Driver::DurableSqlite));
2099 assert!(db.credentials().unwrap().is_none());
2100 assert!(
2102 db.bundle_enabled(),
2103 "durable-sqlite should auto-enable bundle"
2104 );
2105 }
2106
2107 #[test]
2108 fn durable_sqlite_explicit_bundle_false_respected() {
2109 let cfg = Config::load_from_str(
2111 r#"
2112 dialect = "sqlite"
2113 driver = "durable-sqlite"
2114 [migrations]
2115 bundle = false
2116 "#,
2117 Path::new("test.toml"),
2118 )
2119 .unwrap();
2120 assert!(!cfg.default_database().unwrap().bundle_enabled());
2121 }
2122
2123 #[test]
2124 fn durable_sqlite_rejects_non_sqlite_dialect() {
2125 let err = Config::load_from_str(
2126 r#"
2127 dialect = "postgresql"
2128 driver = "durable-sqlite"
2129 [dbCredentials]
2130 url = "postgres://localhost/db"
2131 "#,
2132 Path::new("test.toml"),
2133 )
2134 .unwrap_err();
2135 assert!(
2136 err.to_string().contains("invalid for postgresql"),
2137 "expected dialect/driver mismatch error, got: {err}"
2138 );
2139 }
2140
2141 #[test]
2142 fn driver_valid_for_sqlite_includes_cloudflare() {
2143 let drivers = Driver::valid_for(Dialect::Sqlite);
2144 assert!(drivers.contains(&Driver::Rusqlite));
2145 assert!(drivers.contains(&Driver::D1Http));
2146 assert!(drivers.contains(&Driver::DurableSqlite));
2147 for drv in [Driver::D1Http, Driver::DurableSqlite] {
2149 assert!(!drv.is_valid_for(Dialect::Postgresql));
2150 assert!(!drv.is_valid_for(Dialect::Turso));
2151 }
2152 }
2153
2154 #[test]
2155 fn driver_is_codegen_only_flag() {
2156 assert!(Driver::DurableSqlite.is_codegen_only());
2157 assert!(!Driver::D1Http.is_codegen_only());
2158 assert!(!Driver::Rusqlite.is_codegen_only());
2159 assert!(!Driver::AwsDataApi.is_codegen_only());
2160 }
2161
2162 #[test]
2167 fn aws_data_api_credentials_parse() {
2168 let cfg = Config::load_from_str(
2169 r#"
2170 dialect = "postgresql"
2171 driver = "aws-data-api"
2172 [dbCredentials]
2173 database = "mydb"
2174 secretArn = "arn:aws:secretsmanager:us-east-1:123:secret:db-xyz"
2175 resourceArn = "arn:aws:rds:us-east-1:123:cluster:my-aurora"
2176 "#,
2177 Path::new("test.toml"),
2178 )
2179 .unwrap();
2180
2181 let db = cfg.default_database().unwrap();
2182 assert_eq!(db.driver, Some(Driver::AwsDataApi));
2183 match db.credentials().unwrap() {
2184 Some(Credentials::AwsDataApi {
2185 database,
2186 secret_arn,
2187 resource_arn,
2188 }) => {
2189 assert_eq!(&*database, "mydb");
2190 assert!(secret_arn.starts_with("arn:aws:secretsmanager"));
2191 assert!(resource_arn.starts_with("arn:aws:rds"));
2192 }
2193 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2194 }
2195 }
2196
2197 #[test]
2198 fn aws_data_api_credentials_resolve_from_env() {
2199 unsafe {
2200 std::env::set_var("TEST_AWS_DB", "envdb");
2201 std::env::set_var("TEST_AWS_SECRET", "arn:env:secret");
2202 std::env::set_var("TEST_AWS_RESOURCE", "arn:env:resource");
2203 }
2204 let cfg = Config::load_from_str(
2205 r#"
2206 dialect = "postgresql"
2207 driver = "aws-data-api"
2208 [dbCredentials]
2209 database = { env = "TEST_AWS_DB" }
2210 secretArn = { env = "TEST_AWS_SECRET" }
2211 resourceArn = { env = "TEST_AWS_RESOURCE" }
2212 "#,
2213 Path::new("test.toml"),
2214 )
2215 .unwrap();
2216
2217 match cfg.default_database().unwrap().credentials().unwrap() {
2218 Some(Credentials::AwsDataApi {
2219 database,
2220 secret_arn,
2221 resource_arn,
2222 }) => {
2223 assert_eq!(&*database, "envdb");
2224 assert_eq!(&*secret_arn, "arn:env:secret");
2225 assert_eq!(&*resource_arn, "arn:env:resource");
2226 }
2227 other => panic!("expected Credentials::AwsDataApi, got {other:?}"),
2228 }
2229 }
2230
2231 #[test]
2232 fn aws_data_api_requires_postgres_dialect() {
2233 let err = Config::load_from_str(
2234 r#"
2235 dialect = "sqlite"
2236 [dbCredentials]
2237 database = "mydb"
2238 secretArn = "arn:aws:secretsmanager:..."
2239 resourceArn = "arn:aws:rds:..."
2240 "#,
2241 Path::new("test.toml"),
2242 )
2243 .unwrap_err();
2244 assert!(
2245 err.to_string().contains("AWS Data API dbCredentials"),
2246 "expected AWS-specific error, got: {err}"
2247 );
2248 }
2249
2250 #[test]
2251 fn aws_data_api_requires_aws_data_api_driver() {
2252 let err = Config::load_from_str(
2254 r#"
2255 dialect = "postgresql"
2256 driver = "tokio-postgres"
2257 [dbCredentials]
2258 database = "mydb"
2259 secretArn = "arn:aws:secretsmanager:..."
2260 resourceArn = "arn:aws:rds:..."
2261 "#,
2262 Path::new("test.toml"),
2263 )
2264 .unwrap_err();
2265 assert!(
2266 err.to_string().contains("driver = \"aws-data-api\""),
2267 "expected aws-data-api driver error, got: {err}"
2268 );
2269 }
2270
2271 #[test]
2272 fn aws_data_api_driver_requires_aws_credentials() {
2273 let err = Config::load_from_str(
2275 r#"
2276 dialect = "postgresql"
2277 driver = "aws-data-api"
2278 [dbCredentials]
2279 url = "postgres://localhost/db"
2280 "#,
2281 Path::new("test.toml"),
2282 )
2283 .unwrap_err();
2284 assert!(
2285 err.to_string()
2286 .contains("database, secretArn, and resourceArn"),
2287 "expected aws-data-api creds-shape error, got: {err}"
2288 );
2289 }
2290
2291 #[test]
2292 fn aws_data_api_rejected_for_non_postgres_dialect() {
2293 let err = Config::load_from_str(
2294 r#"
2295 dialect = "sqlite"
2296 driver = "aws-data-api"
2297 "#,
2298 Path::new("test.toml"),
2299 )
2300 .unwrap_err();
2301 assert!(
2302 err.to_string().contains("invalid for sqlite"),
2303 "expected dialect/driver mismatch error, got: {err}"
2304 );
2305 }
2306
2307 #[test]
2308 fn driver_valid_for_postgres_includes_aws_data_api() {
2309 let drivers = Driver::valid_for(Dialect::Postgresql);
2310 assert!(drivers.contains(&Driver::PostgresSync));
2311 assert!(drivers.contains(&Driver::TokioPostgres));
2312 assert!(drivers.contains(&Driver::AwsDataApi));
2313 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Sqlite));
2315 assert!(!Driver::AwsDataApi.is_valid_for(Dialect::Turso));
2316 }
2317
2318 #[test]
2319 fn credentials_debug_redacts_secrets() {
2320 let postgres = Credentials::Postgres(PostgresCreds::Host {
2321 host: "localhost".into(),
2322 port: 5432,
2323 user: Some("alice".into()),
2324 password: Some("super-secret-password".into()),
2325 database: "app".into(),
2326 ssl: PostgresSslMode::Require,
2327 });
2328 let turso = Credentials::Turso {
2329 url: "libsql://example.turso.io".into(),
2330 auth_token: Some("super-secret-token".into()),
2331 };
2332 let d1 = Credentials::D1 {
2333 account_id: "account".into(),
2334 database_id: "database".into(),
2335 token: "super-secret-d1-token".into(),
2336 };
2337 let postgres_url = Credentials::Postgres(PostgresCreds::Url(
2338 "postgres://alice:super-secret-url-password@localhost/app".into(),
2339 ));
2340
2341 let rendered = format!("{postgres:?} {turso:?} {d1:?} {postgres_url:?}");
2342 assert!(rendered.contains("[REDACTED]"));
2343 assert!(!rendered.contains("super-secret"));
2344 }
2345
2346 #[cfg(windows)]
2347 #[test]
2348 fn schema_files_accept_backslash_paths() {
2349 let tmp = TempDir::new().unwrap();
2350 let cfg_dir = tmp.path().join("cfg");
2351 fs::create_dir_all(&cfg_dir).unwrap();
2352
2353 let schema_path = cfg_dir.join("src").join("schema.rs");
2354 fs::create_dir_all(schema_path.parent().unwrap()).unwrap();
2355 fs::write(&schema_path, "#[allow(dead_code)]\npub struct X;").unwrap();
2356
2357 let schema_str = schema_path.to_string_lossy().replace('/', "\\");
2359 let schema_toml = schema_str.replace('\\', "\\\\");
2361 let cfg_path = cfg_dir.join("drizzle.config.toml");
2362 let cfg = Config::load_from_str(
2363 &format!(
2364 r#"
2365 dialect = "sqlite"
2366 schema = "{}"
2367 "#,
2368 schema_toml
2369 ),
2370 &cfg_path,
2371 )
2372 .unwrap();
2373
2374 let db = cfg.default_database().unwrap();
2375 let files = db.schema_files().unwrap();
2376 assert_eq!(files, vec![schema_path]);
2377 }
2378}