1use std::collections::HashMap;
19
20use aws_config::{BehaviorVersion, Region, SdkConfig};
21use aws_sdk_glue::config::Credentials;
22use aws_sdk_glue::types::{Database, DatabaseInput, StorageDescriptor, TableInput};
23use iceberg::spec::TableMetadata;
24use iceberg::{Error, ErrorKind, Namespace, NamespaceIdent, Result};
25
26use crate::error::from_aws_build_error;
27use crate::schema::GlueSchemaBuilder;
28
29pub const AWS_PROFILE_NAME: &str = "profile_name";
31pub const AWS_REGION_NAME: &str = "region_name";
33pub const AWS_ACCESS_KEY_ID: &str = "aws_access_key_id";
35pub const AWS_SECRET_ACCESS_KEY: &str = "aws_secret_access_key";
37pub const AWS_SESSION_TOKEN: &str = "aws_session_token";
39const DESCRIPTION: &str = "description";
41const LOCATION: &str = "location_uri";
43const METADATA_LOCATION: &str = "metadata_location";
45const PREV_METADATA_LOCATION: &str = "previous_metadata_location";
47const EXTERNAL_TABLE: &str = "EXTERNAL_TABLE";
49const TABLE_TYPE: &str = "table_type";
51const ICEBERG: &str = "ICEBERG";
53
54pub(crate) async fn create_sdk_config(
57 properties: &HashMap<String, String>,
58 endpoint_uri: Option<&String>,
59) -> SdkConfig {
60 let mut config = aws_config::defaults(BehaviorVersion::latest());
61
62 if let Some(endpoint) = endpoint_uri {
63 config = config.endpoint_url(endpoint)
64 };
65
66 if properties.is_empty() {
67 return config.load().await;
68 }
69
70 if let (Some(access_key), Some(secret_key)) = (
71 properties.get(AWS_ACCESS_KEY_ID),
72 properties.get(AWS_SECRET_ACCESS_KEY),
73 ) {
74 let session_token = properties.get(AWS_SESSION_TOKEN).cloned();
75 let credentials_provider =
76 Credentials::new(access_key, secret_key, session_token, None, "properties");
77
78 config = config.credentials_provider(credentials_provider)
79 };
80
81 if let Some(profile_name) = properties.get(AWS_PROFILE_NAME) {
82 config = config.profile_name(profile_name);
83 }
84
85 if let Some(region_name) = properties.get(AWS_REGION_NAME) {
86 let region = Region::new(region_name.clone());
87 config = config.region(region);
88 }
89
90 config.load().await
91}
92
93pub(crate) fn convert_to_database(
95 namespace: &NamespaceIdent,
96 properties: &HashMap<String, String>,
97) -> Result<DatabaseInput> {
98 let db_name = validate_namespace(namespace)?;
99 let mut builder = DatabaseInput::builder().name(db_name);
100
101 for (k, v) in properties.iter() {
102 match k.as_ref() {
103 DESCRIPTION => {
104 builder = builder.description(v);
105 }
106 LOCATION => {
107 builder = builder.location_uri(v);
108 }
109 _ => {
110 builder = builder.parameters(k, v);
111 }
112 }
113 }
114
115 builder.build().map_err(from_aws_build_error)
116}
117
118pub(crate) fn convert_to_namespace(database: &Database) -> Namespace {
120 let db_name = database.name().to_string();
121 let mut properties = database
122 .parameters()
123 .map_or_else(HashMap::new, |p| p.clone());
124
125 if let Some(location_uri) = database.location_uri() {
126 properties.insert(LOCATION.to_string(), location_uri.to_string());
127 };
128
129 if let Some(description) = database.description() {
130 properties.insert(DESCRIPTION.to_string(), description.to_string());
131 }
132
133 Namespace::with_properties(NamespaceIdent::new(db_name), properties)
134}
135
136pub(crate) fn convert_to_glue_table(
143 table_name: impl Into<String>,
144 metadata_location: String,
145 metadata: &TableMetadata,
146 properties: &HashMap<String, String>,
147 prev_metadata_location: Option<String>,
148) -> Result<TableInput> {
149 let glue_schema = GlueSchemaBuilder::from_iceberg(metadata)?.build();
150
151 let storage_descriptor = StorageDescriptor::builder()
152 .set_columns(Some(glue_schema))
153 .location(metadata.location().to_string())
154 .build();
155
156 let mut parameters = HashMap::from([
157 (TABLE_TYPE.to_string(), ICEBERG.to_string()),
158 (METADATA_LOCATION.to_string(), metadata_location),
159 ]);
160
161 if let Some(prev) = prev_metadata_location {
162 parameters.insert(PREV_METADATA_LOCATION.to_string(), prev);
163 }
164
165 let mut table_input_builder = TableInput::builder()
166 .name(table_name)
167 .set_parameters(Some(parameters))
168 .storage_descriptor(storage_descriptor)
169 .table_type(EXTERNAL_TABLE);
170
171 if let Some(description) = properties.get(DESCRIPTION) {
172 table_input_builder = table_input_builder.description(description);
173 }
174
175 let table_input = table_input_builder.build().map_err(from_aws_build_error)?;
176
177 Ok(table_input)
178}
179
180pub(crate) fn validate_namespace(namespace: &NamespaceIdent) -> Result<String> {
182 let name = namespace.as_ref();
183
184 if name.len() != 1 {
185 return Err(Error::new(
186 ErrorKind::DataInvalid,
187 format!(
188 "Invalid database name: {namespace:?}, hierarchical namespaces are not supported"
189 ),
190 ));
191 }
192
193 let name = name[0].clone();
194
195 if name.is_empty() {
196 return Err(Error::new(
197 ErrorKind::DataInvalid,
198 "Invalid database, provided namespace is empty.",
199 ));
200 }
201
202 Ok(name)
203}
204
205pub(crate) fn get_default_table_location(
207 namespace: &Namespace,
208 db_name: impl AsRef<str>,
209 table_name: impl AsRef<str>,
210 warehouse: impl AsRef<str>,
211) -> String {
212 let properties = namespace.properties();
213
214 match properties.get(LOCATION) {
215 Some(location) => format!("{}/{}", location, table_name.as_ref()),
216 None => {
217 let warehouse_location = warehouse.as_ref().trim_end_matches('/');
218
219 format!(
220 "{}/{}.db/{}",
221 warehouse_location,
222 db_name.as_ref(),
223 table_name.as_ref()
224 )
225 }
226 }
227}
228
229pub(crate) fn is_iceberg_table(parameters: &Option<HashMap<String, String>>) -> bool {
234 parameters
235 .as_ref()
236 .and_then(|p| p.get(TABLE_TYPE))
237 .is_some_and(|v| v.eq_ignore_ascii_case(ICEBERG))
238}
239
240pub(crate) fn get_metadata_location(
242 parameters: &Option<HashMap<String, String>>,
243) -> Result<String> {
244 match parameters {
245 Some(properties) => match properties.get(METADATA_LOCATION) {
246 Some(location) => Ok(location.to_string()),
247 None => Err(Error::new(
248 ErrorKind::DataInvalid,
249 format!("No '{METADATA_LOCATION}' set on table"),
250 )),
251 },
252 None => Err(Error::new(
253 ErrorKind::DataInvalid,
254 "No 'parameters' set on table. Location of metadata is undefined",
255 )),
256 }
257}
258
259#[macro_export]
260macro_rules! with_catalog_id {
262 ($builder:expr, $config:expr) => {{
263 if let Some(catalog_id) = &$config.catalog_id {
264 $builder.catalog_id(catalog_id)
265 } else {
266 $builder
267 }
268 }};
269}
270
271#[cfg(test)]
272mod tests {
273 use aws_sdk_glue::config::ProvideCredentials;
274 use aws_sdk_glue::types::Column;
275 use iceberg::spec::{NestedField, PrimitiveType, Schema, TableMetadataBuilder, Type};
276 use iceberg::{MetadataLocation, Namespace, Result, TableCreation};
277
278 use super::*;
279 use crate::schema::{ICEBERG_FIELD_CURRENT, ICEBERG_FIELD_ID, ICEBERG_FIELD_OPTIONAL};
280
281 fn create_metadata(schema: Schema) -> Result<TableMetadata> {
282 let table_creation = TableCreation::builder()
283 .name("my_table".to_string())
284 .location("my_location".to_string())
285 .schema(schema)
286 .build();
287 let metadata = TableMetadataBuilder::from_table_creation(table_creation)?
288 .build()?
289 .metadata;
290
291 Ok(metadata)
292 }
293
294 #[test]
295 fn test_get_metadata_location() -> Result<()> {
296 let params_valid = Some(HashMap::from([(
297 METADATA_LOCATION.to_string(),
298 "my_location".to_string(),
299 )]));
300 let params_missing_key = Some(HashMap::from([(
301 "not_here".to_string(),
302 "my_location".to_string(),
303 )]));
304
305 let result_valid = get_metadata_location(¶ms_valid)?;
306 let result_missing_key = get_metadata_location(¶ms_missing_key);
307 let result_no_params = get_metadata_location(&None);
308
309 assert_eq!(result_valid, "my_location");
310 assert!(result_missing_key.is_err());
311 assert!(result_no_params.is_err());
312
313 Ok(())
314 }
315
316 #[test]
317 fn test_is_iceberg_table() {
318 let params_table_type = Some(HashMap::from([(
320 TABLE_TYPE.to_string(),
321 ICEBERG.to_string(),
322 )]));
323 assert!(is_iceberg_table(¶ms_table_type));
324
325 let params_table_type_lower = Some(HashMap::from([(
327 TABLE_TYPE.to_string(),
328 "iceberg".to_string(),
329 )]));
330 assert!(is_iceberg_table(¶ms_table_type_lower));
331
332 let params_hive = Some(HashMap::from([(
334 TABLE_TYPE.to_string(),
335 "EXTERNAL_TABLE".to_string(),
336 )]));
337 assert!(!is_iceberg_table(¶ms_hive));
338
339 assert!(!is_iceberg_table(&None));
341
342 assert!(!is_iceberg_table(&Some(HashMap::new())));
344 }
345
346 #[test]
347 fn test_convert_to_glue_table() -> Result<()> {
348 let table_name = "my_table".to_string();
349 let location = "s3a://warehouse/hive".to_string();
350 let schema = Schema::builder()
351 .with_schema_id(1)
352 .with_fields(vec![
353 NestedField::required(1, "foo", Type::Primitive(PrimitiveType::Int)).into(),
354 ])
355 .build()?;
356
357 let metadata = create_metadata(schema)?;
358 let metadata_location =
359 MetadataLocation::new_with_metadata(location, &metadata).to_string();
360
361 let parameters = HashMap::from([
362 (ICEBERG_FIELD_ID.to_string(), "1".to_string()),
363 (ICEBERG_FIELD_OPTIONAL.to_string(), "false".to_string()),
364 (ICEBERG_FIELD_CURRENT.to_string(), "true".to_string()),
365 ]);
366
367 let column = Column::builder()
368 .name("foo")
369 .r#type("int")
370 .set_parameters(Some(parameters))
371 .set_comment(None)
372 .build()
373 .map_err(from_aws_build_error)?;
374
375 let storage_descriptor = StorageDescriptor::builder()
376 .set_columns(Some(vec![column]))
377 .location(metadata.location())
378 .build();
379
380 let result = convert_to_glue_table(
381 &table_name,
382 metadata_location,
383 &metadata,
384 metadata.properties(),
385 None,
386 )?;
387
388 assert_eq!(result.name(), &table_name);
389 assert_eq!(result.description(), None);
390 assert_eq!(result.storage_descriptor, Some(storage_descriptor));
391
392 Ok(())
393 }
394
395 #[test]
396 fn test_get_default_table_location() -> Result<()> {
397 let properties = HashMap::from([(LOCATION.to_string(), "db_location".to_string())]);
398
399 let namespace =
400 Namespace::with_properties(NamespaceIdent::new("default".into()), properties);
401 let db_name = validate_namespace(namespace.name())?;
402 let table_name = "my_table";
403
404 let expected = "db_location/my_table";
405 let result =
406 get_default_table_location(&namespace, db_name, table_name, "warehouse_location");
407
408 assert_eq!(expected, result);
409
410 Ok(())
411 }
412
413 #[test]
414 fn test_get_default_table_location_warehouse() -> Result<()> {
415 let namespace = Namespace::new(NamespaceIdent::new("default".into()));
416 let db_name = validate_namespace(namespace.name())?;
417 let table_name = "my_table";
418
419 let expected = "warehouse_location/default.db/my_table";
420 let result =
421 get_default_table_location(&namespace, db_name, table_name, "warehouse_location");
422
423 assert_eq!(expected, result);
424
425 Ok(())
426 }
427
428 #[test]
429 fn test_convert_to_namespace() -> Result<()> {
430 let db = Database::builder()
431 .name("my_db")
432 .location_uri("my_location")
433 .description("my_description")
434 .build()
435 .map_err(from_aws_build_error)?;
436
437 let properties = HashMap::from([
438 (DESCRIPTION.to_string(), "my_description".to_string()),
439 (LOCATION.to_string(), "my_location".to_string()),
440 ]);
441
442 let expected =
443 Namespace::with_properties(NamespaceIdent::new("my_db".to_string()), properties);
444 let result = convert_to_namespace(&db);
445
446 assert_eq!(result, expected);
447
448 Ok(())
449 }
450
451 #[test]
452 fn test_convert_to_database() -> Result<()> {
453 let namespace = NamespaceIdent::new("my_database".to_string());
454 let properties = HashMap::from([(LOCATION.to_string(), "my_location".to_string())]);
455
456 let result = convert_to_database(&namespace, &properties)?;
457
458 assert_eq!("my_database", result.name());
459 assert_eq!(Some("my_location".to_string()), result.location_uri);
460
461 Ok(())
462 }
463
464 #[test]
465 fn test_validate_namespace() {
466 let valid_ns = Namespace::new(NamespaceIdent::new("ns".to_string()));
467 let empty_ns = Namespace::new(NamespaceIdent::new("".to_string()));
468 let hierarchical_ns = Namespace::new(
469 NamespaceIdent::from_vec(vec!["level1".to_string(), "level2".to_string()]).unwrap(),
470 );
471
472 let valid = validate_namespace(valid_ns.name());
473 let empty = validate_namespace(empty_ns.name());
474 let hierarchical = validate_namespace(hierarchical_ns.name());
475
476 assert!(valid.is_ok());
477 assert!(empty.is_err());
478 assert!(hierarchical.is_err());
479 }
480
481 #[tokio::test]
482 async fn test_config_with_custom_endpoint() {
483 let properties = HashMap::new();
484 let endpoint_url = "http://custom_url:5001";
485
486 let sdk_config = create_sdk_config(&properties, Some(&endpoint_url.to_string())).await;
487
488 let result = sdk_config.endpoint_url().unwrap();
489
490 assert_eq!(result, endpoint_url);
491 }
492
493 #[tokio::test]
494 async fn test_config_with_properties() {
495 let properties = HashMap::from([
496 (AWS_PROFILE_NAME.to_string(), "my_profile".to_string()),
497 (AWS_REGION_NAME.to_string(), "us-east-1".to_string()),
498 (AWS_ACCESS_KEY_ID.to_string(), "my-access-id".to_string()),
499 (
500 AWS_SECRET_ACCESS_KEY.to_string(),
501 "my-secret-key".to_string(),
502 ),
503 (AWS_SESSION_TOKEN.to_string(), "my-token".to_string()),
504 ]);
505
506 let sdk_config = create_sdk_config(&properties, None).await;
507
508 let region = sdk_config.region().unwrap().as_ref();
509 let credentials = sdk_config
510 .credentials_provider()
511 .unwrap()
512 .provide_credentials()
513 .await
514 .unwrap();
515
516 assert_eq!("us-east-1", region);
517 assert_eq!("my-access-id", credentials.access_key_id());
518 assert_eq!("my-secret-key", credentials.secret_access_key());
519 assert_eq!("my-token", credentials.session_token().unwrap());
520 }
521}