nautilus_infrastructure/sql/
pg.rs1use derive_builder::Builder;
17use regex::Regex;
18use serde::{Deserialize, Serialize};
19use sqlx::{AssertSqlSafe, ConnectOptions, PgPool, postgres::PgConnectOptions};
20
21fn validate_sql_identifier(value: &str, label: &str) -> anyhow::Result<()> {
22 if value.is_empty() {
23 anyhow::bail!("{label} must not be empty");
24 }
25
26 if !value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
27 anyhow::bail!(
28 "{label} contains invalid characters (only alphanumeric and underscore allowed): {value}"
29 );
30 }
31 Ok(())
32}
33
34fn escape_sql_string(value: &str) -> String {
35 value.replace('\'', "''")
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
39#[serde(deny_unknown_fields)]
40#[builder(default)]
41#[cfg_attr(
42 feature = "python",
43 pyo3::pyclass(
44 module = "nautilus_trader.core.nautilus_pyo3.infrastructure",
45 from_py_object
46 )
47)]
48#[cfg_attr(
49 feature = "python",
50 pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.infrastructure")
51)]
52#[allow(
53 clippy::unsafe_derive_deserialize,
54 reason = "config type deserializes plain field values; unsafe PyO3 methods are unrelated"
55)]
56pub struct PostgresConnectOptions {
57 pub host: String,
58 pub port: u16,
59 pub username: String,
60 pub password: String,
61 pub database: String,
62}
63
64impl PostgresConnectOptions {
65 #[must_use]
67 pub const fn new(
68 host: String,
69 port: u16,
70 username: String,
71 password: String,
72 database: String,
73 ) -> Self {
74 Self {
75 host,
76 port,
77 username,
78 password,
79 database,
80 }
81 }
82
83 #[must_use]
84 pub fn connection_string(&self) -> String {
85 format!(
86 "postgres://{username}:{password}@{host}:{port}/{database}",
87 username = self.username,
88 password = self.password,
89 host = self.host,
90 port = self.port,
91 database = self.database
92 )
93 }
94
95 #[must_use]
97 pub fn connection_string_masked(&self) -> String {
98 format!(
99 "postgres://{username}:***@{host}:{port}/{database}",
100 username = self.username,
101 host = self.host,
102 port = self.port,
103 database = self.database
104 )
105 }
106
107 #[must_use]
108 pub fn default_administrator() -> Self {
109 Self::new(
110 String::from("localhost"),
111 5432,
112 String::from("nautilus"),
113 String::from("pass"),
114 String::from("nautilus"),
115 )
116 }
117}
118
119impl Default for PostgresConnectOptions {
120 fn default() -> Self {
121 Self::new(
122 String::from("localhost"),
123 5432,
124 String::from("nautilus"),
125 String::from("pass"),
126 String::from("nautilus"),
127 )
128 }
129}
130
131impl From<PostgresConnectOptions> for PgConnectOptions {
132 fn from(opt: PostgresConnectOptions) -> Self {
133 Self::new()
134 .host(opt.host.as_str())
135 .port(opt.port)
136 .username(opt.username.as_str())
137 .password(opt.password.as_str())
138 .database(opt.database.as_str())
139 .disable_statement_logging()
140 }
141}
142
143#[must_use]
149pub fn get_postgres_connect_options(
150 host: Option<String>,
151 port: Option<u16>,
152 username: Option<String>,
153 password: Option<String>,
154 database: Option<String>,
155) -> PostgresConnectOptions {
156 let defaults = PostgresConnectOptions::default_administrator();
157 let host = host
158 .or_else(|| std::env::var("POSTGRES_HOST").ok())
159 .unwrap_or(defaults.host);
160 let port = port
161 .or_else(|| {
162 std::env::var("POSTGRES_PORT")
163 .map(|port| port.parse::<u16>().unwrap())
164 .ok()
165 })
166 .unwrap_or(defaults.port);
167 let username = username
168 .or_else(|| std::env::var("POSTGRES_USERNAME").ok())
169 .unwrap_or(defaults.username);
170 let database = database
171 .or_else(|| std::env::var("POSTGRES_DATABASE").ok())
172 .unwrap_or(defaults.database);
173 let password = password
174 .or_else(|| std::env::var("POSTGRES_PASSWORD").ok())
175 .unwrap_or(defaults.password);
176 PostgresConnectOptions::new(host, port, username, password, database)
177}
178
179pub async fn connect_pg(options: PgConnectOptions) -> anyhow::Result<PgPool> {
185 Ok(PgPool::connect_with(options).await?)
186}
187
188fn get_schema_dir() -> anyhow::Result<String> {
200 std::env::var("SCHEMA_DIR").or_else(|_| {
201 let nautilus_git_repo_name = "nautilus_trader";
202 let binding = std::env::current_dir().unwrap();
203 let current_dir = binding.to_str().unwrap();
204 match current_dir.find(nautilus_git_repo_name){
205 Some(index) => {
206 let schema_path = current_dir[0..index + nautilus_git_repo_name.len()].to_string() + "/schema/sql";
207 Ok(schema_path)
208 }
209 None => anyhow::bail!("Could not calculate schema dir from current directory path or SCHEMA_DIR env variable")
210 }
211 })
212}
213
214#[expect(
224 clippy::too_many_lines,
225 reason = "Postgres initialization follows the ordered schema and role setup steps"
226)]
227pub async fn init_postgres(
228 pg: &PgPool,
229 database: String,
230 password: String,
231 schema_dir: Option<String>,
232) -> anyhow::Result<()> {
233 log::info!("Initializing Postgres database with target permissions and schema");
234
235 validate_sql_identifier(&database, "database")?;
236
237 match sqlx::query("CREATE SCHEMA IF NOT EXISTS public;")
239 .execute(pg)
240 .await
241 {
242 Ok(_) => log::info!("Schema public created successfully"),
243 Err(e) => log::error!("Error creating schema public: {e:?}"),
244 }
245
246 let escaped_password = escape_sql_string(&password);
248 match sqlx::query(AssertSqlSafe(format!(
249 "CREATE ROLE {database} PASSWORD '{escaped_password}' LOGIN;"
250 )))
251 .execute(pg)
252 .await
253 {
254 Ok(_) => log::info!("Role {database} created successfully"),
255 Err(e) => {
256 if e.to_string().contains("already exists") {
257 log::info!("Role {database} already exists");
258 } else {
259 log::error!("Error creating role {database}: {e:?}");
260 }
261 }
262 }
263
264 let schema_dir = schema_dir.unwrap_or_else(|| get_schema_dir().unwrap());
266 let sql_files = vec!["types.sql", "functions.sql", "partitions.sql", "tables.sql"];
267 let plpgsql_regex =
268 Regex::new(r"\$\$ LANGUAGE plpgsql(?:[ \t\r\n]+SECURITY[ \t\r\n]+DEFINER)?;")?;
269
270 for file_name in &sql_files {
271 log::info!("Executing schema file: {file_name:?}");
272 let file_path = format!("{schema_dir}/{file_name}");
273 let sql_content = std::fs::read_to_string(&file_path)?;
274 let sql_statements: Vec<String> = match *file_name {
275 "functions.sql" | "partitions.sql" => {
276 let mut statements = Vec::new();
277 let mut last_end = 0;
278
279 for mat in plpgsql_regex.find_iter(&sql_content) {
280 let statement = sql_content[last_end..mat.end()].to_string();
281 if !statement.trim().is_empty() {
282 statements.push(statement);
283 }
284 last_end = mat.end();
285 }
286 statements
287 }
288 _ => split_sql_statements(&sql_content),
289 };
290
291 for sql_statement in sql_statements {
292 sqlx::query(AssertSqlSafe(sql_statement.as_str()))
293 .execute(pg)
294 .await
295 .map_err(|e| {
296 if e.to_string().contains("already exists") {
297 log::info!("Already exists error on statement, skipping");
298 } else {
299 panic!("Error executing statement {sql_statement} with error: {e:?}")
300 }
301 })
302 .unwrap();
303 }
304 }
305
306 match sqlx::query(AssertSqlSafe(format!(
308 "GRANT CONNECT ON DATABASE {database} TO {database};"
309 )))
310 .execute(pg)
311 .await
312 {
313 Ok(_) => log::info!("Connect privileges granted to role {database}"),
314 Err(e) => log::error!("Error granting connect privileges to role {database}: {e:?}"),
315 }
316
317 match sqlx::query(AssertSqlSafe(format!(
319 "GRANT ALL PRIVILEGES ON SCHEMA public TO {database};"
320 )))
321 .execute(pg)
322 .await
323 {
324 Ok(_) => log::info!("All schema privileges granted to role {database}"),
325 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
326 }
327
328 match sqlx::query(AssertSqlSafe(format!(
330 "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {database};"
331 )))
332 .execute(pg)
333 .await
334 {
335 Ok(_) => log::info!("All tables privileges granted to role {database}"),
336 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
337 }
338
339 match sqlx::query(AssertSqlSafe(format!(
341 "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {database};"
342 )))
343 .execute(pg)
344 .await
345 {
346 Ok(_) => log::info!("All sequences privileges granted to role {database}"),
347 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
348 }
349
350 match sqlx::query(AssertSqlSafe(format!(
352 "GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO {database};"
353 )))
354 .execute(pg)
355 .await
356 {
357 Ok(_) => log::info!("All functions privileges granted to role {database}"),
358 Err(e) => log::error!("Error granting all privileges to role {database}: {e:?}"),
359 }
360
361 Ok(())
362}
363
364fn split_sql_statements(sql: &str) -> Vec<String> {
370 let mut statements = Vec::new();
371 let mut current = String::new();
372 let mut chars = sql.chars().peekable();
373 let mut in_string = false;
374
375 while let Some(c) = chars.next() {
376 match c {
377 '\'' => {
378 in_string = !in_string;
380 current.push(c);
381 }
382 '-' if !in_string && chars.peek() == Some(&'-') => {
383 for next in chars.by_ref() {
384 if next == '\n' {
385 current.push('\n');
386 break;
387 }
388 }
389 }
390 ';' if !in_string => {
391 let trimmed = current.trim();
392 if !trimmed.is_empty() {
393 statements.push(format!("{trimmed};"));
394 }
395 current.clear();
396 }
397 _ => current.push(c),
398 }
399 }
400
401 let trimmed = current.trim();
402 if !trimmed.is_empty() {
403 statements.push(format!("{trimmed};"));
404 }
405
406 statements
407}
408
409pub async fn drop_postgres(pg: &PgPool, database: String) -> anyhow::Result<()> {
415 validate_sql_identifier(&database, "database")?;
416
417 match sqlx::query(AssertSqlSafe(format!("DROP OWNED BY {database}")))
419 .execute(pg)
420 .await
421 {
422 Ok(_) => log::info!("Dropped owned objects by role {database}"),
423 Err(e) => {
424 let err_msg = e.to_string();
425 if err_msg.contains("2BP01") || err_msg.contains("required by the database system") {
426 log::warn!("Skipping system-required objects for role {database}");
427 } else {
428 log::error!("Error dropping owned by role {database}: {e:?}");
429 }
430 }
431 }
432
433 match sqlx::query(AssertSqlSafe(format!(
435 "REVOKE CONNECT ON DATABASE {database} FROM {database};"
436 )))
437 .execute(pg)
438 .await
439 {
440 Ok(_) => log::info!("Revoked connect privileges from role {database}"),
441 Err(e) => log::error!("Error revoking connect privileges from role {database}: {e:?}"),
442 }
443
444 match sqlx::query(AssertSqlSafe(format!(
446 "REVOKE ALL PRIVILEGES ON DATABASE {database} FROM {database};"
447 )))
448 .execute(pg)
449 .await
450 {
451 Ok(_) => log::info!("Revoked all privileges from role {database}"),
452 Err(e) => log::error!("Error revoking all privileges from role {database}: {e:?}"),
453 }
454
455 match sqlx::query("DROP SCHEMA IF EXISTS public CASCADE")
457 .execute(pg)
458 .await
459 {
460 Ok(_) => log::info!("Dropped schema public"),
461 Err(e) => log::error!("Error dropping schema public: {e:?}"),
462 }
463
464 match sqlx::query(AssertSqlSafe(format!("DROP ROLE IF EXISTS {database};")))
466 .execute(pg)
467 .await
468 {
469 Ok(_) => log::info!("Dropped role {database}"),
470 Err(e) => {
471 let err_msg = e.to_string();
472 if err_msg.contains("55006") || err_msg.contains("current user cannot be dropped") {
473 log::warn!("Cannot drop currently connected role {database}");
474 } else {
475 log::error!("Error dropping role {database}: {e:?}");
476 }
477 }
478 }
479 Ok(())
480}
481
482#[cfg(test)]
483mod tests {
484 use rstest::rstest;
485
486 use super::*;
487
488 #[rstest]
489 fn test_postgres_connect_options_toml_round_trip() {
490 let config: PostgresConnectOptions = toml::from_str(
491 r#"
492host = "localhost"
493port = 5432
494username = "nautilus"
495password = "secret"
496database = "nautilus"
497"#,
498 )
499 .unwrap();
500
501 assert_eq!(config.host, "localhost");
502 assert_eq!(config.port, 5432);
503 assert_eq!(config.username, "nautilus");
504 assert_eq!(config.database, "nautilus");
505 }
506
507 #[rstest]
508 fn test_split_sql_statements_basic() {
509 let sql = "CREATE TABLE a (id INT); CREATE TABLE b (id INT);";
510 assert_eq!(
511 split_sql_statements(sql),
512 vec!["CREATE TABLE a (id INT);", "CREATE TABLE b (id INT);"]
513 );
514 }
515
516 #[rstest]
517 fn test_split_sql_statements_ignores_semicolon_in_line_comment() {
518 let sql = "\
520-- start points; a later run re-validates them.
521ALTER TABLE pool_snapshot ADD COLUMN IF NOT EXISTS validation_state TEXT;";
522 assert_eq!(
523 split_sql_statements(sql),
524 vec!["ALTER TABLE pool_snapshot ADD COLUMN IF NOT EXISTS validation_state TEXT;"]
525 );
526 }
527
528 #[rstest]
529 fn test_split_sql_statements_keeps_code_before_trailing_comment() {
530 let sql = "CREATE TABLE a (\n id INT, -- REFERENCES x;\n name TEXT\n);";
531 assert_eq!(
532 split_sql_statements(sql),
533 vec!["CREATE TABLE a (\n id INT, \n name TEXT\n);"]
534 );
535 }
536
537 #[rstest]
538 fn test_split_sql_statements_ignores_semicolon_in_string_literal() {
539 let sql = "INSERT INTO t VALUES ('a;b'); SELECT 1;";
540 assert_eq!(
541 split_sql_statements(sql),
542 vec!["INSERT INTO t VALUES ('a;b');", "SELECT 1;"]
543 );
544 }
545
546 #[rstest]
547 fn test_split_sql_statements_drops_comment_only_lines() {
548 let sql =
549 "------------------- ENUMS -------------------\nCREATE TYPE x AS ENUM ('A', 'B');";
550 assert_eq!(
551 split_sql_statements(sql),
552 vec!["CREATE TYPE x AS ENUM ('A', 'B');"]
553 );
554 }
555}