1use 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
31type CatalogBuilderFactory = fn() -> Box<dyn BoxedCatalogBuilder>;
33
34static 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
43pub fn supported_types() -> Vec<&'static str> {
45 CATALOG_REGISTRY.iter().map(|(k, _)| *k).collect()
46}
47
48#[async_trait]
49pub trait BoxedCatalogBuilder: Send {
50 fn with_storage_factory(
53 self: Box<Self>,
54 storage_factory: Arc<dyn StorageFactory>,
55 ) -> Box<dyn BoxedCatalogBuilder>;
56
57 fn with_kms_client_factory(
60 self: Box<Self>,
61 kms_client_factory: Arc<dyn KmsClientFactory>,
62 ) -> Box<dyn BoxedCatalogBuilder>;
63
64 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
108pub 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
128pub 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 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 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 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 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 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 #[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 #[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 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 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 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 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 assert!(msg.contains("rest"));
522 }
523}