1use std::collections::HashMap;
19use std::fmt::Debug;
20use std::str::FromStr;
21use std::sync::Arc;
22
23use anyhow::anyhow;
24use async_trait::async_trait;
25use aws_sdk_glue::operation::create_table::CreateTableError;
26use aws_sdk_glue::operation::update_table::UpdateTableError;
27use aws_sdk_glue::types::TableInput;
28use iceberg::encryption::kms::{KeyManagementClient, KmsClientFactory};
29use iceberg::io::{
30 FileIO, FileIOBuilder, S3_ACCESS_KEY_ID, S3_ENDPOINT, S3_REGION, S3_SECRET_ACCESS_KEY,
31 S3_SESSION_TOKEN, StorageFactory,
32};
33use iceberg::spec::{TableMetadata, TableMetadataBuilder};
34use iceberg::table::Table;
35use iceberg::{
36 Catalog, CatalogBuilder, Error, ErrorKind, MetadataLocation, Namespace, NamespaceIdent, Result,
37 Runtime, TableCommit, TableCreation, TableIdent,
38};
39use iceberg_property_macro::Properties;
40use iceberg_storage_opendal::OpenDalStorageFactory;
41
42use crate::error::{from_aws_build_error, from_aws_sdk_error};
43use crate::utils::{
44 convert_to_database, convert_to_glue_table, convert_to_namespace, create_sdk_config,
45 get_default_table_location, get_metadata_location, is_iceberg_table, validate_namespace,
46};
47use crate::{
48 AWS_ACCESS_KEY_ID, AWS_REGION_NAME, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, with_catalog_id,
49};
50
51pub const GLUE_CATALOG_PROP_URI: &str = "uri";
53pub const GLUE_CATALOG_PROP_CATALOG_ID: &str = "catalog_id";
55pub const GLUE_CATALOG_PROP_WAREHOUSE: &str = "warehouse";
57
58#[derive(Debug, Default)]
60pub struct GlueCatalogBuilder {
61 storage_factory: Option<Arc<dyn StorageFactory>>,
62 kms_client_factory: Option<Arc<dyn KmsClientFactory>>,
63 runtime: Option<Runtime>,
64}
65
66impl CatalogBuilder for GlueCatalogBuilder {
67 type C = GlueCatalog;
68
69 fn with_storage_factory(mut self, storage_factory: Arc<dyn StorageFactory>) -> Self {
70 self.storage_factory = Some(storage_factory);
71 self
72 }
73
74 fn with_kms_client_factory(mut self, kms_client_factory: Arc<dyn KmsClientFactory>) -> Self {
75 self.kms_client_factory = Some(kms_client_factory);
76 self
77 }
78
79 fn with_runtime(mut self, runtime: Runtime) -> Self {
80 self.runtime = Some(runtime);
81 self
82 }
83
84 fn load(
85 self,
86 name: impl Into<String>,
87 props: HashMap<String, String>,
88 ) -> impl Future<Output = Result<Self::C>> + Send {
89 let name = name.into();
90
91 async move {
92 let catalog_properties = GlueCatalogProperties::from_properties(&props)?;
93 if catalog_properties.warehouse.is_empty() {
94 return Err(Error::new(
95 ErrorKind::DataInvalid,
96 "Catalog warehouse is required",
97 ));
98 }
99
100 let runtime = match self.runtime {
101 Some(rt) => rt,
102 None => Runtime::try_current()?,
103 };
104 let kms_client = match self.kms_client_factory {
105 Some(factory) => Some(factory.create_kms_client(&props).await?),
106 None => None,
107 };
108 GlueCatalog::new(
109 name,
110 catalog_properties,
111 props,
112 self.storage_factory,
113 runtime,
114 kms_client,
115 )
116 .await
117 }
118 }
119}
120
121#[derive(Debug, Properties)]
123pub(crate) struct GlueCatalogProperties {
124 #[property(key = GLUE_CATALOG_PROP_URI, default = None)]
125 uri: Option<String>,
126 #[property(key = GLUE_CATALOG_PROP_CATALOG_ID, default = None)]
127 catalog_id: Option<String>,
128 #[property(key = GLUE_CATALOG_PROP_WAREHOUSE, default = "")]
129 warehouse: String,
130}
131
132struct GlueClient(aws_sdk_glue::Client);
133
134pub struct GlueCatalog {
136 name: String,
137 properties: GlueCatalogProperties,
138 client: GlueClient,
139 file_io: FileIO,
140 runtime: Runtime,
141 kms_client: Option<Arc<dyn KeyManagementClient>>,
142}
143
144impl Debug for GlueCatalog {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("GlueCatalog")
147 .field("name", &self.name)
148 .field("properties", &self.properties)
149 .finish_non_exhaustive()
150 }
151}
152
153impl GlueCatalog {
154 async fn new(
156 name: String,
157 properties: GlueCatalogProperties,
158 props: HashMap<String, String>,
159 storage_factory: Option<Arc<dyn StorageFactory>>,
160 runtime: Runtime,
161 kms_client: Option<Arc<dyn KeyManagementClient>>,
162 ) -> Result<Self> {
163 let sdk_config = create_sdk_config(&props, properties.uri.as_ref()).await;
164 let mut file_io_props = props;
165 if !file_io_props.contains_key(S3_ACCESS_KEY_ID)
166 && let Some(access_key_id) = file_io_props.get(AWS_ACCESS_KEY_ID)
167 {
168 file_io_props.insert(S3_ACCESS_KEY_ID.to_string(), access_key_id.to_string());
169 }
170 if !file_io_props.contains_key(S3_SECRET_ACCESS_KEY)
171 && let Some(secret_access_key) = file_io_props.get(AWS_SECRET_ACCESS_KEY)
172 {
173 file_io_props.insert(
174 S3_SECRET_ACCESS_KEY.to_string(),
175 secret_access_key.to_string(),
176 );
177 }
178 if !file_io_props.contains_key(S3_REGION)
179 && let Some(region) = file_io_props.get(AWS_REGION_NAME)
180 {
181 file_io_props.insert(S3_REGION.to_string(), region.to_string());
182 }
183 if !file_io_props.contains_key(S3_SESSION_TOKEN)
184 && let Some(session_token) = file_io_props.get(AWS_SESSION_TOKEN)
185 {
186 file_io_props.insert(S3_SESSION_TOKEN.to_string(), session_token.to_string());
187 }
188 if !file_io_props.contains_key(S3_ENDPOINT)
189 && let Some(aws_endpoint) = properties.uri.as_ref()
190 {
191 file_io_props.insert(S3_ENDPOINT.to_string(), aws_endpoint.to_string());
192 }
193
194 let client = aws_sdk_glue::Client::new(&sdk_config);
195
196 let factory = storage_factory.unwrap_or_else(|| {
198 Arc::new(OpenDalStorageFactory::S3 {
199 customized_credential_load: None,
200 })
201 });
202 let file_io = FileIOBuilder::new(factory)
203 .with_props(file_io_props)
204 .build();
205
206 Ok(GlueCatalog {
207 name,
208 properties,
209 client: GlueClient(client),
210 file_io,
211 runtime,
212 kms_client,
213 })
214 }
215 pub fn file_io(&self) -> FileIO {
217 self.file_io.clone()
218 }
219
220 async fn load_table_with_version_id(
233 &self,
234 table: &TableIdent,
235 ) -> Result<(Table, Option<String>)> {
236 let db_name = validate_namespace(table.namespace())?;
237 let table_name = table.name();
238
239 let builder = self
240 .client
241 .0
242 .get_table()
243 .database_name(&db_name)
244 .name(table_name);
245 let builder = with_catalog_id!(builder, self.properties);
246
247 let glue_table_output = builder.send().await.map_err(from_aws_sdk_error)?;
248
249 let glue_table = glue_table_output.table().ok_or_else(|| {
250 Error::new(
251 ErrorKind::TableNotFound,
252 format!(
253 "Table object for database: {db_name} and table: {table_name} does not exist"
254 ),
255 )
256 })?;
257
258 let version_id = glue_table.version_id.clone();
259 let metadata_location = get_metadata_location(&glue_table.parameters)?;
260
261 let metadata = TableMetadata::read_from(&self.file_io, &metadata_location).await?;
262
263 let mut builder = Table::builder()
264 .file_io(self.file_io())
265 .metadata_location(metadata_location)
266 .metadata(metadata)
267 .identifier(TableIdent::new(
268 NamespaceIdent::new(db_name),
269 table_name.to_owned(),
270 ))
271 .runtime(self.runtime.clone());
272 if let Some(kms_client) = self.kms_client.clone() {
273 builder = builder.kms_client(kms_client);
274 }
275 let table = builder.build()?;
276
277 Ok((table, version_id))
278 }
279}
280
281#[async_trait]
282impl Catalog for GlueCatalog {
283 async fn list_namespaces(
288 &self,
289 parent: Option<&NamespaceIdent>,
290 ) -> Result<Vec<NamespaceIdent>> {
291 if parent.is_some() {
292 return Ok(vec![]);
293 }
294
295 let mut database_list: Vec<NamespaceIdent> = Vec::new();
296 let mut next_token: Option<String> = None;
297
298 loop {
299 let builder = match &next_token {
300 Some(token) => self.client.0.get_databases().next_token(token),
301 None => self.client.0.get_databases(),
302 };
303 let builder = with_catalog_id!(builder, self.properties);
304 let resp = builder.send().await.map_err(from_aws_sdk_error)?;
305
306 let dbs: Vec<NamespaceIdent> = resp
307 .database_list()
308 .iter()
309 .map(|db| NamespaceIdent::new(db.name().to_string()))
310 .collect();
311
312 database_list.extend(dbs);
313
314 next_token = resp.next_token().map(ToOwned::to_owned);
315 if next_token.is_none() {
316 break;
317 }
318 }
319
320 Ok(database_list)
321 }
322
323 async fn create_namespace(
337 &self,
338 namespace: &NamespaceIdent,
339 properties: HashMap<String, String>,
340 ) -> Result<Namespace> {
341 if self.namespace_exists(namespace).await? {
342 return Err(Error::new(
343 ErrorKind::NamespaceAlreadyExists,
344 format!("Namespace {namespace:?} already exists"),
345 ));
346 }
347
348 let db_input = convert_to_database(namespace, &properties)?;
349
350 let builder = self.client.0.create_database().database_input(db_input);
351 let builder = with_catalog_id!(builder, self.properties);
352
353 builder.send().await.map_err(from_aws_sdk_error)?;
354
355 Ok(Namespace::with_properties(namespace.clone(), properties))
356 }
357
358 async fn get_namespace(&self, namespace: &NamespaceIdent) -> Result<Namespace> {
369 let db_name = validate_namespace(namespace)?;
370
371 let builder = self.client.0.get_database().name(&db_name);
372 let builder = with_catalog_id!(builder, self.properties);
373
374 let resp = builder.send().await.map_err(|err| {
375 if err
376 .as_service_error()
377 .map(|e| e.is_entity_not_found_exception())
378 == Some(true)
379 {
380 return Error::new(
381 ErrorKind::NamespaceNotFound,
382 format!("Namespace {namespace:?} does not exist"),
383 );
384 }
385 from_aws_sdk_error(err)
386 })?;
387
388 match resp.database() {
389 Some(db) => {
390 let namespace = convert_to_namespace(db);
391 Ok(namespace)
392 }
393 None => Err(Error::new(
394 ErrorKind::NamespaceNotFound,
395 format!("Database with name: {db_name} does not exist"),
396 )),
397 }
398 }
399
400 async fn namespace_exists(&self, namespace: &NamespaceIdent) -> Result<bool> {
413 let db_name = validate_namespace(namespace)?;
414
415 let builder = self.client.0.get_database().name(&db_name);
416 let builder = with_catalog_id!(builder, self.properties);
417
418 let resp = builder.send().await;
419
420 match resp {
421 Ok(_) => Ok(true),
422 Err(err) => {
423 if err
424 .as_service_error()
425 .map(|e| e.is_entity_not_found_exception())
426 == Some(true)
427 {
428 return Ok(false);
429 }
430 Err(from_aws_sdk_error(err))
431 }
432 }
433 }
434
435 async fn update_namespace(
446 &self,
447 namespace: &NamespaceIdent,
448 properties: HashMap<String, String>,
449 ) -> Result<()> {
450 if !self.namespace_exists(namespace).await? {
451 return Err(Error::new(
452 ErrorKind::NamespaceNotFound,
453 format!("Namespace {namespace:?} does not exist"),
454 ));
455 }
456
457 let db_name = validate_namespace(namespace)?;
458 let db_input = convert_to_database(namespace, &properties)?;
459
460 let builder = self
461 .client
462 .0
463 .update_database()
464 .name(&db_name)
465 .database_input(db_input);
466 let builder = with_catalog_id!(builder, self.properties);
467
468 builder.send().await.map_err(from_aws_sdk_error)?;
469
470 Ok(())
471 }
472
473 async fn drop_namespace(&self, namespace: &NamespaceIdent) -> Result<()> {
484 if !self.namespace_exists(namespace).await? {
485 return Err(Error::new(
486 ErrorKind::NamespaceNotFound,
487 format!("Namespace {namespace:?} does not exist"),
488 ));
489 }
490
491 let db_name = validate_namespace(namespace)?;
492
493 let builder = self
498 .client
499 .0
500 .get_tables()
501 .database_name(&db_name)
502 .max_results(1);
503 let builder = with_catalog_id!(builder, self.properties);
504 let resp = builder.send().await.map_err(from_aws_sdk_error)?;
505
506 if !resp.table_list().is_empty() {
507 return Err(Error::new(
508 ErrorKind::DataInvalid,
509 format!("Database with name: {} is not empty", db_name),
510 ));
511 }
512
513 let builder = self.client.0.delete_database().name(db_name);
514 let builder = with_catalog_id!(builder, self.properties);
515
516 builder.send().await.map_err(from_aws_sdk_error)?;
517
518 Ok(())
519 }
520
521 async fn list_tables(&self, namespace: &NamespaceIdent) -> Result<Vec<TableIdent>> {
534 let db_name = validate_namespace(namespace)?;
535
536 let mut table_list: Vec<TableIdent> = Vec::new();
537 let mut next_token: Option<String> = None;
538
539 loop {
540 let builder = match &next_token {
541 Some(token) => self
542 .client
543 .0
544 .get_tables()
545 .database_name(&db_name)
546 .next_token(token),
547 None => self.client.0.get_tables().database_name(&db_name),
548 };
549 let builder = with_catalog_id!(builder, self.properties);
550 let resp = builder.send().await.map_err(from_aws_sdk_error)?;
551
552 let tables: Vec<_> = resp
553 .table_list()
554 .iter()
555 .filter(|tbl| is_iceberg_table(&tbl.parameters))
556 .map(|tbl| TableIdent::new(namespace.clone(), tbl.name().to_string()))
557 .collect();
558
559 table_list.extend(tables);
560
561 next_token = resp.next_token().map(ToOwned::to_owned);
562 if next_token.is_none() {
563 break;
564 }
565 }
566
567 Ok(table_list)
568 }
569
570 async fn create_table(
583 &self,
584 namespace: &NamespaceIdent,
585 mut creation: TableCreation,
586 ) -> Result<Table> {
587 let db_name = validate_namespace(namespace)?;
588 let table_name = creation.name.clone();
589
590 if creation.location.is_none() {
591 let ns = self.get_namespace(namespace).await?;
592 let location =
593 get_default_table_location(&ns, &db_name, &table_name, &self.properties.warehouse);
594 creation.location = Some(location);
595 }
596 let metadata = TableMetadataBuilder::from_table_creation(creation)?
597 .build()?
598 .metadata;
599 let metadata_location = MetadataLocation::try_new_with_metadata(&metadata)?;
600
601 metadata.write_to(&self.file_io, &metadata_location).await?;
602
603 let metadata_location_str = metadata_location.to_string();
604 let glue_table = convert_to_glue_table(
605 &table_name,
606 metadata_location_str.clone(),
607 &metadata,
608 metadata.properties(),
609 None,
610 )?;
611
612 let builder = self
613 .client
614 .0
615 .create_table()
616 .database_name(&db_name)
617 .table_input(glue_table);
618 let builder = with_catalog_id!(builder, self.properties);
619
620 builder.send().await.map_err(from_aws_sdk_error)?;
621
622 let mut builder = Table::builder()
623 .file_io(self.file_io())
624 .metadata_location(metadata_location_str)
625 .metadata(metadata)
626 .identifier(TableIdent::new(NamespaceIdent::new(db_name), table_name))
627 .runtime(self.runtime.clone());
628 if let Some(kms_client) = self.kms_client.clone() {
629 builder = builder.kms_client(kms_client);
630 }
631 builder.build()
632 }
633
634 async fn load_table(&self, table: &TableIdent) -> Result<Table> {
647 let (table, _) = self.load_table_with_version_id(table).await?;
648 Ok(table)
649 }
650
651 async fn drop_table(&self, table: &TableIdent) -> Result<()> {
662 let db_name = validate_namespace(table.namespace())?;
663 let table_name = table.name();
664
665 let builder = self
666 .client
667 .0
668 .delete_table()
669 .database_name(&db_name)
670 .name(table_name);
671 let builder = with_catalog_id!(builder, self.properties);
672
673 builder.send().await.map_err(from_aws_sdk_error)?;
674
675 Ok(())
676 }
677
678 async fn purge_table(&self, table: &TableIdent) -> Result<()> {
679 let table_info = self.load_table(table).await?;
680 self.drop_table(table).await?;
681 iceberg::drop_table_data(&table_info).await
682 }
683
684 async fn table_exists(&self, table: &TableIdent) -> Result<bool> {
692 let db_name = validate_namespace(table.namespace())?;
693 let table_name = table.name();
694
695 let builder = self
696 .client
697 .0
698 .get_table()
699 .database_name(&db_name)
700 .name(table_name);
701 let builder = with_catalog_id!(builder, self.properties);
702
703 let resp = builder.send().await;
704
705 match resp {
706 Ok(_) => Ok(true),
707 Err(err) => {
708 if err
709 .as_service_error()
710 .map(|e| e.is_entity_not_found_exception())
711 == Some(true)
712 {
713 return Ok(false);
714 }
715 Err(from_aws_sdk_error(err))
716 }
717 }
718 }
719
720 async fn rename_table(&self, src: &TableIdent, dest: &TableIdent) -> Result<()> {
727 let src_db_name = validate_namespace(src.namespace())?;
728 let dest_db_name = validate_namespace(dest.namespace())?;
729
730 let src_table_name = src.name();
731 let dest_table_name = dest.name();
732
733 let builder = self
734 .client
735 .0
736 .get_table()
737 .database_name(&src_db_name)
738 .name(src_table_name);
739 let builder = with_catalog_id!(builder, self.properties);
740
741 let glue_table_output = builder.send().await.map_err(from_aws_sdk_error)?;
742
743 match glue_table_output.table() {
744 None => Err(Error::new(
745 ErrorKind::TableNotFound,
746 format!(
747 "'Table' object for database: {src_db_name} and table: {src_table_name} does not exist"
748 ),
749 )),
750 Some(table) => {
751 let rename_table_input = TableInput::builder()
752 .name(dest_table_name)
753 .set_parameters(table.parameters.clone())
754 .set_storage_descriptor(table.storage_descriptor.clone())
755 .set_table_type(table.table_type.clone())
756 .set_description(table.description.clone())
757 .build()
758 .map_err(from_aws_build_error)?;
759
760 let builder = self
761 .client
762 .0
763 .create_table()
764 .database_name(&dest_db_name)
765 .table_input(rename_table_input);
766 let builder = with_catalog_id!(builder, self.properties);
767
768 builder.send().await.map_err(from_aws_sdk_error)?;
769
770 let drop_src_table_result = self.drop_table(src).await;
771
772 match drop_src_table_result {
773 Ok(_) => Ok(()),
774 Err(_) => {
775 let err_msg_src_table =
776 format!("Failed to drop old table {src_db_name}.{src_table_name}.");
777
778 let drop_dest_table_result = self.drop_table(dest).await;
779
780 match drop_dest_table_result {
781 Ok(_) => Err(Error::new(
782 ErrorKind::Unexpected,
783 format!(
784 "{err_msg_src_table} Rolled back table creation for {dest_db_name}.{dest_table_name}."
785 ),
786 )),
787 Err(_) => Err(Error::new(
788 ErrorKind::Unexpected,
789 format!(
790 "{err_msg_src_table} Failed to roll back table creation for {dest_db_name}.{dest_table_name}. Please clean up manually."
791 ),
792 )),
793 }
794 }
795 }
796 }
797 }
798 }
799
800 async fn register_table(
812 &self,
813 table_ident: &TableIdent,
814 metadata_location: String,
815 ) -> Result<Table> {
816 let db_name = validate_namespace(table_ident.namespace())?;
817 let table_name = table_ident.name();
818 let metadata = TableMetadata::read_from(&self.file_io, &metadata_location).await?;
819
820 let table_input = convert_to_glue_table(
821 table_name,
822 metadata_location.clone(),
823 &metadata,
824 metadata.properties(),
825 None,
826 )?;
827
828 let builder = self
829 .client
830 .0
831 .create_table()
832 .database_name(&db_name)
833 .table_input(table_input);
834 let builder = with_catalog_id!(builder, self.properties);
835
836 builder.send().await.map_err(|e| {
837 let error = e.into_service_error();
838 match error {
839 CreateTableError::EntityNotFoundException(_) => Error::new(
840 ErrorKind::NamespaceNotFound,
841 format!("Database {db_name} does not exist"),
842 ),
843 CreateTableError::AlreadyExistsException(_) => Error::new(
844 ErrorKind::TableAlreadyExists,
845 format!("Table {table_ident} already exists"),
846 ),
847 _ => Error::new(
848 ErrorKind::Unexpected,
849 format!("Failed to register table {table_ident} due to AWS SDK error"),
850 ),
851 }
852 .with_source(anyhow!("aws sdk error: {error:?}"))
853 })?;
854
855 let mut builder = Table::builder()
856 .identifier(table_ident.clone())
857 .metadata_location(metadata_location)
858 .metadata(metadata)
859 .file_io(self.file_io())
860 .runtime(self.runtime.clone());
861 if let Some(kms_client) = self.kms_client.clone() {
862 builder = builder.kms_client(kms_client);
863 }
864 Ok(builder.build()?)
865 }
866
867 async fn update_table(&self, commit: TableCommit) -> Result<Table> {
868 let table_ident = commit.identifier().clone();
869 let table_namespace = validate_namespace(table_ident.namespace())?;
870
871 let (current_table, current_version_id) =
872 self.load_table_with_version_id(&table_ident).await?;
873 let current_metadata_location = current_table.metadata_location_result()?.to_string();
874
875 let staged_table = commit.apply(current_table)?;
876 let staged_metadata_location_str = staged_table.metadata_location_result()?;
877 let staged_metadata_location = MetadataLocation::from_str(staged_metadata_location_str)?;
878
879 staged_table
881 .metadata()
882 .write_to(staged_table.file_io(), &staged_metadata_location)
883 .await?;
884
885 let mut builder = self
887 .client
888 .0
889 .update_table()
890 .database_name(table_namespace)
891 .set_skip_archive(Some(true)) .table_input(convert_to_glue_table(
893 table_ident.name(),
894 staged_metadata_location.to_string(),
895 staged_table.metadata(),
896 staged_table.metadata().properties(),
897 Some(current_metadata_location),
898 )?);
899
900 if let Some(version_id) = current_version_id {
902 builder = builder.version_id(version_id);
903 }
904
905 let builder = with_catalog_id!(builder, self.properties);
906 let _ = builder.send().await.map_err(|e| {
907 let error = e.into_service_error();
908 match error {
909 UpdateTableError::EntityNotFoundException(_) => Error::new(
910 ErrorKind::TableNotFound,
911 format!("Table {table_ident} is not found"),
912 ),
913 UpdateTableError::ConcurrentModificationException(_) => Error::new(
914 ErrorKind::CatalogCommitConflicts,
915 format!("Commit failed for table: {table_ident}"),
916 )
917 .with_retryable(true),
918 _ => Error::new(
919 ErrorKind::Unexpected,
920 format!("Operation failed for table: {table_ident} for hitting aws sdk error"),
921 ),
922 }
923 .with_source(anyhow!("aws sdk error: {error:?}"))
924 })?;
925
926 Ok(staged_table)
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use std::sync::Mutex;
933
934 use super::*;
935
936 #[test]
937 fn test_catalog_properties() {
938 let properties = GlueCatalogProperties::from_properties(&HashMap::from([
939 (
940 GLUE_CATALOG_PROP_URI.to_string(),
941 "http://localhost".to_string(),
942 ),
943 (
944 GLUE_CATALOG_PROP_CATALOG_ID.to_string(),
945 "catalog".to_string(),
946 ),
947 (
948 GLUE_CATALOG_PROP_WAREHOUSE.to_string(),
949 "s3://warehouse".to_string(),
950 ),
951 ]))
952 .unwrap();
953
954 assert_eq!(properties.uri.as_deref(), Some("http://localhost"));
955 assert_eq!(properties.catalog_id.as_deref(), Some("catalog"));
956 assert_eq!(properties.warehouse, "s3://warehouse");
957 }
958
959 #[test]
960 fn test_catalog_properties_defaults() {
961 let properties = GlueCatalogProperties::from_properties(&HashMap::new()).unwrap();
962
963 assert_eq!(properties.uri, None);
964 assert_eq!(properties.catalog_id, None);
965 assert_eq!(properties.warehouse, "");
966 }
967
968 #[tokio::test]
969 async fn test_load_rejects_missing_or_empty_warehouse() {
970 for (case, props) in [
971 ("missing", HashMap::new()),
972 (
973 "empty",
974 HashMap::from([(GLUE_CATALOG_PROP_WAREHOUSE.to_string(), String::new())]),
975 ),
976 ] {
977 let error = GlueCatalogBuilder::default()
978 .load("glue", props)
979 .await
980 .unwrap_err();
981
982 assert_eq!(
983 (error.kind(), error.message()),
984 (ErrorKind::DataInvalid, "Catalog warehouse is required"),
985 "{case}"
986 );
987 }
988 }
989
990 #[tokio::test]
991 async fn test_load_forwards_all_properties_to_kms_factory() {
992 #[derive(Debug, Default)]
993 struct RecordingKmsClientFactory(Mutex<HashMap<String, String>>);
994
995 #[async_trait]
996 impl KmsClientFactory for RecordingKmsClientFactory {
997 async fn create_kms_client(
998 &self,
999 properties: &HashMap<String, String>,
1000 ) -> Result<Arc<dyn KeyManagementClient>> {
1001 *self.0.lock().unwrap() = properties.clone();
1002 Err(Error::new(
1003 ErrorKind::Unexpected,
1004 "KMS initialization failed",
1005 ))
1006 }
1007 }
1008
1009 let properties = HashMap::from([
1010 (
1011 GLUE_CATALOG_PROP_URI.to_string(),
1012 "http://localhost".to_string(),
1013 ),
1014 (
1015 GLUE_CATALOG_PROP_CATALOG_ID.to_string(),
1016 "catalog".to_string(),
1017 ),
1018 (
1019 GLUE_CATALOG_PROP_WAREHOUSE.to_string(),
1020 "s3://warehouse".to_string(),
1021 ),
1022 (AWS_REGION_NAME.to_string(), "us-east-1".to_string()),
1023 ("custom.property".to_string(), "value".to_string()),
1024 ]);
1025 let factory = Arc::new(RecordingKmsClientFactory::default());
1026 let error = GlueCatalogBuilder::default()
1027 .with_kms_client_factory(factory.clone())
1028 .load("glue", properties.clone())
1029 .await
1030 .unwrap_err();
1031
1032 assert_eq!(*factory.0.lock().unwrap(), properties);
1033 assert_eq!(
1034 (error.kind(), error.message()),
1035 (ErrorKind::Unexpected, "KMS initialization failed")
1036 );
1037 }
1038}