1use crate::actions::helpers;
2use crate::errors::{DynoxideError, Result};
3use crate::storage_backend::StorageBackend;
4use crate::types::{self, AttributeValue, Item};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Default, Deserialize)]
9pub struct BatchWriteItemRequest {
10 #[serde(rename = "RequestItems")]
11 pub request_items: HashMap<String, Vec<WriteRequest>>,
12 #[serde(rename = "ReturnConsumedCapacity", default)]
13 pub return_consumed_capacity: Option<String>,
14 #[serde(rename = "ReturnItemCollectionMetrics", default)]
15 pub return_item_collection_metrics: Option<String>,
16}
17
18#[derive(Debug, Default, Deserialize)]
19pub struct WriteRequest {
20 #[serde(rename = "PutRequest", default)]
21 pub put_request: Option<PutRequest>,
22 #[serde(rename = "DeleteRequest", default)]
23 pub delete_request: Option<DeleteRequest>,
24}
25
26#[derive(Debug, Default, Deserialize)]
27pub struct PutRequest {
28 #[serde(rename = "Item")]
29 pub item: Item,
30}
31
32#[derive(Debug, Default, Deserialize)]
33pub struct DeleteRequest {
34 #[serde(rename = "Key")]
35 pub key: HashMap<String, AttributeValue>,
36}
37
38#[derive(Debug, Default, Serialize)]
39pub struct BatchWriteItemResponse {
40 #[serde(rename = "UnprocessedItems")]
41 pub unprocessed_items: HashMap<String, serde_json::Value>,
42 #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
43 pub consumed_capacity: Option<Vec<crate::types::ConsumedCapacity>>,
44 #[serde(
45 rename = "ItemCollectionMetrics",
46 skip_serializing_if = "Option::is_none"
47 )]
48 pub item_collection_metrics: Option<HashMap<String, Vec<crate::types::ItemCollectionMetrics>>>,
49}
50
51fn accumulate_index_units(
55 totals: &mut HashMap<String, HashMap<String, f64>>,
56 table_name: &str,
57 units: &HashMap<String, f64>,
58) {
59 if units.is_empty() {
60 return;
61 }
62 let table_entry = totals.entry(table_name.to_string()).or_default();
63 for (index_name, units) in units {
64 *table_entry.entry(index_name.clone()).or_insert(0.0) += units;
65 }
66}
67
68pub(crate) fn check_request(request: &BatchWriteItemRequest) -> Result<()> {
77 if request.request_items.is_empty() {
86 return Err(DynoxideError::ValidationException(
87 "The requestItems parameter is required for BatchWriteItem".to_string(),
88 ));
89 }
90
91 let mut member_errors = Vec::new();
97 let total_requests: usize = request.request_items.values().map(|v| v.len()).sum();
98 if total_requests > 25 || request.request_items.values().any(|wrs| wrs.is_empty()) {
99 member_errors.push(format!(
100 "Value '{}' at 'requestItems' failed to satisfy constraint: Map value must satisfy constraint: [Member must have length less than or equal to 25, Member must have length greater than or equal to 1]",
101 request_items_echo(&request.request_items)
102 ));
103 }
104 if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
105 request.return_consumed_capacity.as_deref(),
106 ) {
107 member_errors.push(msg);
108 }
109 if let Some(msg) = crate::validation::return_item_collection_metrics_rejection(
110 request.return_item_collection_metrics.as_deref(),
111 ) {
112 member_errors.push(msg);
113 }
114 if let Some(msg) = crate::validation::format_validation_errors(&member_errors) {
115 return Err(DynoxideError::ValidationException(msg));
116 }
117
118 if !request
122 .request_items
123 .keys()
124 .all(|name| crate::validation::member_table_name_is_valid(name))
125 {
126 return Err(DynoxideError::ValidationException(
127 crate::validation::envelope_message(&format!(
128 "Value '{}' at 'requestItems' failed to satisfy constraint: {}",
129 request_items_echo(&request.request_items),
130 crate::validation::REQUEST_ITEMS_KEY_CONSTRAINT
131 )),
132 ));
133 }
134
135 Ok(())
136}
137
138pub async fn execute<S: StorageBackend>(
139 storage: &S,
140 mut request: BatchWriteItemRequest,
141) -> Result<BatchWriteItemResponse> {
142 const MAX_REQUEST_SIZE: usize = 16 * 1024 * 1024; check_request(&request)?;
145
146 let empty_name = request.request_items.values().flatten().any(|wr| {
153 wr.put_request
154 .as_ref()
155 .is_some_and(|put| crate::empty_names::map_has_empty_name(&put.item))
156 || wr
157 .delete_request
158 .as_ref()
159 .is_some_and(|delete| crate::empty_names::map_has_empty_name(&delete.key))
160 });
161 if empty_name {
162 return Err(crate::empty_names::bare());
163 }
164 for write_requests in request.request_items.values() {
165 for wr in write_requests {
166 if wr.put_request.is_none() && wr.delete_request.is_none() {
167 return Err(DynoxideError::ValidationException(
168 "Supplied AttributeValue has more than one datatypes set, must contain exactly one of the supported datatypes".to_string(),
169 ));
170 }
171 if let Some(ref put_req) = wr.put_request {
172 crate::validation::validate_item_attribute_values(&put_req.item)?;
174
175 let size = types::item_size(&put_req.item);
177 if size > types::MAX_ITEM_SIZE {
178 return Err(DynoxideError::ValidationException(
179 "Item size has exceeded the maximum allowed size".to_string(),
180 ));
181 }
182 }
183 if let Some(ref del_req) = wr.delete_request {
184 crate::validation::validate_item_attribute_values(&del_req.key)?;
185 }
186 }
187 }
188
189 let total_size: usize = request
191 .request_items
192 .values()
193 .flat_map(|wrs| wrs.iter())
194 .map(|wr| {
195 if let Some(ref put_req) = wr.put_request {
196 types::item_size(&put_req.item)
197 } else if let Some(ref del_req) = wr.delete_request {
198 types::item_size(&del_req.key)
199 } else {
200 0
201 }
202 })
203 .sum();
204 if total_size > MAX_REQUEST_SIZE {
205 return Err(DynoxideError::ValidationException(
206 "Item collection too large: aggregate size of items in BatchWriteItem exceeds 16MB limit".to_string(),
207 ));
208 }
209
210 {
212 let mut seen_keys: std::collections::HashSet<(String, String, String)> =
213 std::collections::HashSet::new();
214 for (table_name, write_requests) in &request.request_items {
215 let meta = helpers::require_table_for_item_op(storage, table_name).await?;
216 let key_schema = helpers::parse_key_schema(&meta)?;
217 for wr in write_requests {
218 let key_item = if let Some(ref put) = wr.put_request {
224 require_key_attributes(&put.item, &key_schema)?;
225 helpers::validate_item_keys_for_batch(&put.item, &key_schema, &meta)?;
226 &put.item
227 } else if let Some(ref del) = wr.delete_request {
228 helpers::validate_key_only(&del.key, &key_schema)?;
229 &del.key
230 } else {
231 continue;
232 };
233 let (pk, sk) = helpers::extract_key_strings(key_item, &key_schema)?;
234 let key = (table_name.clone(), pk, sk);
235 if !seen_keys.insert(key) {
236 return Err(DynoxideError::ValidationException(
237 "Provided list of item keys contains duplicates".to_string(),
238 ));
239 }
240 }
241 }
242 }
243
244 let mut table_gsi_units: HashMap<String, HashMap<String, f64>> = HashMap::new();
247 let mut table_lsi_units: HashMap<String, HashMap<String, f64>> = HashMap::new();
248 let mut table_vector_bytes: HashMap<String, HashMap<String, f64>> = HashMap::new();
251 let mut table_wcu: HashMap<String, f64> = HashMap::new();
253 let mut affected_partitions: Vec<(String, String, String, AttributeValue)> = Vec::new();
255
256 for (table_name, write_requests) in &mut request.request_items {
257 let meta = helpers::require_table_for_item_op(storage, table_name).await?;
258 let key_schema = helpers::parse_key_schema(&meta)?;
259 let gsi_defs = super::gsi::parse_gsi_defs(&meta)?;
263 let lsi_defs = super::lsi::parse_lsi_defs(&meta)?;
264 let vector_defs = super::vector_index::parse_vector_defs(&meta)?;
265 let attr_defs = super::vector_index::parse_attr_defs(&meta)?;
266
267 for wr in write_requests {
268 if let Some(ref mut put_req) = wr.put_request {
269 helpers::validate_item_keys_for_batch(&put_req.item, &key_schema, &meta)?;
271
272 crate::validation::validate_item_attribute_values(&put_req.item)?;
274
275 crate::validation::normalize_item_sets(&mut put_req.item);
277
278 if types::item_size(&put_req.item) > types::MAX_ITEM_SIZE {
280 return Err(DynoxideError::ValidationException(
281 "Item size has exceeded the maximum allowed size".to_string(),
282 ));
283 }
284 let size = types::billed_item_size(&put_req.item, &vector_defs);
287
288 let (pk, sk) = helpers::extract_key_strings(&put_req.item, &key_schema)?;
290 let item_json = serde_json::to_string(&put_req.item)
291 .map_err(|e| DynoxideError::InternalServerError(e.to_string()))?;
292 let hash_prefix = put_req
293 .item
294 .get(&key_schema.partition_key)
295 .map(crate::storage::compute_hash_prefix)
296 .unwrap_or_default();
297 let (old_size, gsi_units, lsi_units, vector_bytes) =
302 helpers::with_write_transaction(storage, async {
303 let old_json = storage
304 .put_item_with_hash(
305 table_name,
306 &pk,
307 &sk,
308 &item_json,
309 size,
310 &hash_prefix,
311 )
312 .await?;
313 let old_item: Option<Item> =
314 old_json.and_then(|j| serde_json::from_str(&j).ok());
315 let target = super::gsi::IndexWrite {
316 table_name,
317 pk: &pk,
318 sk: &sk,
319 pk_attr: &key_schema.partition_key,
320 sk_attr: key_schema.sort_key.as_deref(),
321 old_item: old_item.as_ref(),
322 capacity_mode: request.return_consumed_capacity.as_deref(),
323 };
324 let gsi_units = super::gsi::maintain_gsis_after_write_with_defs(
325 storage,
326 &gsi_defs,
327 &target,
328 &put_req.item,
329 )
330 .await?;
331 let lsi_units = super::lsi::maintain_lsis_after_write_with_defs(
332 storage,
333 &lsi_defs,
334 &target,
335 &put_req.item,
336 )
337 .await?;
338 let vector_bytes =
339 super::vector_index::maintain_vector_indexes_after_write_with_defs(
340 storage,
341 &vector_defs,
342 &attr_defs,
343 &target,
344 &put_req.item,
345 false,
346 )
347 .await?;
348 crate::streams::record_stream_event(
349 storage,
350 &meta,
351 old_item.as_ref(),
352 Some(&put_req.item),
353 )
354 .await?;
355 Ok((
356 old_item
357 .as_ref()
358 .map(|old| types::billed_item_size(old, &vector_defs)),
359 gsi_units,
360 lsi_units,
361 vector_bytes,
362 ))
363 })
364 .await?;
365
366 *table_wcu.entry(table_name.clone()).or_insert(0.0) +=
368 types::table_write_capacity_units(old_size, Some(size));
369
370 accumulate_index_units(&mut table_gsi_units, table_name, &gsi_units);
372 accumulate_index_units(&mut table_lsi_units, table_name, &lsi_units);
373 accumulate_index_units(&mut table_vector_bytes, table_name, &vector_bytes);
374
375 if let Some(pk_val) = put_req.item.get(&key_schema.partition_key) {
377 affected_partitions.push((
378 table_name.clone(),
379 pk.clone(),
380 key_schema.partition_key.clone(),
381 pk_val.clone(),
382 ));
383 }
384 } else if let Some(ref del_req) = wr.delete_request {
385 helpers::validate_key_only(&del_req.key, &key_schema)?;
386 let (pk, sk) = helpers::extract_key_strings(&del_req.key, &key_schema)?;
388
389 let (old_item, gsi_units, lsi_units, vector_bytes) =
391 helpers::with_write_transaction(storage, async {
392 let old_json = storage.delete_item(table_name, &pk, &sk).await?;
393 let old_item: Option<Item> =
394 old_json.as_ref().and_then(|j| serde_json::from_str(j).ok());
395 let target = super::gsi::IndexWrite {
396 table_name,
397 pk: &pk,
398 sk: &sk,
399 pk_attr: &key_schema.partition_key,
400 sk_attr: key_schema.sort_key.as_deref(),
401 old_item: old_item.as_ref(),
402 capacity_mode: request.return_consumed_capacity.as_deref(),
403 };
404 let gsi_units = super::gsi::maintain_gsis_after_delete_with_defs(
405 storage, &gsi_defs, &target,
406 )
407 .await?;
408 let lsi_units = super::lsi::maintain_lsis_after_delete_with_defs(
409 storage, &lsi_defs, &target,
410 )
411 .await?;
412 let vector_bytes =
413 super::vector_index::maintain_vector_indexes_after_delete_with_defs(
414 storage,
415 &vector_defs,
416 &attr_defs,
417 &target,
418 )
419 .await?;
420 if old_item.is_some() {
421 crate::streams::record_stream_event(
422 storage,
423 &meta,
424 old_item.as_ref(),
425 None,
426 )
427 .await?;
428 }
429 Ok((old_item, gsi_units, lsi_units, vector_bytes))
430 })
431 .await?;
432
433 *table_wcu.entry(table_name.clone()).or_insert(0.0) +=
435 types::table_write_capacity_units(
436 old_item
437 .as_ref()
438 .map(|old| types::billed_item_size(old, &vector_defs)),
439 None,
440 );
441
442 accumulate_index_units(&mut table_gsi_units, table_name, &gsi_units);
444 accumulate_index_units(&mut table_lsi_units, table_name, &lsi_units);
445 accumulate_index_units(&mut table_vector_bytes, table_name, &vector_bytes);
446
447 if let Some(pk_val) = del_req.key.get(&key_schema.partition_key) {
449 affected_partitions.push((
450 table_name.clone(),
451 pk.clone(),
452 key_schema.partition_key.clone(),
453 pk_val.clone(),
454 ));
455 }
456 } else {
457 return Err(DynoxideError::ValidationException(
458 "WriteRequest must contain either PutRequest or DeleteRequest".to_string(),
459 ));
460 }
461 }
462 }
463
464 let consumed_capacity = if matches!(
466 request.return_consumed_capacity.as_deref(),
467 Some("TOTAL") | Some("INDEXES")
468 ) {
469 let mut caps = Vec::new();
470 for table_name in request.request_items.keys() {
471 let total_wcu = table_wcu.get(table_name).copied().unwrap_or(0.0);
472 let gsi_units = table_gsi_units.get(table_name).cloned().unwrap_or_default();
473 let lsi_units = table_lsi_units.get(table_name).cloned().unwrap_or_default();
474 let vector_bytes = table_vector_bytes
475 .get(table_name)
476 .cloned()
477 .unwrap_or_default();
478 if let Some(cc) = crate::types::consumed_capacity_with_vector_indexes(
479 table_name,
480 total_wcu,
481 &gsi_units,
482 &lsi_units,
483 &vector_bytes,
484 &request.return_consumed_capacity,
485 ) {
486 caps.push(cc);
487 }
488 }
489 Some(caps)
490 } else {
491 None
492 };
493
494 let mut all_item_collection_metrics: HashMap<String, Vec<crate::types::ItemCollectionMetrics>> =
496 HashMap::new();
497 if matches!(
498 request.return_item_collection_metrics.as_deref(),
499 Some("SIZE")
500 ) {
501 let mut seen = std::collections::HashSet::new();
503 for (tbl, pk_str, pk_attr, pk_val) in &affected_partitions {
504 let key = (tbl.as_str(), pk_str.as_str());
505 if !seen.insert(key) {
506 continue;
507 }
508 let meta = helpers::require_table(storage, tbl).await?;
509 if let Some(icm) = helpers::build_item_collection_metrics(
510 storage,
511 &meta,
512 tbl,
513 pk_str,
514 pk_attr,
515 pk_val,
516 &request.return_item_collection_metrics,
517 )
518 .await?
519 {
520 all_item_collection_metrics
521 .entry(tbl.clone())
522 .or_default()
523 .push(icm);
524 }
525 }
526 }
527 let item_collection_metrics = if all_item_collection_metrics.is_empty() {
528 None
529 } else {
530 Some(all_item_collection_metrics)
531 };
532
533 Ok(BatchWriteItemResponse {
534 unprocessed_items: HashMap::new(),
535 consumed_capacity,
536 item_collection_metrics,
537 })
538}
539
540fn require_key_attributes(item: &Item, schema: &helpers::KeySchema) -> Result<()> {
544 let missing = !item.contains_key(&schema.partition_key)
545 || schema
546 .sort_key
547 .as_ref()
548 .is_some_and(|sk| !item.contains_key(sk));
549 if missing {
550 return Err(DynoxideError::ValidationException(
551 "The provided key element does not match the schema".to_string(),
552 ));
553 }
554 Ok(())
555}
556
557pub(crate) const ECHO_LIMIT: usize = 1024 * 1024;
569
570pub(crate) struct BoundedEcho {
574 text: String,
575 full: bool,
576}
577
578impl BoundedEcho {
579 pub(crate) fn new() -> Self {
580 BoundedEcho {
581 text: String::new(),
582 full: false,
583 }
584 }
585
586 pub(crate) fn finish(mut self) -> String {
588 if self.full {
589 self.text.push_str("...");
590 }
591 self.text
592 }
593}
594
595impl std::fmt::Write for BoundedEcho {
596 fn write_str(&mut self, s: &str) -> std::fmt::Result {
597 if self.full {
598 return Err(std::fmt::Error);
599 }
600 let room = ECHO_LIMIT - self.text.len();
601 if s.len() <= room {
602 self.text.push_str(s);
603 return Ok(());
604 }
605 let mut cut = room;
606 while !s.is_char_boundary(cut) {
607 cut -= 1;
608 }
609 self.text.push_str(&s[..cut]);
610 self.full = true;
611 Err(std::fmt::Error)
612 }
613}
614
615pub(crate) fn bound_echo(echo: String) -> String {
618 let mut out = BoundedEcho::new();
619 let _ = std::fmt::Write::write_str(&mut out, &echo);
620 out.finish()
621}
622
623fn request_items_echo(request_items: &HashMap<String, Vec<WriteRequest>>) -> String {
631 let mut out = BoundedEcho::new();
632 let _ = write_request_items(&mut out, request_items);
633 out.finish()
634}
635
636fn write_request_items(
637 out: &mut dyn std::fmt::Write,
638 request_items: &HashMap<String, Vec<WriteRequest>>,
639) -> std::fmt::Result {
640 let mut tables: Vec<(&String, &Vec<WriteRequest>)> = request_items.iter().collect();
641 tables.sort_by(|a, b| a.0.cmp(b.0));
642 out.write_str("{")?;
643 for (i, (table_name, requests)) in tables.into_iter().enumerate() {
644 if i > 0 {
645 out.write_str(", ")?;
646 }
647 write!(out, "{table_name}=[")?;
648 for (j, request) in requests.iter().enumerate() {
649 if j > 0 {
650 out.write_str(", ")?;
651 }
652 write_write_request(out, request)?;
653 }
654 out.write_str("]")?;
655 }
656 out.write_str("}")
657}
658
659fn write_write_request(out: &mut dyn std::fmt::Write, request: &WriteRequest) -> std::fmt::Result {
660 out.write_str("WriteRequest(putRequest=")?;
661 match request.put_request {
662 Some(ref put) => {
663 out.write_str("PutRequest(item=")?;
664 write_java_item(out, &put.item)?;
665 out.write_str(", workloadProfileName=null)")?;
666 }
667 None => out.write_str("null")?,
668 }
669 out.write_str(", deleteRequest=")?;
670 match request.delete_request {
671 Some(ref delete) => {
672 out.write_str("DeleteRequest(key=")?;
673 write_java_item(out, &delete.key)?;
674 out.write_str(", workloadProfileName=null)")?;
675 }
676 None => out.write_str("null")?,
677 }
678 out.write_str(")")
679}
680
681fn java_map_order<'a, I: Iterator<Item = &'a String>>(names: I) -> Vec<&'a str> {
690 let mut names: Vec<&str> = names.map(String::as_str).collect();
691 names.sort_unstable();
692 crate::validation::java_hash_order(&names, true)
693}
694
695pub(crate) fn write_java_item(
698 out: &mut dyn std::fmt::Write,
699 item: &HashMap<String, AttributeValue>,
700) -> std::fmt::Result {
701 out.write_str("{")?;
702 for (i, name) in java_map_order(item.keys()).into_iter().enumerate() {
703 if i > 0 {
704 out.write_str(", ")?;
705 }
706 write!(out, "{name}=")?;
707 write_java_attribute_value(out, &item[name])?;
708 }
709 out.write_str("}")
710}
711
712pub(crate) fn write_java_names(
714 out: &mut dyn std::fmt::Write,
715 names: Option<&HashMap<String, String>>,
716) -> std::fmt::Result {
717 let Some(names) = names else {
718 return out.write_str("null");
719 };
720 out.write_str("{")?;
721 for (i, name) in java_map_order(names.keys()).into_iter().enumerate() {
722 if i > 0 {
723 out.write_str(", ")?;
724 }
725 write!(out, "{name}={}", names[name])?;
726 }
727 out.write_str("}")
728}
729
730pub(crate) fn write_java_values(
732 out: &mut dyn std::fmt::Write,
733 values: Option<&HashMap<String, AttributeValue>>,
734) -> std::fmt::Result {
735 match values {
736 Some(values) => write_java_item(out, values),
737 None => out.write_str("null"),
738 }
739}
740
741pub(crate) fn write_java_text(
743 out: &mut dyn std::fmt::Write,
744 text: Option<&str>,
745) -> std::fmt::Result {
746 out.write_str(text.unwrap_or("null"))
747}
748
749pub(crate) fn write_java_attribute_value(
753 out: &mut dyn std::fmt::Write,
754 value: &AttributeValue,
755) -> std::fmt::Result {
756 const FIELDS: [&str; 16] = [
757 "s",
758 "n",
759 "b",
760 "iNT",
761 "dECIMAL",
762 "dOUBLE",
763 "sS",
764 "nS",
765 "bS",
766 "iNTSET",
767 "dECIMALSET",
768 "dOUBLESET",
769 "m",
770 "l",
771 "nULL",
772 "bOOL",
773 ];
774 let slot = match value {
775 AttributeValue::S(_) => 0,
776 AttributeValue::N(_) => 1,
777 AttributeValue::B(_) => 2,
778 AttributeValue::SS(_) => 6,
779 AttributeValue::NS(_) => 7,
780 AttributeValue::BS(_) => 8,
781 AttributeValue::M(_) => 12,
782 AttributeValue::L(_) => 13,
783 AttributeValue::NULL(_) => 14,
784 AttributeValue::BOOL(_) => 15,
785 };
786 out.write_str("AttributeValue(")?;
787 for (i, name) in FIELDS.iter().enumerate() {
788 if i > 0 {
789 out.write_str(", ")?;
790 }
791 write!(out, "{name}=")?;
792 if i == slot {
793 write_java_shown(out, value)?;
794 } else {
795 out.write_str("null")?;
796 }
797 }
798 out.write_str(")")
799}
800
801fn write_java_shown(out: &mut dyn std::fmt::Write, value: &AttributeValue) -> std::fmt::Result {
802 let byte_buffer = |out: &mut dyn std::fmt::Write, bytes: &[u8]| {
803 write!(
804 out,
805 "java.nio.HeapByteBuffer[pos=0 lim={len} cap={len}]",
806 len = bytes.len()
807 )
808 };
809 match value {
810 AttributeValue::S(s) => out.write_str(s),
811 AttributeValue::N(n) => out.write_str(n),
812 AttributeValue::B(b) => byte_buffer(out, b),
813 AttributeValue::SS(set) | AttributeValue::NS(set) => {
814 out.write_str("[")?;
815 for (i, member) in set.iter().enumerate() {
816 if i > 0 {
817 out.write_str(", ")?;
818 }
819 out.write_str(member)?;
820 }
821 out.write_str("]")
822 }
823 AttributeValue::BS(set) => {
824 out.write_str("[")?;
825 for (i, member) in set.iter().enumerate() {
826 if i > 0 {
827 out.write_str(", ")?;
828 }
829 byte_buffer(out, member)?;
830 }
831 out.write_str("]")
832 }
833 AttributeValue::M(map) => write_java_item(out, map),
834 AttributeValue::L(list) => {
835 out.write_str("[")?;
836 for (i, member) in list.iter().enumerate() {
837 if i > 0 {
838 out.write_str(", ")?;
839 }
840 write_java_attribute_value(out, member)?;
841 }
842 out.write_str("]")
843 }
844 AttributeValue::NULL(null) => write!(out, "{null}"),
845 AttributeValue::BOOL(b) => write!(out, "{b}"),
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::{BoundedEcho, ECHO_LIMIT, bound_echo};
852 use crate::actions::{batch_write_item, create_table};
853 use crate::storage::Storage;
854 use crate::storage_backend::StorageBackend;
855 use std::fmt::Write;
856
857 #[test]
860 fn an_echo_of_exactly_the_limit_is_untouched() {
861 let echo = "a".repeat(ECHO_LIMIT);
862 assert_eq!(bound_echo(echo.clone()), echo);
863 assert_eq!(bound_echo("short".to_string()), "short");
864 }
865
866 #[test]
868 fn an_echo_one_byte_over_the_limit_is_cut_and_marked() {
869 let cut = bound_echo("a".repeat(ECHO_LIMIT + 1));
870 assert_eq!(cut.len(), ECHO_LIMIT + 3);
871 assert!(cut.ends_with("a..."));
872 }
873
874 #[test]
878 fn an_echo_is_never_cut_inside_a_character() {
879 for (filler, ch) in [(ECHO_LIMIT - 1, "é"), (ECHO_LIMIT - 2, "😀")] {
880 let echo = format!("{}{ch}tail", "a".repeat(filler));
881 let cut = bound_echo(echo);
882 assert_eq!(cut, format!("{}...", "a".repeat(filler)), "cut at {ch}");
883
884 let mut pieces = BoundedEcho::new();
885 for _ in 0..filler {
886 let _ = pieces.write_str("a");
887 }
888 assert!(pieces.write_str(ch).is_err(), "{ch} did not fill the echo");
889 assert!(pieces.write_str("more").is_err(), "a full echo took more");
890 assert_eq!(pieces.finish(), format!("{}...", "a".repeat(filler)));
891 }
892 }
893
894 #[test]
897 fn batch_put_rolls_back_base_write_when_gsi_fan_out_fails() {
898 let storage = Storage::memory().unwrap();
899
900 let create = serde_json::from_value(serde_json::json!({
901 "TableName": "Orders",
902 "KeySchema": [{"AttributeName": "UserId", "KeyType": "HASH"}],
903 "AttributeDefinitions": [
904 {"AttributeName": "UserId", "AttributeType": "S"},
905 {"AttributeName": "Status", "AttributeType": "S"},
906 {"AttributeName": "Priority", "AttributeType": "S"}
907 ],
908 "GlobalSecondaryIndexes": [
909 {"IndexName": "StatusIndex", "KeySchema": [{"AttributeName": "Status", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}},
910 {"IndexName": "PriorityIndex", "KeySchema": [{"AttributeName": "Priority", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}
911 ]
912 }))
913 .unwrap();
914 pollster::block_on(create_table::execute(&storage, create)).unwrap();
915
916 storage.drop_gsi_table("Orders", "PriorityIndex").unwrap();
918
919 let batch = serde_json::from_value(serde_json::json!({
920 "RequestItems": {
921 "Orders": [
922 {"PutRequest": {"Item": {"UserId": {"S": "u1"}, "Status": {"S": "SHIPPED"}, "Priority": {"S": "HIGH"}}}}
923 ]
924 }
925 }))
926 .unwrap();
927 let res = pollster::block_on(batch_write_item::execute(&storage, batch));
928 assert!(
929 res.is_err(),
930 "a mid-fan-out failure must surface as an error"
931 );
932
933 let count =
935 pollster::block_on(<Storage as StorageBackend>::count_items(&storage, "Orders"))
936 .unwrap();
937 assert_eq!(
938 count, 0,
939 "batch put base write must roll back when fan-out fails"
940 );
941 }
942}