Skip to content

Commit ec75af0

Browse files
authored
Allow any valid sqlite or pg connection string (#355)
1 parent e6753a1 commit ec75af0

File tree

9 files changed

+859
-58
lines changed

9 files changed

+859
-58
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
sudo cat /etc/postgresql/16/main/pg_hba.conf
4848
sudo service postgresql restart && sleep 3
4949
echo BUTANE_PG_CONNSTR="host=localhost user=postgres sslmode=disable port=5432" >> $GITHUB_ENV
50+
echo "/usr/lib/postgresql/16/bin" >> $GITHUB_PATH
5051
- name: Setup PostgreSQL on MacOS
5152
if: runner.os == 'macOS'
5253
run: |
@@ -109,6 +110,8 @@ jobs:
109110
run: |
110111
set -ex
111112
make regenerate-example-migrations
113+
# TODO: This file is created by one of the tests on Windows
114+
rm -f butane_core/sqlite
112115
git add -A
113116
git diff --cached --exit-code
114117
- name: Run tests in examples

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ log = "0.4"
4343
maybe-async-cfg = { version = "0.2.5", default-features = false }
4444
nonempty = "0.11"
4545
paste = "1.0.11"
46+
postgres-native-tls = "0.5"
4647
pretty_assertions = "1.4"
4748
proc-macro2 = { version = "1.0", default-features = false }
4849
quote = { version = "1.0", default-features = false }
@@ -54,6 +55,7 @@ serde_json = "1.0"
5455
sqlparser = "0.56"
5556
syn = { version = "2", features = ["extra-traits", "full"] }
5657
tempfile = "3.10"
58+
test-log = { version = "0.2", features = ["log", "trace"] }
5759
thiserror = "2.0"
5860
tokio = { version = "1"}
5961
tokio-postgres = "0.7"

butane_core/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ nonempty.workspace = true
4343
pin-project = "1"
4444
tokio = {workspace = true, optional = true, features = ["rt", "sync", "rt-multi-thread"]}
4545
tokio-postgres = { optional = true, workspace = true }
46-
postgres-native-tls = { version = "0.5", optional = true }
46+
postgres-native-tls = { optional = true, workspace = true }
4747
proc-macro2 = { workspace = true }
4848
quote = { workspace = true }
4949
rand = { optional = true, workspace = true }
@@ -66,6 +66,8 @@ paste = { workspace = true }
6666
pretty_assertions.workspace = true
6767
tempfile.workspace = true
6868
tokio = { workspace = true, features = ["macros"] }
69+
uuid.workspace = true
70+
whoami = "1.6"
6971

7072
[[test]]
7173
name = "uuid"

butane_core/src/db/mod.rs

Lines changed: 108 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,9 @@ pub trait Backend: Send + Sync + DynClone {
526526

527527
dyn_clone::clone_trait_object!(Backend);
528528

529+
/// Regular expression to match PostgreSQL key-value pairs in a connection string.
530+
const PG_KEY_PAIR_RE: &str = r"(hostaddr|host|dbname|user|port)\s*=";
531+
529532
/// Connection specification. Contains the name of a database backend
530533
/// and the backend-specific connection string. See [`connect`]
531534
/// to make a [`Connection`] from a `ConnectionSpec`.
@@ -549,7 +552,7 @@ impl ConnectionSpec {
549552
contents.push('\n');
550553
f.write_all(contents.as_bytes()).map_err(|e| e.into())
551554
}
552-
/// Load a previously saved connection spec
555+
/// Load a previously saved connection spec.
553556
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
554557
let path = conn_complete_if_dir(path.as_ref());
555558
serde_json::from_reader(fs::File::open(path)?).map_err(|e| e.into())
@@ -560,28 +563,115 @@ impl ConnectionSpec {
560563
None => Err(crate::Error::UnknownBackend(self.backend_name.clone())),
561564
}
562565
}
566+
/// Get the backend name.
567+
pub const fn backend_name(&self) -> &String {
568+
&self.backend_name
569+
}
570+
/// Get the database connection string.
571+
pub const fn connection_string(&self) -> &String {
572+
&self.conn_str
573+
}
574+
/// Get the connection string URI if it is a URI.
575+
/// Returns None if the connection string is not a URI.
576+
pub fn connection_string_uri(&self) -> Option<url::Url> {
577+
url::Url::parse(self.connection_string()).ok()
578+
}
579+
580+
fn is_pg_key_value_pairs(connection_string: &str) -> bool {
581+
// host and hostaddr are optional in Postgresql, however this prevents connecting
582+
// without one of them: https://github.com/sfackler/rust-postgres/issues/1239
583+
let re = regex::Regex::new(PG_KEY_PAIR_RE).unwrap();
584+
re.is_match(connection_string)
585+
}
586+
587+
/// Add a query parameter to the connection string.
588+
pub fn add_parameter(&mut self, name: &str, value: &str) -> Result<()> {
589+
if self.backend_name() == "pg" && Self::is_pg_key_value_pairs(&self.conn_str) {
590+
// Append using PostgreSQL key-value pair syntax.
591+
self.conn_str.push_str(&format!(" {}={}", name, value));
592+
return Ok(());
593+
}
594+
if self.connection_string_uri().is_none() {
595+
return Err(crate::Error::UnknownConnectString(
596+
"Cannot add query parameter to connection string that is not a postgres or URI"
597+
.to_string(),
598+
));
599+
}
600+
601+
if self.conn_str.contains('?') {
602+
self.conn_str.push_str(&format!("&{}={}", name, value));
603+
} else {
604+
self.conn_str.push_str(&format!("?{}={}", name, value));
605+
}
606+
Ok(())
607+
}
563608
}
564609

610+
/// Convert a string to a connection spec.
611+
///
612+
/// The string may be any connection string supported by the database backend.
613+
/// If it is a URL with a scheme of "sqlite", the backend will be set to "sqlite",
614+
/// and the scheme will be set to "file" as required by the sqlite engine.
615+
/// If it is a URI with a scheme of "file", the backend will be set to "sqlite".
616+
/// If it does not have a scheme, it will be set to "pg" if it contains any of
617+
/// `host=`, `hostaddr=`, `dbname=`, or `user=`.
618+
/// Otherwise it will default to "sqlite".
565619
impl TryFrom<&str> for ConnectionSpec {
566620
type Error = crate::Error;
567621
fn try_from(value: &str) -> Result<Self> {
568-
let parsed = url::Url::parse(value)?;
569-
if parsed.scheme() == "sqlite" {
570-
let path = value.trim_start_matches("sqlite://");
571-
Ok(ConnectionSpec {
572-
backend_name: "sqlite".to_string(),
573-
conn_str: path.to_string(),
574-
})
575-
} else if ["postgres", "postgresql"].contains(&parsed.scheme()) {
576-
Ok(ConnectionSpec {
577-
backend_name: "pg".to_string(),
578-
conn_str: value.to_string(),
579-
})
580-
} else {
581-
Ok(ConnectionSpec {
582-
backend_name: parsed.scheme().to_string(),
583-
conn_str: value.to_string(),
584-
})
622+
match url::Url::parse(value) {
623+
Ok(parsed) => {
624+
match parsed.scheme() {
625+
"sqlite" => {
626+
// SQLite uses file: URLs, but we want to accept sqlite:
627+
// in order that the URIs are more expressive.
628+
let value = value.replacen("sqlite:", "file:", 1);
629+
Ok(ConnectionSpec {
630+
backend_name: "sqlite".to_string(),
631+
conn_str: value,
632+
})
633+
}
634+
"file" => Ok(ConnectionSpec {
635+
backend_name: "sqlite".to_string(),
636+
conn_str: value.to_string(),
637+
}),
638+
"postgres" | "postgresql" => Ok(ConnectionSpec {
639+
backend_name: "pg".to_string(),
640+
conn_str: value.to_string(),
641+
}),
642+
_ => Ok(ConnectionSpec {
643+
backend_name: parsed.scheme().to_string(),
644+
conn_str: value.to_string(),
645+
}),
646+
}
647+
}
648+
Err(url::ParseError::InvalidPort) => {
649+
// This occurs when using a PostgreSQL multi-host connection string.
650+
if value.starts_with("postgres") {
651+
Ok(ConnectionSpec {
652+
backend_name: "pg".to_string(),
653+
conn_str: value.to_string(),
654+
})
655+
} else {
656+
Ok(ConnectionSpec {
657+
backend_name: "sqlite".to_string(),
658+
conn_str: value.to_string(),
659+
})
660+
}
661+
}
662+
Err(_) => {
663+
// Spaces are allowed between the key and the equals sign in a PostgreSQL connection string.
664+
if Self::is_pg_key_value_pairs(value) {
665+
return Ok(ConnectionSpec {
666+
backend_name: "pg".to_string(),
667+
conn_str: value.to_string(),
668+
});
669+
}
670+
Ok(ConnectionSpec {
671+
backend_name: "sqlite".to_string(),
672+
conn_str: value.to_string(),
673+
})
674+
}
585675
}
586676
}
587677
}

butane_core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,8 @@ pub enum Error {
260260
UriParse(#[from] url::ParseError),
261261
#[error("Unknown backend {0}")]
262262
UnknownBackend(String),
263+
#[error("Unknown connect string {0}")]
264+
UnknownConnectString(String),
263265
#[error("Range error")]
264266
OutOfRange,
265267
#[error("Internal logic error {0}")]

0 commit comments

Comments
 (0)