1use std::fmt::Debug;
17
18use derive_builder::Builder;
19use regex::Regex;
20use serde::{Deserialize, Serialize};
21use sqlx::{
22 AssertSqlSafe, ConnectOptions, PgPool,
23 postgres::{PgConnectOptions, PgConnection},
24};
25
26fn validate_sql_identifier(value: &str, label: &str) -> anyhow::Result<()> {
27 if value.is_empty() {
28 anyhow::bail!("{label} must not be empty");
29 }
30
31 if !value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
32 anyhow::bail!(
33 "{label} contains invalid characters (only alphanumeric and underscore allowed): {value}"
34 );
35 }
36 Ok(())
37}
38
39fn escape_sql_string(value: &str) -> String {
40 value.replace('\'', "''")
41}
42
43#[derive(Clone, Serialize, Deserialize, Builder)]
44#[serde(deny_unknown_fields)]
45#[builder(default)]
46#[cfg_attr(
47 feature = "python",
48 pyo3::pyclass(module = "nautilus_trader.infrastructure", from_py_object)
49)]
50#[cfg_attr(
51 feature = "python",
52 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.infrastructure")
53)]
54#[allow(
55 clippy::unsafe_derive_deserialize,
56 reason = "config type deserializes plain field values; unsafe PyO3 methods are unrelated"
57)]
58pub struct PostgresConnectOptions {
59 pub host: String,
60 pub port: u16,
61 pub username: String,
62 pub password: String,
63 pub database: String,
64}
65
66impl Debug for PostgresConnectOptions {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct(stringify!(PostgresConnectOptions))
69 .field("host", &self.host)
70 .field("port", &self.port)
71 .field("username", &self.username)
72 .field("password", &"***")
73 .field("database", &self.database)
74 .finish()
75 }
76}
77
78impl PostgresConnectOptions {
79 #[must_use]
81 pub const fn new(
82 host: String,
83 port: u16,
84 username: String,
85 password: String,
86 database: String,
87 ) -> Self {
88 Self {
89 host,
90 port,
91 username,
92 password,
93 database,
94 }
95 }
96
97 #[must_use]
98 pub fn connection_string(&self) -> String {
99 format!(
100 "postgres://{username}:{password}@{host}:{port}/{database}",
101 username = self.username,
102 password = self.password,
103 host = self.host,
104 port = self.port,
105 database = self.database
106 )
107 }
108
109 #[must_use]
111 pub fn connection_string_masked(&self) -> String {
112 format!(
113 "postgres://{username}:***@{host}:{port}/{database}",
114 username = self.username,
115 host = self.host,
116 port = self.port,
117 database = self.database
118 )
119 }
120
121 #[must_use]
122 pub fn default_administrator() -> Self {
123 Self::new(
124 String::from("localhost"),
125 5432,
126 String::from("nautilus"),
127 String::from("pass"),
128 String::from("nautilus"),
129 )
130 }
131}
132
133impl Default for PostgresConnectOptions {
134 fn default() -> Self {
135 Self::new(
136 String::from("localhost"),
137 5432,
138 String::from("nautilus"),
139 String::from("pass"),
140 String::from("nautilus"),
141 )
142 }
143}
144
145impl From<PostgresConnectOptions> for PgConnectOptions {
146 fn from(opt: PostgresConnectOptions) -> Self {
147 Self::new()
148 .host(opt.host.as_str())
149 .port(opt.port)
150 .username(opt.username.as_str())
151 .password(opt.password.as_str())
152 .database(opt.database.as_str())
153 .disable_statement_logging()
154 }
155}
156
157#[must_use]
163pub fn get_postgres_connect_options(
164 host: Option<String>,
165 port: Option<u16>,
166 username: Option<String>,
167 password: Option<String>,
168 database: Option<String>,
169) -> PostgresConnectOptions {
170 let defaults = PostgresConnectOptions::default_administrator();
171 let host = host
172 .or_else(|| std::env::var("POSTGRES_HOST").ok())
173 .unwrap_or(defaults.host);
174 let port = port
175 .or_else(|| {
176 std::env::var("POSTGRES_PORT")
177 .map(|port| port.parse::<u16>().unwrap())
178 .ok()
179 })
180 .unwrap_or(defaults.port);
181 let username = username
182 .or_else(|| std::env::var("POSTGRES_USERNAME").ok())
183 .unwrap_or(defaults.username);
184 let database = database
185 .or_else(|| std::env::var("POSTGRES_DATABASE").ok())
186 .unwrap_or(defaults.database);
187 let password = password
188 .or_else(|| std::env::var("POSTGRES_PASSWORD").ok())
189 .unwrap_or(defaults.password);
190 PostgresConnectOptions::new(host, port, username, password, database)
191}
192
193pub async fn connect_pg(options: PgConnectOptions) -> anyhow::Result<PgPool> {
199 Ok(PgPool::connect_with(options).await?)
200}
201
202fn get_schema_dir() -> anyhow::Result<String> {
214 std::env::var("SCHEMA_DIR").or_else(|_| {
215 let nautilus_git_repo_name = "nautilus_trader";
216 let binding = std::env::current_dir().unwrap();
217 let current_dir = binding.to_str().unwrap();
218 match current_dir.find(nautilus_git_repo_name){
219 Some(index) => {
220 let schema_path = current_dir[0..index + nautilus_git_repo_name.len()].to_string() + "/schema/sql";
221 Ok(schema_path)
222 }
223 None => anyhow::bail!("Could not calculate schema dir from current directory path or SCHEMA_DIR env variable")
224 }
225 })
226}
227
228pub async fn init_postgres(
238 pg: &PgPool,
239 database: String,
240 password: String,
241 schema_dir: Option<String>,
242) -> anyhow::Result<()> {
243 log::info!("Initializing Postgres database with target permissions and schema");
244
245 validate_sql_identifier(&database, "database")?;
246 let mut connection = pg.acquire().await?;
247
248 match sqlx::query("CREATE SCHEMA IF NOT EXISTS public;")
250 .execute(&mut *connection)
251 .await
252 {
253 Ok(_) => log::info!("Schema public created successfully"),
254 Err(e) => log::error!("Error creating schema public: {e:?}"),
255 }
256
257 let escaped_password = escape_sql_string(&password);
259 match sqlx::query(AssertSqlSafe(format!(
260 "CREATE ROLE {database} PASSWORD '{escaped_password}' LOGIN;"
261 )))
262 .execute(&mut *connection)
263 .await
264 {
265 Ok(_) => log::info!("Role {database} created successfully"),
266 Err(e) => {
267 if e.to_string().contains("already exists") {
268 log::info!("Role {database} already exists");
269 } else {
270 log::error!("Error creating role {database}: {e:?}");
271 }
272 }
273 }
274
275 let schema_dir = schema_dir.unwrap_or_else(|| get_schema_dir().unwrap());
276 assign_schema_ownership(&mut connection, &database).await?;
277 sqlx::query(AssertSqlSafe(format!(
278 "ALTER DATABASE {database} OWNER TO {database};"
279 )))
280 .execute(&mut *connection)
281 .await?;
282 sqlx::query(AssertSqlSafe(format!(
283 "ALTER SCHEMA public OWNER TO {database};"
284 )))
285 .execute(&mut *connection)
286 .await?;
287 execute_schema_as_role(&mut connection, &database, &schema_dir).await?;
288
289 match sqlx::query(AssertSqlSafe(format!(
291 "GRANT CONNECT ON DATABASE {database} TO {database};"
292 )))
293 .execute(&mut *connection)
294 .await
295 {
296 Ok(_) => log::info!("Connect privileges granted to role {database}"),
297 Err(e) => log::error!("Error granting connect privileges to role {database}: {e:?}"),
298 }
299
300 match sqlx::query(AssertSqlSafe(format!(
302 "GRANT ALL PRIVILEGES ON SCHEMA public TO {database};"
303 )))
304 .execute(&mut *connection)
305 .await
306 {
307 Ok(_) => log::info!("All schema privileges granted to role {database}"),
308 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
309 }
310
311 match sqlx::query(AssertSqlSafe(format!(
313 "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {database};"
314 )))
315 .execute(&mut *connection)
316 .await
317 {
318 Ok(_) => log::info!("All tables privileges granted to role {database}"),
319 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
320 }
321
322 match sqlx::query(AssertSqlSafe(format!(
324 "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {database};"
325 )))
326 .execute(&mut *connection)
327 .await
328 {
329 Ok(_) => log::info!("All sequences privileges granted to role {database}"),
330 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
331 }
332
333 match sqlx::query(AssertSqlSafe(format!(
335 "GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO {database};"
336 )))
337 .execute(&mut *connection)
338 .await
339 {
340 Ok(_) => log::info!("All functions privileges granted to role {database}"),
341 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
342 }
343
344 Ok(())
345}
346
347#[expect(
348 clippy::too_many_lines,
349 reason = "The catalog query stays intact as one ownership migration boundary"
350)]
351async fn assign_schema_ownership(
352 connection: &mut PgConnection,
353 database: &str,
354) -> anyhow::Result<()> {
355 let statements: Vec<String> = sqlx::query_scalar(
356 "
357 SELECT statement
358 FROM (
359 SELECT
360 1 AS object_order,
361 CASE c.relkind
362 WHEN 'S' THEN format(
363 'ALTER SEQUENCE %I.%I OWNER TO %I',
364 n.nspname,
365 c.relname,
366 $1
367 )
368 WHEN 'v' THEN format(
369 'ALTER VIEW %I.%I OWNER TO %I',
370 n.nspname,
371 c.relname,
372 $1
373 )
374 WHEN 'm' THEN format(
375 'ALTER MATERIALIZED VIEW %I.%I OWNER TO %I',
376 n.nspname,
377 c.relname,
378 $1
379 )
380 WHEN 'f' THEN format(
381 'ALTER FOREIGN TABLE %I.%I OWNER TO %I',
382 n.nspname,
383 c.relname,
384 $1
385 )
386 ELSE format(
387 'ALTER TABLE %I.%I OWNER TO %I',
388 n.nspname,
389 c.relname,
390 $1
391 )
392 END AS statement
393 FROM pg_class c
394 JOIN pg_namespace n ON n.oid = c.relnamespace
395 WHERE n.nspname = 'public'
396 AND c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
397 AND (
398 c.relkind <> 'S'
399 OR NOT EXISTS (
400 SELECT 1
401 FROM pg_depend d
402 WHERE d.classid = 'pg_class'::regclass
403 AND d.objid = c.oid
404 AND d.refclassid = 'pg_class'::regclass
405 AND d.deptype IN ('a', 'i')
406 )
407 )
408
409 UNION ALL
410
411 SELECT
412 2 AS object_order,
413 format(
414 'ALTER %s %I.%I(%s) OWNER TO %I',
415 CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END,
416 n.nspname,
417 p.proname,
418 pg_get_function_identity_arguments(p.oid),
419 $1
420 ) AS statement
421 FROM pg_proc p
422 JOIN pg_namespace n ON n.oid = p.pronamespace
423 WHERE n.nspname = 'public'
424 AND p.prokind IN ('f', 'p', 'w')
425
426 UNION ALL
427
428 SELECT
429 3 AS object_order,
430 CASE t.typtype
431 WHEN 'd' THEN format(
432 'ALTER DOMAIN %I.%I OWNER TO %I',
433 n.nspname,
434 t.typname,
435 $1
436 )
437 ELSE format(
438 'ALTER TYPE %I.%I OWNER TO %I',
439 n.nspname,
440 t.typname,
441 $1
442 )
443 END AS statement
444 FROM pg_type t
445 JOIN pg_namespace n ON n.oid = t.typnamespace
446 WHERE n.nspname = 'public'
447 AND t.typtype IN ('d', 'e')
448 ) objects
449 ORDER BY object_order, statement
450 ",
451 )
452 .bind(database)
453 .fetch_all(&mut *connection)
454 .await?;
455
456 for statement in statements {
457 sqlx::query(AssertSqlSafe(statement))
458 .execute(&mut *connection)
459 .await?;
460 }
461
462 Ok(())
463}
464
465async fn execute_schema_as_role(
466 connection: &mut PgConnection,
467 database: &str,
468 schema_dir: &str,
469) -> anyhow::Result<()> {
470 sqlx::query(AssertSqlSafe(format!("SET ROLE {database};")))
471 .execute(&mut *connection)
472 .await?;
473
474 let result = async {
475 let sql_files = ["types.sql", "functions.sql", "partitions.sql", "tables.sql"];
476 let plpgsql_regex =
477 Regex::new(r"\$\$ LANGUAGE plpgsql(?:[ \t\r\n]+SECURITY[ \t\r\n]+DEFINER)?;")?;
478
479 for file_name in sql_files {
480 log::info!("Executing schema file: {file_name:?}");
481 let file_path = format!("{schema_dir}/{file_name}");
482 let sql_content = std::fs::read_to_string(&file_path)?;
483 let sql_statements = match file_name {
484 "functions.sql" | "partitions.sql" => {
485 let mut statements = Vec::new();
486 let mut last_end = 0;
487
488 for mat in plpgsql_regex.find_iter(&sql_content) {
489 let statement = sql_content[last_end..mat.end()].to_string();
490 if !statement.trim().is_empty() {
491 statements.push(statement);
492 }
493 last_end = mat.end();
494 }
495 statements
496 }
497 _ => split_sql_statements(&sql_content),
498 };
499
500 for sql_statement in sql_statements {
501 if let Err(e) = sqlx::query(AssertSqlSafe(sql_statement.as_str()))
502 .execute(&mut *connection)
503 .await
504 {
505 if e.to_string().contains("already exists") {
506 log::info!("Already exists error on statement, skipping");
507 } else {
508 anyhow::bail!(
509 "Error executing statement {sql_statement} with error: {e:?}"
510 );
511 }
512 }
513 }
514 }
515
516 Ok(())
517 }
518 .await;
519
520 let reset_result = sqlx::query("RESET ROLE;").execute(connection).await;
521 match (result, reset_result) {
522 (Err(e), Err(reset_error)) => {
523 log::error!("Error resetting Postgres role after schema failure: {reset_error:?}");
524 Err(e)
525 }
526 (Err(e), Ok(_)) => Err(e),
527 (Ok(()), Err(e)) => Err(e.into()),
528 (Ok(()), Ok(_)) => Ok(()),
529 }
530}
531
532fn split_sql_statements(sql: &str) -> Vec<String> {
540 let mut statements = Vec::new();
541 let mut current = String::new();
542 let mut chars = sql.chars().peekable();
543 let mut in_string = false;
544 let mut in_dollar_quote = false;
545
546 while let Some(c) = chars.next() {
547 match c {
548 '\'' if !in_dollar_quote => {
549 in_string = !in_string;
551 current.push(c);
552 }
553
554 '$' if !in_string && chars.peek() == Some(&'$') => {
555 chars.next();
556 in_dollar_quote = !in_dollar_quote;
557 current.push_str("$$");
558 }
559
560 '-' if !in_string && !in_dollar_quote && chars.peek() == Some(&'-') => {
561 for next in chars.by_ref() {
562 if next == '\n' {
563 current.push('\n');
564 break;
565 }
566 }
567 }
568
569 ';' if !in_string && !in_dollar_quote => {
570 let trimmed = current.trim();
571 if !trimmed.is_empty() {
572 statements.push(format!("{trimmed};"));
573 }
574 current.clear();
575 }
576 _ => current.push(c),
577 }
578 }
579
580 let trimmed = current.trim();
581 if !trimmed.is_empty() {
582 statements.push(format!("{trimmed};"));
583 }
584
585 statements
586}
587
588pub async fn drop_postgres(pg: &PgPool, database: String) -> anyhow::Result<()> {
594 validate_sql_identifier(&database, "database")?;
595
596 sqlx::query(AssertSqlSafe(format!(
597 "ALTER DATABASE {database} OWNER TO SESSION_USER"
598 )))
599 .execute(pg)
600 .await?;
601
602 match sqlx::query(AssertSqlSafe(format!("DROP OWNED BY {database}")))
604 .execute(pg)
605 .await
606 {
607 Ok(_) => log::info!("Dropped owned objects by role {database}"),
608 Err(e) => {
609 let err_msg = e.to_string();
610 if err_msg.contains("2BP01") || err_msg.contains("required by the database system") {
611 log::warn!("Skipping system-required objects for role {database}");
612 } else {
613 log::error!("Error dropping owned by role {database}: {e:?}");
614 }
615 }
616 }
617
618 match sqlx::query(AssertSqlSafe(format!(
620 "REVOKE CONNECT ON DATABASE {database} FROM {database};"
621 )))
622 .execute(pg)
623 .await
624 {
625 Ok(_) => log::info!("Revoked connect privileges from role {database}"),
626 Err(e) => log::error!("Error revoking connect privileges from role {database}: {e:?}"),
627 }
628
629 match sqlx::query(AssertSqlSafe(format!(
631 "REVOKE ALL PRIVILEGES ON DATABASE {database} FROM {database};"
632 )))
633 .execute(pg)
634 .await
635 {
636 Ok(_) => log::info!("Revoked all privileges from role {database}"),
637 Err(e) => log::error!("Error revoking all privileges from role {database}: {e:?}"),
638 }
639
640 match sqlx::query("DROP SCHEMA IF EXISTS public CASCADE")
642 .execute(pg)
643 .await
644 {
645 Ok(_) => log::info!("Dropped schema public"),
646 Err(e) => log::error!("Error dropping schema public: {e:?}"),
647 }
648
649 match sqlx::query(AssertSqlSafe(format!("DROP ROLE IF EXISTS {database};")))
651 .execute(pg)
652 .await
653 {
654 Ok(_) => log::info!("Dropped role {database}"),
655 Err(e) => {
656 let err_msg = e.to_string();
657 if err_msg.contains("55006") || err_msg.contains("current user cannot be dropped") {
658 log::warn!("Cannot drop currently connected role {database}");
659 } else {
660 anyhow::bail!("Error dropping role {database}: {e:?}");
661 }
662 }
663 }
664 Ok(())
665}
666
667#[cfg(test)]
668mod tests {
669 use rstest::rstest;
670
671 use super::*;
672
673 #[rstest]
674 fn test_postgres_connect_options_toml_round_trip() {
675 let config: PostgresConnectOptions = toml::from_str(
676 r#"
677host = "localhost"
678port = 5432
679username = "nautilus"
680password = "secret"
681database = "nautilus"
682"#,
683 )
684 .unwrap();
685
686 assert_eq!(config.host, "localhost");
687 assert_eq!(config.port, 5432);
688 assert_eq!(config.username, "nautilus");
689 assert_eq!(config.database, "nautilus");
690 }
691
692 #[rstest]
693 fn test_postgres_connect_options_debug_redacts_password() {
694 let config = PostgresConnectOptions::new(
695 "localhost".to_string(),
696 5432,
697 "nautilus".to_string(),
698 "secret-password".to_string(),
699 "nautilus".to_string(),
700 );
701
702 let debug = format!("{config:?}");
703
704 assert!(debug.contains("password: \"***\""));
705 assert!(!debug.contains("secret-password"));
706 }
707
708 #[rstest]
709 fn test_split_sql_statements_basic() {
710 let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
711 assert_eq!(
712 split_sql_statements(sql),
713 vec!["CREATE TABLE a (id INT);", "CREATE TABLE b (id INT);"]
714 );
715 }
716
717 #[rstest]
718 fn test_split_sql_statements_ignores_semicolon_in_line_comment() {
719 let sql = "\
721-- start points; a later run re-validates them.
722ALTER TABLE pool_snapshot ADD COLUMN IF NOT EXISTS validation_state TEXT;";
723 assert_eq!(
724 split_sql_statements(sql),
725 vec!["ALTER TABLE pool_snapshot ADD COLUMN IF NOT EXISTS validation_state TEXT;"]
726 );
727 }
728
729 #[rstest]
730 fn test_split_sql_statements_keeps_code_before_trailing_comment() {
731 let sql = "CREATE TABLE a (\n id INT, -- REFERENCES x;\n name TEXT\n);";
732 assert_eq!(
733 split_sql_statements(sql),
734 vec!["CREATE TABLE a (\n id INT, \n name TEXT\n);"]
735 );
736 }
737
738 #[rstest]
739 fn test_split_sql_statements_keeps_dollar_quoted_body_intact() {
740 let sql = "\
743DO $$
744BEGIN
745 IF EXISTS (SELECT 1 FROM information_schema.columns WHERE column_name = 'avg_px') THEN
746 ALTER TABLE \"order\" ALTER COLUMN avg_px TYPE NUMERIC;
747 END IF;
748END $$;
749SELECT 1;";
750 let statements = split_sql_statements(sql);
751
752 assert_eq!(statements.len(), 2);
753 assert!(statements[0].starts_with("DO $$"));
754 assert!(statements[0].ends_with("END $$;"));
755 assert!(statements[0].contains("ALTER COLUMN avg_px TYPE NUMERIC;"));
756 assert_eq!(statements[1], "SELECT 1;");
757 }
758
759 #[rstest]
760 fn test_split_sql_statements_ignores_semicolon_in_string_literal() {
761 let sql = "INSERT INTO t VALUES ('a;b'); SELECT 1;";
762 assert_eq!(
763 split_sql_statements(sql),
764 vec!["INSERT INTO t VALUES ('a;b');", "SELECT 1;"]
765 );
766 }
767
768 #[rstest]
769 fn test_split_sql_statements_drops_comment_only_lines() {
770 let sql =
771 "------------------- ENUMS -------------------\nCREATE TYPE x AS ENUM ('A', 'B');";
772 assert_eq!(
773 split_sql_statements(sql),
774 vec!["CREATE TYPE x AS ENUM ('A', 'B');"]
775 );
776 }
777}