martin 1.13.0

Blazing fast and lightweight tile server with PostGIS, MBTiles, and PMTiles support
Documentation
//! Shared `#[cfg(test)]` fixtures for the reload / tile-source machinery.

/// `PostgreSQL` test-container helpers shared by the builder, discovery, and reload tests.
///
/// Each helper spins up its own pinned `PostGIS` container, so tests never touch the shared
/// `just start` database (which ships pre-existing TIGER/public tables and is ANALYZE-sensitive).
#[cfg(feature = "test-pg")]
pub(crate) mod pg {
    use backon::{ConstantBuilder, Retryable as _};
    use martin_core::tiles::postgres::PostgresPool;
    use testcontainers_modules::postgres::Postgres;
    use testcontainers_modules::testcontainers::runners::AsyncRunner as _;
    use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt as _};

    use crate::config::file::CachePolicy;
    use crate::config::file::postgres::{PostgresAutoDiscoveryBuilder, PostgresConfig};
    use crate::config::primitives::IdResolver;

    /// Launches the pinned, purposely-old `PostGIS` image, retrying a few times for flaky CI pulls.
    pub(crate) async fn start_postgres_11_with_posgis_3_container() -> ContainerAsync<Postgres> {
        const MAX_START_ATTEMPTS: usize = 3;
        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(2);

        (|| async {
            Postgres::default()
                .with_name("postgis/postgis")
                .with_tag("11-3.0") // purposely very old and stable
                .start()
                .await
        })
        .retry(
            ConstantBuilder::default()
                .with_delay(RETRY_DELAY)
                .with_max_times(MAX_START_ATTEMPTS),
        )
        .sleep(tokio::time::sleep)
        .await
        .expect("failed to launch container after retry attempts")
    }

    /// The libpq connection string for a running container.
    pub(crate) async fn connection_string(container: &ContainerAsync<Postgres>) -> String {
        let host = container.get_host().await.expect("resolve container host");
        let port = container
            .get_host_port_ipv4(5432)
            .await
            .expect("resolve container port");
        format!("postgres://postgres:postgres@{host}:{port}/postgres?sslmode=disable")
    }

    /// A builder wired to a fresh container from the given [`PostgresConfig`] YAML.
    ///
    /// Returns the connection string too, so a test can [`seed`] through a separate connection
    /// (the builder's own pool is private) or hand it to a [`PostgresDiscovery`].
    ///
    /// [`PostgresDiscovery`]: crate::config::file::discovery::PostgresDiscovery
    pub(crate) async fn builder_for(
        config_yaml: &str,
    ) -> (
        PostgresAutoDiscoveryBuilder,
        ContainerAsync<Postgres>,
        String,
    ) {
        let container = start_postgres_11_with_posgis_3_container().await;
        let connection_string = connection_string(&container).await;

        let mut config: PostgresConfig =
            serde_saphyr::from_str(config_yaml).expect("parse PostgresConfig YAML");
        config.connection_string = Some(connection_string.clone());

        let builder = PostgresAutoDiscoveryBuilder::new(
            &config,
            IdResolver::default(),
            CachePolicy::default(),
        )
        .await
        .expect("create PostgresAutoDiscoveryBuilder");
        (builder, container, connection_string)
    }

    /// Runs arbitrary setup SQL against the database behind `connection_string`.
    pub(crate) async fn seed(connection_string: &str, sql: &str) {
        let pool = PostgresPool::new(connection_string, None, None, None, 2)
            .await
            .expect("open seed pool");
        pool.get()
            .await
            .expect("acquire seed connection")
            .batch_execute(sql)
            .await
            .expect("execute seed SQL");
    }
}

/// DuckDB shared test helpers.
///
/// Creates a writable temp database file before opening the read-only [`DuckDBPool`]
#[cfg(feature = "unstable-duckdb")]
pub(crate) mod duckdb {
    use std::path::PathBuf;

    use duckdb::Connection;
    use martin_core::tiles::duckdb::DuckDBPool;
    use tempfile::TempDir;

    /// A temp `.duckdb` file populated from a SQL fixture.
    pub(crate) struct TestDatabase {
        _dir: TempDir,
        path: PathBuf,
    }

    impl TestDatabase {
        /// Creates a database file at `filename` inside a temp dir and runs `sql` after
        /// loading the spatial extension.
        pub(crate) fn from_sql(filename: &str, sql: &str) -> Self {
            let dir = TempDir::new().expect("temporary DuckDB directory");
            let path = dir.path().join(filename);
            let conn = Connection::open(&path).expect("writable DuckDB database");
            conn.execute_batch("INSTALL spatial;")
                .expect("spatial extension installed");
            conn.execute_batch("LOAD spatial;")
                .expect("spatial extension loaded");
            conn.execute_batch(sql).expect("fixture SQL executed");
            Self { _dir: dir, path }
        }

        /// Opens a read-only pool against the prepared database file.
        pub(crate) fn read_only_pool(&self, id: &str, pool_size: usize) -> DuckDBPool {
            DuckDBPool::new_database_file(id.to_string(), self.path.clone(), pool_size, None, None)
                .expect("read-only DuckDB pool")
        }
    }

    /// A temp `.parquet` file populated from a SQL fixture.
    pub(crate) struct TestGeoParquet {
        _dir: TempDir,
        path: PathBuf,
    }

    impl TestGeoParquet {
        /// Runs `setup_sql` in memory, then exports `source_table` to a parquet file.
        pub(crate) fn from_sql(
            parquet_filename: &str,
            setup_sql: &str,
            source_table: &str,
        ) -> Self {
            let dir = TempDir::new().expect("temporary GeoParquet directory");
            let path = dir.path().join(parquet_filename);
            let conn = Connection::open_in_memory().expect("in-memory DuckDB database");
            conn.execute_batch("INSTALL spatial;")
                .expect("spatial extension installed");
            conn.execute_batch("LOAD spatial;")
                .expect("spatial extension loaded");
            conn.execute_batch(setup_sql)
                .expect("GeoParquet fixture SQL executed");

            let export_path = path.to_str().expect("parquet path must be UTF-8");
            let escaped_export_path = export_path.replace('\'', "''");
            conn.execute_batch(&format!(
                "COPY {source_table} TO '{escaped_export_path}' (FORMAT PARQUET);"
            ))
            .expect("GeoParquet fixture exported");

            Self { _dir: dir, path }
        }

        #[must_use]
        pub(crate) fn path(&self) -> &std::path::Path {
            &self.path
        }

        /// Opens a read-only database pool for running `read_parquet(...)` queries.
        pub(crate) fn query_pool(&self, id: &str, pool_size: usize) -> DuckDBPool {
            let db_path = self._dir.path().join("_query.duckdb");
            let conn = Connection::open(&db_path).expect("writable scratch DuckDB database");
            conn.execute_batch("INSTALL spatial;")
                .expect("spatial extension installed");
            conn.execute_batch("LOAD spatial;")
                .expect("spatial extension loaded");
            drop(conn);

            DuckDBPool::new_database_file(id.to_string(), db_path, pool_size, None, None)
                .expect("read-only DuckDB query pool")
        }
    }
}