Skip to main content

dynoxide/
validation.rs

1use crate::errors::{DynoxideError, Result};
2use crate::types::{
3    AttributeDefinition, AttributeValue, GlobalSecondaryIndex, Item, KeySchemaElement, KeyType,
4    ScalarAttributeType,
5};
6
7/// Per-operation context for table-name validation.
8///
9/// AWS DynamoDB applies different constraints to `tableName` depending on the
10/// operation. CreateTable enforces a minimum of 3 characters and a regex
11/// pattern. Read/write operations (PutItem, GetItem, Query, Scan, UpdateItem,
12/// DeleteItem, BatchGet/Write, TransactGet/Write) only enforce a minimum of 1
13/// character; the regex pattern only fires on a non-empty invalid name.
14#[derive(Copy, Clone, Debug)]
15pub enum TableNameContext {
16    /// CreateTable: regex pattern + minimum length 3.
17    CreateTable,
18    /// PutItem and friends: minimum length 1, regex pattern only on non-empty input.
19    ReadWrite,
20}
21
22/// Validate a DynamoDB table name for a read/write operation.
23///
24/// Equivalent to `table_name_constraint_errors(Some(name), TableNameContext::ReadWrite)`
25/// followed by formatting into the multi-error envelope. CreateTable callers must use
26/// `table_name_constraint_errors` directly with `TableNameContext::CreateTable` because
27/// CreateTable's full validation produces additional errors that need to be folded into
28/// a single envelope.
29pub fn validate_table_name(name: &str) -> Result<()> {
30    let errors = table_name_constraint_errors(Some(name), TableNameContext::ReadWrite);
31    if errors.is_empty() {
32        return Ok(());
33    }
34    let count = errors.len();
35    let msg = format!(
36        "{count} validation error{} detected: {}",
37        if count == 1 { "" } else { "s" },
38        errors.join("; ")
39    );
40    Err(DynoxideError::ValidationException(msg))
41}
42
43/// Collect table-name constraint errors for the multi-error validation format.
44///
45/// Returns a (possibly empty) list of error strings. If `table_name` is `None`,
46/// a "must not be null" error is emitted. If it is present but invalid, pattern
47/// and/or length errors are emitted, gated by `context`.
48pub fn table_name_constraint_errors(
49    table_name: Option<&str>,
50    context: TableNameContext,
51) -> Vec<String> {
52    let mut errors = Vec::new();
53    match table_name {
54        None => {
55            errors.push(
56                "Value null at 'tableName' failed to satisfy constraint: \
57                 Member must not be null"
58                    .to_string(),
59            );
60        }
61        Some(name) => match context {
62            TableNameContext::CreateTable => {
63                if name.is_empty()
64                    || !name
65                        .chars()
66                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
67                {
68                    errors.push(format!(
69                        "Value '{}' at 'tableName' failed to satisfy constraint: \
70                         Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
71                        name
72                    ));
73                }
74                if name.len() < 3 {
75                    errors.push(format!(
76                        "Value '{}' at 'tableName' failed to satisfy constraint: \
77                         Member must have length greater than or equal to 3",
78                        name
79                    ));
80                }
81                if name.len() > 255 {
82                    errors.push(format!(
83                        "Value '{}' at 'tableName' failed to satisfy constraint: \
84                         Member must have length less than or equal to 255",
85                        name
86                    ));
87                }
88            }
89            TableNameContext::ReadWrite => {
90                if name.is_empty() {
91                    errors.push(
92                        "Value '' at 'tableName' failed to satisfy constraint: \
93                         Member must have length greater than or equal to 1"
94                            .to_string(),
95                    );
96                } else if !name
97                    .chars()
98                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
99                {
100                    errors.push(format!(
101                        "Value '{}' at 'tableName' failed to satisfy constraint: \
102                         Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
103                        name
104                    ));
105                }
106                if name.len() > 255 {
107                    errors.push(format!(
108                        "Value '{}' at 'tableName' failed to satisfy constraint: \
109                         Member must have length less than or equal to 255",
110                        name
111                    ));
112                }
113            }
114        },
115    }
116    errors
117}
118
119/// Format a list of constraint validation errors into the DynamoDB multi-error format.
120///
121/// Returns `Some(message)` if there are errors, `None` if empty.
122pub fn format_validation_errors(errors: &[String]) -> Option<String> {
123    if errors.is_empty() {
124        return None;
125    }
126    let prefix = format!(
127        "{} validation error{} detected: ",
128        errors.len(),
129        if errors.len() == 1 { "" } else { "s" }
130    );
131    Some(format!("{}{}", prefix, errors.join("; ")))
132}
133
134/// Validate key schema: exactly one HASH key, optionally one RANGE key.
135///
136/// DynamoDB validates positionally: the first element must be HASH and, if a
137/// second element is present, it must be RANGE.
138pub fn validate_key_schema(key_schema: &[KeySchemaElement]) -> Result<()> {
139    if key_schema.is_empty() || key_schema.len() > 2 {
140        return Err(DynoxideError::ValidationException(
141            "1 validation error detected: Value null at 'keySchema' failed to satisfy constraint: \
142             Member must have length less than or equal to 2"
143                .to_string(),
144        ));
145    }
146
147    // First element must be HASH.
148    if key_schema[0].key_type != KeyType::HASH {
149        return Err(DynoxideError::ValidationException(
150            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
151        ));
152    }
153
154    // Check for duplicate attribute names (before type check, matching DynamoDB ordering).
155    if key_schema.len() == 2 && key_schema[0].attribute_name == key_schema[1].attribute_name {
156        return Err(DynoxideError::ValidationException(
157            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
158                .to_string(),
159        ));
160    }
161
162    // Second element, if present, must be RANGE.
163    if key_schema.len() == 2 && key_schema[1].key_type != KeyType::RANGE {
164        return Err(DynoxideError::ValidationException(
165            "Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
166        ));
167    }
168
169    Ok(())
170}
171
172/// Validate attribute definitions: types must be S, N, or B.
173pub fn validate_attribute_definitions(defs: &[AttributeDefinition]) -> Result<()> {
174    if defs.is_empty() {
175        return Err(DynoxideError::ValidationException(
176            "1 validation error detected: Value null at 'attributeDefinitions' failed to satisfy \
177             constraint: Member must have length greater than or equal to 1"
178                .to_string(),
179        ));
180    }
181
182    for def in defs {
183        match def.attribute_type {
184            ScalarAttributeType::S | ScalarAttributeType::N | ScalarAttributeType::B => {}
185        }
186    }
187
188    Ok(())
189}
190
191/// Validate that all key schema attributes are defined in attribute definitions.
192pub fn validate_key_attributes_in_definitions(
193    key_schema: &[KeySchemaElement],
194    definitions: &[AttributeDefinition],
195) -> Result<()> {
196    for key_elem in key_schema {
197        let found = definitions
198            .iter()
199            .any(|def| def.attribute_name == key_elem.attribute_name);
200        if !found {
201            return Err(DynoxideError::ValidationException(format!(
202                "One or more parameter values were invalid: Some index key attributes are not \
203                 defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
204                key_elem.attribute_name,
205                definitions
206                    .iter()
207                    .map(|d| d.attribute_name.as_str())
208                    .collect::<Vec<_>>()
209                    .join(", ")
210            )));
211        }
212    }
213
214    Ok(())
215}
216
217/// Validate a Global Secondary Index definition.
218pub fn validate_gsi(
219    gsi: &GlobalSecondaryIndex,
220    all_definitions: &[AttributeDefinition],
221) -> Result<()> {
222    // Validate index name length
223    if gsi.index_name.len() < 3 || gsi.index_name.len() > 255 {
224        return Err(DynoxideError::ValidationException(format!(
225            "1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
226             failed to satisfy constraint: Member must have length greater than or equal to 3",
227            gsi.index_name
228        )));
229    }
230
231    // Validate index name character set (same as table names)
232    if !gsi
233        .index_name
234        .chars()
235        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
236    {
237        return Err(DynoxideError::ValidationException(format!(
238            "1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
239             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
240            gsi.index_name
241        )));
242    }
243
244    // Validate key schema
245    validate_key_schema(&gsi.key_schema)?;
246
247    // Validate projection
248    validate_projection(&gsi.projection, &gsi.index_name)?;
249
250    // Validate GSI key attributes exist in definitions
251    validate_key_attributes_in_definitions(&gsi.key_schema, all_definitions)?;
252
253    Ok(())
254}
255
256/// Validate a Projection (for GSI or LSI).
257///
258/// DynamoDB checks:
259/// 1. ProjectionType must be present (not null)
260/// 2. If NonKeyAttributes is specified, ProjectionType must be INCLUDE
261pub fn validate_projection(projection: &crate::types::Projection, _index_name: &str) -> Result<()> {
262    match &projection.projection_type {
263        None => {
264            return Err(DynoxideError::ValidationException(
265                "One or more parameter values were invalid: Unknown ProjectionType: null"
266                    .to_string(),
267            ));
268        }
269        Some(pt) => {
270            if let Some(ref nka) = projection.non_key_attributes {
271                // NonKeyAttributes is present; check ProjectionType compatibility
272                match pt {
273                    crate::types::ProjectionType::ALL => {
274                        return Err(DynoxideError::ValidationException(
275                            "One or more parameter values were invalid: \
276                             ProjectionType is ALL, but NonKeyAttributes is specified"
277                                .to_string(),
278                        ));
279                    }
280                    crate::types::ProjectionType::KEYS_ONLY => {
281                        return Err(DynoxideError::ValidationException(
282                            "One or more parameter values were invalid: \
283                             ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified"
284                                .to_string(),
285                        ));
286                    }
287                    crate::types::ProjectionType::INCLUDE => {
288                        // NonKeyAttributes with INCLUDE is valid, but must not be empty
289                        if nka.is_empty() {
290                            return Err(DynoxideError::ValidationException(
291                                "One or more parameter values were invalid: \
292                                 NonKeyAttributes must not be empty"
293                                    .to_string(),
294                            ));
295                        }
296                    }
297                }
298            }
299        }
300    }
301    Ok(())
302}
303
304/// Extract the partition key name from a key schema.
305pub fn partition_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
306    key_schema
307        .iter()
308        .find(|k| k.key_type == KeyType::HASH)
309        .map(|k| k.attribute_name.as_str())
310}
311
312/// Maximum nesting depth for item attribute values (DynamoDB's limit).
313const MAX_NESTING_DEPTH: usize = 32;
314
315/// Validate all attribute values in an item.
316///
317/// Rejects:
318/// - Empty sets (`{"SS": []}`, `{"NS": []}`, `{"BS": []}`)
319/// - Numbers that violate DynamoDB's precision/range constraints
320/// - Nesting deeper than 32 levels
321///
322/// Validation is recursive: invalid values nested inside L (list) or M (map) are also rejected.
323///
324/// **Note:** Empty strings (`{"S": ""}`) and empty binary values (`{"B": ""}`) are
325/// permitted in non-key attributes since DynamoDB's 2020 update. Key attributes
326/// are validated separately in `helpers::validate_key_type`.
327///
328/// **Important:** This must only be called on items being persisted, NOT on
329/// `ExpressionAttributeValues` (which may legitimately contain empty strings for comparisons).
330pub fn validate_item_attribute_values(item: &Item) -> Result<()> {
331    for value in item.values() {
332        validate_attribute_value(value, 0)?;
333    }
334    Ok(())
335}
336
337fn validate_attribute_value(value: &AttributeValue, depth: usize) -> Result<()> {
338    if depth > MAX_NESTING_DEPTH {
339        return Err(DynoxideError::ValidationException(
340            "Nesting level exceeds limit of 32".to_string(),
341        ));
342    }
343    match value {
344        AttributeValue::NULL(b) if !b => Err(DynoxideError::ValidationException(
345            "One or more parameter values were invalid: \
346             Null attribute value types must have the value of true"
347                .to_string(),
348        )),
349        AttributeValue::SS(set) if set.is_empty() => Err(DynoxideError::ValidationException(
350            "One or more parameter values were invalid: An string set  may not be empty"
351                .to_string(),
352        )),
353        AttributeValue::NS(set) if set.is_empty() => Err(DynoxideError::ValidationException(
354            "One or more parameter values were invalid: An number set  may not be empty"
355                .to_string(),
356        )),
357        AttributeValue::BS(set) if set.is_empty() => Err(DynoxideError::ValidationException(
358            "One or more parameter values were invalid: Binary sets should not be empty"
359                .to_string(),
360        )),
361        AttributeValue::SS(set) if !set.is_empty() => {
362            let mut seen = std::collections::HashSet::new();
363            for s in set {
364                if !seen.insert(s.clone()) {
365                    let display: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
366                    return Err(DynoxideError::ValidationException(format!(
367                        "One or more parameter values were invalid: Input collection [{}] contains duplicates.",
368                        display.join(", ")
369                    )));
370                }
371            }
372            Ok(())
373        }
374        AttributeValue::BS(set) if !set.is_empty() => {
375            let mut seen = std::collections::HashSet::new();
376            for b in set {
377                if !seen.insert(b.clone()) {
378                    use base64::Engine;
379                    let display: Vec<String> = set
380                        .iter()
381                        .map(|s| base64::engine::general_purpose::STANDARD.encode(s))
382                        .collect();
383                    return Err(DynoxideError::ValidationException(format!(
384                        "One or more parameter values were invalid: Input collection [{}]of type BS contains duplicates.",
385                        display.join(", ")
386                    )));
387                }
388            }
389            Ok(())
390        }
391        AttributeValue::NS(set) if !set.is_empty() => {
392            for n in set {
393                crate::types::validate_dynamo_number(n)?;
394            }
395            // Check for numeric duplicates
396            let mut seen = std::collections::HashSet::new();
397            for n in set {
398                let normalized = crate::types::normalize_dynamo_number(n);
399                if !seen.insert(normalized) {
400                    return Err(DynoxideError::ValidationException(
401                        "Input collection contains duplicates".to_string(),
402                    ));
403                }
404            }
405            Ok(())
406        }
407        AttributeValue::N(n) => {
408            crate::types::validate_dynamo_number(n)?;
409            Ok(())
410        }
411        AttributeValue::L(list) => {
412            for v in list {
413                validate_attribute_value(v, depth + 1)?;
414            }
415            Ok(())
416        }
417        AttributeValue::M(map) => {
418            for v in map.values() {
419                validate_attribute_value(v, depth + 1)?;
420            }
421            Ok(())
422        }
423        _ => Ok(()),
424    }
425}
426
427/// Validate Key attribute values before table-level checks.
428///
429/// This validates the attribute values in a Key map for:
430/// - Invalid/empty numbers
431/// - Empty sets, duplicate sets
432/// - NULL attribute with non-true value
433/// - Multiple datatypes
434///
435/// These errors are returned with "One or more parameter values were invalid: " prefix.
436pub fn validate_key_attribute_values(key: &Item) -> Result<()> {
437    for value in key.values() {
438        validate_key_attr_value(value)?;
439    }
440    Ok(())
441}
442
443fn validate_key_attr_value(value: &AttributeValue) -> Result<()> {
444    match value {
445        AttributeValue::NULL(b) if !b => {
446            return Err(DynoxideError::ValidationException(
447                "One or more parameter values were invalid: \
448                 Null attribute value types must have the value of true"
449                    .to_string(),
450            ));
451        }
452        AttributeValue::SS(set) if set.is_empty() => {
453            return Err(DynoxideError::ValidationException(
454                "One or more parameter values were invalid: An string set  may not be empty"
455                    .to_string(),
456            ));
457        }
458        AttributeValue::NS(set) if set.is_empty() => {
459            return Err(DynoxideError::ValidationException(
460                "One or more parameter values were invalid: An number set  may not be empty"
461                    .to_string(),
462            ));
463        }
464        AttributeValue::BS(set) if set.is_empty() => {
465            return Err(DynoxideError::ValidationException(
466                "One or more parameter values were invalid: Binary sets should not be empty"
467                    .to_string(),
468            ));
469        }
470        AttributeValue::SS(set) => {
471            // Check for duplicates
472            let mut seen = std::collections::HashSet::new();
473            for s in set {
474                if !seen.insert(s.clone()) {
475                    let display: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
476                    return Err(DynoxideError::ValidationException(format!(
477                        "One or more parameter values were invalid: \
478                         Input collection [{}] contains duplicates.",
479                        display.join(", ")
480                    )));
481                }
482            }
483        }
484        AttributeValue::NS(set) if !set.is_empty() => {
485            // Validate numbers and check for duplicates
486            for n in set {
487                crate::types::validate_dynamo_number(n)?;
488            }
489            let mut seen = std::collections::HashSet::new();
490            for n in set {
491                let normalized = crate::types::normalize_dynamo_number(n);
492                if !seen.insert(normalized) {
493                    return Err(DynoxideError::ValidationException(
494                        "Input collection contains duplicates".to_string(),
495                    ));
496                }
497            }
498        }
499        AttributeValue::BS(set) => {
500            // Check for duplicates
501            let mut seen = std::collections::HashSet::new();
502            for b in set {
503                if !seen.insert(b.clone()) {
504                    use base64::Engine;
505                    let display: Vec<String> = set
506                        .iter()
507                        .map(|s| base64::engine::general_purpose::STANDARD.encode(s))
508                        .collect();
509                    return Err(DynoxideError::ValidationException(format!(
510                        "One or more parameter values were invalid: \
511                         Input collection [{}]of type BS contains duplicates.",
512                        display.join(", ")
513                    )));
514                }
515            }
516        }
517        AttributeValue::N(n) => {
518            crate::types::validate_dynamo_number(n)?;
519        }
520        _ => {}
521    }
522    Ok(())
523}
524
525/// Normalize sets within an item by deduplicating them in-place.
526///
527/// - SS: deduplicates by string value
528/// - NS: deduplicates by numeric value (e.g., "1.0" and "1" are the same)
529/// - BS: deduplicates by byte content
530///
531/// Recursively normalizes sets inside L (list) and M (map) values.
532pub fn normalize_item_sets(item: &mut Item) {
533    for value in item.values_mut() {
534        normalize_attribute_sets(value);
535    }
536}
537
538fn normalize_attribute_sets(value: &mut AttributeValue) {
539    match value {
540        AttributeValue::N(n) => {
541            *n = crate::types::normalize_dynamo_number(n);
542        }
543        AttributeValue::SS(set) => {
544            let mut seen = std::collections::HashSet::new();
545            set.retain(|s| seen.insert(s.clone()));
546        }
547        AttributeValue::NS(set) => {
548            let mut seen = std::collections::HashSet::new();
549            set.retain(|n| seen.insert(normalize_number_for_dedup(n)));
550            // Normalize each number in the set
551            for n in set.iter_mut() {
552                *n = crate::types::normalize_dynamo_number(n);
553            }
554        }
555        AttributeValue::BS(set) => {
556            let mut seen = std::collections::HashSet::new();
557            set.retain(|b| seen.insert(b.clone()));
558        }
559        AttributeValue::L(list) => {
560            for v in list.iter_mut() {
561                normalize_attribute_sets(v);
562            }
563        }
564        AttributeValue::M(map) => {
565            for v in map.values_mut() {
566                normalize_attribute_sets(v);
567            }
568        }
569        _ => {}
570    }
571}
572
573/// Produce a canonical string for a DynamoDB number for deduplication purposes.
574/// Strips leading/trailing zeros and normalizes to a canonical form so that
575/// "1.0", "1", "1.00", "01" all map to the same string.
576fn normalize_number_for_dedup(n: &str) -> String {
577    let trimmed = n.trim();
578    let negative = trimmed.starts_with('-');
579    let abs_str = if negative { &trimmed[1..] } else { trimmed };
580
581    let (digits, exponent) = crate::types::parse_number_parts(abs_str);
582
583    if digits.is_empty() {
584        return "0".to_string();
585    }
586
587    let mantissa: String = digits.iter().map(|&d| (b'0' + d) as char).collect();
588    let sign = if negative { "-" } else { "" };
589    format!("{sign}{mantissa}E{exponent}")
590}
591
592/// Validate a Local Secondary Index definition.
593pub fn validate_lsi(
594    lsi: &crate::types::LocalSecondaryIndex,
595    table_key_schema: &[KeySchemaElement],
596    all_definitions: &[AttributeDefinition],
597) -> Result<()> {
598    // Validate index name length
599    if lsi.index_name.len() < 3 || lsi.index_name.len() > 255 {
600        return Err(DynoxideError::ValidationException(format!(
601            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
602             failed to satisfy constraint: Member must have length greater than or equal to 3",
603            lsi.index_name
604        )));
605    }
606
607    // Validate index name character set
608    if !lsi
609        .index_name
610        .chars()
611        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
612    {
613        return Err(DynoxideError::ValidationException(format!(
614            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
615             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
616            lsi.index_name
617        )));
618    }
619
620    // Validate key schema
621    validate_key_schema(&lsi.key_schema)?;
622
623    // Validate projection (DynamoDB checks this before hash key / sort key checks)
624    validate_projection(&lsi.projection, &lsi.index_name)?;
625
626    // LSI must have a RANGE key (sort key)
627    let lsi_pk = lsi
628        .key_schema
629        .iter()
630        .find(|k| k.key_type == KeyType::HASH)
631        .map(|k| k.attribute_name.as_str());
632    let lsi_sk = lsi
633        .key_schema
634        .iter()
635        .find(|k| k.key_type == KeyType::RANGE)
636        .map(|k| k.attribute_name.as_str());
637
638    let table_pk = partition_key_name(table_key_schema);
639    let table_sk = sort_key_name(table_key_schema);
640
641    // LSI partition key MUST match table partition key
642    if lsi_pk != table_pk {
643        return Err(DynoxideError::ValidationException(
644            "One or more parameter values were invalid: Table KeySchema: The AttributeValue for a key attribute for the table must match the AttributeValue definition".to_string(),
645        ));
646    }
647
648    // LSI sort key must be different from table sort key
649    if lsi_sk.is_some() && lsi_sk == table_sk {
650        return Err(DynoxideError::ValidationException(
651            "One or more parameter values were invalid: Index KeySchema: The index KeySchema must not be the same as the table KeySchema".to_string(),
652        ));
653    }
654
655    // LSI sort key must be in AttributeDefinitions
656    validate_key_attributes_in_definitions(&lsi.key_schema, all_definitions)?;
657
658    Ok(())
659}
660
661/// Extract the sort key name from a key schema (if present).
662pub fn sort_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
663    key_schema
664        .iter()
665        .find(|k| k.key_type == KeyType::RANGE)
666        .map(|k| k.attribute_name.as_str())
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    fn hash_key(name: &str) -> KeySchemaElement {
674        KeySchemaElement {
675            attribute_name: name.to_string(),
676            key_type: KeyType::HASH,
677        }
678    }
679
680    fn range_key(name: &str) -> KeySchemaElement {
681        KeySchemaElement {
682            attribute_name: name.to_string(),
683            key_type: KeyType::RANGE,
684        }
685    }
686
687    fn attr_def(name: &str, attr_type: ScalarAttributeType) -> AttributeDefinition {
688        AttributeDefinition {
689            attribute_name: name.to_string(),
690            attribute_type: attr_type,
691        }
692    }
693
694    #[test]
695    fn test_valid_table_name() {
696        assert!(validate_table_name("MyTable").is_ok());
697        assert!(validate_table_name("my-table.v2").is_ok());
698        assert!(validate_table_name("a_b").is_ok());
699    }
700
701    #[test]
702    fn test_short_table_name_accepted_for_read_write() {
703        // ReadWrite context (the default for validate_table_name) only enforces min length 1,
704        // matching AWS's per-operation rules. CreateTable's min-length-3 lives behind
705        // table_name_constraint_errors with TableNameContext::CreateTable.
706        assert!(validate_table_name("ab").is_ok());
707        assert!(validate_table_name("a").is_ok());
708    }
709
710    #[test]
711    fn test_empty_table_name_rejected_for_read_write() {
712        let err = validate_table_name("").unwrap_err().to_string();
713        assert!(err.contains("Member must have length greater than or equal to 1"));
714        assert!(!err.contains("greater than or equal to 3"));
715    }
716
717    #[test]
718    fn test_invalid_table_name_bad_chars() {
719        assert!(validate_table_name("my table").is_err());
720        assert!(validate_table_name("my@table").is_err());
721    }
722
723    #[test]
724    fn test_create_table_context_keeps_min_length_3() {
725        let errs = table_name_constraint_errors(Some("ab"), TableNameContext::CreateTable);
726        assert!(
727            errs.iter()
728                .any(|e| e.contains("Member must have length greater than or equal to 3"))
729        );
730    }
731
732    #[test]
733    fn test_valid_key_schema() {
734        let schema = vec![hash_key("pk")];
735        assert!(validate_key_schema(&schema).is_ok());
736
737        let schema = vec![hash_key("pk"), range_key("sk")];
738        assert!(validate_key_schema(&schema).is_ok());
739    }
740
741    #[test]
742    fn test_invalid_key_schema_empty() {
743        assert!(validate_key_schema(&[]).is_err());
744    }
745
746    #[test]
747    fn test_invalid_key_schema_no_hash() {
748        let schema = vec![range_key("sk")];
749        assert!(validate_key_schema(&schema).is_err());
750    }
751
752    #[test]
753    fn test_valid_key_attributes_in_definitions() {
754        let schema = vec![hash_key("pk"), range_key("sk")];
755        let defs = vec![
756            attr_def("pk", ScalarAttributeType::S),
757            attr_def("sk", ScalarAttributeType::N),
758        ];
759        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_ok());
760    }
761
762    #[test]
763    fn test_missing_key_attribute_in_definitions() {
764        let schema = vec![hash_key("pk"), range_key("sk")];
765        let defs = vec![attr_def("pk", ScalarAttributeType::S)];
766        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_err());
767    }
768
769    #[test]
770    fn test_partition_key_name() {
771        let schema = vec![hash_key("pk"), range_key("sk")];
772        assert_eq!(partition_key_name(&schema), Some("pk"));
773    }
774
775    #[test]
776    fn test_sort_key_name() {
777        let schema = vec![hash_key("pk"), range_key("sk")];
778        assert_eq!(sort_key_name(&schema), Some("sk"));
779
780        let schema = vec![hash_key("pk")];
781        assert_eq!(sort_key_name(&schema), None);
782    }
783}