diff --git a/crates/core/src/validation/mod.rs b/crates/core/src/validation/mod.rs index a512c6c9..ff18c057 100755 --- a/crates/core/src/validation/mod.rs +++ b/crates/core/src/validation/mod.rs @@ -1,6 +1,9 @@ // Copyright 2026 ExtendDB contributors // SPDX-License-Identifier: Apache-2.0 pub mod number; +pub mod table_arn; + +pub use table_arn::resolve_table_arn; use crate::error::{DynamoDbError, ErrorMessageKey, error_message}; use crate::limits::LimitsConfig; diff --git a/crates/core/src/validation/table_arn.rs b/crates/core/src/validation/table_arn.rs new file mode 100644 index 00000000..0243f8a2 --- /dev/null +++ b/crates/core/src/validation/table_arn.rs @@ -0,0 +1,132 @@ +// Copyright 2026 ExtendDB contributors +// SPDX-License-Identifier: Apache-2.0 + +//! Resolve a table ARN supplied in place of a bare `TableName`. + +use crate::error::DynamoDbError; + +/// Resolve a `TableName` that may be supplied as a table ARN to its bare name. +/// +/// A non-ARN value is returned unchanged. A table ARN +/// (`arn:::::table/`) resolves to +/// ``; the account and region are ignored, so the name resolves within +/// the caller's account. This matches Amazon DynamoDB and DynamoDB Local, which +/// both accept a table ARN wherever a table name is expected. Index, non-table, +/// or malformed ARNs are rejected as a validation error. +/// +/// # Errors +/// +/// Returns `ValidationException` when `name` begins with `arn:` but is not a +/// well-formed `table/` ARN. +pub fn resolve_table_arn(name: &str) -> Result<&str, DynamoDbError> { + if !name.starts_with("arn:") { + return Ok(name); + } + // arn:::::. The 6th field + // keeps everything after the 5th colon, including any slashes. + let resource = match name.splitn(6, ':').nth(5) { + Some(resource) if !resource.is_empty() => resource, + _ => return Err(invalid_arn_format(name)), + }; + let table = match resource.split_once('/') { + Some(("table", table)) => table, + Some(_) => return Err(constraint_error(name, "Invalid resource type")), + None => return Err(invalid_arn_format(name)), + }; + // A bare table name only: an index ARN (`table/T/index/i`) leaves a slash + // here and fails the character check below. + if table.len() < 3 { + return Err(constraint_error( + name, + "Table name of ARN must have length greater than or equal to 3", + )); + } + if table.len() > 255 + || !table + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-') + { + return Err(invalid_arn_format(name)); + } + Ok(table) +} + +fn constraint_error(arn: &str, constraint: &str) -> DynamoDbError { + DynamoDbError::ValidationException(format!( + "1 validation error detected: Value '{arn}' at 'tableName' failed to satisfy constraint: \ + {constraint}" + )) +} + +fn invalid_arn_format(arn: &str) -> DynamoDbError { + constraint_error( + arn, + "Valid ARN format is 'arn:::::resourceType/resourceName', \ + where Resource name must have length less than or equal to 255, Resource name must have length \ + greater than or equal to 3, Resource name must satisfy regular expression pattern: [a-zA-Z0-9_.-]+", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn err_msg(name: &str) -> String { + match resolve_table_arn(name) { + Err(DynamoDbError::ValidationException(m)) => m, + other => panic!("expected ValidationException, got {other:?}"), + } + } + + #[test] + fn non_arn_passthrough() { + assert_eq!(resolve_table_arn("my-table").unwrap(), "my-table"); + // A plain name with a colon is not an ARN and is left for the normal + // table-name validator to reject. + assert_eq!(resolve_table_arn("foo:bar").unwrap(), "foo:bar"); + } + + #[test] + fn table_arn_resolves_to_bare_name() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/my-table"; + assert_eq!(resolve_table_arn(arn).unwrap(), "my-table"); + } + + #[test] + fn account_and_region_are_ignored() { + // Any account/region resolves to the same bare name. + let a = "arn:aws:dynamodb:us-east-1:000000000000:table/T.able_1"; + let b = "arn:aws:dynamodb:eu-west-1:999999999999:table/T.able_1"; + assert_eq!(resolve_table_arn(a).unwrap(), "T.able_1"); + assert_eq!(resolve_table_arn(b).unwrap(), "T.able_1"); + } + + #[test] + fn index_arn_rejected() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/my-table/index/gsi1"; + assert!(err_msg(arn).contains("Valid ARN format is")); + assert!(err_msg(arn).contains("[a-zA-Z0-9_.-]+")); + } + + #[test] + fn non_table_resource_type_rejected() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:stream/my-table"; + assert!(err_msg(arn).contains("Invalid resource type")); + } + + #[test] + fn empty_table_name_rejected() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/"; + assert!( + err_msg(arn).contains("Table name of ARN must have length greater than or equal to 3") + ); + } + + #[test] + fn malformed_arn_missing_resource_rejected() { + assert!(err_msg("arn:aws:dynamodb").contains("Valid ARN format is")); + assert!( + err_msg("arn:aws:dynamodb:us-east-1:123456789012:").contains("Valid ARN format is") + ); + } +} diff --git a/crates/server/src/handler.rs b/crates/server/src/handler.rs index 5a5a5f51..74867cfe 100755 --- a/crates/server/src/handler.rs +++ b/crates/server/src/handler.rs @@ -16,7 +16,10 @@ use extenddb_engine::OperationContext; use serde_json::Value; use crate::AppState; -use crate::request_helpers::{authorize_request, extract_operation, extract_table_name}; +use crate::request_helpers::{ + authorize_request, denormalize_table_arns, extract_operation, extract_table_name, + normalize_table_arns, +}; use crate::response::{error_response, record_error_metrics, success_response}; use crate::throttle_helpers::{ classify_data_operation, extract_partition_value, table_description_to_throughput, @@ -76,7 +79,7 @@ pub(crate) async fn handle_request( // --- Pre-auth body validation --- // Real DynamoDB validates request format (empty body, invalid JSON) before // authentication. Malformed requests get 400 regardless of auth state. - let input: Value = match serde_json::from_slice(&body) { + let mut input: Value = match serde_json::from_slice(&body) { Ok(v) => v, Err(e) => { return error_response( @@ -104,6 +107,15 @@ pub(crate) async fn handle_request( } }; + // Accept a table ARN in place of a bare TableName by normalizing it to the + // bare name for validation, authorization, throttling, and lookup, matching + // Amazon DynamoDB and DynamoDB Local. The returned pairs let the response + // layer echo the caller's original ARN back in table-name fields. + let table_arn_echo = match normalize_table_arns(&mut input, &operation) { + Ok(echo) => echo, + Err(e) => return error_response(&e, &request_id), + }; + // --- Authz segment --- let authz_start = std::time::Instant::now(); let pre_fetched_key_info; @@ -237,7 +249,7 @@ pub(crate) async fn handle_request( // --- Response segment --- let response_start = std::time::Instant::now(); let response = match dispatch_result { - Ok(result) => { + Ok(mut result) => { update_throttle_buckets( &state.throttle, &operation, @@ -285,6 +297,8 @@ pub(crate) async fn handle_request( ); } + // Echo the caller's original ARN back in table-name fields. + denormalize_table_arns(&mut result.body, &operation, &table_arn_echo); success_response(&result.body, &request_id) } Err(e) => { diff --git a/crates/server/src/request_helpers.rs b/crates/server/src/request_helpers.rs index 92cea3ac..e56646d0 100755 --- a/crates/server/src/request_helpers.rs +++ b/crates/server/src/request_helpers.rs @@ -34,6 +34,161 @@ pub(crate) fn extract_operation(headers: &HeaderMap) -> Result Result, DynamoDbError> { + let mut echo: Vec<(String, String)> = Vec::new(); + match operation { + "GetItem" | "PutItem" | "DeleteItem" | "UpdateItem" | "Query" | "Scan" => { + resolve_table_name_field(input, &mut echo)?; + } + "BatchGetItem" | "BatchWriteItem" => normalize_request_items_keys(input, &mut echo)?, + "TransactGetItems" | "TransactWriteItems" => normalize_transact_items(input, &mut echo)?, + _ => {} + } + Ok(echo) +} + +/// Restore the caller's original ARN in the echoed table names of a response. +/// +/// Swaps the bare name back to the supplied ARN in `ConsumedCapacity.TableName` +/// (object or per-table array) and in the table-name-keyed response maps for the +/// operation, so the response mirrors what the caller sent, as Amazon DynamoDB +/// does. The keyed-map set is operation-scoped: `ItemCollectionMetrics` is a +/// table-keyed map only for batch-write/transact-write; for single-item writes +/// it is a field-keyed object and must not be rewritten. +pub(crate) fn denormalize_table_arns(body: &mut Value, operation: &str, echo: &[(String, String)]) { + if echo.is_empty() { + return; + } + let bare_to_original: std::collections::HashMap<&str, &str> = + echo.iter().map(|(b, o)| (b.as_str(), o.as_str())).collect(); + + if let Some(cc) = body.get_mut("ConsumedCapacity") { + match cc { + Value::Array(entries) => { + for entry in entries { + swap_table_name_field(entry, &bare_to_original); + } + } + other => swap_table_name_field(other, &bare_to_original), + } + } + let table_keyed_maps: &[&str] = match operation { + "BatchGetItem" => &["Responses", "UnprocessedKeys"], + "BatchWriteItem" => &["UnprocessedItems", "ItemCollectionMetrics"], + "TransactWriteItems" => &["ItemCollectionMetrics"], + _ => &[], + }; + for key in table_keyed_maps { + if let Some(map) = body.get_mut(*key).and_then(Value::as_object_mut) { + rename_map_keys(map, &bare_to_original); + } + } +} + +fn swap_table_name_field( + entry: &mut Value, + bare_to_original: &std::collections::HashMap<&str, &str>, +) { + if let Some(name) = entry.get("TableName").and_then(Value::as_str) + && let Some(original) = bare_to_original.get(name) + { + entry["TableName"] = Value::String((*original).to_owned()); + } +} + +fn rename_map_keys( + map: &mut serde_json::Map, + bare_to_original: &std::collections::HashMap<&str, &str>, +) { + let renames: Vec<(String, String)> = map + .keys() + .filter_map(|k| { + bare_to_original + .get(k.as_str()) + .map(|o| (k.clone(), (*o).to_owned())) + }) + .collect(); + for (bare, original) in renames { + if let Some(value) = map.remove(&bare) { + map.insert(original, value); + } + } +} + +/// Resolve `input["TableName"]` if it is a string ARN, recording the swap. +fn resolve_table_name_field( + input: &mut Value, + echo: &mut Vec<(String, String)>, +) -> Result<(), DynamoDbError> { + if let Some(name) = input.get("TableName").and_then(Value::as_str) { + let resolved = extenddb_core::validation::resolve_table_arn(name)?; + if resolved != name { + let pair = (resolved.to_owned(), name.to_owned()); + input["TableName"] = Value::String(pair.0.clone()); + echo.push(pair); + } + } + Ok(()) +} + +/// Batch operations key `RequestItems` by table name. Rebuild the map with any +/// ARN keys resolved to bare names, recording each swap for response echo. When +/// an ARN key and a bare key resolve to the same table, the entries collapse to +/// one; Amazon DynamoDB likewise collapses duplicate table references rather +/// than rejecting them. +fn normalize_request_items_keys( + input: &mut Value, + echo: &mut Vec<(String, String)>, +) -> Result<(), DynamoDbError> { + let Some(items) = input.get_mut("RequestItems").and_then(Value::as_object_mut) else { + return Ok(()); + }; + if !items.keys().any(|k| k.starts_with("arn:")) { + return Ok(()); + } + let mut rebuilt = serde_json::Map::with_capacity(items.len()); + for (key, value) in std::mem::take(items) { + let resolved = extenddb_core::validation::resolve_table_arn(&key)?; + if resolved != key { + echo.push((resolved.to_owned(), key.clone())); + } + rebuilt.insert(resolved.to_owned(), value); + } + *items = rebuilt; + Ok(()) +} + +/// Transact operations carry a `TableName` inside each sub-operation object. +fn normalize_transact_items( + input: &mut Value, + echo: &mut Vec<(String, String)>, +) -> Result<(), DynamoDbError> { + let Some(items) = input.get_mut("TransactItems").and_then(Value::as_array_mut) else { + return Ok(()); + }; + for item in items { + let Some(sub_op) = item.as_object_mut() else { + continue; + }; + for value in sub_op.values_mut() { + resolve_table_name_field(value, echo)?; + } + } + Ok(()) +} + /// Extract the table name from a `DynamoDB` request body. /// /// Most operations use `TableName`. Batch and transact operations embed table @@ -188,6 +343,148 @@ fn build_resource_arn(region: &str, account_id: &str, table_name: Option<&str>) } } +#[cfg(test)] +mod arn_tests { + use super::*; + use serde_json::json; + + #[test] + fn top_level_table_name_arn_is_resolved() { + let mut input = json!({ + "TableName": "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl", + "Key": {"pk": {"S": "a"}} + }); + normalize_table_arns(&mut input, "GetItem").unwrap(); + assert_eq!(input["TableName"], json!("Tbl")); + } + + #[test] + fn top_level_arn_echo_pair_is_recorded() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl"; + let mut input = json!({"TableName": arn}); + let echo = normalize_table_arns(&mut input, "PutItem").unwrap(); + assert_eq!(echo, vec![("Tbl".to_owned(), arn.to_owned())]); + } + + #[test] + fn bare_table_name_is_untouched() { + let mut input = json!({"TableName": "Tbl"}); + normalize_table_arns(&mut input, "PutItem").unwrap(); + assert_eq!(input["TableName"], json!("Tbl")); + } + + #[test] + fn index_arn_table_name_is_rejected() { + let mut input = json!({ + "TableName": "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl/index/i" + }); + let err = normalize_table_arns(&mut input, "Query").unwrap_err(); + assert!(matches!(err, DynamoDbError::ValidationException(_))); + } + + #[test] + fn batch_request_items_keys_are_resolved() { + let mut input = json!({ + "RequestItems": { + "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl": { + "Keys": [{"pk": {"S": "a"}}] + } + } + }); + normalize_table_arns(&mut input, "BatchGetItem").unwrap(); + let items = input["RequestItems"].as_object().unwrap(); + assert!(items.contains_key("Tbl")); + assert!(!items.keys().any(|k| k.starts_with("arn:"))); + } + + #[test] + fn transact_item_table_names_are_resolved() { + let mut input = json!({ + "TransactItems": [ + {"Put": {"TableName": "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl", + "Item": {"pk": {"S": "a"}}}}, + {"Delete": {"TableName": "other", "Key": {"pk": {"S": "b"}}}} + ] + }); + normalize_table_arns(&mut input, "TransactWriteItems").unwrap(); + assert_eq!(input["TransactItems"][0]["Put"]["TableName"], json!("Tbl")); + assert_eq!( + input["TransactItems"][1]["Delete"]["TableName"], + json!("other") + ); + } + + #[test] + fn denormalize_restores_arn_in_consumed_capacity_object() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl"; + let echo = vec![("Tbl".to_owned(), arn.to_owned())]; + let mut body = json!({"ConsumedCapacity": {"TableName": "Tbl", "CapacityUnits": 0.5}}); + denormalize_table_arns(&mut body, "GetItem", &echo); + assert_eq!(body["ConsumedCapacity"]["TableName"], json!(arn)); + } + + #[test] + fn denormalize_restores_arn_in_consumed_capacity_array() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl"; + let echo = vec![("Tbl".to_owned(), arn.to_owned())]; + let mut body = json!({"ConsumedCapacity": [{"TableName": "Tbl", "CapacityUnits": 1.0}]}); + denormalize_table_arns(&mut body, "BatchGetItem", &echo); + assert_eq!(body["ConsumedCapacity"][0]["TableName"], json!(arn)); + } + + #[test] + fn denormalize_restores_arn_in_batch_response_keys() { + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl"; + let echo = vec![("Tbl".to_owned(), arn.to_owned())]; + let mut body = json!({ + "Responses": {"Tbl": [{"pk": {"S": "a"}}]}, + "UnprocessedKeys": {} + }); + denormalize_table_arns(&mut body, "BatchGetItem", &echo); + let responses = body["Responses"].as_object().unwrap(); + assert!(responses.contains_key(arn)); + assert!(!responses.contains_key("Tbl")); + } + + #[test] + fn denormalize_batch_echo_is_selective_per_table() { + // One ARN table and one bare table in the same batch: only the ARN + // table's key is rewritten; the bare table's key is left untouched. + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl"; + let echo = vec![("Tbl".to_owned(), arn.to_owned())]; + let mut body = json!({ + "Responses": {"Tbl": [{"pk": {"S": "a"}}], "Other": [{"pk": {"S": "b"}}]} + }); + denormalize_table_arns(&mut body, "BatchGetItem", &echo); + let responses = body["Responses"].as_object().unwrap(); + assert!(responses.contains_key(arn)); + assert!(responses.contains_key("Other")); + assert!(!responses.contains_key("Tbl")); + } + + #[test] + fn denormalize_leaves_single_op_item_collection_metrics_untouched() { + // For single-item writes ItemCollectionMetrics is field-keyed, not + // table-keyed, so its keys must never be rewritten. + let arn = "arn:aws:dynamodb:us-east-1:123456789012:table/Tbl"; + let echo = vec![("Tbl".to_owned(), arn.to_owned())]; + let mut body = json!({ + "ItemCollectionMetrics": {"ItemCollectionKey": {}, "SizeEstimateRangeGB": [0.0, 1.0]} + }); + denormalize_table_arns(&mut body, "PutItem", &echo); + let icm = body["ItemCollectionMetrics"].as_object().unwrap(); + assert!(icm.contains_key("ItemCollectionKey")); + assert!(icm.contains_key("SizeEstimateRangeGB")); + } + + #[test] + fn denormalize_is_noop_without_echo() { + let mut body = json!({"ConsumedCapacity": {"TableName": "Tbl"}}); + denormalize_table_arns(&mut body, "GetItem", &[]); + assert_eq!(body["ConsumedCapacity"]["TableName"], json!("Tbl")); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/differences-from-dynamodb.md b/docs/differences-from-dynamodb.md index 045484a6..243e64c2 100755 --- a/docs/differences-from-dynamodb.md +++ b/docs/differences-from-dynamodb.md @@ -57,6 +57,21 @@ adaptation when switching between ExtendDB and the real service. |------|----------|------| | TagResource / UntagResource | Validates resource ARN exists, returns `ResourceNotFoundException` for missing tables | Matches DynamoDB — validates resource ARN and returns `ResourceNotFoundException` for missing tables. | +## Table References (ARN as TableName) + +A data-plane request may reference a table by its full ARN in place of the bare +table name (`GetItem`, `PutItem`, `UpdateItem`, `DeleteItem`, `Query`, `Scan`, +`BatchGetItem`, `BatchWriteItem`, `TransactGetItems`, `TransactWriteItems`). + +| Area | DynamoDB | ExtendDB | +|------|----------|------| +| ARN as `TableName` | Accepted; resolves to the table name | Accepted; resolves to the table name | +| ARN account id | Cross-account access is authorized (or `AccessDeniedException` for a foreign account) | Ignored: the name resolves within the caller's account. True cross-account semantics are deferred. | +| ARN region | Mismatched region rejected (`ValidationException`, "Invalid AWS region") | Ignored (matches DynamoDB Local) | +| Echoed table name | The supplied ARN is echoed verbatim in `ConsumedCapacity.TableName` and `BatchGetItem` response keys | Same: the supplied ARN is echoed verbatim | +| Index / non-table / malformed ARN | Rejected (`ValidationException`) | Rejected (`ValidationException`) | +| ARN on control-plane ops (e.g. `DescribeTable`) | Accepted | Not accepted (deferred); use the bare name | + ## Secondary Indexes | Area | DynamoDB | ExtendDB | diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 70a74f47..ce2fb45d 100755 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -1,6 +1,6 @@ # Technical Debt Tracker -Last updated: 2026-05-04 (P112) +Last updated: 2026-07-02 (#200) ## Categories @@ -30,6 +30,9 @@ Last updated: 2026-05-04 (P112) | F-15 | ~~TTL worker bypasses stream capture — expired item deletions don't generate REMOVE stream records~~ | `bin/cmd_serve.rs:ttl_cleanup_worker` | ~~High~~ | P26 | | F-16 | `transact_write_items.rs` passes `None` for `old_item` in stream capture — `OldImage` always `None` for transaction-originated stream records | `engine/transact_write_items.rs` | Medium | P27 | | F-17 | `validate_attribute_name_sizes` only checks top-level attribute names — nested map keys not validated | `core/validation/mod.rs` | Low | P30 | +| F-18 | Table ARN as `TableName`: cross-account not authorized. A foreign-account ARN resolves within the caller's account instead of returning `AccessDeniedException` | `core/validation/table_arn.rs` | Medium | #200 | +| F-19 | Table ARN as `TableName`: ARN region ignored. A mismatched region is not rejected (matches DynamoDB Local; DynamoDB returns `ValidationException`, "Invalid AWS region") | `core/validation/table_arn.rs` | Low | #200 | +| F-20 | Table ARN as `TableName` accepted only on data-plane operations. Control-plane ops (e.g. `DescribeTable`) do not resolve an ARN; the bare name is required | `server/request_helpers.rs` | Low | #200 | ## Cleanup diff --git a/tests/test_table_arn_as_name.py b/tests/test_table_arn_as_name.py new file mode 100644 index 00000000..0b97787c --- /dev/null +++ b/tests/test_table_arn_as_name.py @@ -0,0 +1,236 @@ +# Copyright 2026 ExtendDB contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Table ARN accepted as TableName — dual-target against real DynamoDB and extenddb. + +A data-plane request may reference a table by its full ARN in place of the bare +table name. Amazon DynamoDB and DynamoDB Local both resolve the ARN to the table +name and serve the request. These tests exercise that resolution across the +table-name-bearing API surface (item, query/scan, batch, transact), plus the +rejection of an index/non-table ARN. + +The ARN is taken from the table's own DescribeTable output, so it carries the +correct account and region for whichever target the suite runs against. + +REQ-TEST-002, REQ-TEST-003 +""" + +from __future__ import annotations + +import uuid + +import pytest +from botocore.exceptions import ClientError + +from conftest import scoped_table + + +@pytest.fixture(scope="class") +def hash_table(dynamodb_client): + """Hash-only table for the class; yields (name, arn).""" + with scoped_table(dynamodb_client) as name: + arn = dynamodb_client.describe_table(TableName=name)["Table"]["TableArn"] + yield name, arn + + +@pytest.fixture(scope="class") +def second_table(dynamodb_client): + """A second hash-only table for multi-table batch cases; yields (name, arn).""" + with scoped_table(dynamodb_client) as name: + arn = dynamodb_client.describe_table(TableName=name)["Table"]["TableArn"] + yield name, arn + + +class TestTableArnAsName: + """A full table ARN may be supplied wherever a TableName is expected.""" + + def test_get_item_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + item = {"pk": {"S": "g1"}, "v": {"S": "hello"}} + dynamodb_client.put_item(TableName=name, Item=item) + resp = dynamodb_client.get_item(TableName=arn, Key={"pk": {"S": "g1"}}) + assert resp["Item"] == item + + def test_put_item_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + item = {"pk": {"S": "p1"}, "v": {"S": "world"}} + dynamodb_client.put_item(TableName=arn, Item=item) + resp = dynamodb_client.get_item(TableName=name, Key={"pk": {"S": "p1"}}) + assert resp["Item"] == item + + def test_update_item_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "u1"}}) + dynamodb_client.update_item( + TableName=arn, + Key={"pk": {"S": "u1"}}, + UpdateExpression="SET v = :v", + ExpressionAttributeValues={":v": {"S": "updated"}}, + ) + resp = dynamodb_client.get_item(TableName=name, Key={"pk": {"S": "u1"}}) + assert resp["Item"]["v"] == {"S": "updated"} + + def test_delete_item_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "d1"}}) + dynamodb_client.delete_item(TableName=arn, Key={"pk": {"S": "d1"}}) + resp = dynamodb_client.get_item(TableName=name, Key={"pk": {"S": "d1"}}) + assert "Item" not in resp + + def test_query_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "q1"}, "v": {"S": "x"}}) + resp = dynamodb_client.query( + TableName=arn, + KeyConditionExpression="pk = :p", + ExpressionAttributeValues={":p": {"S": "q1"}}, + ) + assert resp["Count"] == 1 + assert resp["Items"][0]["pk"] == {"S": "q1"} + + def test_scan_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "s1"}}) + resp = dynamodb_client.scan( + TableName=arn, + FilterExpression="pk = :p", + ExpressionAttributeValues={":p": {"S": "s1"}}, + ) + assert resp["Count"] == 1 + + def test_batch_get_item_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "bg1"}, "v": {"S": "b"}}) + resp = dynamodb_client.batch_get_item( + RequestItems={arn: {"Keys": [{"pk": {"S": "bg1"}}]}} + ) + # The response echoes the caller's supplied key verbatim (the ARN), + # matching Amazon DynamoDB. + assert resp["Responses"][arn] == [{"pk": {"S": "bg1"}, "v": {"S": "b"}}] + + def test_consumed_capacity_echoes_supplied_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "cc1"}}) + resp = dynamodb_client.get_item( + TableName=arn, + Key={"pk": {"S": "cc1"}}, + ReturnConsumedCapacity="TOTAL", + ) + # ConsumedCapacity.TableName echoes exactly what the caller supplied. + assert resp["ConsumedCapacity"]["TableName"] == arn + + def test_batch_write_item_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.batch_write_item( + RequestItems={arn: [{"PutRequest": {"Item": {"pk": {"S": "bw1"}}}}]} + ) + resp = dynamodb_client.get_item(TableName=name, Key={"pk": {"S": "bw1"}}) + assert resp["Item"]["pk"] == {"S": "bw1"} + + def test_transact_write_and_get_by_arn(self, dynamodb_client, hash_table): + name, arn = hash_table + dynamodb_client.transact_write_items( + TransactItems=[{"Put": {"TableName": arn, "Item": {"pk": {"S": "tw1"}}}}] + ) + resp = dynamodb_client.transact_get_items( + TransactItems=[{"Get": {"TableName": arn, "Key": {"pk": {"S": "tw1"}}}}] + ) + assert resp["Responses"][0]["Item"]["pk"] == {"S": "tw1"} + + def test_index_arn_as_table_name_rejected(self, dynamodb_client, hash_table): + _name, arn = hash_table + # An index ARN (or any non-table resource) is not a valid TableName. + # The message text differs across implementations; assert the class. + with pytest.raises(ClientError) as exc: + dynamodb_client.get_item( + TableName=f"{arn}/index/some-index", Key={"pk": {"S": "g1"}} + ) + assert exc.value.response["Error"]["Code"] == "ValidationException" + + def test_bare_nonexistent_name_still_not_found(self, dynamodb_client): + # Control: a plain (non-ARN) name for a missing table is still a 404. + # The ARN path must not swallow genuine resource-not-found errors. + missing = f"extenddb-missing-{uuid.uuid4().hex[:12]}" + with pytest.raises(ClientError) as exc: + dynamodb_client.get_item(TableName=missing, Key={"pk": {"S": "x"}}) + assert exc.value.response["Error"]["Code"] == "ResourceNotFoundException" + + def test_batch_get_arn_and_bare_same_table_collapse(self, dynamodb_client, hash_table): + # An ARN key and the bare key for the same table are duplicate + # references. DynamoDB collapses them to a single entry rather than + # rejecting; both keys request the same item so the result is stable. + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "col1"}, "v": {"S": "c"}}) + resp = dynamodb_client.batch_get_item( + RequestItems={ + arn: {"Keys": [{"pk": {"S": "col1"}}]}, + name: {"Keys": [{"pk": {"S": "col1"}}]}, + } + ) + responses = resp["Responses"] + assert len(responses) == 1 + (items,) = responses.values() + assert items == [{"pk": {"S": "col1"}, "v": {"S": "c"}}] + + def test_batch_write_arn_and_bare_distinct_tables( + self, dynamodb_client, hash_table, second_table + ): + # Distinct tables addressed by an ARN key and a bare key in one batch + # must both be written (no collision, both entries preserved). + name_a, arn_a = hash_table + name_b, _arn_b = second_table + dynamodb_client.batch_write_item( + RequestItems={ + arn_a: [{"PutRequest": {"Item": {"pk": {"S": "mt-a"}}}}], + name_b: [{"PutRequest": {"Item": {"pk": {"S": "mt-b"}}}}], + } + ) + assert "Item" in dynamodb_client.get_item(TableName=name_a, Key={"pk": {"S": "mt-a"}}) + assert "Item" in dynamodb_client.get_item(TableName=name_b, Key={"pk": {"S": "mt-b"}}) + + def test_batch_get_mixed_arn_and_bare_echo_selectivity( + self, dynamodb_client, hash_table, second_table + ): + # One table addressed by ARN, one by bare name, in a single batch: the + # response echoes the ARN for the ARN table and the bare name for the + # bare table, simultaneously (per-reference echo, matching DynamoDB). + name_a, arn_a = hash_table + name_b, _arn_b = second_table + dynamodb_client.put_item(TableName=name_a, Item={"pk": {"S": "mx-a"}}) + dynamodb_client.put_item(TableName=name_b, Item={"pk": {"S": "mx-b"}}) + resp = dynamodb_client.batch_get_item( + RequestItems={ + arn_a: {"Keys": [{"pk": {"S": "mx-a"}}]}, + name_b: {"Keys": [{"pk": {"S": "mx-b"}}]}, + } + ) + responses = resp["Responses"] + assert responses[arn_a] == [{"pk": {"S": "mx-a"}}] + assert responses[name_b] == [{"pk": {"S": "mx-b"}}] + + def test_transact_write_condition_check_and_update_by_arn(self, dynamodb_client, hash_table): + # ConditionCheck and Update sub-operations also accept an ARN TableName. + name, arn = hash_table + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "tc1"}, "n": {"N": "1"}}) + dynamodb_client.put_item(TableName=name, Item={"pk": {"S": "tc2"}, "n": {"N": "1"}}) + dynamodb_client.transact_write_items( + TransactItems=[ + { + "ConditionCheck": { + "TableName": arn, + "Key": {"pk": {"S": "tc1"}}, + "ConditionExpression": "attribute_exists(pk)", + } + }, + { + "Update": { + "TableName": arn, + "Key": {"pk": {"S": "tc2"}}, + "UpdateExpression": "SET n = :two", + "ExpressionAttributeValues": {":two": {"N": "2"}}, + } + }, + ] + ) + resp = dynamodb_client.get_item(TableName=name, Key={"pk": {"S": "tc2"}}) + assert resp["Item"]["n"] == {"N": "2"}