Skip to main content

iceberg_catalog_sql/
catalog.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18use std::collections::{HashMap, HashSet};
19use std::str::FromStr;
20use std::sync::Arc;
21use std::time::Duration;
22
23use async_trait::async_trait;
24use iceberg::encryption::kms::{KeyManagementClient, KmsClientFactory};
25use iceberg::io::{FileIO, FileIOBuilder, StorageFactory};
26use iceberg::spec::{TableMetadata, TableMetadataBuilder};
27use iceberg::table::Table;
28use iceberg::{
29    Catalog, CatalogBuilder, Error, ErrorKind, MetadataLocation, Namespace, NamespaceIdent, Result,
30    Runtime, TableCommit, TableCreation, TableIdent,
31};
32use sqlx::any::{AnyPoolOptions, AnyQueryResult, AnyRow, install_default_drivers};
33use sqlx::{Any, AnyPool, Column, Executor, Row, Transaction};
34
35use crate::error::{
36    from_sqlx_error, no_such_namespace_err, no_such_table_err, table_already_exists_err,
37};
38
39/// catalog URI
40pub const SQL_CATALOG_PROP_URI: &str = "uri";
41/// catalog warehouse location
42pub const SQL_CATALOG_PROP_WAREHOUSE: &str = "warehouse";
43/// catalog sql bind style
44pub const SQL_CATALOG_PROP_BIND_STYLE: &str = "sql.bind-style";
45/// Legacy (pre-`sql.bind-style`) key for [`SQL_CATALOG_PROP_BIND_STYLE`], still accepted for
46/// backward compatibility.
47const SQL_CATALOG_PROP_BIND_STYLE_LEGACY: &str = "sql_bind_style";
48/// Expected catalog schema version.
49///
50/// If this property is set and it is newer than the detected schema version,
51/// a migration will be attempted.
52/// If it is older, it is ignored with a warning.
53/// If the catalog table didn't already exist, this value is ignored and it will be created with `V1`.
54///
55/// `V0` is a compatibility mode for catalog tables created before the `iceberg_type` column
56/// existed; it cannot be requested for a new catalog table, since table creation and
57/// registration are unsupported on `V0`.
58pub const SQL_CATALOG_PROP_SCHEMA_VERSION: &str = "sql.schema-version";
59
60static CATALOG_TABLE_NAME: &str = "iceberg_tables";
61static CATALOG_FIELD_CATALOG_NAME: &str = "catalog_name";
62static CATALOG_FIELD_TABLE_NAME: &str = "table_name";
63static CATALOG_FIELD_TABLE_NAMESPACE: &str = "table_namespace";
64static CATALOG_FIELD_METADATA_LOCATION_PROP: &str = "metadata_location";
65static CATALOG_FIELD_PREVIOUS_METADATA_LOCATION_PROP: &str = "previous_metadata_location";
66static CATALOG_FIELD_RECORD_TYPE: &str = "iceberg_type";
67static CATALOG_FIELD_TABLE_RECORD_TYPE: &str = "TABLE";
68
69static NAMESPACE_TABLE_NAME: &str = "iceberg_namespace_properties";
70static NAMESPACE_FIELD_NAME: &str = "namespace";
71static NAMESPACE_FIELD_PROPERTY_KEY: &str = "property_key";
72static NAMESPACE_FIELD_PROPERTY_VALUE: &str = "property_value";
73
74static NAMESPACE_LOCATION_PROPERTY_KEY: &str = "location";
75
76static MAX_CONNECTIONS: u32 = 10; // Default the SQL pool to 10 connections if not provided
77static IDLE_TIMEOUT: u64 = 10; // Default the maximum idle timeout per connection to 10s before it is closed
78static TEST_BEFORE_ACQUIRE: bool = true; // Default the health-check of each connection to enabled prior to returning
79
80fn parse_pool_property<T>(
81    props: &HashMap<String, String>,
82    property: &'static str,
83    default: T,
84) -> Result<T>
85where
86    T: FromStr,
87    T::Err: std::error::Error + Send + Sync + 'static,
88{
89    props.get(property).map_or(Ok(default), |value| {
90        value.parse().map_err(|error| {
91            Error::new(
92                ErrorKind::DataInvalid,
93                "Failed to parse SQL catalog pool property",
94            )
95            .with_context("property", property)
96            .with_context("value", value)
97            .with_source(error)
98        })
99    })
100}
101
102/// Builder for [`SqlCatalog`]
103#[derive(Debug)]
104pub struct SqlCatalogBuilder {
105    config: SqlCatalogConfig,
106    storage_factory: Option<Arc<dyn StorageFactory>>,
107    kms_client_factory: Option<Arc<dyn KmsClientFactory>>,
108    runtime: Option<Runtime>,
109}
110
111impl Default for SqlCatalogBuilder {
112    fn default() -> Self {
113        Self {
114            config: SqlCatalogConfig {
115                uri: "".to_string(),
116                name: "".to_string(),
117                warehouse_location: "".to_string(),
118                sql_bind_style: SqlBindStyle::DollarNumeric,
119                schema_version: None,
120                props: HashMap::new(),
121            },
122            storage_factory: None,
123            kms_client_factory: None,
124            runtime: None,
125        }
126    }
127}
128
129impl SqlCatalogBuilder {
130    /// Configure the database URI
131    ///
132    /// If `SQL_CATALOG_PROP_URI` has a value set in `props` during `SqlCatalogBuilder::load`,
133    /// that value takes precedence, and the value specified by this method will not be used.
134    pub fn uri(mut self, uri: impl Into<String>) -> Self {
135        self.config.uri = uri.into();
136        self
137    }
138
139    /// Configure the warehouse location
140    ///
141    /// If `SQL_CATALOG_PROP_WAREHOUSE` has a value set in `props` during `SqlCatalogBuilder::load`,
142    /// that value takes precedence, and the value specified by this method will not be used.
143    pub fn warehouse_location(mut self, location: impl Into<String>) -> Self {
144        self.config.warehouse_location = location.into();
145        self
146    }
147
148    /// Configure the bound SQL Statement
149    ///
150    /// If `SQL_CATALOG_PROP_BIND_STYLE` has a value set in `props` during `SqlCatalogBuilder::load`,
151    /// that value takes precedence, and the value specified by this method will not be used.
152    pub fn sql_bind_style(mut self, sql_bind_style: SqlBindStyle) -> Self {
153        self.config.sql_bind_style = sql_bind_style;
154        self
155    }
156
157    /// Configure the any properties
158    ///
159    /// If the same key has values set in `props` during `SqlCatalogBuilder::load`,
160    /// those values will take precedence.
161    pub fn props(mut self, props: HashMap<String, String>) -> Self {
162        for (k, v) in props {
163            self.config.props.insert(k, v);
164        }
165        self
166    }
167
168    /// Set a new property on the property to be configured.
169    /// When multiple methods are executed with the same key,
170    /// the later-set value takes precedence.
171    ///
172    /// If the same key has values set in `props` during `SqlCatalogBuilder::load`,
173    /// those values will take precedence.
174    pub fn prop(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
175        self.config.props.insert(key.into(), value.into());
176        self
177    }
178}
179
180impl CatalogBuilder for SqlCatalogBuilder {
181    type C = SqlCatalog;
182
183    fn with_storage_factory(mut self, storage_factory: Arc<dyn StorageFactory>) -> Self {
184        self.storage_factory = Some(storage_factory);
185        self
186    }
187
188    fn with_kms_client_factory(mut self, kms_client_factory: Arc<dyn KmsClientFactory>) -> Self {
189        self.kms_client_factory = Some(kms_client_factory);
190        self
191    }
192
193    fn with_runtime(mut self, runtime: Runtime) -> Self {
194        self.runtime = Some(runtime);
195        self
196    }
197
198    fn load(
199        mut self,
200        name: impl Into<String>,
201        props: HashMap<String, String>,
202    ) -> impl Future<Output = Result<Self::C>> + Send {
203        for (k, v) in props {
204            self.config.props.insert(k, v);
205        }
206
207        if let Some(uri) = self.config.props.remove(SQL_CATALOG_PROP_URI) {
208            self.config.uri = uri;
209        }
210        if let Some(warehouse_location) = self.config.props.remove(SQL_CATALOG_PROP_WAREHOUSE) {
211            self.config.warehouse_location = warehouse_location;
212        }
213
214        let name = name.into();
215
216        let mut valid_sql_bind_style = true;
217
218        // Accept the preferred `sql.bind-style` key, falling back to the legacy `sql_bind_style`.
219        let sql_bind_style = self
220            .config
221            .props
222            .remove(SQL_CATALOG_PROP_BIND_STYLE)
223            .or_else(|| self.config.props.remove(SQL_CATALOG_PROP_BIND_STYLE_LEGACY));
224
225        // Validate the SQL bind style
226        if let Some(sql_bind_style) = sql_bind_style {
227            if let Ok(sql_bind_style) = SqlBindStyle::from_str(&sql_bind_style) {
228                self.config.sql_bind_style = sql_bind_style;
229            } else {
230                valid_sql_bind_style = false;
231            }
232        }
233
234        // Parse the requested schema version up front so invalid values fail fast rather than
235        // silently falling back to V0.
236        let mut valid_schema_version = true;
237        if let Some(schema_version) = self.config.props.remove(SQL_CATALOG_PROP_SCHEMA_VERSION) {
238            match SchemaVersion::from_str(&schema_version) {
239                Ok(schema_version) => self.config.schema_version = Some(schema_version),
240                Err(_) => valid_schema_version = false,
241            }
242        }
243
244        let valid_name = !name.trim().is_empty();
245
246        async move {
247            if !valid_name {
248                Err(Error::new(
249                    ErrorKind::DataInvalid,
250                    "Catalog name cannot be empty",
251                ))
252            } else if !valid_sql_bind_style {
253                Err(Error::new(
254                    ErrorKind::DataInvalid,
255                    format!(
256                        "`{}` values are valid only if they're `{}` or `{}`",
257                        SQL_CATALOG_PROP_BIND_STYLE,
258                        SqlBindStyle::DollarNumeric,
259                        SqlBindStyle::QMark
260                    ),
261                ))
262            } else if !valid_schema_version {
263                Err(Error::new(
264                    ErrorKind::DataInvalid,
265                    format!(
266                        "`{}` values are valid only if they're `{}` or `{}`",
267                        SQL_CATALOG_PROP_SCHEMA_VERSION,
268                        SchemaVersion::V0,
269                        SchemaVersion::V1
270                    ),
271                ))
272            } else {
273                self.config.name = name;
274                let runtime = match self.runtime {
275                    Some(rt) => rt,
276                    None => Runtime::try_current()?,
277                };
278                let kms_client = match self.kms_client_factory {
279                    Some(factory) => Some(factory.create_kms_client(&self.config.props).await?),
280                    None => None,
281                };
282                SqlCatalog::new(self.config, self.storage_factory, runtime, kms_client).await
283            }
284        }
285    }
286}
287
288/// A struct representing the SQL catalog configuration.
289///
290/// This struct contains various parameters that are used to configure a SQL catalog,
291/// such as the database URI, warehouse location, and file I/O settings.
292/// You are required to provide a `SqlBindStyle`, which determines how SQL statements will be bound to values in the catalog.
293/// The options available for this parameter include:
294/// - `SqlBindStyle::DollarNumeric`: Binds SQL statements using `$1`, `$2`, etc., as placeholders. This is for PostgreSQL databases.
295/// - `SqlBindStyle::QuestionMark`: Binds SQL statements using `?` as a placeholder. This is for MySQL and SQLite databases.
296#[derive(Debug)]
297struct SqlCatalogConfig {
298    uri: String,
299    name: String,
300    warehouse_location: String,
301    sql_bind_style: SqlBindStyle,
302    schema_version: Option<SchemaVersion>,
303    props: HashMap<String, String>,
304}
305
306#[derive(Debug)]
307/// SQL catalog implementation.
308///
309/// The catalog supports SQL catalog schema V1, as well as limited support for V0.
310/// Catalogs can opt-in to automatic migration by configuring the `sql.schema-version` catalog property.
311pub struct SqlCatalog {
312    name: String,
313    connection: AnyPool,
314    warehouse_location: String,
315    fileio: FileIO,
316    sql_bind_style: SqlBindStyle,
317    runtime: Runtime,
318    kms_client: Option<Arc<dyn KeyManagementClient>>,
319    schema_version: SchemaVersion,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, strum::EnumString, strum::Display)]
323#[strum(ascii_case_insensitive)]
324/// Schema version of the `iceberg_tables` catalog table.
325pub enum SchemaVersion {
326    /// Original schema without the `iceberg_type` column.
327    V0,
328    /// Extended schema with the `iceberg_type` column for view support.
329    V1,
330}
331
332impl SchemaVersion {
333    /// Detect the schema version of an existing catalog table by introspecting its columns.
334    async fn detect(pool: &AnyPool) -> Result<Self> {
335        let catalog_table_description = pool
336            .describe(&format!("SELECT * FROM {CATALOG_TABLE_NAME}"))
337            .await
338            .map_err(from_sqlx_error)?;
339
340        let has_type_column = catalog_table_description.columns().iter().any(|column| {
341            column
342                .name()
343                .eq_ignore_ascii_case(CATALOG_FIELD_RECORD_TYPE)
344        });
345
346        Ok(if has_type_column {
347            SchemaVersion::V1
348        } else {
349            SchemaVersion::V0
350        })
351    }
352
353    /// The trailing SQL `AND` clause used to exclude view rows when querying for tables.
354    ///
355    /// `V1` schemas carry an `iceberg_type` column, so table rows are those tagged `TABLE`
356    /// (or `NULL`, for rows written before the column existed). `V0` schemas have no such
357    /// column, so no filter is applied.
358    fn record_type_filter(self) -> String {
359        match self {
360            SchemaVersion::V1 => format!(
361                "AND ({CATALOG_FIELD_RECORD_TYPE} = '{CATALOG_FIELD_TABLE_RECORD_TYPE}' \
362                 OR {CATALOG_FIELD_RECORD_TYPE} IS NULL)"
363            ),
364            SchemaVersion::V0 => String::new(),
365        }
366    }
367
368    /// The SQL needed to migrate a `V0` catalog table up to this schema version.
369    ///
370    /// Returns `None` when the target version requires no migration (i.e. `V0`).
371    fn migration_sql(self) -> Option<String> {
372        match self {
373            SchemaVersion::V1 => Some(format!(
374                "ALTER TABLE {CATALOG_TABLE_NAME} ADD COLUMN {CATALOG_FIELD_RECORD_TYPE} VARCHAR(5)"
375            )),
376            SchemaVersion::V0 => None,
377        }
378    }
379}
380
381#[derive(Debug, PartialEq, strum::EnumString, strum::Display)]
382/// Set the SQL parameter bind style to either $1..$N (Postgres style) or ? (SQLite/MySQL/MariaDB)
383pub enum SqlBindStyle {
384    /// DollarNumeric uses parameters of the form `$1..$N``, which is the Postgres style
385    DollarNumeric,
386    /// QMark uses parameters of the form `?` which is the style for other dialects (SQLite/MySQL/MariaDB)
387    QMark,
388}
389
390impl SqlCatalog {
391    /// Create new sql catalog instance
392    async fn new(
393        config: SqlCatalogConfig,
394        storage_factory: Option<Arc<dyn StorageFactory>>,
395        runtime: Runtime,
396        kms_client: Option<Arc<dyn KeyManagementClient>>,
397    ) -> Result<Self> {
398        let factory = storage_factory.ok_or_else(|| {
399            Error::new(
400                ErrorKind::Unexpected,
401                "StorageFactory must be provided for SqlCatalog. Use `with_storage_factory` to configure it.",
402            )
403        })?;
404        // Forward catalog props so storage-backend keys reach the FileIO.
405        // Unrecognized keys are ignored by backends.
406        let fileio = FileIOBuilder::new(factory)
407            .with_props(config.props.clone())
408            .build();
409
410        install_default_drivers();
411        let max_connections =
412            parse_pool_property(&config.props, "pool.max-connections", MAX_CONNECTIONS)?;
413        let idle_timeout = parse_pool_property(&config.props, "pool.idle-timeout", IDLE_TIMEOUT)?;
414        let test_before_acquire = parse_pool_property(
415            &config.props,
416            "pool.test-before-acquire",
417            TEST_BEFORE_ACQUIRE,
418        )?;
419
420        let pool = AnyPoolOptions::new()
421            .max_connections(max_connections)
422            .idle_timeout(Duration::from_secs(idle_timeout))
423            .test_before_acquire(test_before_acquire)
424            .connect(&config.uri)
425            .await
426            .map_err(from_sqlx_error)?;
427
428        sqlx::query(&format!(
429            "CREATE TABLE IF NOT EXISTS {CATALOG_TABLE_NAME} (
430                {CATALOG_FIELD_CATALOG_NAME} VARCHAR(255) NOT NULL,
431                {CATALOG_FIELD_TABLE_NAMESPACE} VARCHAR(255) NOT NULL,
432                {CATALOG_FIELD_TABLE_NAME} VARCHAR(255) NOT NULL,
433                {CATALOG_FIELD_METADATA_LOCATION_PROP} VARCHAR(1000),
434                {CATALOG_FIELD_PREVIOUS_METADATA_LOCATION_PROP} VARCHAR(1000),
435                {CATALOG_FIELD_RECORD_TYPE} VARCHAR(5),
436                PRIMARY KEY ({CATALOG_FIELD_CATALOG_NAME}, {CATALOG_FIELD_TABLE_NAMESPACE}, {CATALOG_FIELD_TABLE_NAME}))"
437        ))
438        .execute(&pool)
439        .await
440        .map_err(from_sqlx_error)?;
441
442        sqlx::query(&format!(
443            "CREATE TABLE IF NOT EXISTS {NAMESPACE_TABLE_NAME} (
444                {CATALOG_FIELD_CATALOG_NAME} VARCHAR(255) NOT NULL,
445                {NAMESPACE_FIELD_NAME} VARCHAR(255) NOT NULL,
446                {NAMESPACE_FIELD_PROPERTY_KEY} VARCHAR(255),
447                {NAMESPACE_FIELD_PROPERTY_VALUE} VARCHAR(1000),
448                PRIMARY KEY ({CATALOG_FIELD_CATALOG_NAME}, {NAMESPACE_FIELD_NAME}, {NAMESPACE_FIELD_PROPERTY_KEY}))"
449        ))
450        .execute(&pool)
451        .await
452        .map_err(from_sqlx_error)?;
453
454        let detected_schema_version = SchemaVersion::detect(&pool).await?;
455        let expected_schema_version = config.schema_version;
456
457        // Detect schema by describing columns. If expected is configured then automigrate, otherwise gracefully support older schemas.
458        let schema_version = match (detected_schema_version, expected_schema_version) {
459            (SchemaVersion::V1, Some(SchemaVersion::V1) | None) => {
460                tracing::debug!(
461                    "detected {CATALOG_TABLE_NAME} schema {} which already supports views",
462                    detected_schema_version,
463                );
464                SchemaVersion::V1
465            }
466            (SchemaVersion::V0, Some(expected_schema_version @ SchemaVersion::V1)) => {
467                tracing::warn!(
468                    "table {CATALOG_TABLE_NAME} has inferred schema {} but expected schema {}, performing migration",
469                    detected_schema_version,
470                    expected_schema_version,
471                );
472                if let Some(migration_sql) = SchemaVersion::V1.migration_sql() {
473                    sqlx::query(&migration_sql)
474                        .execute(&pool)
475                        .await
476                        .map_err(from_sqlx_error)?;
477                }
478                SchemaVersion::V1
479            }
480            (SchemaVersion::V0, Some(SchemaVersion::V0) | None) => {
481                tracing::warn!(
482                    "table {CATALOG_TABLE_NAME} has inferred schema {}; SQL catalog is initialized without view support, table creation, and table registration. \
483                    To auto-migrate the database schema, set {}=V1",
484                    detected_schema_version,
485                    SQL_CATALOG_PROP_SCHEMA_VERSION,
486                );
487                SchemaVersion::V0
488            }
489            (SchemaVersion::V1, Some(expected_schema_version @ SchemaVersion::V0)) => {
490                tracing::warn!(
491                    "ignoring expected schema {} for table {CATALOG_TABLE_NAME}: the table is \
492                    already at schema {}, and downgrade migration is not supported",
493                    expected_schema_version,
494                    detected_schema_version,
495                );
496                SchemaVersion::V1
497            }
498        };
499
500        Ok(SqlCatalog {
501            name: config.name.to_owned(),
502            connection: pool,
503            warehouse_location: config.warehouse_location,
504            fileio,
505            sql_bind_style: config.sql_bind_style,
506            runtime,
507            kms_client,
508            schema_version,
509        })
510    }
511
512    /// SQLX Any does not implement PostgresSQL bindings, so we have to do this.
513    fn replace_placeholders(&self, query: &str) -> String {
514        match self.sql_bind_style {
515            SqlBindStyle::DollarNumeric => {
516                let mut count = 1;
517                query
518                    .chars()
519                    .fold(String::with_capacity(query.len()), |mut acc, c| {
520                        if c == '?' {
521                            acc.push('$');
522                            acc.push_str(&count.to_string());
523                            count += 1;
524                        } else {
525                            acc.push(c);
526                        }
527                        acc
528                    })
529            }
530            _ => query.to_owned(),
531        }
532    }
533
534    /// Fetch a vec of AnyRows from a given query
535    async fn fetch_rows(&self, query: &str, args: Vec<Option<&str>>) -> Result<Vec<AnyRow>> {
536        let query_with_placeholders = self.replace_placeholders(query);
537
538        let mut sqlx_query = sqlx::query(&query_with_placeholders);
539        for arg in args {
540            sqlx_query = sqlx_query.bind(arg);
541        }
542
543        sqlx_query
544            .fetch_all(&self.connection)
545            .await
546            .map_err(from_sqlx_error)
547    }
548
549    /// Execute statements in a transaction, provided or not
550    async fn execute(
551        &self,
552        query: &str,
553        args: Vec<Option<&str>>,
554        transaction: Option<&mut Transaction<'_, Any>>,
555    ) -> Result<AnyQueryResult> {
556        let query_with_placeholders = self.replace_placeholders(query);
557
558        let mut sqlx_query = sqlx::query(&query_with_placeholders);
559        for arg in args {
560            sqlx_query = sqlx_query.bind(arg);
561        }
562
563        match transaction {
564            Some(t) => sqlx_query.execute(&mut **t).await.map_err(from_sqlx_error),
565            None => {
566                let mut tx = self.connection.begin().await.map_err(from_sqlx_error)?;
567                let result = sqlx_query
568                    .execute(&mut *tx)
569                    .await
570                    .map_err(from_sqlx_error)?;
571                tx.commit().await.map_err(from_sqlx_error)?;
572                Ok(result)
573            }
574        }
575    }
576}
577
578#[async_trait]
579impl Catalog for SqlCatalog {
580    async fn list_namespaces(
581        &self,
582        parent: Option<&NamespaceIdent>,
583    ) -> Result<Vec<NamespaceIdent>> {
584        // UNION will remove duplicates.
585        let all_namespaces_stmt = format!(
586            "SELECT {CATALOG_FIELD_TABLE_NAMESPACE}
587             FROM {CATALOG_TABLE_NAME}
588             WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
589             UNION
590             SELECT {NAMESPACE_FIELD_NAME}
591             FROM {NAMESPACE_TABLE_NAME}
592             WHERE {CATALOG_FIELD_CATALOG_NAME} = ?"
593        );
594
595        let namespace_rows = self
596            .fetch_rows(&all_namespaces_stmt, vec![
597                Some(&self.name),
598                Some(&self.name),
599            ])
600            .await?;
601
602        let mut namespaces = HashSet::<NamespaceIdent>::with_capacity(namespace_rows.len());
603
604        if let Some(parent) = parent {
605            if self.namespace_exists(parent).await? {
606                let parent_str = parent.join(".");
607
608                for row in namespace_rows.iter() {
609                    let nsp = row.try_get::<String, _>(0).map_err(from_sqlx_error)?;
610                    // if parent = a, then we only want to see a.b, a.c returned.
611                    if nsp != parent_str && nsp.starts_with(&parent_str) {
612                        namespaces.insert(NamespaceIdent::from_strs(nsp.split("."))?);
613                    }
614                }
615
616                Ok(namespaces.into_iter().collect::<Vec<NamespaceIdent>>())
617            } else {
618                no_such_namespace_err(parent)
619            }
620        } else {
621            for row in namespace_rows.iter() {
622                let nsp = row.try_get::<String, _>(0).map_err(from_sqlx_error)?;
623                let mut levels = nsp.split(".").collect::<Vec<&str>>();
624                if !levels.is_empty() {
625                    let first_level = levels.drain(..1).collect::<Vec<&str>>();
626                    namespaces.insert(NamespaceIdent::from_strs(first_level)?);
627                }
628            }
629
630            Ok(namespaces.into_iter().collect::<Vec<NamespaceIdent>>())
631        }
632    }
633
634    async fn create_namespace(
635        &self,
636        namespace: &NamespaceIdent,
637        properties: HashMap<String, String>,
638    ) -> Result<Namespace> {
639        let exists = self.namespace_exists(namespace).await?;
640
641        if exists {
642            return Err(Error::new(
643                ErrorKind::NamespaceAlreadyExists,
644                format!("Namespace {namespace:?} already exists"),
645            ));
646        }
647
648        let namespace_str = namespace.join(".");
649        let insert = format!(
650            "INSERT INTO {NAMESPACE_TABLE_NAME} ({CATALOG_FIELD_CATALOG_NAME}, {NAMESPACE_FIELD_NAME}, {NAMESPACE_FIELD_PROPERTY_KEY}, {NAMESPACE_FIELD_PROPERTY_VALUE})
651             VALUES (?, ?, ?, ?)");
652        if !properties.is_empty() {
653            let mut insert_properties = properties.clone();
654            insert_properties.insert("exists".to_string(), "true".to_string());
655
656            let mut query_args = Vec::with_capacity(insert_properties.len() * 4);
657            let mut insert_stmt = insert.clone();
658            for (index, (key, value)) in insert_properties.iter().enumerate() {
659                query_args.extend_from_slice(&[
660                    Some(self.name.as_str()),
661                    Some(namespace_str.as_str()),
662                    Some(key.as_str()),
663                    Some(value.as_str()),
664                ]);
665                if index > 0 {
666                    insert_stmt.push_str(", (?, ?, ?, ?)");
667                }
668            }
669
670            self.execute(&insert_stmt, query_args, None).await?;
671
672            Ok(Namespace::with_properties(
673                namespace.clone(),
674                insert_properties,
675            ))
676        } else {
677            // set a default property of exists = true
678            self.execute(
679                &insert,
680                vec![
681                    Some(&self.name),
682                    Some(&namespace_str),
683                    Some("exists"),
684                    Some("true"),
685                ],
686                None,
687            )
688            .await?;
689            Ok(Namespace::with_properties(namespace.clone(), properties))
690        }
691    }
692
693    async fn get_namespace(&self, namespace: &NamespaceIdent) -> Result<Namespace> {
694        let exists = self.namespace_exists(namespace).await?;
695        if exists {
696            let namespace_props = self
697                .fetch_rows(
698                    &format!(
699                        "SELECT
700                            {NAMESPACE_FIELD_NAME},
701                            {NAMESPACE_FIELD_PROPERTY_KEY},
702                            {NAMESPACE_FIELD_PROPERTY_VALUE}
703                            FROM {NAMESPACE_TABLE_NAME}
704                            WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
705                            AND {NAMESPACE_FIELD_NAME} = ?"
706                    ),
707                    vec![Some(&self.name), Some(&namespace.join("."))],
708                )
709                .await?;
710
711            let mut properties = HashMap::with_capacity(namespace_props.len());
712
713            for row in namespace_props {
714                let key = row
715                    .try_get::<String, _>(NAMESPACE_FIELD_PROPERTY_KEY)
716                    .map_err(from_sqlx_error)?;
717                let value = row
718                    .try_get::<String, _>(NAMESPACE_FIELD_PROPERTY_VALUE)
719                    .map_err(from_sqlx_error)?;
720
721                properties.insert(key, value);
722            }
723
724            Ok(Namespace::with_properties(namespace.clone(), properties))
725        } else {
726            no_such_namespace_err(namespace)
727        }
728    }
729
730    async fn namespace_exists(&self, namespace: &NamespaceIdent) -> Result<bool> {
731        let namespace_str = namespace.join(".");
732
733        let table_namespaces = self
734            .fetch_rows(
735                &format!(
736                    "SELECT 1 FROM {CATALOG_TABLE_NAME}
737                     WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
738                      AND {CATALOG_FIELD_TABLE_NAMESPACE} = ?
739                     LIMIT 1"
740                ),
741                vec![Some(&self.name), Some(&namespace_str)],
742            )
743            .await?;
744
745        if !table_namespaces.is_empty() {
746            Ok(true)
747        } else {
748            let namespaces = self
749                .fetch_rows(
750                    &format!(
751                        "SELECT 1 FROM {NAMESPACE_TABLE_NAME}
752                         WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
753                          AND {NAMESPACE_FIELD_NAME} = ?
754                         LIMIT 1"
755                    ),
756                    vec![Some(&self.name), Some(&namespace_str)],
757                )
758                .await?;
759            if !namespaces.is_empty() {
760                Ok(true)
761            } else {
762                Ok(false)
763            }
764        }
765    }
766
767    async fn update_namespace(
768        &self,
769        namespace: &NamespaceIdent,
770        properties: HashMap<String, String>,
771    ) -> Result<()> {
772        let exists = self.namespace_exists(namespace).await?;
773        if exists {
774            let existing_properties = self.get_namespace(namespace).await?.properties().clone();
775            let namespace_str = namespace.join(".");
776
777            let mut updates = vec![];
778            let mut inserts = vec![];
779
780            for (key, value) in properties.iter() {
781                if existing_properties.contains_key(key) {
782                    if existing_properties.get(key) != Some(value) {
783                        updates.push((key, value));
784                    }
785                } else {
786                    inserts.push((key, value));
787                }
788            }
789
790            let mut tx = self.connection.begin().await.map_err(from_sqlx_error)?;
791            let update_stmt = format!(
792                "UPDATE {NAMESPACE_TABLE_NAME} SET {NAMESPACE_FIELD_PROPERTY_VALUE} = ?
793                 WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
794                 AND {NAMESPACE_FIELD_NAME} = ?
795                 AND {NAMESPACE_FIELD_PROPERTY_KEY} = ?"
796            );
797
798            let insert_stmt = format!(
799                "INSERT INTO {NAMESPACE_TABLE_NAME} ({CATALOG_FIELD_CATALOG_NAME}, {NAMESPACE_FIELD_NAME}, {NAMESPACE_FIELD_PROPERTY_KEY}, {NAMESPACE_FIELD_PROPERTY_VALUE})
800                 VALUES (?, ?, ?, ?)"
801            );
802
803            for (key, value) in updates {
804                self.execute(
805                    &update_stmt,
806                    vec![
807                        Some(value),
808                        Some(&self.name),
809                        Some(&namespace_str),
810                        Some(key),
811                    ],
812                    Some(&mut tx),
813                )
814                .await?;
815            }
816
817            for (key, value) in inserts {
818                self.execute(
819                    &insert_stmt,
820                    vec![
821                        Some(&self.name),
822                        Some(&namespace_str),
823                        Some(key),
824                        Some(value),
825                    ],
826                    Some(&mut tx),
827                )
828                .await?;
829            }
830
831            let _ = tx.commit().await.map_err(from_sqlx_error)?;
832
833            Ok(())
834        } else {
835            no_such_namespace_err(namespace)
836        }
837    }
838
839    async fn drop_namespace(&self, namespace: &NamespaceIdent) -> Result<()> {
840        let exists = self.namespace_exists(namespace).await?;
841        if exists {
842            // if there are tables in the namespace, don't allow drop.
843            let tables = self.list_tables(namespace).await?;
844            if !tables.is_empty() {
845                return Err(Error::new(
846                    ErrorKind::Unexpected,
847                    format!(
848                        "Namespace {:?} is not empty. {} tables exist.",
849                        namespace,
850                        tables.len()
851                    ),
852                ));
853            }
854
855            self.execute(
856                &format!(
857                    "DELETE FROM {NAMESPACE_TABLE_NAME}
858                     WHERE {NAMESPACE_FIELD_NAME} = ?
859                      AND {CATALOG_FIELD_CATALOG_NAME} = ?"
860                ),
861                vec![Some(&namespace.join(".")), Some(&self.name)],
862                None,
863            )
864            .await?;
865
866            Ok(())
867        } else {
868            no_such_namespace_err(namespace)
869        }
870    }
871
872    async fn list_tables(&self, namespace: &NamespaceIdent) -> Result<Vec<TableIdent>> {
873        let exists = self.namespace_exists(namespace).await?;
874        if exists {
875            let rows = self
876                .fetch_rows(
877                    &format!(
878                        "SELECT {CATALOG_FIELD_TABLE_NAME},
879                                {CATALOG_FIELD_TABLE_NAMESPACE}
880                         FROM {CATALOG_TABLE_NAME}
881                         WHERE {CATALOG_FIELD_TABLE_NAMESPACE} = ?
882                          AND {CATALOG_FIELD_CATALOG_NAME} = ?
883                          {}",
884                        self.schema_version.record_type_filter()
885                    ),
886                    vec![Some(&namespace.join(".")), Some(&self.name)],
887                )
888                .await?;
889
890            let mut tables = HashSet::<TableIdent>::with_capacity(rows.len());
891
892            for row in rows.iter() {
893                let tbl = row
894                    .try_get::<String, _>(CATALOG_FIELD_TABLE_NAME)
895                    .map_err(from_sqlx_error)?;
896                let ns_strs = row
897                    .try_get::<String, _>(CATALOG_FIELD_TABLE_NAMESPACE)
898                    .map_err(from_sqlx_error)?;
899                let ns = NamespaceIdent::from_strs(ns_strs.split("."))?;
900                tables.insert(TableIdent::new(ns, tbl));
901            }
902
903            Ok(tables.into_iter().collect::<Vec<TableIdent>>())
904        } else {
905            no_such_namespace_err(namespace)
906        }
907    }
908
909    async fn table_exists(&self, identifier: &TableIdent) -> Result<bool> {
910        let namespace = identifier.namespace().join(".");
911        let table_name = identifier.name();
912        let table_counts = self
913            .fetch_rows(
914                &format!(
915                    "SELECT 1
916                     FROM {CATALOG_TABLE_NAME}
917                     WHERE {CATALOG_FIELD_TABLE_NAMESPACE} = ?
918                      AND {CATALOG_FIELD_CATALOG_NAME} = ?
919                      AND {CATALOG_FIELD_TABLE_NAME} = ?
920                      {}",
921                    self.schema_version.record_type_filter()
922                ),
923                vec![Some(&namespace), Some(&self.name), Some(table_name)],
924            )
925            .await?;
926
927        if !table_counts.is_empty() {
928            Ok(true)
929        } else {
930            Ok(false)
931        }
932    }
933
934    async fn drop_table(&self, identifier: &TableIdent) -> Result<()> {
935        if !self.table_exists(identifier).await? {
936            return no_such_table_err(identifier);
937        }
938
939        self.execute(
940            &format!(
941                "DELETE FROM {CATALOG_TABLE_NAME}
942                 WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
943                  AND {CATALOG_FIELD_TABLE_NAME} = ?
944                  AND {CATALOG_FIELD_TABLE_NAMESPACE} = ?
945                  {}",
946                self.schema_version.record_type_filter()
947            ),
948            vec![
949                Some(&self.name),
950                Some(identifier.name()),
951                Some(&identifier.namespace().join(".")),
952            ],
953            None,
954        )
955        .await?;
956
957        Ok(())
958    }
959
960    async fn purge_table(&self, table: &TableIdent) -> Result<()> {
961        let table_info = self.load_table(table).await?;
962        self.drop_table(table).await?;
963        iceberg::drop_table_data(&table_info).await
964    }
965
966    async fn load_table(&self, identifier: &TableIdent) -> Result<Table> {
967        if !self.table_exists(identifier).await? {
968            return no_such_table_err(identifier);
969        }
970
971        let rows = self
972            .fetch_rows(
973                &format!(
974                    "SELECT {CATALOG_FIELD_METADATA_LOCATION_PROP}
975                     FROM {CATALOG_TABLE_NAME}
976                     WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
977                      AND {CATALOG_FIELD_TABLE_NAME} = ?
978                      AND {CATALOG_FIELD_TABLE_NAMESPACE} = ?
979                      {}",
980                    self.schema_version.record_type_filter()
981                ),
982                vec![
983                    Some(&self.name),
984                    Some(identifier.name()),
985                    Some(&identifier.namespace().join(".")),
986                ],
987            )
988            .await?;
989
990        if rows.is_empty() {
991            return no_such_table_err(identifier);
992        }
993
994        let row = &rows[0];
995        let tbl_metadata_location = row
996            .try_get::<String, _>(CATALOG_FIELD_METADATA_LOCATION_PROP)
997            .map_err(from_sqlx_error)?;
998
999        let metadata = TableMetadata::read_from(&self.fileio, &tbl_metadata_location).await?;
1000
1001        let mut builder = Table::builder()
1002            .file_io(self.fileio.clone())
1003            .identifier(identifier.clone())
1004            .metadata_location(tbl_metadata_location)
1005            .metadata(metadata)
1006            .runtime(self.runtime.clone());
1007        if let Some(kms_client) = self.kms_client.clone() {
1008            builder = builder.kms_client(kms_client);
1009        }
1010        Ok(builder.build()?)
1011    }
1012
1013    async fn create_table(
1014        &self,
1015        namespace: &NamespaceIdent,
1016        creation: TableCreation,
1017    ) -> Result<Table> {
1018        if self.schema_version != SchemaVersion::V1 {
1019            return Err(Error::new(
1020                ErrorKind::FeatureUnsupported,
1021                format!(
1022                    "Table creation is not supported for SQL catalog schema version {}",
1023                    self.schema_version
1024                ),
1025            ));
1026        }
1027
1028        if !self.namespace_exists(namespace).await? {
1029            return no_such_namespace_err(namespace);
1030        }
1031
1032        let tbl_name = creation.name.clone();
1033        let tbl_ident = TableIdent::new(namespace.clone(), tbl_name.clone());
1034
1035        if self.table_exists(&tbl_ident).await? {
1036            return table_already_exists_err(&tbl_ident);
1037        }
1038
1039        let tbl_creation = if creation.location.is_some() {
1040            creation
1041        } else {
1042            // fall back to namespace-specific location
1043            // and then to warehouse location
1044            let nsp_properties = self.get_namespace(namespace).await?.properties().clone();
1045            let nsp_location = match nsp_properties.get(NAMESPACE_LOCATION_PROPERTY_KEY) {
1046                Some(location) => location.clone(),
1047                None => {
1048                    format!(
1049                        "{}/{}",
1050                        self.warehouse_location.clone(),
1051                        namespace.join("/")
1052                    )
1053                }
1054            };
1055
1056            let tbl_location = format!("{}/{}", nsp_location, tbl_ident.name());
1057
1058            TableCreation {
1059                location: Some(tbl_location),
1060                ..creation
1061            }
1062        };
1063
1064        let tbl_metadata = TableMetadataBuilder::from_table_creation(tbl_creation)?
1065            .build()?
1066            .metadata;
1067        let tbl_metadata_location = MetadataLocation::try_new_with_metadata(&tbl_metadata)?;
1068
1069        tbl_metadata
1070            .write_to(&self.fileio, &tbl_metadata_location)
1071            .await?;
1072
1073        let tbl_metadata_location_str = tbl_metadata_location.to_string();
1074        self.execute(&format!(
1075            "INSERT INTO {CATALOG_TABLE_NAME}
1076             ({CATALOG_FIELD_CATALOG_NAME}, {CATALOG_FIELD_TABLE_NAMESPACE}, {CATALOG_FIELD_TABLE_NAME}, {CATALOG_FIELD_METADATA_LOCATION_PROP}, {CATALOG_FIELD_RECORD_TYPE})
1077             VALUES (?, ?, ?, ?, ?)
1078            "), vec![Some(&self.name), Some(&namespace.join(".")), Some(&tbl_name.clone()), Some(&tbl_metadata_location_str), Some(CATALOG_FIELD_TABLE_RECORD_TYPE)], None).await?;
1079
1080        let mut builder = Table::builder()
1081            .file_io(self.fileio.clone())
1082            .metadata_location(tbl_metadata_location_str)
1083            .identifier(tbl_ident)
1084            .metadata(tbl_metadata)
1085            .runtime(self.runtime.clone());
1086        if let Some(kms_client) = self.kms_client.clone() {
1087            builder = builder.kms_client(kms_client);
1088        }
1089        Ok(builder.build()?)
1090    }
1091
1092    async fn rename_table(&self, src: &TableIdent, dest: &TableIdent) -> Result<()> {
1093        if src == dest {
1094            return Ok(());
1095        }
1096
1097        if !self.table_exists(src).await? {
1098            return no_such_table_err(src);
1099        }
1100
1101        if !self.namespace_exists(dest.namespace()).await? {
1102            return no_such_namespace_err(dest.namespace());
1103        }
1104
1105        if self.table_exists(dest).await? {
1106            return table_already_exists_err(dest);
1107        }
1108
1109        self.execute(
1110            &format!(
1111                "UPDATE {CATALOG_TABLE_NAME}
1112                 SET {CATALOG_FIELD_TABLE_NAME} = ?, {CATALOG_FIELD_TABLE_NAMESPACE} = ?
1113                 WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
1114                  AND {CATALOG_FIELD_TABLE_NAME} = ?
1115                  AND {CATALOG_FIELD_TABLE_NAMESPACE} = ?
1116                  {}",
1117                self.schema_version.record_type_filter()
1118            ),
1119            vec![
1120                Some(dest.name()),
1121                Some(&dest.namespace().join(".")),
1122                Some(&self.name),
1123                Some(src.name()),
1124                Some(&src.namespace().join(".")),
1125            ],
1126            None,
1127        )
1128        .await?;
1129
1130        Ok(())
1131    }
1132
1133    async fn register_table(
1134        &self,
1135        table_ident: &TableIdent,
1136        metadata_location: String,
1137    ) -> Result<Table> {
1138        if self.schema_version != SchemaVersion::V1 {
1139            return Err(Error::new(
1140                ErrorKind::FeatureUnsupported,
1141                format!(
1142                    "Table registration is not supported for SQL catalog schema version {}",
1143                    self.schema_version
1144                ),
1145            ));
1146        }
1147
1148        if self.table_exists(table_ident).await? {
1149            return table_already_exists_err(table_ident);
1150        }
1151
1152        let metadata = TableMetadata::read_from(&self.fileio, &metadata_location).await?;
1153
1154        let namespace = table_ident.namespace();
1155        let tbl_name = table_ident.name().to_string();
1156
1157        self.execute(&format!(
1158            "INSERT INTO {CATALOG_TABLE_NAME}
1159             ({CATALOG_FIELD_CATALOG_NAME}, {CATALOG_FIELD_TABLE_NAMESPACE}, {CATALOG_FIELD_TABLE_NAME}, {CATALOG_FIELD_METADATA_LOCATION_PROP}, {CATALOG_FIELD_RECORD_TYPE})
1160             VALUES (?, ?, ?, ?, ?)
1161            "), vec![Some(&self.name), Some(&namespace.join(".")), Some(&tbl_name), Some(&metadata_location), Some(CATALOG_FIELD_TABLE_RECORD_TYPE)], None).await?;
1162
1163        let mut builder = Table::builder()
1164            .identifier(table_ident.clone())
1165            .metadata_location(metadata_location)
1166            .metadata(metadata)
1167            .file_io(self.fileio.clone())
1168            .runtime(self.runtime.clone());
1169        if let Some(kms_client) = self.kms_client.clone() {
1170            builder = builder.kms_client(kms_client);
1171        }
1172        Ok(builder.build()?)
1173    }
1174
1175    /// Updates an existing table within the SQL catalog.
1176    async fn update_table(&self, commit: TableCommit) -> Result<Table> {
1177        let table_ident = commit.identifier().clone();
1178        let current_table = self.load_table(&table_ident).await?;
1179        let current_metadata_location = current_table.metadata_location_result()?.to_string();
1180
1181        let staged_table = commit.apply(current_table)?;
1182        let staged_metadata_location_str = staged_table.metadata_location_result()?;
1183        let staged_metadata_location = MetadataLocation::from_str(staged_metadata_location_str)?;
1184
1185        staged_table
1186            .metadata()
1187            .write_to(staged_table.file_io(), &staged_metadata_location)
1188            .await?;
1189
1190        let staged_metadata_location_str = staged_metadata_location.to_string();
1191        let update_result = self
1192            .execute(
1193                &format!(
1194                    "UPDATE {CATALOG_TABLE_NAME}
1195                     SET {CATALOG_FIELD_METADATA_LOCATION_PROP} = ?, {CATALOG_FIELD_PREVIOUS_METADATA_LOCATION_PROP} = ?
1196                     WHERE {CATALOG_FIELD_CATALOG_NAME} = ?
1197                      AND {CATALOG_FIELD_TABLE_NAME} = ?
1198                      AND {CATALOG_FIELD_TABLE_NAMESPACE} = ?
1199                      {}
1200                      AND {CATALOG_FIELD_METADATA_LOCATION_PROP} = ?",
1201                    self.schema_version.record_type_filter()
1202                ),
1203                vec![
1204                    Some(&staged_metadata_location_str),
1205                    Some(current_metadata_location.as_str()),
1206                    Some(&self.name),
1207                    Some(table_ident.name()),
1208                    Some(&table_ident.namespace().join(".")),
1209                    Some(current_metadata_location.as_str()),
1210                ],
1211                None,
1212            )
1213            .await?;
1214
1215        if update_result.rows_affected() == 0 {
1216            return Err(Error::new(
1217                ErrorKind::CatalogCommitConflicts,
1218                format!("Commit conflicted for table: {table_ident}"),
1219            )
1220            .with_retryable(true));
1221        }
1222
1223        Ok(staged_table)
1224    }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use std::collections::{HashMap, HashSet};
1230    use std::hash::Hash;
1231    use std::sync::Arc;
1232
1233    use iceberg::io::LocalFsStorageFactory;
1234    use iceberg::spec::{NestedField, PartitionSpec, PrimitiveType, Schema, SortOrder, Type};
1235    use iceberg::table::Table;
1236    use iceberg::{
1237        Catalog, CatalogBuilder, ErrorKind, Namespace, NamespaceIdent, TableCreation, TableIdent,
1238    };
1239    use itertools::Itertools;
1240    use regex::Regex;
1241    use sqlx::any::install_default_drivers;
1242    use sqlx::migrate::MigrateDatabase;
1243    use sqlx::{Column, Executor};
1244    use tempfile::TempDir;
1245
1246    use crate::catalog::{
1247        CATALOG_FIELD_RECORD_TYPE, CATALOG_TABLE_NAME, NAMESPACE_LOCATION_PROPERTY_KEY,
1248        NAMESPACE_TABLE_NAME, SQL_CATALOG_PROP_BIND_STYLE, SQL_CATALOG_PROP_BIND_STYLE_LEGACY,
1249        SQL_CATALOG_PROP_SCHEMA_VERSION, SQL_CATALOG_PROP_URI, SQL_CATALOG_PROP_WAREHOUSE,
1250    };
1251    use crate::{SchemaVersion, SqlBindStyle, SqlCatalog, SqlCatalogBuilder};
1252
1253    const UUID_REGEX_STR: &str = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
1254
1255    fn temp_path() -> String {
1256        let temp_dir = TempDir::new().unwrap();
1257        temp_dir.path().to_str().unwrap().to_string()
1258    }
1259
1260    fn to_set<T: Eq + Hash>(vec: Vec<T>) -> HashSet<T> {
1261        HashSet::from_iter(vec)
1262    }
1263
1264    fn default_properties() -> HashMap<String, String> {
1265        HashMap::from([("exists".to_string(), "true".to_string())])
1266    }
1267
1268    /// Create a new SQLite catalog for testing. If name is not specified it defaults to "iceberg".
1269    async fn new_sql_catalog(
1270        warehouse_location: String,
1271        name: Option<impl ToString>,
1272    ) -> impl Catalog {
1273        let name = if let Some(name) = name {
1274            name.to_string()
1275        } else {
1276            "iceberg".to_string()
1277        };
1278        let sql_lite_uri = format!("sqlite:{}", temp_path());
1279        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1280
1281        let props = HashMap::from_iter([
1282            (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri.to_string()),
1283            (SQL_CATALOG_PROP_WAREHOUSE.to_string(), warehouse_location),
1284            (
1285                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
1286                SqlBindStyle::DollarNumeric.to_string(),
1287            ),
1288        ]);
1289        SqlCatalogBuilder::default()
1290            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1291            .load(&name, props)
1292            .await
1293            .unwrap()
1294    }
1295
1296    async fn create_namespace<C: Catalog>(catalog: &C, namespace_ident: &NamespaceIdent) {
1297        let _ = catalog
1298            .create_namespace(namespace_ident, HashMap::new())
1299            .await
1300            .unwrap();
1301    }
1302
1303    async fn create_namespaces<C: Catalog>(catalog: &C, namespace_idents: &Vec<&NamespaceIdent>) {
1304        for namespace_ident in namespace_idents {
1305            let _ = create_namespace(catalog, namespace_ident).await;
1306        }
1307    }
1308
1309    fn simple_table_schema() -> Schema {
1310        Schema::builder()
1311            .with_fields(vec![
1312                NestedField::required(1, "foo", Type::Primitive(PrimitiveType::Int)).into(),
1313            ])
1314            .build()
1315            .unwrap()
1316    }
1317
1318    async fn create_table<C: Catalog>(catalog: &C, table_ident: &TableIdent) {
1319        let _ = catalog
1320            .create_table(
1321                &table_ident.namespace,
1322                TableCreation::builder()
1323                    .name(table_ident.name().into())
1324                    .schema(simple_table_schema())
1325                    .location(temp_path())
1326                    .build(),
1327            )
1328            .await
1329            .unwrap();
1330    }
1331
1332    async fn create_tables<C: Catalog>(catalog: &C, table_idents: Vec<&TableIdent>) {
1333        for table_ident in table_idents {
1334            create_table(catalog, table_ident).await;
1335        }
1336    }
1337
1338    fn assert_table_eq(table: &Table, expected_table_ident: &TableIdent, expected_schema: &Schema) {
1339        assert_eq!(table.identifier(), expected_table_ident);
1340
1341        let metadata = table.metadata();
1342
1343        assert_eq!(metadata.current_schema().as_ref(), expected_schema);
1344
1345        let expected_partition_spec = PartitionSpec::builder(expected_schema.clone())
1346            .with_spec_id(0)
1347            .build()
1348            .unwrap();
1349
1350        assert_eq!(
1351            metadata
1352                .partition_specs_iter()
1353                .map(|p| p.as_ref())
1354                .collect_vec(),
1355            vec![&expected_partition_spec]
1356        );
1357
1358        let expected_sorted_order = SortOrder::builder()
1359            .with_order_id(0)
1360            .with_fields(vec![])
1361            .build(expected_schema)
1362            .unwrap();
1363
1364        assert_eq!(
1365            metadata
1366                .sort_orders_iter()
1367                .map(|s| s.as_ref())
1368                .collect_vec(),
1369            vec![&expected_sorted_order]
1370        );
1371
1372        assert_eq!(metadata.properties(), &HashMap::new());
1373
1374        assert!(!table.readonly());
1375    }
1376
1377    fn assert_table_metadata_location_matches(table: &Table, regex_str: &str) {
1378        let actual = table.metadata_location().unwrap().to_string();
1379        let regex = Regex::new(regex_str).unwrap();
1380        assert!(regex.is_match(&actual))
1381    }
1382
1383    #[tokio::test]
1384    async fn test_initialized() {
1385        let warehouse_loc = temp_path();
1386        new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
1387        // catalog instantiation should not fail even if tables exist
1388        new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
1389        new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
1390    }
1391
1392    async fn new_commit_error_catalog() -> SqlCatalog {
1393        let sql_lite_uri = format!("sqlite:{}", temp_path());
1394        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1395        let catalog = SqlCatalogBuilder::default()
1396            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1397            .prop("pool.max-connections", "1")
1398            .load(
1399                "iceberg",
1400                HashMap::from_iter([
1401                    (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri),
1402                    (SQL_CATALOG_PROP_WAREHOUSE.to_string(), temp_path()),
1403                ]),
1404            )
1405            .await
1406            .unwrap();
1407
1408        catalog
1409            .connection
1410            .execute("PRAGMA foreign_keys = ON")
1411            .await
1412            .unwrap();
1413        // This deferred constraint lets an INSERT succeed while COMMIT fails.
1414        catalog
1415            .connection
1416            .execute("CREATE TABLE parent(id INTEGER PRIMARY KEY)")
1417            .await
1418            .unwrap();
1419        catalog
1420            .connection
1421            .execute(
1422                "CREATE TABLE child(parent_id INTEGER REFERENCES parent(id) \
1423                 DEFERRABLE INITIALLY DEFERRED)",
1424            )
1425            .await
1426            .unwrap();
1427
1428        catalog
1429    }
1430
1431    #[tokio::test]
1432    async fn test_execute_returns_commit_error() {
1433        let catalog = new_commit_error_catalog().await;
1434
1435        // Make the public namespace operation insert a child row whose deferred
1436        // foreign-key constraint succeeds during execution but fails at commit.
1437        let trigger = format!(
1438            "CREATE TRIGGER fail_namespace_commit
1439             AFTER INSERT ON {NAMESPACE_TABLE_NAME}
1440             BEGIN INSERT INTO child VALUES (1); END"
1441        );
1442        catalog.connection.execute(trigger.as_str()).await.unwrap();
1443
1444        let failed_namespace = NamespaceIdent::new("failed".into());
1445        let error = catalog
1446            .create_namespace(&failed_namespace, HashMap::new())
1447            .await
1448            .unwrap_err();
1449        assert_eq!(error.kind(), ErrorKind::Unexpected);
1450        assert!(!catalog.namespace_exists(&failed_namespace).await.unwrap());
1451
1452        // A valid relationship confirms that successful transactions still commit.
1453        catalog
1454            .connection
1455            .execute("INSERT INTO parent VALUES (1)")
1456            .await
1457            .unwrap();
1458        let committed_namespace = NamespaceIdent::new("committed".into());
1459        catalog
1460            .create_namespace(&committed_namespace, HashMap::new())
1461            .await
1462            .unwrap();
1463        assert!(
1464            catalog
1465                .namespace_exists(&committed_namespace)
1466                .await
1467                .unwrap()
1468        );
1469    }
1470
1471    // Regression test: storage-backend props set on the catalog must reach
1472    // the FileIO; otherwise authenticated backends fail with 401s on writes.
1473    #[tokio::test]
1474    async fn test_storage_props_propagate_to_file_io() {
1475        let sql_lite_uri = format!("sqlite:{}", temp_path());
1476        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1477        let warehouse_location = temp_path();
1478
1479        let catalog = SqlCatalogBuilder::default()
1480            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1481            .load(
1482                "iceberg",
1483                HashMap::from_iter([
1484                    (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri),
1485                    (SQL_CATALOG_PROP_WAREHOUSE.to_string(), warehouse_location),
1486                    ("s3.region".to_string(), "us-east-1".to_string()),
1487                    ("hf.token".to_string(), "hf_test_token".to_string()),
1488                ]),
1489            )
1490            .await
1491            .unwrap();
1492
1493        let props = catalog.fileio.config().props();
1494        assert_eq!(props.get("s3.region"), Some(&"us-east-1".to_string()));
1495        assert_eq!(props.get("hf.token"), Some(&"hf_test_token".to_string()));
1496    }
1497
1498    #[tokio::test]
1499    async fn test_builder_method() {
1500        let sql_lite_uri = format!("sqlite:{}", temp_path());
1501        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1502        let warehouse_location = temp_path();
1503
1504        let catalog = SqlCatalogBuilder::default()
1505            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1506            .uri(sql_lite_uri.to_string())
1507            .warehouse_location(warehouse_location.clone())
1508            .sql_bind_style(SqlBindStyle::QMark)
1509            .load("iceberg", HashMap::default())
1510            .await;
1511        assert!(catalog.is_ok());
1512
1513        let catalog = catalog.unwrap();
1514        assert!(catalog.warehouse_location == warehouse_location);
1515        assert!(catalog.sql_bind_style == SqlBindStyle::QMark);
1516    }
1517
1518    /// Overwriting an sqlite database with a non-existent path causes
1519    /// catalog generation to fail
1520    #[tokio::test]
1521    async fn test_builder_props_non_existent_path_fails() {
1522        let sql_lite_uri = format!("sqlite:{}", temp_path());
1523        let sql_lite_uri2 = format!("sqlite:{}", temp_path());
1524        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1525        let warehouse_location = temp_path();
1526
1527        let catalog = SqlCatalogBuilder::default()
1528            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1529            .uri(sql_lite_uri)
1530            .warehouse_location(warehouse_location)
1531            .load(
1532                "iceberg",
1533                HashMap::from_iter([(SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri2)]),
1534            )
1535            .await;
1536        assert!(catalog.is_err());
1537    }
1538
1539    /// Even when an invalid URI is specified in a builder method,
1540    /// it can be successfully overridden with a valid URI in props
1541    /// for catalog generation to succeed.
1542    #[tokio::test]
1543    async fn test_builder_props_set_valid_uri() {
1544        let sql_lite_uri = format!("sqlite:{}", temp_path());
1545        let sql_lite_uri2 = format!("sqlite:{}", temp_path());
1546        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1547        let warehouse_location = temp_path();
1548
1549        let catalog = SqlCatalogBuilder::default()
1550            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1551            .uri(sql_lite_uri2)
1552            .warehouse_location(warehouse_location)
1553            .load(
1554                "iceberg",
1555                HashMap::from_iter([(SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri.clone())]),
1556            )
1557            .await;
1558        assert!(catalog.is_ok());
1559    }
1560
1561    /// values assigned via props take precedence
1562    #[tokio::test]
1563    async fn test_builder_props_take_precedence() {
1564        let sql_lite_uri = format!("sqlite:{}", temp_path());
1565        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1566        let warehouse_location = temp_path();
1567        let warehouse_location2 = temp_path();
1568
1569        let catalog = SqlCatalogBuilder::default()
1570            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1571            .warehouse_location(warehouse_location2)
1572            .sql_bind_style(SqlBindStyle::DollarNumeric)
1573            .load(
1574                "iceberg",
1575                HashMap::from_iter([
1576                    (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri),
1577                    (
1578                        SQL_CATALOG_PROP_WAREHOUSE.to_string(),
1579                        warehouse_location.clone(),
1580                    ),
1581                    (
1582                        SQL_CATALOG_PROP_BIND_STYLE.to_string(),
1583                        SqlBindStyle::QMark.to_string(),
1584                    ),
1585                ]),
1586            )
1587            .await;
1588
1589        assert!(catalog.is_ok());
1590
1591        let catalog = catalog.unwrap();
1592        assert!(catalog.warehouse_location == warehouse_location);
1593        assert!(catalog.sql_bind_style == SqlBindStyle::QMark);
1594    }
1595
1596    /// values assigned via props take precedence
1597    #[tokio::test]
1598    async fn test_builder_props_take_precedence_props() {
1599        let sql_lite_uri = format!("sqlite:{}", temp_path());
1600        let sql_lite_uri2 = format!("sqlite:{}", temp_path());
1601        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1602        let warehouse_location = temp_path();
1603        let warehouse_location2 = temp_path();
1604
1605        let props = HashMap::from_iter([
1606            (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri.clone()),
1607            (
1608                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
1609                warehouse_location.clone(),
1610            ),
1611            (
1612                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
1613                SqlBindStyle::QMark.to_string(),
1614            ),
1615        ]);
1616        let props2 = HashMap::from_iter([
1617            (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri2.clone()),
1618            (
1619                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
1620                warehouse_location2.clone(),
1621            ),
1622            (
1623                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
1624                SqlBindStyle::DollarNumeric.to_string(),
1625            ),
1626        ]);
1627
1628        let catalog = SqlCatalogBuilder::default()
1629            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1630            .props(props2)
1631            .load("iceberg", props)
1632            .await;
1633
1634        assert!(catalog.is_ok());
1635
1636        let catalog = catalog.unwrap();
1637        assert!(catalog.warehouse_location == warehouse_location);
1638        assert!(catalog.sql_bind_style == SqlBindStyle::QMark);
1639    }
1640
1641    /// values assigned via props take precedence
1642    #[tokio::test]
1643    async fn test_builder_props_take_precedence_prop() {
1644        let sql_lite_uri = format!("sqlite:{}", temp_path());
1645        let sql_lite_uri2 = format!("sqlite:{}", temp_path());
1646        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1647        let warehouse_location = temp_path();
1648        let warehouse_location2 = temp_path();
1649
1650        let props = HashMap::from_iter([
1651            (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri.clone()),
1652            (
1653                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
1654                warehouse_location.clone(),
1655            ),
1656            (
1657                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
1658                SqlBindStyle::QMark.to_string(),
1659            ),
1660        ]);
1661
1662        let catalog = SqlCatalogBuilder::default()
1663            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1664            .prop(SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri2)
1665            .prop(SQL_CATALOG_PROP_WAREHOUSE.to_string(), warehouse_location2)
1666            .prop(
1667                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
1668                SqlBindStyle::DollarNumeric.to_string(),
1669            )
1670            .load("iceberg", props)
1671            .await;
1672
1673        assert!(catalog.is_ok());
1674
1675        let catalog = catalog.unwrap();
1676        assert!(catalog.warehouse_location == warehouse_location);
1677        assert!(catalog.sql_bind_style == SqlBindStyle::QMark);
1678    }
1679
1680    /// invalid value for `SqlBindStyle` causes catalog creation to fail
1681    #[tokio::test]
1682    async fn test_builder_props_invalid_bind_style_fails() {
1683        let sql_lite_uri = format!("sqlite:{}", temp_path());
1684        sqlx::Sqlite::create_database(&sql_lite_uri).await.unwrap();
1685        let warehouse_location = temp_path();
1686
1687        let catalog = SqlCatalogBuilder::default()
1688            .with_storage_factory(Arc::new(LocalFsStorageFactory))
1689            .load(
1690                "iceberg",
1691                HashMap::from_iter([
1692                    (SQL_CATALOG_PROP_URI.to_string(), sql_lite_uri),
1693                    (SQL_CATALOG_PROP_WAREHOUSE.to_string(), warehouse_location),
1694                    (SQL_CATALOG_PROP_BIND_STYLE.to_string(), "AAA".to_string()),
1695                ]),
1696            )
1697            .await;
1698
1699        assert!(catalog.is_err());
1700    }
1701
1702    #[tokio::test]
1703    async fn test_builder_props_invalid_pool_property_fails() {
1704        for property in [
1705            "pool.max-connections",
1706            "pool.idle-timeout",
1707            "pool.test-before-acquire",
1708        ] {
1709            let error = SqlCatalogBuilder::default()
1710                .with_storage_factory(Arc::new(LocalFsStorageFactory))
1711                .prop(property, "invalid")
1712                .load("iceberg", HashMap::new())
1713                .await
1714                .unwrap_err();
1715
1716            assert_eq!(error.kind(), ErrorKind::DataInvalid);
1717            assert!(error.to_string().contains(property));
1718            assert!(error.to_string().contains("invalid"));
1719        }
1720    }
1721
1722    #[tokio::test]
1723    async fn test_list_namespaces_returns_empty_vector() {
1724        let warehouse_loc = temp_path();
1725        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1726
1727        assert_eq!(catalog.list_namespaces(None).await.unwrap(), vec![]);
1728    }
1729
1730    #[tokio::test]
1731    async fn test_list_namespaces_returns_empty_different_name() {
1732        let warehouse_loc = temp_path();
1733        let catalog = new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
1734        let namespace_ident_1 = NamespaceIdent::new("a".into());
1735        let namespace_ident_2 = NamespaceIdent::new("b".into());
1736        create_namespaces(&catalog, &vec![&namespace_ident_1, &namespace_ident_2]).await;
1737        assert_eq!(
1738            to_set(catalog.list_namespaces(None).await.unwrap()),
1739            to_set(vec![namespace_ident_1, namespace_ident_2])
1740        );
1741
1742        let catalog2 = new_sql_catalog(warehouse_loc, Some("test")).await;
1743        assert_eq!(catalog2.list_namespaces(None).await.unwrap(), vec![]);
1744    }
1745
1746    #[tokio::test]
1747    async fn test_list_namespaces_returns_only_top_level_namespaces() {
1748        let warehouse_loc = temp_path();
1749        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1750        let namespace_ident_1 = NamespaceIdent::new("a".into());
1751        let namespace_ident_2 = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
1752        let namespace_ident_3 = NamespaceIdent::new("b".into());
1753        create_namespaces(&catalog, &vec![
1754            &namespace_ident_1,
1755            &namespace_ident_2,
1756            &namespace_ident_3,
1757        ])
1758        .await;
1759
1760        assert_eq!(
1761            to_set(catalog.list_namespaces(None).await.unwrap()),
1762            to_set(vec![namespace_ident_1, namespace_ident_3])
1763        );
1764    }
1765
1766    #[tokio::test]
1767    async fn test_list_namespaces_returns_no_namespaces_under_parent() {
1768        let warehouse_loc = temp_path();
1769        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1770        let namespace_ident_1 = NamespaceIdent::new("a".into());
1771        let namespace_ident_2 = NamespaceIdent::new("b".into());
1772        create_namespaces(&catalog, &vec![&namespace_ident_1, &namespace_ident_2]).await;
1773
1774        assert_eq!(
1775            catalog
1776                .list_namespaces(Some(&namespace_ident_1))
1777                .await
1778                .unwrap(),
1779            vec![]
1780        );
1781    }
1782
1783    #[tokio::test]
1784    async fn test_list_namespaces_returns_namespace_under_parent() {
1785        let warehouse_loc = temp_path();
1786        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1787        let namespace_ident_1 = NamespaceIdent::new("a".into());
1788        let namespace_ident_2 = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
1789        let namespace_ident_3 = NamespaceIdent::new("c".into());
1790        create_namespaces(&catalog, &vec![
1791            &namespace_ident_1,
1792            &namespace_ident_2,
1793            &namespace_ident_3,
1794        ])
1795        .await;
1796
1797        assert_eq!(
1798            to_set(catalog.list_namespaces(None).await.unwrap()),
1799            to_set(vec![namespace_ident_1.clone(), namespace_ident_3])
1800        );
1801
1802        assert_eq!(
1803            catalog
1804                .list_namespaces(Some(&namespace_ident_1))
1805                .await
1806                .unwrap(),
1807            vec![NamespaceIdent::from_strs(vec!["a", "b"]).unwrap()]
1808        );
1809    }
1810
1811    #[tokio::test]
1812    async fn test_list_namespaces_returns_multiple_namespaces_under_parent() {
1813        let warehouse_loc = temp_path();
1814        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1815        let namespace_ident_1 = NamespaceIdent::new("a".to_string());
1816        let namespace_ident_2 = NamespaceIdent::from_strs(vec!["a", "a"]).unwrap();
1817        let namespace_ident_3 = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
1818        let namespace_ident_4 = NamespaceIdent::from_strs(vec!["a", "c"]).unwrap();
1819        let namespace_ident_5 = NamespaceIdent::new("b".into());
1820        create_namespaces(&catalog, &vec![
1821            &namespace_ident_1,
1822            &namespace_ident_2,
1823            &namespace_ident_3,
1824            &namespace_ident_4,
1825            &namespace_ident_5,
1826        ])
1827        .await;
1828
1829        assert_eq!(
1830            to_set(
1831                catalog
1832                    .list_namespaces(Some(&namespace_ident_1))
1833                    .await
1834                    .unwrap()
1835            ),
1836            to_set(vec![
1837                NamespaceIdent::from_strs(vec!["a", "a"]).unwrap(),
1838                NamespaceIdent::from_strs(vec!["a", "b"]).unwrap(),
1839                NamespaceIdent::from_strs(vec!["a", "c"]).unwrap(),
1840            ])
1841        );
1842    }
1843
1844    #[tokio::test]
1845    async fn test_namespace_exists_returns_false() {
1846        let warehouse_loc = temp_path();
1847        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1848        let namespace_ident = NamespaceIdent::new("a".into());
1849        create_namespace(&catalog, &namespace_ident).await;
1850
1851        assert!(
1852            !catalog
1853                .namespace_exists(&NamespaceIdent::new("b".into()))
1854                .await
1855                .unwrap()
1856        );
1857    }
1858
1859    #[tokio::test]
1860    async fn test_namespace_exists_returns_true() {
1861        let warehouse_loc = temp_path();
1862        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1863        let namespace_ident = NamespaceIdent::new("a".into());
1864        create_namespace(&catalog, &namespace_ident).await;
1865
1866        assert!(catalog.namespace_exists(&namespace_ident).await.unwrap());
1867    }
1868
1869    #[tokio::test]
1870    async fn test_create_namespace_with_properties() {
1871        let warehouse_loc = temp_path();
1872        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1873        let namespace_ident = NamespaceIdent::new("abc".into());
1874
1875        let mut properties = default_properties();
1876        properties.insert("k".into(), "v".into());
1877
1878        assert_eq!(
1879            catalog
1880                .create_namespace(&namespace_ident, properties.clone())
1881                .await
1882                .unwrap(),
1883            Namespace::with_properties(namespace_ident.clone(), properties.clone())
1884        );
1885
1886        assert_eq!(
1887            catalog.get_namespace(&namespace_ident).await.unwrap(),
1888            Namespace::with_properties(namespace_ident, properties)
1889        );
1890    }
1891
1892    #[tokio::test]
1893    async fn test_create_nested_namespace() {
1894        let warehouse_loc = temp_path();
1895        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1896        let parent_namespace_ident = NamespaceIdent::new("a".into());
1897        create_namespace(&catalog, &parent_namespace_ident).await;
1898
1899        let child_namespace_ident = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
1900
1901        assert_eq!(
1902            catalog
1903                .create_namespace(&child_namespace_ident, HashMap::new())
1904                .await
1905                .unwrap(),
1906            Namespace::new(child_namespace_ident.clone())
1907        );
1908
1909        assert_eq!(
1910            catalog.get_namespace(&child_namespace_ident).await.unwrap(),
1911            Namespace::with_properties(child_namespace_ident, default_properties())
1912        );
1913    }
1914
1915    #[tokio::test]
1916    async fn test_create_deeply_nested_namespace() {
1917        let warehouse_loc = temp_path();
1918        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1919        let namespace_ident_a = NamespaceIdent::new("a".into());
1920        let namespace_ident_a_b = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
1921        create_namespaces(&catalog, &vec![&namespace_ident_a, &namespace_ident_a_b]).await;
1922
1923        let namespace_ident_a_b_c = NamespaceIdent::from_strs(vec!["a", "b", "c"]).unwrap();
1924
1925        assert_eq!(
1926            catalog
1927                .create_namespace(&namespace_ident_a_b_c, HashMap::new())
1928                .await
1929                .unwrap(),
1930            Namespace::new(namespace_ident_a_b_c.clone())
1931        );
1932
1933        assert_eq!(
1934            catalog.get_namespace(&namespace_ident_a_b_c).await.unwrap(),
1935            Namespace::with_properties(namespace_ident_a_b_c, default_properties())
1936        );
1937    }
1938
1939    #[tokio::test]
1940    async fn test_update_namespace_noop() {
1941        let warehouse_loc = temp_path();
1942        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1943        let namespace_ident = NamespaceIdent::new("a".into());
1944        create_namespace(&catalog, &namespace_ident).await;
1945
1946        catalog
1947            .update_namespace(&namespace_ident, HashMap::new())
1948            .await
1949            .unwrap();
1950
1951        assert_eq!(
1952            *catalog
1953                .get_namespace(&namespace_ident)
1954                .await
1955                .unwrap()
1956                .properties(),
1957            HashMap::from_iter([("exists".to_string(), "true".to_string())])
1958        )
1959    }
1960
1961    #[tokio::test]
1962    async fn test_update_nested_namespace() {
1963        let warehouse_loc = temp_path();
1964        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1965        let namespace_ident = NamespaceIdent::from_strs(["a", "b"]).unwrap();
1966        create_namespace(&catalog, &namespace_ident).await;
1967
1968        let mut props = HashMap::from_iter([
1969            ("prop1".to_string(), "val1".to_string()),
1970            ("prop2".into(), "val2".into()),
1971        ]);
1972
1973        catalog
1974            .update_namespace(&namespace_ident, props.clone())
1975            .await
1976            .unwrap();
1977
1978        props.insert("exists".into(), "true".into());
1979
1980        assert_eq!(
1981            *catalog
1982                .get_namespace(&namespace_ident)
1983                .await
1984                .unwrap()
1985                .properties(),
1986            props
1987        )
1988    }
1989
1990    #[tokio::test]
1991    async fn test_update_namespace_errors_if_nested_namespace_doesnt_exist() {
1992        let warehouse_loc = temp_path();
1993        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
1994        let namespace_ident = NamespaceIdent::from_strs(["a", "b"]).unwrap();
1995
1996        let props = HashMap::from_iter([
1997            ("prop1".to_string(), "val1".to_string()),
1998            ("prop2".into(), "val2".into()),
1999        ]);
2000
2001        let err = catalog
2002            .update_namespace(&namespace_ident, props)
2003            .await
2004            .unwrap_err();
2005
2006        assert_eq!(
2007            err.message(),
2008            format!("No such namespace: {namespace_ident:?}")
2009        );
2010    }
2011
2012    #[tokio::test]
2013    async fn test_drop_nested_namespace() {
2014        let warehouse_loc = temp_path();
2015        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2016        let namespace_ident_a = NamespaceIdent::new("a".into());
2017        let namespace_ident_a_b = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
2018        create_namespaces(&catalog, &vec![&namespace_ident_a, &namespace_ident_a_b]).await;
2019
2020        catalog.drop_namespace(&namespace_ident_a_b).await.unwrap();
2021
2022        assert!(
2023            !catalog
2024                .namespace_exists(&namespace_ident_a_b)
2025                .await
2026                .unwrap()
2027        );
2028
2029        assert!(catalog.namespace_exists(&namespace_ident_a).await.unwrap());
2030    }
2031
2032    #[tokio::test]
2033    async fn test_drop_deeply_nested_namespace() {
2034        let warehouse_loc = temp_path();
2035        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2036        let namespace_ident_a = NamespaceIdent::new("a".into());
2037        let namespace_ident_a_b = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
2038        let namespace_ident_a_b_c = NamespaceIdent::from_strs(vec!["a", "b", "c"]).unwrap();
2039        create_namespaces(&catalog, &vec![
2040            &namespace_ident_a,
2041            &namespace_ident_a_b,
2042            &namespace_ident_a_b_c,
2043        ])
2044        .await;
2045
2046        catalog
2047            .drop_namespace(&namespace_ident_a_b_c)
2048            .await
2049            .unwrap();
2050
2051        assert!(
2052            !catalog
2053                .namespace_exists(&namespace_ident_a_b_c)
2054                .await
2055                .unwrap()
2056        );
2057
2058        assert!(
2059            catalog
2060                .namespace_exists(&namespace_ident_a_b)
2061                .await
2062                .unwrap()
2063        );
2064
2065        assert!(catalog.namespace_exists(&namespace_ident_a).await.unwrap());
2066    }
2067
2068    #[tokio::test]
2069    async fn test_drop_namespace_throws_error_if_nested_namespace_doesnt_exist() {
2070        let warehouse_loc = temp_path();
2071        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2072        create_namespace(&catalog, &NamespaceIdent::new("a".into())).await;
2073
2074        let non_existent_namespace_ident =
2075            NamespaceIdent::from_vec(vec!["a".into(), "b".into()]).unwrap();
2076        assert_eq!(
2077            catalog
2078                .drop_namespace(&non_existent_namespace_ident)
2079                .await
2080                .unwrap_err()
2081                .to_string(),
2082            format!("NamespaceNotFound => No such namespace: {non_existent_namespace_ident:?}")
2083        )
2084    }
2085
2086    #[tokio::test]
2087    async fn test_dropping_a_namespace_does_not_drop_namespaces_nested_under_that_one() {
2088        let warehouse_loc = temp_path();
2089        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2090        let namespace_ident_a = NamespaceIdent::new("a".into());
2091        let namespace_ident_a_b = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
2092        create_namespaces(&catalog, &vec![&namespace_ident_a, &namespace_ident_a_b]).await;
2093
2094        catalog.drop_namespace(&namespace_ident_a).await.unwrap();
2095
2096        assert!(!catalog.namespace_exists(&namespace_ident_a).await.unwrap());
2097
2098        assert!(
2099            catalog
2100                .namespace_exists(&namespace_ident_a_b)
2101                .await
2102                .unwrap()
2103        );
2104    }
2105
2106    #[tokio::test]
2107    async fn test_create_table_falls_back_to_namespace_location_if_table_location_is_missing() {
2108        let warehouse_loc = temp_path();
2109        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2110
2111        let namespace_ident = NamespaceIdent::new("a".into());
2112        let mut namespace_properties = HashMap::new();
2113        let namespace_location = temp_path();
2114        namespace_properties.insert(
2115            NAMESPACE_LOCATION_PROPERTY_KEY.to_string(),
2116            namespace_location.to_string(),
2117        );
2118        catalog
2119            .create_namespace(&namespace_ident, namespace_properties)
2120            .await
2121            .unwrap();
2122
2123        let table_name = "tbl1";
2124        let expected_table_ident = TableIdent::new(namespace_ident.clone(), table_name.into());
2125        let expected_table_metadata_location_regex =
2126            format!("^{namespace_location}/tbl1/metadata/00000-{UUID_REGEX_STR}.metadata.json$",);
2127
2128        let table = catalog
2129            .create_table(
2130                &namespace_ident,
2131                TableCreation::builder()
2132                    .name(table_name.into())
2133                    .schema(simple_table_schema())
2134                    // no location specified for table
2135                    .build(),
2136            )
2137            .await
2138            .unwrap();
2139        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2140        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2141
2142        let table = catalog.load_table(&expected_table_ident).await.unwrap();
2143        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2144        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2145    }
2146
2147    #[tokio::test]
2148    async fn test_create_table_in_nested_namespace_falls_back_to_nested_namespace_location_if_table_location_is_missing()
2149     {
2150        let warehouse_loc = temp_path();
2151        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2152
2153        let namespace_ident = NamespaceIdent::new("a".into());
2154        let mut namespace_properties = HashMap::new();
2155        let namespace_location = temp_path();
2156        namespace_properties.insert(
2157            NAMESPACE_LOCATION_PROPERTY_KEY.to_string(),
2158            namespace_location.to_string(),
2159        );
2160        catalog
2161            .create_namespace(&namespace_ident, namespace_properties)
2162            .await
2163            .unwrap();
2164
2165        let nested_namespace_ident = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
2166        let mut nested_namespace_properties = HashMap::new();
2167        let nested_namespace_location = temp_path();
2168        nested_namespace_properties.insert(
2169            NAMESPACE_LOCATION_PROPERTY_KEY.to_string(),
2170            nested_namespace_location.to_string(),
2171        );
2172        catalog
2173            .create_namespace(&nested_namespace_ident, nested_namespace_properties)
2174            .await
2175            .unwrap();
2176
2177        let table_name = "tbl1";
2178        let expected_table_ident =
2179            TableIdent::new(nested_namespace_ident.clone(), table_name.into());
2180        let expected_table_metadata_location_regex = format!(
2181            "^{nested_namespace_location}/tbl1/metadata/00000-{UUID_REGEX_STR}.metadata.json$",
2182        );
2183
2184        let table = catalog
2185            .create_table(
2186                &nested_namespace_ident,
2187                TableCreation::builder()
2188                    .name(table_name.into())
2189                    .schema(simple_table_schema())
2190                    // no location specified for table
2191                    .build(),
2192            )
2193            .await
2194            .unwrap();
2195        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2196        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2197
2198        let table = catalog.load_table(&expected_table_ident).await.unwrap();
2199        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2200        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2201    }
2202
2203    #[tokio::test]
2204    async fn test_create_table_falls_back_to_warehouse_location_if_both_table_location_and_namespace_location_are_missing()
2205     {
2206        let warehouse_loc = temp_path();
2207        let catalog = new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
2208
2209        let namespace_ident = NamespaceIdent::new("a".into());
2210        // note: no location specified in namespace_properties
2211        let namespace_properties = HashMap::new();
2212        catalog
2213            .create_namespace(&namespace_ident, namespace_properties)
2214            .await
2215            .unwrap();
2216
2217        let table_name = "tbl1";
2218        let expected_table_ident = TableIdent::new(namespace_ident.clone(), table_name.into());
2219        let expected_table_metadata_location_regex =
2220            format!("^{warehouse_loc}/a/tbl1/metadata/00000-{UUID_REGEX_STR}.metadata.json$");
2221
2222        let table = catalog
2223            .create_table(
2224                &namespace_ident,
2225                TableCreation::builder()
2226                    .name(table_name.into())
2227                    .schema(simple_table_schema())
2228                    // no location specified for table
2229                    .build(),
2230            )
2231            .await
2232            .unwrap();
2233        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2234        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2235
2236        let table = catalog.load_table(&expected_table_ident).await.unwrap();
2237        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2238        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2239    }
2240
2241    #[tokio::test]
2242    async fn test_create_table_in_nested_namespace_falls_back_to_warehouse_location_if_both_table_location_and_namespace_location_are_missing()
2243     {
2244        let warehouse_loc = temp_path();
2245        let catalog = new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
2246
2247        let namespace_ident = NamespaceIdent::new("a".into());
2248        create_namespace(&catalog, &namespace_ident).await;
2249
2250        let nested_namespace_ident = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
2251        create_namespace(&catalog, &nested_namespace_ident).await;
2252
2253        let table_name = "tbl1";
2254        let expected_table_ident =
2255            TableIdent::new(nested_namespace_ident.clone(), table_name.into());
2256        let expected_table_metadata_location_regex =
2257            format!("^{warehouse_loc}/a/b/tbl1/metadata/00000-{UUID_REGEX_STR}.metadata.json$");
2258
2259        let table = catalog
2260            .create_table(
2261                &nested_namespace_ident,
2262                TableCreation::builder()
2263                    .name(table_name.into())
2264                    .schema(simple_table_schema())
2265                    // no location specified for table
2266                    .build(),
2267            )
2268            .await
2269            .unwrap();
2270        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2271        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2272
2273        let table = catalog.load_table(&expected_table_ident).await.unwrap();
2274        assert_table_eq(&table, &expected_table_ident, &simple_table_schema());
2275        assert_table_metadata_location_matches(&table, &expected_table_metadata_location_regex);
2276    }
2277
2278    #[tokio::test]
2279    async fn test_create_table_throws_error_if_table_with_same_name_already_exists() {
2280        let warehouse_loc = temp_path();
2281        let catalog = new_sql_catalog(warehouse_loc.clone(), Some("iceberg")).await;
2282        let namespace_ident = NamespaceIdent::new("a".into());
2283        create_namespace(&catalog, &namespace_ident).await;
2284        let table_name = "tbl1";
2285        let table_ident = TableIdent::new(namespace_ident.clone(), table_name.into());
2286        create_table(&catalog, &table_ident).await;
2287
2288        let tmp_dir = TempDir::new().unwrap();
2289        let location = tmp_dir.path().to_str().unwrap().to_string();
2290
2291        assert_eq!(
2292            catalog
2293                .create_table(
2294                    &namespace_ident,
2295                    TableCreation::builder()
2296                        .name(table_name.into())
2297                        .schema(simple_table_schema())
2298                        .location(location)
2299                        .build()
2300                )
2301                .await
2302                .unwrap_err()
2303                .to_string(),
2304            format!(
2305                "TableAlreadyExists => Table {:?} already exists.",
2306                &table_ident
2307            )
2308        );
2309    }
2310
2311    #[tokio::test]
2312    async fn test_rename_table_src_table_is_same_as_dst_table() {
2313        let warehouse_loc = temp_path();
2314        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2315        let namespace_ident = NamespaceIdent::new("n1".into());
2316        create_namespace(&catalog, &namespace_ident).await;
2317        let table_ident = TableIdent::new(namespace_ident.clone(), "tbl".into());
2318        create_table(&catalog, &table_ident).await;
2319
2320        catalog
2321            .rename_table(&table_ident, &table_ident)
2322            .await
2323            .unwrap();
2324
2325        assert_eq!(catalog.list_tables(&namespace_ident).await.unwrap(), vec![
2326            table_ident
2327        ],);
2328    }
2329
2330    #[tokio::test]
2331    async fn test_rename_table_across_nested_namespaces() {
2332        let warehouse_loc = temp_path();
2333        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2334        let namespace_ident_a = NamespaceIdent::new("a".into());
2335        let namespace_ident_a_b = NamespaceIdent::from_strs(vec!["a", "b"]).unwrap();
2336        let namespace_ident_a_b_c = NamespaceIdent::from_strs(vec!["a", "b", "c"]).unwrap();
2337        create_namespaces(&catalog, &vec![
2338            &namespace_ident_a,
2339            &namespace_ident_a_b,
2340            &namespace_ident_a_b_c,
2341        ])
2342        .await;
2343
2344        let src_table_ident = TableIdent::new(namespace_ident_a_b_c.clone(), "tbl1".into());
2345        create_tables(&catalog, vec![&src_table_ident]).await;
2346
2347        let dst_table_ident = TableIdent::new(namespace_ident_a_b.clone(), "tbl1".into());
2348        catalog
2349            .rename_table(&src_table_ident, &dst_table_ident)
2350            .await
2351            .unwrap();
2352
2353        assert!(!catalog.table_exists(&src_table_ident).await.unwrap());
2354
2355        assert!(catalog.table_exists(&dst_table_ident).await.unwrap());
2356    }
2357
2358    #[tokio::test]
2359    async fn test_rename_table_throws_error_if_dst_namespace_doesnt_exist() {
2360        let warehouse_loc = temp_path();
2361        let catalog = new_sql_catalog(warehouse_loc, Some("iceberg")).await;
2362        let src_namespace_ident = NamespaceIdent::new("n1".into());
2363        let src_table_ident = TableIdent::new(src_namespace_ident.clone(), "tbl1".into());
2364        create_namespace(&catalog, &src_namespace_ident).await;
2365        create_table(&catalog, &src_table_ident).await;
2366
2367        let non_existent_dst_namespace_ident = NamespaceIdent::new("n2".into());
2368        let dst_table_ident =
2369            TableIdent::new(non_existent_dst_namespace_ident.clone(), "tbl1".into());
2370        assert_eq!(
2371            catalog
2372                .rename_table(&src_table_ident, &dst_table_ident)
2373                .await
2374                .unwrap_err()
2375                .to_string(),
2376            format!("NamespaceNotFound => No such namespace: {non_existent_dst_namespace_ident:?}"),
2377        );
2378    }
2379
2380    /// Creates a V0 SQLite database (no `iceberg_type` column) with one pre-inserted table row.
2381    /// Returns the SQLite URI and the temp dir that owns the database file.
2382    async fn create_v0_sqlite_db() -> (String, TempDir) {
2383        let temp_dir = TempDir::new().unwrap();
2384        let uri = format!(
2385            "sqlite:{}",
2386            temp_dir.path().join("catalog.db").to_str().unwrap()
2387        );
2388        sqlx::Sqlite::create_database(&uri).await.unwrap();
2389        let pool = sqlx::AnyPool::connect(&uri).await.unwrap();
2390        sqlx::query(
2391            "CREATE TABLE iceberg_tables (
2392                catalog_name VARCHAR(255) NOT NULL,
2393                table_namespace VARCHAR(255) NOT NULL,
2394                table_name VARCHAR(255) NOT NULL,
2395                metadata_location VARCHAR(1000),
2396                previous_metadata_location VARCHAR(1000),
2397                PRIMARY KEY (catalog_name, table_namespace, table_name)
2398            )",
2399        )
2400        .execute(&pool)
2401        .await
2402        .unwrap();
2403        sqlx::query(
2404            "INSERT INTO iceberg_tables
2405             (catalog_name, table_namespace, table_name, metadata_location)
2406             VALUES ('iceberg', 'test_namespace', 'existing_test_table', '/tmp/fake-location')",
2407        )
2408        .execute(&pool)
2409        .await
2410        .unwrap();
2411        pool.close().await;
2412        (uri, temp_dir)
2413    }
2414
2415    /// Creates a V1 SQLite database (with an `iceberg_type` column) with one pre-inserted table row.
2416    /// Returns the SQLite URI and the temp dir that owns the database file.
2417    async fn create_v1_sqlite_db() -> (String, TempDir) {
2418        let temp_dir = TempDir::new().unwrap();
2419        let uri = format!(
2420            "sqlite:{}",
2421            temp_dir.path().join("catalog.db").to_str().unwrap()
2422        );
2423        sqlx::Sqlite::create_database(&uri).await.unwrap();
2424        let pool = sqlx::AnyPool::connect(&uri).await.unwrap();
2425        sqlx::query(
2426            "CREATE TABLE iceberg_tables (
2427                catalog_name VARCHAR(255) NOT NULL,
2428                table_namespace VARCHAR(255) NOT NULL,
2429                table_name VARCHAR(255) NOT NULL,
2430                metadata_location VARCHAR(1000),
2431                previous_metadata_location VARCHAR(1000),
2432                iceberg_type VARCHAR(5),
2433                PRIMARY KEY (catalog_name, table_namespace, table_name)
2434            )",
2435        )
2436        .execute(&pool)
2437        .await
2438        .unwrap();
2439        sqlx::query(
2440            "INSERT INTO iceberg_tables
2441             (catalog_name, table_namespace, table_name, metadata_location, iceberg_type)
2442             VALUES ('iceberg', 'test_namespace', 'existing_test_table', '/tmp/fake-location', 'TABLE')",
2443        )
2444        .execute(&pool)
2445        .await
2446        .unwrap();
2447        pool.close().await;
2448        (uri, temp_dir)
2449    }
2450
2451    /// Catalog properties for opening `uri` with `warehouse` as the warehouse location,
2452    /// optionally requesting a specific `sql.schema-version`.
2453    fn catalog_props(
2454        uri: &str,
2455        warehouse: &TempDir,
2456        schema_version: Option<SchemaVersion>,
2457    ) -> HashMap<String, String> {
2458        let mut props = HashMap::from_iter([
2459            (SQL_CATALOG_PROP_URI.to_string(), uri.to_string()),
2460            (
2461                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
2462                warehouse.path().to_str().unwrap().to_string(),
2463            ),
2464            (
2465                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
2466                SqlBindStyle::QMark.to_string(),
2467            ),
2468        ]);
2469        if let Some(schema_version) = schema_version {
2470            props.insert(
2471                SQL_CATALOG_PROP_SCHEMA_VERSION.to_string(),
2472                schema_version.to_string(),
2473            );
2474        }
2475        props
2476    }
2477
2478    /// Whether the catalog table in the database at `uri` has the `iceberg_type` column.
2479    ///
2480    /// Connects independently of any catalog under test, so it reports what is actually on disk.
2481    async fn record_type_column_exists(uri: &str) -> bool {
2482        let probe_pool = sqlx::AnyPool::connect(uri).await.unwrap();
2483        let column_exists = probe_pool
2484            .describe(&format!("SELECT * FROM {CATALOG_TABLE_NAME}"))
2485            .await
2486            .expect("connection and query should succeed")
2487            .columns()
2488            .iter()
2489            .any(|column| {
2490                column
2491                    .name()
2492                    .eq_ignore_ascii_case(CATALOG_FIELD_RECORD_TYPE)
2493            });
2494        probe_pool.close().await;
2495        column_exists
2496    }
2497
2498    #[tokio::test]
2499    async fn test_detect_schema_version() {
2500        install_default_drivers();
2501
2502        let detected_schema = {
2503            let (uri, _temp_dir) = create_v0_sqlite_db().await;
2504            let pool = sqlx::AnyPool::connect(&uri).await.unwrap();
2505            let detected_schema = SchemaVersion::detect(&pool).await.unwrap();
2506            pool.close().await;
2507            detected_schema
2508        };
2509        assert_eq!(
2510            detected_schema,
2511            SchemaVersion::V0,
2512            "a catalog table without an iceberg_type column should be V0",
2513        );
2514
2515        let detected_schema = {
2516            let (uri, _temp_dir) = create_v1_sqlite_db().await;
2517            let pool = sqlx::AnyPool::connect(&uri).await.unwrap();
2518            let detected_schema = SchemaVersion::detect(&pool).await.unwrap();
2519            pool.close().await;
2520            detected_schema
2521        };
2522        assert_eq!(
2523            detected_schema,
2524            SchemaVersion::V1,
2525            "a catalog table with an iceberg_type column should be V1",
2526        );
2527    }
2528
2529    #[tokio::test]
2530    async fn test_detect_schema_version_surfaces_errors() {
2531        install_default_drivers();
2532
2533        let temp_dir = TempDir::new().unwrap();
2534        let uri = format!(
2535            "sqlite:{}",
2536            temp_dir.path().join("catalog.db").to_str().unwrap()
2537        );
2538        sqlx::Sqlite::create_database(&uri).await.unwrap();
2539        let pool = sqlx::AnyPool::connect(&uri).await.unwrap();
2540
2541        // No `iceberg_tables` table at all, so the schema version is unknowable.
2542        let err = SchemaVersion::detect(&pool)
2543            .await
2544            .expect_err("detection should fail rather than report V0");
2545        pool.close().await;
2546
2547        assert!(
2548            err.to_string().contains("iceberg_tables"),
2549            "error should name the table it failed to introspect, got: {err}"
2550        );
2551    }
2552
2553    #[tokio::test]
2554    async fn test_v0_schema_migration() {
2555        install_default_drivers();
2556
2557        let (uri, temp_dir) = create_v0_sqlite_db().await;
2558
2559        // Opening the catalog with sql.schema-version=V1 should migrate the V0 schema.
2560        let props = HashMap::from_iter([
2561            (SQL_CATALOG_PROP_URI.to_string(), uri.clone()),
2562            (
2563                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
2564                temp_dir.path().to_str().unwrap().to_string(),
2565            ),
2566            (
2567                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
2568                SqlBindStyle::QMark.to_string(),
2569            ),
2570            (
2571                SQL_CATALOG_PROP_SCHEMA_VERSION.to_string(),
2572                SchemaVersion::V1.to_string(),
2573            ),
2574        ]);
2575        let catalog = SqlCatalogBuilder::default()
2576            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2577            .load("iceberg", props)
2578            .await
2579            .expect("should open V0 catalog and migrate schema when sql.schema-version=V1");
2580
2581        // The V0 row (no "iceberg_type" column) should be treated as a TABLE after migration.
2582        let namespace = NamespaceIdent::from_strs(["test_namespace"]).unwrap();
2583        let tables = catalog.list_tables(&namespace).await.unwrap();
2584        assert_eq!(tables.len(), 1);
2585        assert_eq!(tables[0].name(), "existing_test_table");
2586
2587        assert!(
2588            record_type_column_exists(&uri).await,
2589            "iceberg_type column should exist when sql.schema-version=V1 was set",
2590        );
2591    }
2592
2593    #[tokio::test]
2594    async fn test_v0_schema_no_migration_without_property() {
2595        install_default_drivers();
2596
2597        let (uri, temp_dir) = create_v0_sqlite_db().await;
2598
2599        // Opening without sql.schema-version=V1 should NOT migrate — but should still work.
2600        let props = HashMap::from_iter([
2601            (SQL_CATALOG_PROP_URI.to_string(), uri.clone()),
2602            (
2603                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
2604                temp_dir.path().to_str().unwrap().to_string(),
2605            ),
2606            (
2607                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
2608                SqlBindStyle::QMark.to_string(),
2609            ),
2610        ]);
2611        let catalog = SqlCatalogBuilder::default()
2612            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2613            .load("iceberg", props)
2614            .await
2615            .expect("should open V0 catalog without migrating");
2616
2617        assert_eq!(catalog.schema_version, SchemaVersion::V0);
2618
2619        // The table should still be visible via V0 queries (no iceberg_type filter).
2620        let namespace = NamespaceIdent::from_strs(["test_namespace"]).unwrap();
2621        let tables = catalog.list_tables(&namespace).await.unwrap();
2622        assert_eq!(tables.len(), 1);
2623        assert_eq!(tables[0].name(), "existing_test_table");
2624
2625        assert!(
2626            !record_type_column_exists(&uri).await,
2627            "iceberg_type column should not exist when sql.schema-version=V1 was not set"
2628        );
2629    }
2630
2631    #[tokio::test]
2632    async fn test_explicit_v0_schema_version_is_honored() {
2633        install_default_drivers();
2634
2635        let (uri, temp_dir) = create_v0_sqlite_db().await;
2636
2637        let catalog = SqlCatalogBuilder::default()
2638            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2639            .load(
2640                "iceberg",
2641                catalog_props(&uri, &temp_dir, Some(SchemaVersion::V0)),
2642            )
2643            .await
2644            .expect("requesting V0 against a V0 catalog table should succeed");
2645
2646        assert_eq!(catalog.schema_version, SchemaVersion::V0);
2647
2648        let namespace = NamespaceIdent::from_strs(["test_namespace"]).unwrap();
2649        let tables = catalog.list_tables(&namespace).await.unwrap();
2650        assert_eq!(tables.len(), 1);
2651        assert_eq!(tables[0].name(), "existing_test_table");
2652
2653        assert!(
2654            !record_type_column_exists(&uri).await,
2655            "iceberg_type column should not be added when V0 was requested"
2656        );
2657    }
2658
2659    #[tokio::test]
2660    async fn test_v0_schema_version_is_ignored_on_v1_table() {
2661        install_default_drivers();
2662
2663        let (uri, temp_dir) = create_v1_sqlite_db().await;
2664
2665        let catalog = SqlCatalogBuilder::default()
2666            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2667            .load(
2668                "iceberg",
2669                catalog_props(&uri, &temp_dir, Some(SchemaVersion::V0)),
2670            )
2671            .await
2672            .expect("requesting V0 against a V1 catalog table should succeed");
2673
2674        assert_eq!(catalog.schema_version, SchemaVersion::V1);
2675
2676        let namespace = NamespaceIdent::from_strs(["test_namespace"]).unwrap();
2677        let tables = catalog.list_tables(&namespace).await.unwrap();
2678        assert_eq!(tables.len(), 1);
2679        assert_eq!(tables[0].name(), "existing_test_table");
2680    }
2681
2682    #[tokio::test]
2683    async fn test_v0_schema_version_on_empty_database_yields_v1() {
2684        install_default_drivers();
2685
2686        let temp_dir = TempDir::new().unwrap();
2687        let uri = format!(
2688            "sqlite:{}",
2689            temp_dir.path().join("catalog.db").to_str().unwrap()
2690        );
2691        sqlx::Sqlite::create_database(&uri).await.unwrap();
2692
2693        let catalog = SqlCatalogBuilder::default()
2694            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2695            .load(
2696                "iceberg",
2697                catalog_props(&uri, &temp_dir, Some(SchemaVersion::V0)),
2698            )
2699            .await
2700            .expect("requesting V0 against an empty database should succeed");
2701
2702        assert_eq!(catalog.schema_version, SchemaVersion::V1);
2703    }
2704
2705    #[tokio::test]
2706    async fn test_create_table_unsupported_on_v0_schema() {
2707        install_default_drivers();
2708
2709        let (uri, temp_dir) = create_v0_sqlite_db().await;
2710
2711        let catalog = SqlCatalogBuilder::default()
2712            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2713            .load("iceberg", catalog_props(&uri, &temp_dir, None))
2714            .await
2715            .expect("should open V0 catalog without migrating");
2716        assert_eq!(catalog.schema_version, SchemaVersion::V0);
2717
2718        // Inserts always populate `iceberg_type`, which a V0 catalog table does not have, so
2719        // creation is refused up front rather than failing in the database.
2720        let namespace = NamespaceIdent::from_strs(["test_namespace"]).unwrap();
2721        let err = catalog
2722            .create_table(
2723                &namespace,
2724                TableCreation::builder()
2725                    .name("created_test_table".to_string())
2726                    .schema(simple_table_schema())
2727                    .location(temp_path())
2728                    .build(),
2729            )
2730            .await
2731            .expect_err("table creation should be rejected on a V0 catalog table");
2732
2733        assert_eq!(err.kind(), ErrorKind::FeatureUnsupported);
2734        assert!(
2735            err.to_string().contains("Table creation is not supported"),
2736            "error should explain that table creation is unsupported, got: {err}"
2737        );
2738
2739        let tables = catalog.list_tables(&namespace).await.unwrap();
2740        assert_eq!(
2741            tables.len(),
2742            1,
2743            "only the pre-existing row should be present, got: {tables:?}"
2744        );
2745    }
2746
2747    #[tokio::test]
2748    async fn test_register_table_unsupported_on_v0_schema() {
2749        install_default_drivers();
2750
2751        let (uri, temp_dir) = create_v0_sqlite_db().await;
2752
2753        let catalog = SqlCatalogBuilder::default()
2754            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2755            .load("iceberg", catalog_props(&uri, &temp_dir, None))
2756            .await
2757            .expect("should open V0 catalog without migrating");
2758        assert_eq!(catalog.schema_version, SchemaVersion::V0);
2759
2760        let namespace = NamespaceIdent::from_strs(["test_namespace"]).unwrap();
2761        let table_ident = TableIdent::new(namespace.clone(), "registered_test_table".to_string());
2762        // Register with non-existent table to ensure test is verifying that schema version is checked first.
2763        let err = catalog
2764            .register_table(
2765                &table_ident,
2766                "/tmp/does-not-exist/metadata.json".to_string(),
2767            )
2768            .await
2769            .expect_err("table registration should be rejected on a V0 catalog table");
2770
2771        assert_eq!(err.kind(), ErrorKind::FeatureUnsupported);
2772        assert!(
2773            err.to_string()
2774                .contains("Table registration is not supported"),
2775            "error should explain that table registration is unsupported, got: {err}"
2776        );
2777
2778        let tables = catalog.list_tables(&namespace).await.unwrap();
2779        assert_eq!(
2780            tables.len(),
2781            1,
2782            "only the pre-existing row should be present, got: {tables:?}"
2783        );
2784    }
2785
2786    #[tokio::test]
2787    async fn test_create_table_supported_after_v0_migration() {
2788        install_default_drivers();
2789
2790        let (uri, temp_dir) = create_v0_sqlite_db().await;
2791
2792        let catalog = SqlCatalogBuilder::default()
2793            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2794            .load(
2795                "iceberg",
2796                catalog_props(&uri, &temp_dir, Some(SchemaVersion::V1)),
2797            )
2798            .await
2799            .expect("should open V0 catalog and migrate schema when sql.schema-version=V1");
2800        assert_eq!(catalog.schema_version, SchemaVersion::V1);
2801
2802        let namespace = NamespaceIdent::from_strs(["test_namespace"]).unwrap();
2803        catalog
2804            .create_table(
2805                &namespace,
2806                TableCreation::builder()
2807                    .name("created_test_table".to_string())
2808                    .schema(simple_table_schema())
2809                    .location(temp_path())
2810                    .build(),
2811            )
2812            .await
2813            .expect("table creation should succeed once the schema is migrated to V1");
2814
2815        let table_names = catalog
2816            .list_tables(&namespace)
2817            .await
2818            .unwrap()
2819            .iter()
2820            .map(|table_ident| table_ident.name().to_string())
2821            .sorted()
2822            .collect_vec();
2823        assert_eq!(
2824            table_names,
2825            vec!["created_test_table", "existing_test_table"],
2826            "the migrated pre-existing row and the new table should both be listed"
2827        );
2828    }
2829
2830    #[tokio::test]
2831    async fn test_invalid_schema_version_is_rejected() {
2832        install_default_drivers();
2833
2834        let (uri, temp_dir) = create_v0_sqlite_db().await;
2835
2836        // An unrecognized sql.schema-version value must fail fast rather than silently
2837        // falling back to V0.
2838        let props = HashMap::from_iter([
2839            (SQL_CATALOG_PROP_URI.to_string(), uri),
2840            (
2841                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
2842                temp_dir.path().to_str().unwrap().to_string(),
2843            ),
2844            (
2845                SQL_CATALOG_PROP_BIND_STYLE.to_string(),
2846                SqlBindStyle::QMark.to_string(),
2847            ),
2848            (
2849                SQL_CATALOG_PROP_SCHEMA_VERSION.to_string(),
2850                "v2".to_string(),
2851            ),
2852        ]);
2853        let result = SqlCatalogBuilder::default()
2854            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2855            .load("iceberg", props)
2856            .await;
2857
2858        let err = result.expect_err("an invalid sql.schema-version should be rejected");
2859        assert_eq!(err.kind(), ErrorKind::DataInvalid);
2860    }
2861
2862    #[tokio::test]
2863    async fn test_legacy_bind_style_key_is_accepted() {
2864        install_default_drivers();
2865
2866        let (uri, temp_dir) = create_v0_sqlite_db().await;
2867
2868        // The legacy `sql_bind_style` key must keep working alongside the new `sql.bind-style`.
2869        let props = HashMap::from_iter([
2870            (SQL_CATALOG_PROP_URI.to_string(), uri),
2871            (
2872                SQL_CATALOG_PROP_WAREHOUSE.to_string(),
2873                temp_dir.path().to_str().unwrap().to_string(),
2874            ),
2875            (
2876                SQL_CATALOG_PROP_BIND_STYLE_LEGACY.to_string(),
2877                SqlBindStyle::QMark.to_string(),
2878            ),
2879        ]);
2880        let catalog = SqlCatalogBuilder::default()
2881            .with_storage_factory(Arc::new(LocalFsStorageFactory))
2882            .load("iceberg", props)
2883            .await
2884            .expect("legacy sql_bind_style key should still be accepted");
2885
2886        assert_eq!(catalog.sql_bind_style, SqlBindStyle::QMark);
2887    }
2888}