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