1use crate::dialect::{DatabaseDialect, DialectKind};
9use crate::error::{Result, WaypointError};
10
11#[cfg(feature = "postgres")]
12use fastrand;
13
14#[cfg(feature = "postgres")]
15use tokio_postgres::Client;
16
17#[cfg(feature = "postgres")]
18use crate::config::SslMode;
19
20pub fn quote_ident(name: &str) -> String {
26 format!("\"{}\"", name.replace('"', "\"\""))
27}
28
29pub fn validate_identifier(name: &str) -> Result<()> {
34 if name.is_empty() {
35 return Err(WaypointError::ConfigError(
36 "Identifier cannot be empty".to_string(),
37 ));
38 }
39 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
40 return Err(WaypointError::ConfigError(format!(
41 "Identifier '{}' contains invalid characters. Only [a-zA-Z0-9_] are allowed.",
42 name
43 )));
44 }
45 Ok(())
46}
47
48pub enum DbClient {
58 #[cfg(feature = "postgres")]
60 Postgres(Client),
61 #[cfg(feature = "mysql")]
66 Mysql(mysql_async::Pool),
67}
68
69impl DbClient {
70 #[cfg(feature = "postgres")]
72 pub fn with_postgres(client: Client) -> Self {
73 DbClient::Postgres(client)
74 }
75
76 #[cfg(feature = "mysql")]
78 pub fn with_mysql(pool: mysql_async::Pool) -> Self {
79 DbClient::Mysql(pool)
80 }
81
82 pub fn dialect_kind(&self) -> DialectKind {
84 match self {
85 #[cfg(feature = "postgres")]
86 DbClient::Postgres(_) => DialectKind::Postgres,
87 #[cfg(feature = "mysql")]
88 DbClient::Mysql(_) => DialectKind::Mysql,
89 }
90 }
91
92 pub fn dialect(&self) -> &'static dyn DatabaseDialect {
97 #[cfg(feature = "postgres")]
98 static PG: crate::dialect::postgres::PostgresDialect =
99 crate::dialect::postgres::PostgresDialect;
100 #[cfg(feature = "mysql")]
101 static MY: crate::dialect::mysql::MysqlDialect = crate::dialect::mysql::MysqlDialect;
102 match self.dialect_kind() {
103 #[cfg(feature = "postgres")]
104 DialectKind::Postgres => &PG,
105 #[cfg(not(feature = "postgres"))]
106 DialectKind::Postgres => {
107 panic!("PostgreSQL connection without `postgres` feature compiled in")
108 }
109 #[cfg(feature = "mysql")]
110 DialectKind::Mysql => &MY,
111 #[cfg(not(feature = "mysql"))]
112 DialectKind::Mysql => {
113 panic!("MySQL connection without `mysql` feature compiled in")
114 }
115 }
116 }
117
118 #[cfg(feature = "postgres")]
122 pub fn as_postgres(&self) -> Result<&Client> {
123 match self {
124 DbClient::Postgres(c) => Ok(c),
125 #[cfg(feature = "mysql")]
126 DbClient::Mysql(_) => Err(WaypointError::ConfigError(
127 "This operation is not yet implemented for MySQL".into(),
128 )),
129 }
130 }
131
132 #[cfg(feature = "mysql")]
135 pub fn as_mysql(&self) -> Result<&mysql_async::Pool> {
136 match self {
137 DbClient::Mysql(p) => Ok(p),
138 #[cfg(feature = "postgres")]
139 DbClient::Postgres(_) => Err(WaypointError::ConfigError(
140 "This operation requires a MySQL connection".into(),
141 )),
142 }
143 }
144
145 pub async fn check_connection(&self) -> Result<()> {
147 match self {
148 #[cfg(feature = "postgres")]
149 DbClient::Postgres(c) => check_connection(c).await,
150 #[cfg(feature = "mysql")]
151 DbClient::Mysql(pool) => {
152 use mysql_async::prelude::*;
153 let mut conn =
154 pool.get_conn()
155 .await
156 .map_err(|e| WaypointError::ConnectionLost {
157 operation: "health check".into(),
158 detail: e.to_string(),
159 })?;
160 conn.query_drop("DO 0")
161 .await
162 .map_err(|e| WaypointError::ConnectionLost {
163 operation: "health check".into(),
164 detail: e.to_string(),
165 })?;
166 Ok(())
167 }
168 }
169 }
170
171 pub async fn acquire_lock(&self, table_name: &str) -> Result<()> {
176 match self {
177 #[cfg(feature = "postgres")]
178 DbClient::Postgres(c) => acquire_advisory_lock(c, table_name).await,
179 #[cfg(feature = "mysql")]
180 DbClient::Mysql(pool) => {
181 use mysql_async::prelude::*;
182 let key = mysql_lock_key(table_name);
183 let mut conn = pool.get_conn().await?;
184 let acquired: Option<i64> = conn
185 .exec_first("SELECT GET_LOCK(?, -1)", (key.clone(),))
186 .await?;
187 match acquired {
188 Some(1) => Ok(()),
189 _ => Err(WaypointError::LockError(format!(
190 "Failed to acquire MySQL named lock {}",
191 key
192 ))),
193 }
194 }
195 }
196 }
197
198 pub async fn acquire_lock_with_timeout(
200 &self,
201 table_name: &str,
202 timeout_secs: u32,
203 ) -> Result<()> {
204 match self {
205 #[cfg(feature = "postgres")]
206 DbClient::Postgres(c) => {
207 acquire_advisory_lock_with_timeout(c, table_name, timeout_secs).await
208 }
209 #[cfg(feature = "mysql")]
210 DbClient::Mysql(pool) => {
211 use mysql_async::prelude::*;
212 let key = mysql_lock_key(table_name);
213 let mut conn = pool.get_conn().await?;
214 let acquired: Option<i64> = conn
215 .exec_first("SELECT GET_LOCK(?, ?)", (key.clone(), timeout_secs as i64))
216 .await?;
217 match acquired {
218 Some(1) => Ok(()),
219 Some(0) => Err(WaypointError::LockError(format!(
220 "Timed out waiting for MySQL named lock {} after {}s",
221 key, timeout_secs
222 ))),
223 _ => Err(WaypointError::LockError(format!(
224 "Failed to acquire MySQL named lock {} (NULL result)",
225 key
226 ))),
227 }
228 }
229 }
230 }
231
232 pub async fn release_lock(&self, table_name: &str) -> Result<()> {
234 match self {
235 #[cfg(feature = "postgres")]
236 DbClient::Postgres(c) => release_advisory_lock(c, table_name).await,
237 #[cfg(feature = "mysql")]
238 DbClient::Mysql(pool) => {
239 use mysql_async::prelude::*;
240 let key = mysql_lock_key(table_name);
241 let mut conn = pool.get_conn().await?;
242 conn.exec_drop("SELECT RELEASE_LOCK(?)", (key,)).await?;
243 Ok(())
244 }
245 }
246 }
247
248 pub async fn current_user(&self) -> Result<String> {
250 match self {
251 #[cfg(feature = "postgres")]
252 DbClient::Postgres(c) => get_current_user(c).await,
253 #[cfg(feature = "mysql")]
254 DbClient::Mysql(pool) => {
255 use mysql_async::prelude::*;
256 let mut conn = pool.get_conn().await?;
257 let user: Option<String> = conn.query_first("SELECT CURRENT_USER()").await?;
258 user.ok_or_else(|| {
259 WaypointError::ConfigError("CURRENT_USER() returned no rows".into())
260 })
261 }
262 }
263 }
264
265 pub async fn current_database(&self) -> Result<String> {
267 match self {
268 #[cfg(feature = "postgres")]
269 DbClient::Postgres(c) => get_current_database(c).await,
270 #[cfg(feature = "mysql")]
271 DbClient::Mysql(pool) => {
272 use mysql_async::prelude::*;
273 let mut conn = pool.get_conn().await?;
274 let db: Option<Option<String>> = conn.query_first("SELECT DATABASE()").await?;
276 match db.flatten() {
277 Some(name) => Ok(name),
278 None => Err(WaypointError::ConfigError(
279 "MySQL connection has no current database (none selected in URL)".into(),
280 )),
281 }
282 }
283 }
284 }
285
286 pub async fn resolve_schema(&self, configured: &str) -> Result<String> {
293 match self.dialect_kind() {
294 DialectKind::Postgres => Ok(configured.to_string()),
295 DialectKind::Mysql => {
296 if configured == "public" {
297 self.current_database().await
298 } else {
299 Ok(configured.to_string())
300 }
301 }
302 }
303 }
304
305 pub async fn execute_raw(&self, sql: &str) -> Result<i32> {
314 match self {
315 #[cfg(feature = "postgres")]
316 DbClient::Postgres(c) => execute_raw(c, sql).await,
317 #[cfg(feature = "mysql")]
318 DbClient::Mysql(pool) => {
319 use mysql_async::prelude::*;
320 let start = std::time::Instant::now();
321 let mut conn = pool.get_conn().await?;
322 for stmt in crate::sql_parser::split_mysql_statements(sql) {
323 conn.query_drop(&stmt).await?;
324 }
325 Ok(start.elapsed().as_millis() as i32)
326 }
327 }
328 }
329
330 pub async fn execute_in_transaction(&self, sql: &str) -> Result<i32> {
339 match self {
340 #[cfg(feature = "postgres")]
341 DbClient::Postgres(c) => execute_in_transaction(c, sql).await,
342 #[cfg(feature = "mysql")]
343 DbClient::Mysql(_) => self.execute_raw(sql).await,
344 }
345 }
346}
347
348#[cfg(feature = "mysql")]
354fn mysql_lock_key(table_name: &str) -> String {
355 let mut k = format!("waypoint_{}", table_name);
356 if k.len() > 64 {
357 k.truncate(64);
358 }
359 k
360}
361
362#[cfg(feature = "postgres")]
366fn make_rustls_config() -> rustls::ClientConfig {
367 let root_store =
368 rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
369 rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
370 rustls::crypto::ring::default_provider(),
371 ))
372 .with_safe_default_protocol_versions()
373 .unwrap()
374 .with_root_certificates(root_store)
375 .with_no_client_auth()
376}
377
378#[cfg(feature = "postgres")]
380fn is_permanent_error(e: &tokio_postgres::Error) -> bool {
381 if let Some(db_err) = e.as_db_error() {
382 let code = db_err.code().code();
383 return code == "28P01" || code == "28000";
385 }
386 false
387}
388
389pub fn inject_keepalive(conn_string: &str, keepalive_secs: u32) -> String {
395 if keepalive_secs == 0 {
396 return conn_string.to_string();
397 }
398 let lower = conn_string.to_lowercase();
399 if lower.contains("keepalives") {
400 return conn_string.to_string();
401 }
402 let params = format!("keepalives=1&keepalives_idle={}", keepalive_secs);
403 if conn_string.starts_with("postgres://") || conn_string.starts_with("postgresql://") {
404 if conn_string.contains('?') {
405 format!("{}&{}", conn_string, params)
406 } else {
407 format!("{}?{}", conn_string, params)
408 }
409 } else {
410 format!(
412 "{} keepalives=1 keepalives_idle={}",
413 conn_string, keepalive_secs
414 )
415 }
416}
417
418#[cfg(feature = "postgres")]
424fn spawn_connection_task<F>(connection: F)
425where
426 F: std::future::Future<Output = std::result::Result<(), tokio_postgres::Error>>
427 + Send
428 + 'static,
429{
430 tokio::spawn(async move {
431 if let Err(e) = connection.await {
432 log::error!("Database connection error: {}", e);
433 }
434 });
435}
436
437#[cfg(feature = "postgres")]
441async fn connect_once(
442 conn_string: &str,
443 ssl_mode: &SslMode,
444 connect_timeout_secs: u32,
445) -> std::result::Result<Client, tokio_postgres::Error> {
446 let connect_fut = async {
447 match ssl_mode {
448 SslMode::Disable => {
449 let (client, connection) =
450 tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
451 spawn_connection_task(connection);
452 Ok(client)
453 }
454 SslMode::Require => {
455 let tls_config = make_rustls_config();
456 let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
457 let (client, connection) = tokio_postgres::connect(conn_string, tls).await?;
458 spawn_connection_task(connection);
459 Ok(client)
460 }
461 SslMode::Prefer => {
462 let tls_config = make_rustls_config();
464 let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
465 match tokio_postgres::connect(conn_string, tls).await {
466 Ok((client, connection)) => {
467 spawn_connection_task(connection);
468 Ok(client)
469 }
470 Err(_) => {
471 log::debug!("TLS connection failed, falling back to plaintext");
472 let (client, connection) =
473 tokio_postgres::connect(conn_string, tokio_postgres::NoTls).await?;
474 spawn_connection_task(connection);
475 Ok(client)
476 }
477 }
478 }
479 }
480 };
481
482 if connect_timeout_secs > 0 {
483 match tokio::time::timeout(
484 std::time::Duration::from_secs(connect_timeout_secs as u64),
485 connect_fut,
486 )
487 .await
488 {
489 Ok(result) => result,
490 Err(_) => Err(tokio_postgres::Error::__private_api_timeout()),
491 }
492 } else {
493 connect_fut.await
494 }
495}
496
497#[cfg(feature = "postgres")]
501pub async fn connect(conn_string: &str) -> Result<Client> {
502 connect_with_config(conn_string, &SslMode::Prefer, 0, 30, 0).await
503}
504
505#[cfg(feature = "postgres")]
510pub async fn connect_with_config(
511 conn_string: &str,
512 ssl_mode: &SslMode,
513 retries: u32,
514 connect_timeout_secs: u32,
515 statement_timeout_secs: u32,
516) -> Result<Client> {
517 connect_with_full_config(
518 conn_string,
519 ssl_mode,
520 retries,
521 connect_timeout_secs,
522 statement_timeout_secs,
523 120,
524 )
525 .await
526}
527
528#[cfg(feature = "postgres")]
530pub async fn connect_with_full_config(
531 conn_string: &str,
532 ssl_mode: &SslMode,
533 retries: u32,
534 connect_timeout_secs: u32,
535 statement_timeout_secs: u32,
536 keepalive_secs: u32,
537) -> Result<Client> {
538 let conn_string = inject_keepalive(conn_string, keepalive_secs);
539 let mut last_err = None;
540
541 for attempt in 0..=retries {
542 if attempt > 0 {
543 let base_delay = std::cmp::min(1u64 << attempt, 30);
544 let jitter_ms = fastrand::u64(0..1000);
545 let delay = std::time::Duration::from_secs(base_delay)
546 + std::time::Duration::from_millis(jitter_ms);
547 log::info!(
548 "Connection attempt failed, retrying; attempt={}, max_attempts={}, delay_ms={}",
549 attempt + 1,
550 retries + 1,
551 delay.as_millis() as u64
552 );
553 tokio::time::sleep(delay).await;
554 }
555
556 match connect_once(&conn_string, ssl_mode, connect_timeout_secs).await {
557 Ok(client) => {
558 if attempt > 0 {
559 log::info!(
560 "Connected successfully after retry; attempt={}, max_attempts={}",
561 attempt + 1,
562 retries + 1
563 );
564 }
565
566 if statement_timeout_secs > 0 {
568 let timeout_sql =
569 format!("SET statement_timeout = '{}s'", statement_timeout_secs);
570 client.batch_execute(&timeout_sql).await?;
571 }
572
573 return Ok(client);
574 }
575 Err(e) => {
576 if is_permanent_error(&e) {
578 log::error!("Permanent connection error, not retrying: {}", e);
579 return Err(WaypointError::DatabaseError(e));
580 }
581 last_err = Some(e);
582 }
583 }
584 }
585
586 Err(WaypointError::DatabaseError(last_err.unwrap()))
587}
588
589#[cfg(feature = "postgres")]
593pub async fn acquire_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
594 let lock_id = advisory_lock_id(table_name);
595 log::info!(
596 "Acquiring advisory lock; lock_id={}, table={}",
597 lock_id,
598 table_name
599 );
600
601 client
602 .execute("SELECT pg_advisory_lock($1)", &[&lock_id])
603 .await
604 .map_err(|e| WaypointError::LockError(format!("Failed to acquire advisory lock: {}", e)))?;
605
606 Ok(())
607}
608
609#[cfg(feature = "postgres")]
614pub async fn acquire_advisory_lock_with_timeout(
615 client: &Client,
616 table_name: &str,
617 timeout_secs: u32,
618) -> Result<()> {
619 let lock_id = advisory_lock_id(table_name);
620 log::info!(
621 "Trying to acquire advisory lock with timeout; lock_id={}, table={}, timeout_secs={}",
622 lock_id,
623 table_name,
624 timeout_secs
625 );
626
627 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs as u64);
628
629 loop {
630 let row = client
631 .query_one("SELECT pg_try_advisory_lock($1)", &[&lock_id])
632 .await
633 .map_err(|e| WaypointError::LockError(format!("Failed to try advisory lock: {}", e)))?;
634
635 let acquired: bool = row.get(0);
636 if acquired {
637 return Ok(());
638 }
639
640 if std::time::Instant::now() >= deadline {
641 return Err(WaypointError::LockError(format!(
642 "Timed out waiting for advisory lock after {}s (table: {}). Another migration may be running.",
643 timeout_secs, table_name
644 )));
645 }
646
647 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
649 }
650}
651
652#[cfg(feature = "postgres")]
654pub async fn release_advisory_lock(client: &Client, table_name: &str) -> Result<()> {
655 let lock_id = advisory_lock_id(table_name);
656 log::info!(
657 "Releasing advisory lock; lock_id={}, table={}",
658 lock_id,
659 table_name
660 );
661
662 client
663 .execute("SELECT pg_advisory_unlock($1)", &[&lock_id])
664 .await
665 .map_err(|e| WaypointError::LockError(format!("Failed to release advisory lock: {}", e)))?;
666
667 Ok(())
668}
669
670pub fn advisory_lock_id(table_name: &str) -> i64 {
676 crc32fast::hash(table_name.as_bytes()) as i64
677}
678
679#[cfg(feature = "postgres")]
681pub async fn get_current_user(client: &Client) -> Result<String> {
682 let row = client.query_one("SELECT current_user", &[]).await?;
683 Ok(row.get::<_, String>(0))
684}
685
686#[cfg(feature = "postgres")]
688pub async fn get_current_database(client: &Client) -> Result<String> {
689 let row = client.query_one("SELECT current_database()", &[]).await?;
690 Ok(row.get::<_, String>(0))
691}
692
693#[cfg(feature = "postgres")]
696pub async fn execute_in_transaction(client: &Client, sql: &str) -> Result<i32> {
697 let start = std::time::Instant::now();
698
699 client.batch_execute("BEGIN").await?;
700
701 match client.batch_execute(sql).await {
702 Ok(()) => {
703 client.batch_execute("COMMIT").await?;
704 }
705 Err(e) => {
706 if let Err(rollback_err) = client.batch_execute("ROLLBACK").await {
707 log::warn!("Failed to rollback transaction: {}", rollback_err);
708 }
709 return Err(WaypointError::DatabaseError(e));
710 }
711 }
712
713 let elapsed = start.elapsed().as_millis() as i32;
714 Ok(elapsed)
715}
716
717#[cfg(feature = "postgres")]
719pub async fn execute_raw(client: &Client, sql: &str) -> Result<i32> {
720 let start = std::time::Instant::now();
721 client.batch_execute(sql).await?;
722 let elapsed = start.elapsed().as_millis() as i32;
723 Ok(elapsed)
724}
725
726pub fn is_transient_error(e: &WaypointError) -> bool {
731 match e {
732 #[cfg(feature = "postgres")]
733 WaypointError::DatabaseError(pg_err) => {
734 if pg_err.is_closed() {
736 return true;
737 }
738 if let Some(db_err) = pg_err.as_db_error() {
740 let code = db_err.code().code();
741 return matches!(
745 code,
746 "57P01" | "57P02" | "57P03" | "08000" | "08003" | "08006"
747 );
748 }
749 let msg = pg_err.to_string().to_lowercase();
751 msg.contains("connection reset")
752 || msg.contains("broken pipe")
753 || msg.contains("connection closed")
754 || msg.contains("unexpected eof")
755 }
756 #[cfg(feature = "mysql")]
757 WaypointError::MysqlError(my_err) => {
758 let msg = my_err.to_string().to_lowercase();
762 msg.contains("connection reset")
763 || msg.contains("broken pipe")
764 || msg.contains("connection closed")
765 || msg.contains("server has gone away")
766 || msg.contains("lost connection")
767 || msg.contains("io error")
768 }
769 WaypointError::ConnectionLost { .. } => true,
770 _ => false,
771 }
772}
773
774#[cfg(feature = "postgres")]
776pub async fn check_connection(client: &Client) -> Result<()> {
777 client
778 .simple_query("")
779 .await
780 .map_err(|e| WaypointError::ConnectionLost {
781 operation: "health check".to_string(),
782 detail: e.to_string(),
783 })?;
784 Ok(())
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 #[test]
794 fn test_inject_keepalive_url_style() {
795 let result = inject_keepalive("postgres://user:pass@localhost/db", 120);
796 assert_eq!(
797 result,
798 "postgres://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
799 );
800 }
801
802 #[test]
803 fn test_inject_keepalive_url_with_existing_params() {
804 let result = inject_keepalive("postgres://user:pass@localhost/db?sslmode=require", 60);
805 assert_eq!(
806 result,
807 "postgres://user:pass@localhost/db?sslmode=require&keepalives=1&keepalives_idle=60"
808 );
809 }
810
811 #[test]
812 fn test_inject_keepalive_kv_style() {
813 let result = inject_keepalive("host=localhost port=5432 user=admin dbname=mydb", 90);
814 assert_eq!(
815 result,
816 "host=localhost port=5432 user=admin dbname=mydb keepalives=1 keepalives_idle=90"
817 );
818 }
819
820 #[test]
821 fn test_inject_keepalive_zero_disables() {
822 let result = inject_keepalive("postgres://user:pass@localhost/db", 0);
823 assert_eq!(result, "postgres://user:pass@localhost/db");
824 }
825
826 #[test]
827 fn test_inject_keepalive_already_present() {
828 let result = inject_keepalive("postgres://user:pass@localhost/db?keepalives=1", 120);
829 assert_eq!(result, "postgres://user:pass@localhost/db?keepalives=1");
830 }
831
832 #[test]
835 fn test_transient_error_connection_lost() {
836 let err = WaypointError::ConnectionLost {
837 operation: "test".to_string(),
838 detail: "gone".to_string(),
839 };
840 assert!(is_transient_error(&err));
841 }
842
843 #[test]
844 fn test_transient_error_config_is_not_transient() {
845 let err = WaypointError::ConfigError("bad config".to_string());
846 assert!(!is_transient_error(&err));
847 }
848
849 #[test]
850 fn test_transient_error_migration_failed_is_not_transient() {
851 let err = WaypointError::MigrationFailed {
852 script: "V1__test.sql".to_string(),
853 reason: "syntax error".to_string(),
854 };
855 assert!(!is_transient_error(&err));
856 }
857
858 #[test]
859 fn test_advisory_lock_id_stability() {
860 let id1 = advisory_lock_id("waypoint_schema_history");
862 let id2 = advisory_lock_id("waypoint_schema_history");
863 assert_eq!(id1, id2);
864 let id3 = advisory_lock_id("other_table");
866 assert_ne!(id1, id3);
867 }
868
869 #[test]
870 fn test_transient_error_lock_error_is_not_transient() {
871 let err = WaypointError::LockError("lock failed".to_string());
872 assert!(!is_transient_error(&err));
873 }
874
875 #[test]
876 fn test_transient_error_io_error_is_not_transient() {
877 let err = WaypointError::IoError(std::io::Error::new(
878 std::io::ErrorKind::NotFound,
879 "file not found",
880 ));
881 assert!(!is_transient_error(&err));
882 }
883
884 #[test]
885 fn test_validate_identifier_valid() {
886 assert!(validate_identifier("users").is_ok());
887 assert!(validate_identifier("my_table").is_ok());
888 assert!(validate_identifier("Table123").is_ok());
889 assert!(validate_identifier("a").is_ok());
890 }
891
892 #[test]
893 fn test_validate_identifier_invalid() {
894 assert!(validate_identifier("").is_err());
895 assert!(validate_identifier("my-table").is_err());
896 assert!(validate_identifier("my table").is_err());
897 assert!(validate_identifier("table.name").is_err());
898 assert!(validate_identifier("table;drop").is_err());
899 }
900
901 #[test]
902 fn test_quote_ident_simple() {
903 assert_eq!(quote_ident("users"), "\"users\"");
904 }
905
906 #[test]
907 fn test_quote_ident_embedded_quotes() {
908 assert_eq!(quote_ident("my\"table"), "\"my\"\"table\"");
909 }
910
911 #[test]
912 fn test_quote_ident_empty() {
913 assert_eq!(quote_ident(""), "\"\"");
914 }
915
916 #[test]
917 fn test_inject_keepalive_postgresql_prefix() {
918 let result = inject_keepalive("postgresql://user:pass@localhost/db", 120);
919 assert_eq!(
920 result,
921 "postgresql://user:pass@localhost/db?keepalives=1&keepalives_idle=120"
922 );
923 }
924}