Skip to main content

iceberg_catalog_loader/
lib.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;
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use iceberg::encryption::kms::KmsClientFactory;
23use iceberg::io::StorageFactory;
24use iceberg::{Catalog, CatalogBuilder, Error, ErrorKind, Result, Runtime};
25use iceberg_catalog_glue::GlueCatalogBuilder;
26use iceberg_catalog_hms::HmsCatalogBuilder;
27use iceberg_catalog_rest::RestCatalogBuilder;
28use iceberg_catalog_s3tables::S3TablesCatalogBuilder;
29use iceberg_catalog_sql::SqlCatalogBuilder;
30
31/// A CatalogBuilderFactory creating a new catalog builder.
32type CatalogBuilderFactory = fn() -> Box<dyn BoxedCatalogBuilder>;
33
34/// A registry of catalog builders.
35static CATALOG_REGISTRY: &[(&str, CatalogBuilderFactory)] = &[
36    ("rest", || Box::new(RestCatalogBuilder::default())),
37    ("glue", || Box::new(GlueCatalogBuilder::default())),
38    ("s3tables", || Box::new(S3TablesCatalogBuilder::default())),
39    ("hms", || Box::new(HmsCatalogBuilder::default())),
40    ("sql", || Box::new(SqlCatalogBuilder::default())),
41];
42
43/// Return the list of supported catalog types.
44pub fn supported_types() -> Vec<&'static str> {
45    CATALOG_REGISTRY.iter().map(|(k, _)| *k).collect()
46}
47
48#[async_trait]
49pub trait BoxedCatalogBuilder: Send {
50    /// Sets the storage factory used to build the catalog's `FileIO`; see
51    /// [`CatalogBuilder::with_storage_factory`].
52    fn with_storage_factory(
53        self: Box<Self>,
54        storage_factory: Arc<dyn StorageFactory>,
55    ) -> Box<dyn BoxedCatalogBuilder>;
56
57    /// Sets the KMS client factory used to enable table encryption; see
58    /// [`CatalogBuilder::with_kms_client_factory`].
59    fn with_kms_client_factory(
60        self: Box<Self>,
61        kms_client_factory: Arc<dyn KmsClientFactory>,
62    ) -> Box<dyn BoxedCatalogBuilder>;
63
64    /// Sets the runtime the catalog, and the tables it creates, spawn their
65    /// tasks on; see [`CatalogBuilder::with_runtime`].
66    fn with_runtime(self: Box<Self>, runtime: Runtime) -> Box<dyn BoxedCatalogBuilder>;
67
68    async fn load(
69        self: Box<Self>,
70        name: String,
71        props: HashMap<String, String>,
72    ) -> Result<Arc<dyn Catalog>>;
73}
74
75#[async_trait]
76impl<T: CatalogBuilder + 'static> BoxedCatalogBuilder for T {
77    fn with_storage_factory(
78        self: Box<Self>,
79        storage_factory: Arc<dyn StorageFactory>,
80    ) -> Box<dyn BoxedCatalogBuilder> {
81        Box::new(CatalogBuilder::with_storage_factory(*self, storage_factory))
82    }
83
84    fn with_kms_client_factory(
85        self: Box<Self>,
86        kms_client_factory: Arc<dyn KmsClientFactory>,
87    ) -> Box<dyn BoxedCatalogBuilder> {
88        Box::new(CatalogBuilder::with_kms_client_factory(
89            *self,
90            kms_client_factory,
91        ))
92    }
93
94    fn with_runtime(self: Box<Self>, runtime: Runtime) -> Box<dyn BoxedCatalogBuilder> {
95        Box::new(CatalogBuilder::with_runtime(*self, runtime))
96    }
97
98    async fn load(
99        self: Box<Self>,
100        name: String,
101        props: HashMap<String, String>,
102    ) -> Result<Arc<dyn Catalog>> {
103        let builder = *self;
104        Ok(Arc::new(builder.load(name, props).await?) as Arc<dyn Catalog>)
105    }
106}
107
108/// Load a catalog from a string.
109pub fn load(r#type: &str) -> Result<Box<dyn BoxedCatalogBuilder>> {
110    let key = r#type.trim();
111    if let Some((_, factory)) = CATALOG_REGISTRY
112        .iter()
113        .find(|(k, _)| k.eq_ignore_ascii_case(key))
114    {
115        Ok(factory())
116    } else {
117        Err(Error::new(
118            ErrorKind::FeatureUnsupported,
119            format!(
120                "Unsupported catalog type: {}. Supported types: {}",
121                r#type,
122                supported_types().join(", ")
123            ),
124        ))
125    }
126}
127
128/// Ergonomic catalog loader builder pattern.
129pub struct CatalogLoader<'a> {
130    catalog_type: &'a str,
131    storage_factory: Option<Arc<dyn StorageFactory>>,
132    kms_client_factory: Option<Arc<dyn KmsClientFactory>>,
133    runtime: Option<Runtime>,
134}
135
136impl<'a> From<&'a str> for CatalogLoader<'a> {
137    fn from(s: &'a str) -> Self {
138        Self {
139            catalog_type: s,
140            storage_factory: None,
141            kms_client_factory: None,
142            runtime: None,
143        }
144    }
145}
146
147impl CatalogLoader<'_> {
148    /// Sets the storage factory used to build the catalog's `FileIO`; see
149    /// [`CatalogBuilder::with_storage_factory`].
150    pub fn with_storage_factory(mut self, storage_factory: Arc<dyn StorageFactory>) -> Self {
151        self.storage_factory = Some(storage_factory);
152        self
153    }
154
155    /// Sets the KMS client factory used to enable table encryption; see
156    /// [`CatalogBuilder::with_kms_client_factory`].
157    pub fn with_kms_client_factory(
158        mut self,
159        kms_client_factory: Arc<dyn KmsClientFactory>,
160    ) -> Self {
161        self.kms_client_factory = Some(kms_client_factory);
162        self
163    }
164
165    /// Sets the runtime the catalog, and the tables it creates, spawn their
166    /// tasks on; see [`CatalogBuilder::with_runtime`].
167    pub fn with_runtime(mut self, runtime: Runtime) -> Self {
168        self.runtime = Some(runtime);
169        self
170    }
171
172    pub async fn load(
173        self,
174        name: String,
175        props: HashMap<String, String>,
176    ) -> Result<Arc<dyn Catalog>> {
177        let builder = load(self.catalog_type)?;
178        self.configure(builder).load(name, props).await
179    }
180
181    /// Applies the settings given to this loader to `builder`.
182    fn configure(self, mut builder: Box<dyn BoxedCatalogBuilder>) -> Box<dyn BoxedCatalogBuilder> {
183        if let Some(storage_factory) = self.storage_factory {
184            builder = builder.with_storage_factory(storage_factory);
185        }
186        if let Some(kms_client_factory) = self.kms_client_factory {
187            builder = builder.with_kms_client_factory(kms_client_factory);
188        }
189        if let Some(runtime) = self.runtime {
190            builder = builder.with_runtime(runtime);
191        }
192        builder
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use std::collections::HashMap;
199    use std::sync::{Arc, Mutex};
200
201    use iceberg::encryption::kms::{KmsClientFactory, MemoryKmsClientFactory};
202    use iceberg::io::{LocalFsStorageFactory, StorageFactory};
203    use iceberg::memory::{MEMORY_CATALOG_WAREHOUSE, MemoryCatalog, MemoryCatalogBuilder};
204    use iceberg::{CatalogBuilder, Result, Runtime};
205    use sqlx::migrate::MigrateDatabase;
206    use tempfile::TempDir;
207
208    use crate::{BoxedCatalogBuilder, CatalogLoader, load};
209
210    #[tokio::test]
211    async fn test_load_unsupported_catalog() {
212        let result = load("unsupported");
213        assert!(result.is_err());
214    }
215
216    #[tokio::test]
217    async fn test_catalog_loader_pattern() {
218        use iceberg_catalog_rest::REST_CATALOG_PROP_URI;
219
220        let catalog = CatalogLoader::from("rest")
221            .load(
222                "rest".to_string(),
223                HashMap::from([
224                    (
225                        REST_CATALOG_PROP_URI.to_string(),
226                        "http://localhost:8080".to_string(),
227                    ),
228                    ("key".to_string(), "value".to_string()),
229                ]),
230            )
231            .await;
232
233        assert!(catalog.is_ok());
234    }
235
236    #[tokio::test]
237    async fn test_catalog_loader_pattern_rest_catalog() {
238        use iceberg_catalog_rest::REST_CATALOG_PROP_URI;
239
240        let catalog_loader = load("rest").unwrap();
241        let catalog = catalog_loader
242            .load(
243                "rest".to_string(),
244                HashMap::from([
245                    (
246                        REST_CATALOG_PROP_URI.to_string(),
247                        "http://localhost:8080".to_string(),
248                    ),
249                    ("key".to_string(), "value".to_string()),
250                ]),
251            )
252            .await;
253
254        assert!(catalog.is_ok());
255    }
256
257    #[tokio::test]
258    async fn test_catalog_loader_pattern_glue_catalog() {
259        use iceberg_catalog_glue::GLUE_CATALOG_PROP_WAREHOUSE;
260
261        let catalog_loader = load("glue").unwrap();
262        let catalog = catalog_loader
263            .load(
264                "glue".to_string(),
265                HashMap::from([
266                    (
267                        GLUE_CATALOG_PROP_WAREHOUSE.to_string(),
268                        "s3://test".to_string(),
269                    ),
270                    ("key".to_string(), "value".to_string()),
271                ]),
272            )
273            .await;
274
275        assert!(catalog.is_ok());
276    }
277
278    #[tokio::test]
279    async fn test_catalog_loader_pattern_s3tables() {
280        use iceberg_catalog_s3tables::S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN;
281
282        let catalog = CatalogLoader::from("s3tables")
283            .load(
284                "s3tables".to_string(),
285                HashMap::from([
286                    (
287                        S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN.to_string(),
288                        "arn:aws:s3tables:us-east-1:123456789012:bucket/test".to_string(),
289                    ),
290                    ("key".to_string(), "value".to_string()),
291                ]),
292            )
293            .await;
294
295        assert!(catalog.is_ok());
296    }
297
298    #[tokio::test]
299    async fn test_catalog_loader_pattern_hms_catalog() {
300        use iceberg_catalog_hms::{HMS_CATALOG_PROP_URI, HMS_CATALOG_PROP_WAREHOUSE};
301
302        let catalog_loader = load("hms").unwrap();
303        let catalog = catalog_loader
304            .with_storage_factory(Arc::new(LocalFsStorageFactory))
305            .load(
306                "hms".to_string(),
307                HashMap::from([
308                    (HMS_CATALOG_PROP_URI.to_string(), "127.0.0.1:1".to_string()),
309                    (
310                        HMS_CATALOG_PROP_WAREHOUSE.to_string(),
311                        "s3://warehouse".to_string(),
312                    ),
313                    ("key".to_string(), "value".to_string()),
314                ]),
315            )
316            .await;
317
318        assert!(catalog.is_ok());
319    }
320
321    fn temp_path() -> String {
322        let temp_dir = TempDir::new().unwrap();
323        temp_dir.path().to_str().unwrap().to_string()
324    }
325
326    #[tokio::test]
327    async fn test_catalog_loader_pattern_sql_catalog() {
328        use iceberg_catalog_sql::{SQL_CATALOG_PROP_URI, SQL_CATALOG_PROP_WAREHOUSE};
329
330        let uri = format!("sqlite:{}", temp_path());
331        sqlx::Sqlite::create_database(&uri).await.unwrap();
332
333        let catalog_loader = load("sql").unwrap();
334        let catalog = catalog_loader
335            .with_storage_factory(Arc::new(LocalFsStorageFactory))
336            .load(
337                "sql".to_string(),
338                HashMap::from([
339                    (SQL_CATALOG_PROP_URI.to_string(), uri),
340                    (
341                        SQL_CATALOG_PROP_WAREHOUSE.to_string(),
342                        "s3://warehouse".to_string(),
343                    ),
344                ]),
345            )
346            .await;
347
348        assert!(catalog.is_ok());
349    }
350
351    #[tokio::test]
352    async fn test_catalog_loader_with_storage_factory_loads_sql_catalog() {
353        use iceberg_catalog_sql::{SQL_CATALOG_PROP_URI, SQL_CATALOG_PROP_WAREHOUSE};
354
355        let uri = format!("sqlite:{}", temp_path());
356        sqlx::Sqlite::create_database(&uri).await.unwrap();
357
358        // A SQL catalog can't be loaded without a storage factory.
359        CatalogLoader::from("sql")
360            .with_storage_factory(Arc::new(LocalFsStorageFactory))
361            .load(
362                "sql".to_string(),
363                HashMap::from([
364                    (SQL_CATALOG_PROP_URI.to_string(), uri),
365                    (
366                        SQL_CATALOG_PROP_WAREHOUSE.to_string(),
367                        "s3://warehouse".to_string(),
368                    ),
369                ]),
370            )
371            .await
372            .unwrap();
373    }
374
375    /// The settings a [`RecordingBuilder`] was given.
376    #[derive(Debug, Default)]
377    struct Recorded {
378        storage_factory: Option<Arc<dyn StorageFactory>>,
379        kms_client_factory: Option<Arc<dyn KmsClientFactory>>,
380        runtime: Option<Runtime>,
381    }
382
383    /// A memory catalog builder that records the settings it is given.
384    #[derive(Debug, Default)]
385    struct RecordingBuilder {
386        recorded: Arc<Mutex<Recorded>>,
387    }
388
389    impl CatalogBuilder for RecordingBuilder {
390        type C = MemoryCatalog;
391
392        fn with_storage_factory(self, storage_factory: Arc<dyn StorageFactory>) -> Self {
393            self.recorded.lock().unwrap().storage_factory = Some(storage_factory);
394            self
395        }
396
397        fn with_kms_client_factory(self, kms_client_factory: Arc<dyn KmsClientFactory>) -> Self {
398            self.recorded.lock().unwrap().kms_client_factory = Some(kms_client_factory);
399            self
400        }
401
402        fn with_runtime(self, runtime: Runtime) -> Self {
403            self.recorded.lock().unwrap().runtime = Some(runtime);
404            self
405        }
406
407        fn load(
408            self,
409            name: impl Into<String>,
410            props: HashMap<String, String>,
411        ) -> impl Future<Output = Result<MemoryCatalog>> + Send {
412            MemoryCatalogBuilder::default().load(name, props)
413        }
414    }
415
416    /// A boxed [`RecordingBuilder`], and the settings it records.
417    fn recording_builder() -> (Box<dyn BoxedCatalogBuilder>, Arc<Mutex<Recorded>>) {
418        let recorded = Arc::new(Mutex::new(Recorded::default()));
419        let builder = Box::new(RecordingBuilder {
420            recorded: recorded.clone(),
421        });
422        (builder, recorded)
423    }
424
425    /// A tokio runtime whose worker thread is named `loader-test-runtime`.
426    fn named_tokio_runtime() -> tokio::runtime::Runtime {
427        tokio::runtime::Builder::new_multi_thread()
428            .worker_threads(1)
429            .thread_name("loader-test-runtime")
430            .enable_all()
431            .build()
432            .unwrap()
433    }
434
435    /// Asserts that tasks spawned on `runtime` run on a thread of
436    /// [`named_tokio_runtime`].
437    async fn assert_spawns_on_named_runtime(runtime: &Runtime) {
438        let thread = runtime
439            .io()
440            .spawn(async { std::thread::current().name().map(str::to_string) })
441            .await
442            .unwrap();
443        assert!(
444            thread
445                .as_deref()
446                .is_some_and(|n| n.starts_with("loader-test-runtime")),
447            "got: {thread:?}"
448        );
449    }
450
451    #[test]
452    fn test_with_runtime_reaches_the_catalog_builder() {
453        let tokio_runtime = named_tokio_runtime();
454        let (builder, recorded) = recording_builder();
455
456        tokio_runtime.block_on(async {
457            builder
458                .with_runtime(Runtime::new(&tokio_runtime))
459                .load(
460                    "memory".to_string(),
461                    HashMap::from([(MEMORY_CATALOG_WAREHOUSE.to_string(), temp_path())]),
462                )
463                .await
464                .unwrap();
465
466            // The builder got the runtime passed to the boxed builder: its
467            // tasks run on that runtime's threads.
468            let runtime = recorded.lock().unwrap().runtime.take().unwrap();
469            assert_spawns_on_named_runtime(&runtime).await;
470        });
471    }
472
473    #[test]
474    fn test_catalog_loader_settings_reach_the_catalog_builder() {
475        let tokio_runtime = named_tokio_runtime();
476        let storage_factory: Arc<dyn StorageFactory> = Arc::new(LocalFsStorageFactory);
477        let kms_client_factory: Arc<dyn KmsClientFactory> =
478            Arc::new(MemoryKmsClientFactory::default());
479        let (builder, recorded) = recording_builder();
480
481        CatalogLoader::from("memory")
482            .with_storage_factory(storage_factory.clone())
483            .with_kms_client_factory(kms_client_factory.clone())
484            .with_runtime(Runtime::new(&tokio_runtime))
485            .configure(builder);
486
487        let recorded = std::mem::take(&mut *recorded.lock().unwrap());
488        assert!(Arc::ptr_eq(
489            &recorded.storage_factory.unwrap(),
490            &storage_factory
491        ));
492        assert!(Arc::ptr_eq(
493            &recorded.kms_client_factory.unwrap(),
494            &kms_client_factory
495        ));
496        tokio_runtime.block_on(assert_spawns_on_named_runtime(&recorded.runtime.unwrap()));
497    }
498
499    #[test]
500    fn test_catalog_loader_without_settings_leaves_the_builder_default() {
501        let (builder, recorded) = recording_builder();
502
503        CatalogLoader::from("memory").configure(builder);
504
505        assert!(matches!(*recorded.lock().unwrap(), Recorded {
506            storage_factory: None,
507            kms_client_factory: None,
508            runtime: None,
509        }));
510    }
511
512    #[tokio::test]
513    async fn test_error_message_includes_supported_types() {
514        let err = match load("does-not-exist") {
515            Ok(_) => panic!("expected error for unsupported type"),
516            Err(e) => e,
517        };
518        let msg = err.message().to_string();
519        assert!(msg.contains("Supported types:"));
520        // Should include at least the built-in type
521        assert!(msg.contains("rest"));
522    }
523}