Skip to main content

nautilus_infrastructure/sql/
pg.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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    /// Creates a new [`PostgresConnectOptions`] instance.
80    #[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    /// Returns the connection string with the password masked for safe logging.
110    #[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/// Constructs `PostgresConnectOptions` by merging provided arguments, environment variables, and defaults.
158///
159/// # Panics
160///
161/// Panics if an environment variable for port cannot be parsed into a `u16`.
162#[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
193/// Connects to a Postgres database with the provided connection `options` returning a connection pool.
194///
195/// # Errors
196///
197/// Returns an error if establishing the database connection fails.
198pub async fn connect_pg(options: PgConnectOptions) -> anyhow::Result<PgPool> {
199    Ok(PgPool::connect_with(options).await?)
200}
201
202/// Scans the current working directory for the `nautilus_trader` repository
203/// and constructs the path to the SQL schema directory.
204///
205/// # Errors
206///
207/// Returns an error if the `SCHEMA_DIR` environment variable is not set and the repository
208/// cannot be located in the current directory path.
209///
210/// # Panics
211///
212/// Panics if the current working directory cannot be determined or contains invalid UTF-8.
213fn 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
228/// Initializes the Postgres database by creating schema, roles, and executing SQL files from `schema_dir`.
229///
230/// # Errors
231///
232/// Returns an error if any SQL execution or file system operation fails.
233///
234/// # Panics
235///
236/// Panics if `schema_dir` is missing and cannot be determined or if other unwraps fail.
237pub 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    // Create public schema
249    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    // Create role if not exists
258    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    // Grant connect
290    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    // Grant all schema privileges to the role
301    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    // Grant all table privileges to the role
312    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    // Grant all sequence privileges to the role
323    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    // Grant all function privileges to the role
334    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
532// Splits semicolon-delimited SQL into individual statements.
533//
534// Skips `--` line comments and respects single-quoted string literals and `$$` dollar-quoted
535// bodies, so a semicolon inside a comment, string literal, or `DO` block does not split a
536// statement. Tagged `$tag$` quoting is not recognised; keep the schema files on bare `$$`.
537// Used for the plain DDL schema files; the PL/pgSQL files are split separately on their
538// function terminators.
539fn 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                // A `''` escape toggles twice, leaving the state unchanged, which is correct
550                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
588/// Drops the Postgres database with the given name using the provided connection pool.
589///
590/// # Errors
591///
592/// Returns an error if the DROP DATABASE command fails.
593pub 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    // Execute drop owned
603    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    // Revoke connect
619    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    // Revoke privileges
630    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    // Execute drop schema
641    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    // Drop role
650    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        // Regression: a `;` inside a `--` comment must not split the following statement
720        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        // The guarded column migrations are `DO $$ ... $$` blocks whose bodies carry their own
741        // semicolons; splitting on those would hand Postgres a fragment.
742        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}