1use std::collections::HashMap;
19use std::future::Future;
20use std::str::FromStr;
21use std::sync::Arc;
22
23use async_trait::async_trait;
24use aws_sdk_s3tables::operation::create_table::CreateTableOutput;
25use aws_sdk_s3tables::operation::get_namespace::GetNamespaceOutput;
26use aws_sdk_s3tables::operation::get_table::{GetTableError, GetTableOutput};
27use aws_sdk_s3tables::operation::list_tables::ListTablesOutput;
28use aws_sdk_s3tables::operation::update_table_metadata_location::UpdateTableMetadataLocationError;
29use aws_sdk_s3tables::types::OpenTableFormat;
30use iceberg::encryption::kms::{KeyManagementClient, KmsClientFactory};
31use iceberg::io::{FileIO, FileIOBuilder, StorageFactory};
32use iceberg::spec::{TableMetadata, TableMetadataBuilder};
33use iceberg::table::Table;
34use iceberg::{
35 Catalog, CatalogBuilder, Error, ErrorKind, MetadataLocation, Namespace, NamespaceIdent, Result,
36 Runtime, TableCommit, TableCreation, TableIdent,
37};
38use iceberg_storage_opendal::OpenDalStorageFactory;
39
40use crate::utils::create_sdk_config;
41
42pub const S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN: &str = "table_bucket_arn";
44pub const S3TABLES_CATALOG_PROP_ENDPOINT_URL: &str = "endpoint_url";
46
47#[derive(Debug)]
49struct S3TablesCatalogConfig {
50 name: Option<String>,
52 table_bucket_arn: String,
57 endpoint_url: Option<String>,
59 client: Option<aws_sdk_s3tables::Client>,
61 props: HashMap<String, String>,
68}
69
70#[derive(Debug)]
72pub struct S3TablesCatalogBuilder {
73 config: S3TablesCatalogConfig,
74 storage_factory: Option<Arc<dyn StorageFactory>>,
75 kms_client_factory: Option<Arc<dyn KmsClientFactory>>,
76 runtime: Option<Runtime>,
77}
78
79impl Default for S3TablesCatalogBuilder {
81 fn default() -> Self {
82 Self {
83 config: S3TablesCatalogConfig {
84 name: None,
85 table_bucket_arn: "".to_string(),
86 endpoint_url: None,
87 client: None,
88 props: HashMap::new(),
89 },
90 storage_factory: None,
91 kms_client_factory: None,
92 runtime: None,
93 }
94 }
95}
96
97impl S3TablesCatalogBuilder {
99 pub fn with_endpoint_url(mut self, endpoint_url: impl Into<String>) -> Self {
108 self.config.endpoint_url = Some(endpoint_url.into());
109 self
110 }
111
112 pub fn with_client(mut self, client: aws_sdk_s3tables::Client) -> Self {
114 self.config.client = Some(client);
115 self
116 }
117
118 pub fn with_table_bucket_arn(mut self, table_bucket_arn: impl Into<String>) -> Self {
127 self.config.table_bucket_arn = table_bucket_arn.into();
128 self
129 }
130}
131
132impl CatalogBuilder for S3TablesCatalogBuilder {
133 type C = S3TablesCatalog;
134
135 fn with_storage_factory(mut self, storage_factory: Arc<dyn StorageFactory>) -> Self {
136 self.storage_factory = Some(storage_factory);
137 self
138 }
139
140 fn with_kms_client_factory(mut self, kms_client_factory: Arc<dyn KmsClientFactory>) -> Self {
141 self.kms_client_factory = Some(kms_client_factory);
142 self
143 }
144
145 fn with_runtime(mut self, runtime: Runtime) -> Self {
146 self.runtime = Some(runtime);
147 self
148 }
149
150 fn load(
151 mut self,
152 name: impl Into<String>,
153 props: HashMap<String, String>,
154 ) -> impl Future<Output = Result<Self::C>> + Send {
155 let catalog_name = name.into();
156 self.config.name = Some(catalog_name.clone());
157
158 if props.contains_key(S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN) {
159 self.config.table_bucket_arn = props
160 .get(S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN)
161 .cloned()
162 .unwrap_or_default();
163 }
164
165 if props.contains_key(S3TABLES_CATALOG_PROP_ENDPOINT_URL) {
166 self.config.endpoint_url = props.get(S3TABLES_CATALOG_PROP_ENDPOINT_URL).cloned();
167 }
168
169 self.config.props = props
171 .into_iter()
172 .filter(|(k, _)| {
173 k != S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN
174 && k != S3TABLES_CATALOG_PROP_ENDPOINT_URL
175 })
176 .collect();
177
178 async move {
179 if catalog_name.trim().is_empty() {
180 Err(Error::new(
181 ErrorKind::DataInvalid,
182 "Catalog name cannot be empty",
183 ))
184 } else if self.config.table_bucket_arn.is_empty() {
185 Err(Error::new(
186 ErrorKind::DataInvalid,
187 "Table bucket ARN is required",
188 ))
189 } else {
190 let runtime = match self.runtime {
191 Some(rt) => rt,
192 None => Runtime::try_current()?,
193 };
194 let kms_client = match self.kms_client_factory {
195 Some(factory) => Some(factory.create_kms_client(&self.config.props).await?),
196 None => None,
197 };
198 S3TablesCatalog::new(self.config, self.storage_factory, runtime, kms_client).await
199 }
200 }
201 }
202}
203
204#[derive(Debug)]
206pub struct S3TablesCatalog {
207 config: S3TablesCatalogConfig,
208 s3tables_client: aws_sdk_s3tables::Client,
209 file_io: FileIO,
210 runtime: Runtime,
211 kms_client: Option<Arc<dyn KeyManagementClient>>,
212}
213
214impl S3TablesCatalog {
215 async fn new(
217 config: S3TablesCatalogConfig,
218 storage_factory: Option<Arc<dyn StorageFactory>>,
219 runtime: Runtime,
220 kms_client: Option<Arc<dyn KeyManagementClient>>,
221 ) -> Result<Self> {
222 let s3tables_client = if let Some(client) = config.client.clone() {
223 client
224 } else {
225 let aws_config = create_sdk_config(&config.props, config.endpoint_url.clone()).await;
226 aws_sdk_s3tables::Client::new(&aws_config)
227 };
228
229 let factory = storage_factory.unwrap_or_else(|| {
231 Arc::new(OpenDalStorageFactory::S3 {
232 customized_credential_load: None,
233 })
234 });
235 let file_io = FileIOBuilder::new(factory)
236 .with_props(&config.props)
237 .build();
238
239 Ok(Self {
240 config,
241 s3tables_client,
242 file_io,
243 runtime,
244 kms_client,
245 })
246 }
247
248 async fn load_table_with_version_token(
249 &self,
250 table_ident: &TableIdent,
251 ) -> Result<(Table, String)> {
252 let req = self
253 .s3tables_client
254 .get_table()
255 .table_bucket_arn(self.config.table_bucket_arn.clone())
256 .namespace(table_ident.namespace().to_url_string())
257 .name(table_ident.name());
258
259 let resp = req.send().await.map_err(|err| {
260 if err
261 .as_service_error()
262 .is_some_and(GetTableError::is_not_found_exception)
263 {
264 Error::new(
267 ErrorKind::TableNotFound,
268 format!("Table {table_ident} is not found, either because the namespace or table did not exist"),
269 )
270 .with_source(err)
271 } else {
272 from_aws_sdk_error(err)
273 }
274 })?;
275
276 let metadata_location = resp.metadata_location().ok_or_else(|| {
278 Error::new(
279 ErrorKind::Unexpected,
280 format!(
281 "Table {} does not have metadata location",
282 table_ident.name()
283 ),
284 )
285 })?;
286 let metadata = TableMetadata::read_from(&self.file_io, metadata_location).await?;
287
288 let mut builder = Table::builder()
289 .identifier(table_ident.clone())
290 .metadata(metadata)
291 .metadata_location(metadata_location)
292 .file_io(self.file_io.clone())
293 .runtime(self.runtime.clone());
294 if let Some(kms_client) = self.kms_client.clone() {
295 builder = builder.kms_client(kms_client);
296 }
297 let table = builder.build()?;
298 Ok((table, resp.version_token))
299 }
300}
301
302#[async_trait]
303impl Catalog for S3TablesCatalog {
304 async fn list_namespaces(
309 &self,
310 parent: Option<&NamespaceIdent>,
311 ) -> Result<Vec<NamespaceIdent>> {
312 if parent.is_some() {
313 return Ok(vec![]);
314 }
315
316 let mut result = Vec::new();
317 let mut continuation_token = None;
318 loop {
319 let mut req = self
320 .s3tables_client
321 .list_namespaces()
322 .table_bucket_arn(self.config.table_bucket_arn.clone());
323 if let Some(token) = continuation_token {
324 req = req.continuation_token(token);
325 }
326 let resp = req.send().await.map_err(from_aws_sdk_error)?;
327 for ns in resp.namespaces() {
328 result.push(NamespaceIdent::from_vec(ns.namespace().to_vec())?);
329 }
330 continuation_token = resp.continuation_token().map(|s| s.to_string());
331 if continuation_token.is_none() {
332 break;
333 }
334 }
335 Ok(result)
336 }
337
338 async fn create_namespace(
355 &self,
356 namespace: &NamespaceIdent,
357 _properties: HashMap<String, String>,
358 ) -> Result<Namespace> {
359 if self.namespace_exists(namespace).await? {
360 return Err(Error::new(
361 ErrorKind::NamespaceAlreadyExists,
362 format!("Namespace {namespace:?} already exists"),
363 ));
364 }
365
366 let req = self
367 .s3tables_client
368 .create_namespace()
369 .table_bucket_arn(self.config.table_bucket_arn.clone())
370 .namespace(namespace.to_url_string());
371 req.send().await.map_err(from_aws_sdk_error)?;
372 Ok(Namespace::with_properties(
373 namespace.clone(),
374 HashMap::new(),
375 ))
376 }
377
378 async fn get_namespace(&self, namespace: &NamespaceIdent) -> Result<Namespace> {
388 if !self.namespace_exists(namespace).await? {
389 return Err(Error::new(
390 ErrorKind::NamespaceNotFound,
391 format!("Namespace {namespace:?} does not exist"),
392 ));
393 }
394
395 let req = self
396 .s3tables_client
397 .get_namespace()
398 .table_bucket_arn(self.config.table_bucket_arn.clone())
399 .namespace(namespace.to_url_string());
400 let resp: GetNamespaceOutput = req.send().await.map_err(from_aws_sdk_error)?;
401 let properties = HashMap::new();
402 Ok(Namespace::with_properties(
403 NamespaceIdent::from_vec(resp.namespace().to_vec())?,
404 properties,
405 ))
406 }
407
408 async fn namespace_exists(&self, namespace: &NamespaceIdent) -> Result<bool> {
421 let req = self
422 .s3tables_client
423 .get_namespace()
424 .table_bucket_arn(self.config.table_bucket_arn.clone())
425 .namespace(namespace.to_url_string());
426 match req.send().await {
427 Ok(_) => Ok(true),
428 Err(err) => {
429 if err.as_service_error().map(|e| e.is_not_found_exception()) == Some(true) {
430 Ok(false)
431 } else {
432 Err(from_aws_sdk_error(err))
433 }
434 }
435 }
436 }
437
438 async fn update_namespace(
443 &self,
444 _namespace: &NamespaceIdent,
445 _properties: HashMap<String, String>,
446 ) -> Result<()> {
447 Err(Error::new(
448 ErrorKind::FeatureUnsupported,
449 "Update namespace is not supported for s3tables catalog",
450 ))
451 }
452
453 async fn drop_namespace(&self, namespace: &NamespaceIdent) -> Result<()> {
462 if !self.namespace_exists(namespace).await? {
463 return Err(Error::new(
464 ErrorKind::NamespaceNotFound,
465 format!("Namespace {namespace:?} does not exist"),
466 ));
467 }
468
469 let req = self
470 .s3tables_client
471 .delete_namespace()
472 .table_bucket_arn(self.config.table_bucket_arn.clone())
473 .namespace(namespace.to_url_string());
474 req.send().await.map_err(from_aws_sdk_error)?;
475 Ok(())
476 }
477
478 async fn list_tables(&self, namespace: &NamespaceIdent) -> Result<Vec<TableIdent>> {
487 let mut result = Vec::new();
488 let mut continuation_token = None;
489 loop {
490 let mut req = self
491 .s3tables_client
492 .list_tables()
493 .table_bucket_arn(self.config.table_bucket_arn.clone())
494 .namespace(namespace.to_url_string());
495 if let Some(token) = continuation_token {
496 req = req.continuation_token(token);
497 }
498 let resp: ListTablesOutput = req.send().await.map_err(from_aws_sdk_error)?;
499 for table in resp.tables() {
500 result.push(TableIdent::new(
501 NamespaceIdent::from_vec(table.namespace().to_vec())?,
502 table.name().to_string(),
503 ));
504 }
505 continuation_token = resp.continuation_token().map(|s| s.to_string());
506 if continuation_token.is_none() {
507 break;
508 }
509 }
510 Ok(result)
511 }
512
513 async fn create_table(
528 &self,
529 namespace: &NamespaceIdent,
530 mut creation: TableCreation,
531 ) -> Result<Table> {
532 let table_ident = TableIdent::new(namespace.clone(), creation.name.clone());
533
534 let create_resp: CreateTableOutput = self
536 .s3tables_client
537 .create_table()
538 .table_bucket_arn(self.config.table_bucket_arn.clone())
539 .namespace(namespace.to_url_string())
540 .format(OpenTableFormat::Iceberg)
541 .name(table_ident.name())
542 .send()
543 .await
544 .map_err(from_aws_sdk_error)?;
545
546 let table_location = match &creation.location {
549 Some(_) => {
550 return Err(Error::new(
551 ErrorKind::DataInvalid,
552 "The location of the table is generated by s3tables catalog, can't be set by user.",
553 ));
554 }
555 None => {
556 let get_resp: GetTableOutput = self
557 .s3tables_client
558 .get_table()
559 .table_bucket_arn(self.config.table_bucket_arn.clone())
560 .namespace(namespace.to_url_string())
561 .name(table_ident.name())
562 .send()
563 .await
564 .map_err(from_aws_sdk_error)?;
565 get_resp.warehouse_location().to_string()
566 }
567 };
568
569 creation.location = Some(table_location.clone());
571 let metadata = TableMetadataBuilder::from_table_creation(creation)?
572 .build()?
573 .metadata;
574 let metadata_location = MetadataLocation::try_new_with_metadata(&metadata)?;
575 metadata.write_to(&self.file_io, &metadata_location).await?;
576
577 let metadata_location_str = metadata_location.to_string();
579 self.s3tables_client
580 .update_table_metadata_location()
581 .table_bucket_arn(self.config.table_bucket_arn.clone())
582 .namespace(namespace.to_url_string())
583 .name(table_ident.name())
584 .metadata_location(metadata_location_str.clone())
585 .version_token(create_resp.version_token())
586 .send()
587 .await
588 .map_err(from_aws_sdk_error)?;
589
590 let mut builder = Table::builder()
591 .identifier(table_ident)
592 .metadata_location(metadata_location_str)
593 .metadata(metadata)
594 .file_io(self.file_io.clone())
595 .runtime(self.runtime.clone());
596 if let Some(kms_client) = self.kms_client.clone() {
597 builder = builder.kms_client(kms_client);
598 }
599 let table = builder.build()?;
600 Ok(table)
601 }
602
603 async fn load_table(&self, table_ident: &TableIdent) -> Result<Table> {
614 Ok(self.load_table_with_version_token(table_ident).await?.0)
615 }
616
617 async fn drop_table(&self, _table: &TableIdent) -> Result<()> {
621 Err(Error::new(
622 ErrorKind::FeatureUnsupported,
623 "drop_table is not supported for S3Tables; use purge_table instead",
624 ))
625 }
626
627 async fn purge_table(&self, table: &TableIdent) -> Result<()> {
629 let req = self
630 .s3tables_client
631 .delete_table()
632 .table_bucket_arn(self.config.table_bucket_arn.clone())
633 .namespace(table.namespace().to_url_string())
634 .name(table.name());
635 req.send().await.map_err(from_aws_sdk_error)?;
636 Ok(())
637 }
638
639 async fn table_exists(&self, table_ident: &TableIdent) -> Result<bool> {
652 let req = self
653 .s3tables_client
654 .get_table()
655 .table_bucket_arn(self.config.table_bucket_arn.clone())
656 .namespace(table_ident.namespace().to_url_string())
657 .name(table_ident.name());
658 match req.send().await {
659 Ok(_) => Ok(true),
660 Err(err) => {
661 if err.as_service_error().map(|e| e.is_not_found_exception()) == Some(true) {
662 Ok(false)
663 } else {
664 Err(from_aws_sdk_error(err))
665 }
666 }
667 }
668 }
669
670 async fn rename_table(&self, src: &TableIdent, dest: &TableIdent) -> Result<()> {
679 let req = self
680 .s3tables_client
681 .rename_table()
682 .table_bucket_arn(self.config.table_bucket_arn.clone())
683 .namespace(src.namespace().to_url_string())
684 .name(src.name())
685 .new_namespace_name(dest.namespace().to_url_string())
686 .new_name(dest.name());
687 req.send().await.map_err(from_aws_sdk_error)?;
688 Ok(())
689 }
690
691 async fn register_table(
692 &self,
693 _table_ident: &TableIdent,
694 _metadata_location: String,
695 ) -> Result<Table> {
696 Err(Error::new(
697 ErrorKind::FeatureUnsupported,
698 "Registering a table is not supported yet",
699 ))
700 }
701
702 async fn update_table(&self, commit: TableCommit) -> Result<Table> {
704 let table_ident = commit.identifier().clone();
705 let table_namespace = table_ident.namespace();
706 let (current_table, version_token) =
707 self.load_table_with_version_token(&table_ident).await?;
708
709 let staged_table = commit.apply(current_table)?;
710 let staged_metadata_location_str = staged_table.metadata_location_result()?;
711 let staged_metadata_location = MetadataLocation::from_str(staged_metadata_location_str)?;
712
713 staged_table
714 .metadata()
715 .write_to(staged_table.file_io(), &staged_metadata_location)
716 .await?;
717
718 let builder = self
719 .s3tables_client
720 .update_table_metadata_location()
721 .table_bucket_arn(&self.config.table_bucket_arn)
722 .namespace(table_namespace.to_url_string())
723 .name(table_ident.name())
724 .version_token(version_token)
725 .metadata_location(staged_metadata_location_str);
726
727 let _ = builder
728 .send()
729 .await
730 .map_err(|e| match e.as_service_error() {
731 Some(UpdateTableMetadataLocationError::ConflictException(_)) => Error::new(
732 ErrorKind::CatalogCommitConflicts,
733 format!("Commit conflicted for table: {table_ident}"),
734 )
735 .with_retryable(true)
736 .with_source(e),
737 Some(UpdateTableMetadataLocationError::NotFoundException(_)) => Error::new(
738 ErrorKind::TableNotFound,
739 format!("Table {table_ident} is not found"),
740 )
741 .with_source(e),
742 _ => from_aws_sdk_error(e),
743 })?;
744
745 Ok(staged_table)
746 }
747}
748
749pub(crate) fn from_aws_sdk_error<T>(error: aws_sdk_s3tables::error::SdkError<T>) -> Error
751where T: std::error::Error + Send + Sync + 'static {
752 Error::new(
753 ErrorKind::Unexpected,
754 format!("Operation failed for hitting aws sdk error: {error:?}"),
755 )
756 .with_source(error)
757}
758
759#[cfg(test)]
760mod tests {
761 use std::error::Error as _;
762
763 use futures::TryStreamExt;
764 use iceberg::spec::{NestedField, PrimitiveType, Schema, Type};
765 use iceberg::transaction::{ApplyTransactionAction, Transaction};
766
767 use super::*;
768
769 #[test]
770 fn test_from_aws_sdk_error_keeps_sdk_error_as_source() {
771 let err = from_aws_sdk_error(
772 aws_sdk_s3tables::error::SdkError::<GetTableError>::construction_failure("boom"),
773 );
774
775 assert_eq!(err.kind(), ErrorKind::Unexpected);
776 assert!(
777 err.source()
778 .and_then(|source| source
779 .downcast_ref::<aws_sdk_s3tables::error::SdkError<GetTableError>>())
780 .is_some()
781 );
782 }
783
784 async fn load_s3tables_catalog_from_env() -> Result<Option<S3TablesCatalog>> {
785 let table_bucket_arn = match std::env::var("TABLE_BUCKET_ARN").ok() {
786 Some(table_bucket_arn) => table_bucket_arn,
787 None => return Ok(None),
788 };
789
790 let config = S3TablesCatalogConfig {
791 name: None,
792 table_bucket_arn,
793 endpoint_url: None,
794 client: None,
795 props: HashMap::new(),
796 };
797
798 Ok(Some(
799 S3TablesCatalog::new(config, None, Runtime::current(), None).await?,
800 ))
801 }
802
803 #[tokio::test]
804 async fn test_s3tables_list_namespace() {
805 let catalog = match load_s3tables_catalog_from_env().await {
806 Ok(Some(catalog)) => catalog,
807 Ok(None) => return,
808 Err(e) => panic!("Error loading catalog: {e}"),
809 };
810
811 let namespaces = catalog.list_namespaces(None).await.unwrap();
812 assert!(!namespaces.is_empty());
813 }
814
815 #[tokio::test]
816 async fn test_s3tables_list_tables() {
817 let catalog = match load_s3tables_catalog_from_env().await {
818 Ok(Some(catalog)) => catalog,
819 Ok(None) => return,
820 Err(e) => panic!("Error loading catalog: {e}"),
821 };
822
823 let tables = catalog
824 .list_tables(&NamespaceIdent::new("aws_s3_metadata".to_string()))
825 .await
826 .unwrap();
827 assert!(!tables.is_empty());
828 }
829
830 #[tokio::test]
831 async fn test_s3tables_load_table() {
832 let catalog = match load_s3tables_catalog_from_env().await {
833 Ok(Some(catalog)) => catalog,
834 Ok(None) => return,
835 Err(e) => panic!("Error loading catalog: {e}"),
836 };
837
838 let _table = catalog
839 .load_table(&TableIdent::new(
840 NamespaceIdent::new("aws_s3_metadata".to_string()),
841 "query_storage_metadata".to_string(),
842 ))
843 .await
844 .expect("table that exists should be loaded");
845
846 let load_table_err = catalog
847 .load_table(&TableIdent::new(
848 NamespaceIdent::new("not_a_namespace".to_string()),
849 "not_a_table_name".to_string(),
850 ))
851 .await
852 .expect_err("loading a table that does not exist should fail");
853 assert_eq!(
854 load_table_err.kind(),
855 ErrorKind::TableNotFound,
856 "must return table not found error for non-existent table"
857 );
858 }
859
860 #[tokio::test]
861 async fn test_s3tables_create_delete_namespace() {
862 let catalog = match load_s3tables_catalog_from_env().await {
863 Ok(Some(catalog)) => catalog,
864 Ok(None) => return,
865 Err(e) => panic!("Error loading catalog: {e}"),
866 };
867
868 let namespace = NamespaceIdent::new("test_s3tables_create_delete_namespace".to_string());
869 catalog
870 .create_namespace(&namespace, HashMap::new())
871 .await
872 .unwrap();
873 assert!(catalog.namespace_exists(&namespace).await.unwrap());
874 catalog.drop_namespace(&namespace).await.unwrap();
875 assert!(!catalog.namespace_exists(&namespace).await.unwrap());
876 }
877
878 #[tokio::test]
879 async fn test_s3tables_create_delete_table() {
880 let catalog = match load_s3tables_catalog_from_env().await {
881 Ok(Some(catalog)) => catalog,
882 Ok(None) => return,
883 Err(e) => panic!("Error loading catalog: {e}"),
884 };
885
886 let creation = {
887 let schema = Schema::builder()
888 .with_schema_id(0)
889 .with_fields(vec![
890 NestedField::required(1, "foo", Type::Primitive(PrimitiveType::Int)).into(),
891 NestedField::required(2, "bar", Type::Primitive(PrimitiveType::String)).into(),
892 ])
893 .build()
894 .unwrap();
895 TableCreation::builder()
896 .name("test_s3tables_create_delete_table".to_string())
897 .properties(HashMap::new())
898 .schema(schema)
899 .build()
900 };
901
902 let namespace = NamespaceIdent::new("test_s3tables_create_delete_table".to_string());
903 let table_ident = TableIdent::new(
904 namespace.clone(),
905 "test_s3tables_create_delete_table".to_string(),
906 );
907 catalog.drop_namespace(&namespace).await.ok();
908 catalog.drop_table(&table_ident).await.ok();
909
910 catalog
911 .create_namespace(&namespace, HashMap::new())
912 .await
913 .unwrap();
914 catalog.create_table(&namespace, creation).await.unwrap();
915 assert!(catalog.table_exists(&table_ident).await.unwrap());
916 catalog.drop_table(&table_ident).await.unwrap();
917 assert!(!catalog.table_exists(&table_ident).await.unwrap());
918 catalog.drop_namespace(&namespace).await.unwrap();
919 }
920
921 #[tokio::test]
922 async fn test_s3tables_update_table() {
923 let catalog = match load_s3tables_catalog_from_env().await {
924 Ok(Some(catalog)) => catalog,
925 Ok(None) => return,
926 Err(e) => panic!("Error loading catalog: {e}"),
927 };
928
929 let namespace = NamespaceIdent::new("test_s3tables_update_table".to_string());
931 let table_ident =
932 TableIdent::new(namespace.clone(), "test_s3tables_update_table".to_string());
933
934 catalog.drop_table(&table_ident).await.ok();
936 catalog.drop_namespace(&namespace).await.ok();
937
938 catalog
940 .create_namespace(&namespace, HashMap::new())
941 .await
942 .unwrap();
943
944 let creation = {
945 let schema = Schema::builder()
946 .with_schema_id(0)
947 .with_fields(vec![
948 NestedField::required(1, "foo", Type::Primitive(PrimitiveType::Int)).into(),
949 NestedField::required(2, "bar", Type::Primitive(PrimitiveType::String)).into(),
950 ])
951 .build()
952 .unwrap();
953 TableCreation::builder()
954 .name(table_ident.name().to_string())
955 .properties(HashMap::new())
956 .schema(schema)
957 .build()
958 };
959
960 let table = catalog.create_table(&namespace, creation).await.unwrap();
961
962 let tx = Transaction::new(&table);
964
965 let original_metadata_location = table.metadata_location();
967
968 let tx = tx
970 .update_table_properties()
971 .set("test_property".to_string(), "test_value".to_string())
972 .apply(tx)
973 .unwrap();
974
975 let updated_table = tx.commit(&catalog).await.unwrap();
977
978 assert_eq!(
980 updated_table.metadata().properties().get("test_property"),
981 Some(&"test_value".to_string())
982 );
983
984 assert_ne!(
986 updated_table.metadata_location(),
987 original_metadata_location,
988 "Metadata location should be updated after commit"
989 );
990
991 let reloaded_table = catalog.load_table(&table_ident).await.unwrap();
993
994 assert_eq!(
996 reloaded_table.metadata().properties().get("test_property"),
997 Some(&"test_value".to_string())
998 );
999 assert_eq!(
1000 reloaded_table.metadata_location(),
1001 updated_table.metadata_location(),
1002 "Reloaded table should have the same metadata location as the updated table"
1003 );
1004 }
1005
1006 #[tokio::test]
1007 async fn test_builder_load_missing_bucket_arn() {
1008 let builder = S3TablesCatalogBuilder::default();
1009 let result = builder.load("s3tables", HashMap::new()).await;
1010
1011 assert!(result.is_err());
1012 if let Err(err) = result {
1013 assert_eq!(err.kind(), ErrorKind::DataInvalid);
1014 assert_eq!(err.message(), "Table bucket ARN is required");
1015 }
1016 }
1017
1018 #[tokio::test]
1019 async fn test_builder_with_endpoint_url_ok() {
1020 let builder = S3TablesCatalogBuilder::default().with_endpoint_url("http://localhost:4566");
1021
1022 let result = builder
1023 .load(
1024 "s3tables",
1025 HashMap::from([
1026 (
1027 S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN.to_string(),
1028 "arn:aws:s3tables:us-east-1:123456789012:bucket/test".to_string(),
1029 ),
1030 ("some_prop".to_string(), "some_value".to_string()),
1031 ]),
1032 )
1033 .await;
1034
1035 assert!(result.is_ok());
1036 }
1037
1038 #[tokio::test]
1039 async fn test_builder_with_client_ok() {
1040 use aws_config::BehaviorVersion;
1041
1042 let sdk_config = aws_config::defaults(BehaviorVersion::latest()).load().await;
1043 let client = aws_sdk_s3tables::Client::new(&sdk_config);
1044
1045 let builder = S3TablesCatalogBuilder::default().with_client(client);
1046 let result = builder
1047 .load(
1048 "s3tables",
1049 HashMap::from([(
1050 S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN.to_string(),
1051 "arn:aws:s3tables:us-east-1:123456789012:bucket/test".to_string(),
1052 )]),
1053 )
1054 .await;
1055
1056 assert!(result.is_ok());
1057 }
1058
1059 #[tokio::test]
1060 async fn test_builder_with_table_bucket_arn() {
1061 let test_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/test-bucket";
1062 let builder = S3TablesCatalogBuilder::default().with_table_bucket_arn(test_arn);
1063
1064 let result = builder.load("s3tables", HashMap::new()).await;
1065
1066 assert!(result.is_ok());
1067 let catalog = result.unwrap();
1068 assert_eq!(catalog.config.table_bucket_arn, test_arn);
1069 }
1070
1071 #[tokio::test]
1072 async fn test_builder_empty_table_bucket_arn_edge_cases() {
1073 let mut props = HashMap::new();
1074 props.insert(
1075 S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN.to_string(),
1076 "".to_string(),
1077 );
1078
1079 let builder = S3TablesCatalogBuilder::default();
1080 let result = builder.load("s3tables", props).await;
1081
1082 assert!(result.is_err());
1083 if let Err(err) = result {
1084 assert_eq!(err.kind(), ErrorKind::DataInvalid);
1085 assert_eq!(err.message(), "Table bucket ARN is required");
1086 }
1087 }
1088
1089 #[tokio::test]
1090 async fn test_endpoint_url_property_overrides_builder_method() {
1091 let test_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/test-bucket";
1092 let builder_endpoint = "http://localhost:4566";
1093 let property_endpoint = "http://localhost:8080";
1094
1095 let builder = S3TablesCatalogBuilder::default()
1096 .with_table_bucket_arn(test_arn)
1097 .with_endpoint_url(builder_endpoint);
1098
1099 let mut props = HashMap::new();
1100 props.insert(
1101 S3TABLES_CATALOG_PROP_ENDPOINT_URL.to_string(),
1102 property_endpoint.to_string(),
1103 );
1104
1105 let result = builder.load("s3tables", props).await;
1106
1107 assert!(result.is_ok());
1108 let catalog = result.unwrap();
1109
1110 assert_eq!(
1112 catalog.config.endpoint_url,
1113 Some(property_endpoint.to_string())
1114 );
1115 assert_ne!(
1116 catalog.config.endpoint_url,
1117 Some(builder_endpoint.to_string())
1118 );
1119 }
1120
1121 #[tokio::test]
1122 async fn test_endpoint_url_builder_method_only() {
1123 let test_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/test-bucket";
1124 let builder_endpoint = "http://localhost:4566";
1125
1126 let builder = S3TablesCatalogBuilder::default()
1127 .with_table_bucket_arn(test_arn)
1128 .with_endpoint_url(builder_endpoint);
1129
1130 let result = builder.load("s3tables", HashMap::new()).await;
1131
1132 assert!(result.is_ok());
1133 let catalog = result.unwrap();
1134
1135 assert_eq!(
1136 catalog.config.endpoint_url,
1137 Some(builder_endpoint.to_string())
1138 );
1139 }
1140
1141 #[tokio::test]
1142 async fn test_endpoint_url_property_only() {
1143 let test_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/test-bucket";
1144 let property_endpoint = "http://localhost:8080";
1145
1146 let builder = S3TablesCatalogBuilder::default().with_table_bucket_arn(test_arn);
1147
1148 let mut props = HashMap::new();
1149 props.insert(
1150 S3TABLES_CATALOG_PROP_ENDPOINT_URL.to_string(),
1151 property_endpoint.to_string(),
1152 );
1153
1154 let result = builder.load("s3tables", props).await;
1155
1156 assert!(result.is_ok());
1157 let catalog = result.unwrap();
1158
1159 assert_eq!(
1160 catalog.config.endpoint_url,
1161 Some(property_endpoint.to_string())
1162 );
1163 }
1164
1165 #[tokio::test]
1166 async fn test_table_bucket_arn_property_overrides_builder_method() {
1167 let builder_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/builder-bucket";
1168 let property_arn = "arn:aws:s3tables:us-east-1:987654321098:bucket/property-bucket";
1169
1170 let builder = S3TablesCatalogBuilder::default().with_table_bucket_arn(builder_arn);
1171
1172 let mut props = HashMap::new();
1173 props.insert(
1174 S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN.to_string(),
1175 property_arn.to_string(),
1176 );
1177
1178 let result = builder.load("s3tables", props).await;
1179
1180 assert!(result.is_ok());
1181 let catalog = result.unwrap();
1182
1183 assert_eq!(catalog.config.table_bucket_arn, property_arn);
1184 assert_ne!(catalog.config.table_bucket_arn, builder_arn);
1185 }
1186
1187 #[tokio::test]
1188 async fn test_table_bucket_arn_builder_method_only() {
1189 let builder_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/builder-bucket";
1190
1191 let builder = S3TablesCatalogBuilder::default().with_table_bucket_arn(builder_arn);
1192
1193 let result = builder.load("s3tables", HashMap::new()).await;
1194
1195 assert!(result.is_ok());
1196 let catalog = result.unwrap();
1197
1198 assert_eq!(catalog.config.table_bucket_arn, builder_arn);
1199 }
1200
1201 #[tokio::test]
1202 async fn test_table_bucket_arn_property_only() {
1203 let property_arn = "arn:aws:s3tables:us-east-1:987654321098:bucket/property-bucket";
1204
1205 let builder = S3TablesCatalogBuilder::default();
1206
1207 let mut props = HashMap::new();
1208 props.insert(
1209 S3TABLES_CATALOG_PROP_TABLE_BUCKET_ARN.to_string(),
1210 property_arn.to_string(),
1211 );
1212
1213 let result = builder.load("s3tables", props).await;
1214
1215 assert!(result.is_ok());
1216 let catalog = result.unwrap();
1217
1218 assert_eq!(catalog.config.table_bucket_arn, property_arn);
1219 }
1220
1221 #[tokio::test]
1222 async fn test_builder_empty_name_validation() {
1223 let test_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/test-bucket";
1224 let builder = S3TablesCatalogBuilder::default().with_table_bucket_arn(test_arn);
1225
1226 let result = builder.load("", HashMap::new()).await;
1227
1228 assert!(result.is_err());
1229 if let Err(err) = result {
1230 assert_eq!(err.kind(), ErrorKind::DataInvalid);
1231 assert_eq!(err.message(), "Catalog name cannot be empty");
1232 }
1233 }
1234
1235 #[tokio::test]
1236 async fn test_builder_whitespace_only_name_validation() {
1237 let test_arn = "arn:aws:s3tables:us-west-2:123456789012:bucket/test-bucket";
1238 let builder = S3TablesCatalogBuilder::default().with_table_bucket_arn(test_arn);
1239
1240 let result = builder.load(" \t\n ", HashMap::new()).await;
1241
1242 assert!(result.is_err());
1243 if let Err(err) = result {
1244 assert_eq!(err.kind(), ErrorKind::DataInvalid);
1245 assert_eq!(err.message(), "Catalog name cannot be empty");
1246 }
1247 }
1248
1249 #[tokio::test]
1250 async fn test_builder_name_validation_with_missing_arn() {
1251 let builder = S3TablesCatalogBuilder::default();
1252
1253 let result = builder.load("", HashMap::new()).await;
1254
1255 assert!(result.is_err());
1256 if let Err(err) = result {
1257 assert_eq!(err.kind(), ErrorKind::DataInvalid);
1258 assert_eq!(err.message(), "Catalog name cannot be empty");
1259 }
1260 }
1261
1262 #[tokio::test]
1264 async fn test_s3tables_create_table_write_load_table_read() {
1265 use iceberg::writer::base_writer::data_file_writer::DataFileWriterBuilder;
1266 use iceberg::writer::file_writer::ParquetWriterBuilder;
1267 use iceberg::writer::file_writer::location_generator::{
1268 DefaultFileNameGenerator, DefaultLocationGenerator,
1269 };
1270 use iceberg::writer::file_writer::rolling_writer::RollingFileWriterBuilder;
1271 use iceberg::writer::{IcebergWriter, IcebergWriterBuilder};
1272
1273 let catalog = match load_s3tables_catalog_from_env().await {
1274 Ok(Some(c)) => c,
1275 Ok(None) => return,
1276 Err(e) => panic!("Error loading catalog: {e}"),
1277 };
1278
1279 let ns = NamespaceIdent::new(format!("test_rw_{}", uuid::Uuid::new_v4().simple()));
1280 catalog.create_namespace(&ns, HashMap::new()).await.unwrap();
1281
1282 let table_name = String::from("table");
1283
1284 let schema = Schema::builder()
1285 .with_fields(vec![
1286 NestedField::required(1, "id", Type::Primitive(PrimitiveType::Int)).into(),
1287 ])
1288 .build()
1289 .unwrap();
1290 let creation = TableCreation::builder()
1291 .name(table_name.clone())
1292 .schema(schema)
1293 .build();
1294
1295 let table = catalog.create_table(&ns, creation).await.unwrap();
1296
1297 let arrow_schema: Arc<arrow_schema::Schema> = Arc::new(
1299 table
1300 .metadata()
1301 .current_schema()
1302 .as_ref()
1303 .try_into()
1304 .unwrap(),
1305 );
1306 let batch = arrow_array::RecordBatch::try_new(arrow_schema, vec![Arc::new(
1307 arrow_array::Int32Array::from(vec![42]),
1308 )])
1309 .unwrap();
1310
1311 let location_generator = DefaultLocationGenerator::new(table.metadata()).unwrap();
1313 let file_name_generator = DefaultFileNameGenerator::new(
1314 "test".to_string(),
1315 None,
1316 iceberg::spec::DataFileFormat::Parquet,
1317 );
1318 let parquet_writer_builder = ParquetWriterBuilder::new(
1319 parquet::file::properties::WriterProperties::default(),
1320 table.metadata().current_schema().clone(),
1321 );
1322 let rw = RollingFileWriterBuilder::new_with_default_file_size(
1323 parquet_writer_builder,
1324 table.file_io().clone(),
1325 location_generator,
1326 file_name_generator,
1327 );
1328 let mut writer = DataFileWriterBuilder::new(rw).build(None).await.unwrap();
1329 writer.write(batch.clone()).await.unwrap();
1330 let data_files = writer.close().await.unwrap();
1331
1332 let tx = Transaction::new(&table);
1333 let tx = tx
1334 .fast_append()
1335 .add_data_files(data_files)
1336 .apply(tx)
1337 .unwrap();
1338 tx.commit(&catalog).await.unwrap();
1339
1340 let table_ident = TableIdent::new(ns.clone(), table_name.clone());
1342 let reloaded = catalog.load_table(&table_ident).await.unwrap();
1343 let batches: Vec<arrow_array::RecordBatch> = reloaded
1344 .scan()
1345 .select_all()
1346 .build()
1347 .expect("scan to be valid (snapshot exists, schema is OK)")
1348 .to_arrow()
1349 .await
1350 .expect("scan tasks should be OK")
1351 .try_collect()
1352 .await
1353 .expect("scan should complete successfully");
1354
1355 assert_eq!(batches.len(), 1);
1356 assert_eq!(
1357 batches[0], batch,
1358 "read records should match records written earlier"
1359 );
1360
1361 catalog.purge_table(&table_ident).await.ok();
1363 catalog.drop_namespace(&ns).await.ok();
1364 }
1365}