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/// DynamoDB caps document nesting at 32 levels, counting the top-level attribute
313/// value as level 1. Values are validated 0-indexed (top-level = depth 0), so the
314/// deepest permitted leaf sits at depth 31 and a value reaching depth 32 is rejected.
315const MAX_NESTING_DEPTH: usize = 32;
316
317/// Real DynamoDB's verbatim message when document nesting exceeds the limit. Shared
318/// by the stored-item and ExpressionAttributeValue checks so both match AWS.
319const NESTING_LIMIT_MESSAGE: &str = "Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit";
320
321/// Validate all attribute values in an item.
322///
323/// Rejects:
324/// - Empty sets (`{"SS": []}`, `{"NS": []}`, `{"BS": []}`)
325/// - Numbers that violate DynamoDB's precision/range constraints
326/// - Nesting deeper than 32 levels
327///
328/// Validation is recursive: invalid values nested inside L (list) or M (map) are also rejected.
329///
330/// **Note:** Empty strings (`{"S": ""}`) and empty binary values (`{"B": ""}`) are
331/// permitted in non-key attributes since DynamoDB's 2020 update. Key attributes
332/// are validated separately in `helpers::validate_key_type`.
333///
334/// **Important:** This must only be called on items being persisted, NOT on
335/// `ExpressionAttributeValues` (which may legitimately contain empty strings for comparisons).
336pub fn validate_item_attribute_values(item: &Item) -> Result<()> {
337    for value in item.values() {
338        validate_attribute_value(value, 0)?;
339    }
340    Ok(())
341}
342
343fn validate_attribute_value(value: &AttributeValue, depth: usize) -> Result<()> {
344    if depth >= MAX_NESTING_DEPTH {
345        return Err(DynoxideError::ValidationException(
346            NESTING_LIMIT_MESSAGE.to_string(),
347        ));
348    }
349    match value {
350        AttributeValue::SS(set) if set.is_empty() => Err(DynoxideError::ValidationException(
351            "One or more parameter values were invalid: An string set  may not be empty"
352                .to_string(),
353        )),
354        AttributeValue::NS(set) if set.is_empty() => Err(DynoxideError::ValidationException(
355            "One or more parameter values were invalid: An number set  may not be empty"
356                .to_string(),
357        )),
358        AttributeValue::BS(set) if set.is_empty() => Err(DynoxideError::ValidationException(
359            "One or more parameter values were invalid: Binary sets should not be empty"
360                .to_string(),
361        )),
362        AttributeValue::SS(set) if !set.is_empty() => {
363            let mut seen = std::collections::HashSet::new();
364            for s in set {
365                if !seen.insert(s.clone()) {
366                    let display: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
367                    return Err(DynoxideError::ValidationException(format!(
368                        "One or more parameter values were invalid: Input collection [{}] contains duplicates.",
369                        display.join(", ")
370                    )));
371                }
372            }
373            Ok(())
374        }
375        AttributeValue::BS(set) if !set.is_empty() => {
376            let mut seen = std::collections::HashSet::new();
377            for b in set {
378                if !seen.insert(b.clone()) {
379                    use base64::Engine;
380                    let display: Vec<String> = set
381                        .iter()
382                        .map(|s| base64::engine::general_purpose::STANDARD.encode(s))
383                        .collect();
384                    return Err(DynoxideError::ValidationException(format!(
385                        "One or more parameter values were invalid: Input collection [{}]of type BS contains duplicates.",
386                        display.join(", ")
387                    )));
388                }
389            }
390            Ok(())
391        }
392        AttributeValue::NS(set) if !set.is_empty() => {
393            for n in set {
394                crate::types::validate_dynamo_number(n)?;
395            }
396            // Check for numeric duplicates
397            let mut seen = std::collections::HashSet::new();
398            for n in set {
399                let normalized = crate::types::normalize_dynamo_number(n);
400                if !seen.insert(normalized) {
401                    return Err(DynoxideError::ValidationException(
402                        "Input collection contains duplicates".to_string(),
403                    ));
404                }
405            }
406            Ok(())
407        }
408        AttributeValue::N(n) => {
409            crate::types::validate_dynamo_number(n)?;
410            Ok(())
411        }
412        AttributeValue::L(list) => {
413            for v in list {
414                validate_attribute_value(v, depth + 1)?;
415            }
416            Ok(())
417        }
418        AttributeValue::M(map) => {
419            for v in map.values() {
420                validate_attribute_value(v, depth + 1)?;
421            }
422            Ok(())
423        }
424        _ => Ok(()),
425    }
426}
427
428/// Validate that a single `ExpressionAttributeValue` does not nest deeper than
429/// DynamoDB allows.
430///
431/// Real DynamoDB rejects expression values whose document nesting exceeds 32 levels
432/// up front, before the expression is evaluated, raising the same bare nesting
433/// `ValidationException` it raises for over-deep stored items (no "ExpressionAttributeValues
434/// contains invalid value" wrapper). Only the nesting depth is checked here; empty
435/// strings and other shapes that are legal in comparisons are left untouched.
436pub fn validate_nesting_depth(value: &AttributeValue) -> Result<()> {
437    check_nesting_depth(value, 0)
438}
439
440fn check_nesting_depth(value: &AttributeValue, depth: usize) -> Result<()> {
441    if depth >= MAX_NESTING_DEPTH {
442        return Err(DynoxideError::ValidationException(
443            NESTING_LIMIT_MESSAGE.to_string(),
444        ));
445    }
446    match value {
447        AttributeValue::L(list) => list
448            .iter()
449            .try_for_each(|v| check_nesting_depth(v, depth + 1)),
450        AttributeValue::M(map) => map
451            .values()
452            .try_for_each(|v| check_nesting_depth(v, depth + 1)),
453        _ => Ok(()),
454    }
455}
456
457/// Validate Key attribute values before table-level checks.
458///
459/// This validates the attribute values in a Key map for:
460/// - Invalid/empty numbers
461/// - Empty sets, duplicate sets
462/// - Multiple datatypes
463///
464/// These errors are returned with "One or more parameter values were invalid: " prefix.
465pub fn validate_key_attribute_values(key: &Item) -> Result<()> {
466    for value in key.values() {
467        validate_key_attr_value(value)?;
468    }
469    Ok(())
470}
471
472fn validate_key_attr_value(value: &AttributeValue) -> Result<()> {
473    match value {
474        AttributeValue::SS(set) if set.is_empty() => {
475            return Err(DynoxideError::ValidationException(
476                "One or more parameter values were invalid: An string set  may not be empty"
477                    .to_string(),
478            ));
479        }
480        AttributeValue::NS(set) if set.is_empty() => {
481            return Err(DynoxideError::ValidationException(
482                "One or more parameter values were invalid: An number set  may not be empty"
483                    .to_string(),
484            ));
485        }
486        AttributeValue::BS(set) if set.is_empty() => {
487            return Err(DynoxideError::ValidationException(
488                "One or more parameter values were invalid: Binary sets should not be empty"
489                    .to_string(),
490            ));
491        }
492        AttributeValue::SS(set) => {
493            // Check for duplicates
494            let mut seen = std::collections::HashSet::new();
495            for s in set {
496                if !seen.insert(s.clone()) {
497                    let display: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
498                    return Err(DynoxideError::ValidationException(format!(
499                        "One or more parameter values were invalid: \
500                         Input collection [{}] contains duplicates.",
501                        display.join(", ")
502                    )));
503                }
504            }
505        }
506        AttributeValue::NS(set) if !set.is_empty() => {
507            // Validate numbers and check for duplicates
508            for n in set {
509                crate::types::validate_dynamo_number(n)?;
510            }
511            let mut seen = std::collections::HashSet::new();
512            for n in set {
513                let normalized = crate::types::normalize_dynamo_number(n);
514                if !seen.insert(normalized) {
515                    return Err(DynoxideError::ValidationException(
516                        "Input collection contains duplicates".to_string(),
517                    ));
518                }
519            }
520        }
521        AttributeValue::BS(set) => {
522            // Check for duplicates
523            let mut seen = std::collections::HashSet::new();
524            for b in set {
525                if !seen.insert(b.clone()) {
526                    use base64::Engine;
527                    let display: Vec<String> = set
528                        .iter()
529                        .map(|s| base64::engine::general_purpose::STANDARD.encode(s))
530                        .collect();
531                    return Err(DynoxideError::ValidationException(format!(
532                        "One or more parameter values were invalid: \
533                         Input collection [{}]of type BS contains duplicates.",
534                        display.join(", ")
535                    )));
536                }
537            }
538        }
539        AttributeValue::N(n) => {
540            crate::types::validate_dynamo_number(n)?;
541        }
542        _ => {}
543    }
544    Ok(())
545}
546
547/// Normalize sets within an item by deduplicating them in-place.
548///
549/// - SS: deduplicates by string value
550/// - NS: deduplicates by numeric value (e.g., "1.0" and "1" are the same)
551/// - BS: deduplicates by byte content
552///
553/// Recursively normalizes sets inside L (list) and M (map) values.
554pub fn normalize_item_sets(item: &mut Item) {
555    for value in item.values_mut() {
556        normalize_attribute_sets(value);
557    }
558}
559
560fn normalize_attribute_sets(value: &mut AttributeValue) {
561    match value {
562        AttributeValue::N(n) => {
563            *n = crate::types::normalize_dynamo_number(n);
564        }
565        AttributeValue::SS(set) => {
566            let mut seen = std::collections::HashSet::new();
567            set.retain(|s| seen.insert(s.clone()));
568        }
569        AttributeValue::NS(set) => {
570            let mut seen = std::collections::HashSet::new();
571            set.retain(|n| seen.insert(normalize_number_for_dedup(n)));
572            // Normalize each number in the set
573            for n in set.iter_mut() {
574                *n = crate::types::normalize_dynamo_number(n);
575            }
576        }
577        AttributeValue::BS(set) => {
578            let mut seen = std::collections::HashSet::new();
579            set.retain(|b| seen.insert(b.clone()));
580        }
581        AttributeValue::L(list) => {
582            for v in list.iter_mut() {
583                normalize_attribute_sets(v);
584            }
585        }
586        AttributeValue::M(map) => {
587            for v in map.values_mut() {
588                normalize_attribute_sets(v);
589            }
590        }
591        _ => {}
592    }
593}
594
595/// Produce a canonical string for a DynamoDB number for deduplication purposes.
596/// Strips leading/trailing zeros and normalizes to a canonical form so that
597/// "1.0", "1", "1.00", "01" all map to the same string.
598fn normalize_number_for_dedup(n: &str) -> String {
599    let trimmed = n.trim();
600    let negative = trimmed.starts_with('-');
601    let abs_str = if negative { &trimmed[1..] } else { trimmed };
602
603    let (digits, exponent) = crate::types::parse_number_parts(abs_str);
604
605    if digits.is_empty() {
606        return "0".to_string();
607    }
608
609    let mantissa: String = digits.iter().map(|&d| (b'0' + d) as char).collect();
610    let sign = if negative { "-" } else { "" };
611    format!("{sign}{mantissa}E{exponent}")
612}
613
614/// Validate a Local Secondary Index definition.
615pub fn validate_lsi(
616    lsi: &crate::types::LocalSecondaryIndex,
617    table_key_schema: &[KeySchemaElement],
618    all_definitions: &[AttributeDefinition],
619) -> Result<()> {
620    // Validate index name length
621    if lsi.index_name.len() < 3 || lsi.index_name.len() > 255 {
622        return Err(DynoxideError::ValidationException(format!(
623            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
624             failed to satisfy constraint: Member must have length greater than or equal to 3",
625            lsi.index_name
626        )));
627    }
628
629    // Validate index name character set
630    if !lsi
631        .index_name
632        .chars()
633        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
634    {
635        return Err(DynoxideError::ValidationException(format!(
636            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
637             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
638            lsi.index_name
639        )));
640    }
641
642    // Validate key schema
643    validate_key_schema(&lsi.key_schema)?;
644
645    // Validate projection (DynamoDB checks this before hash key / sort key checks)
646    validate_projection(&lsi.projection, &lsi.index_name)?;
647
648    // LSI must have a RANGE key (sort key)
649    let lsi_pk = lsi
650        .key_schema
651        .iter()
652        .find(|k| k.key_type == KeyType::HASH)
653        .map(|k| k.attribute_name.as_str());
654    let lsi_sk = lsi
655        .key_schema
656        .iter()
657        .find(|k| k.key_type == KeyType::RANGE)
658        .map(|k| k.attribute_name.as_str());
659
660    let table_pk = partition_key_name(table_key_schema);
661    let table_sk = sort_key_name(table_key_schema);
662
663    // LSI partition key MUST match table partition key
664    if lsi_pk != table_pk {
665        return Err(DynoxideError::ValidationException(
666            "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(),
667        ));
668    }
669
670    // LSI sort key must be different from table sort key
671    if lsi_sk.is_some() && lsi_sk == table_sk {
672        return Err(DynoxideError::ValidationException(
673            "One or more parameter values were invalid: Index KeySchema: The index KeySchema must not be the same as the table KeySchema".to_string(),
674        ));
675    }
676
677    // LSI sort key must be in AttributeDefinitions
678    validate_key_attributes_in_definitions(&lsi.key_schema, all_definitions)?;
679
680    Ok(())
681}
682
683/// Extract the sort key name from a key schema (if present).
684pub fn sort_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
685    key_schema
686        .iter()
687        .find(|k| k.key_type == KeyType::RANGE)
688        .map(|k| k.attribute_name.as_str())
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    fn hash_key(name: &str) -> KeySchemaElement {
696        KeySchemaElement {
697            attribute_name: name.to_string(),
698            key_type: KeyType::HASH,
699        }
700    }
701
702    fn range_key(name: &str) -> KeySchemaElement {
703        KeySchemaElement {
704            attribute_name: name.to_string(),
705            key_type: KeyType::RANGE,
706        }
707    }
708
709    fn attr_def(name: &str, attr_type: ScalarAttributeType) -> AttributeDefinition {
710        AttributeDefinition {
711            attribute_name: name.to_string(),
712            attribute_type: attr_type,
713        }
714    }
715
716    #[test]
717    fn test_valid_table_name() {
718        assert!(validate_table_name("MyTable").is_ok());
719        assert!(validate_table_name("my-table.v2").is_ok());
720        assert!(validate_table_name("a_b").is_ok());
721    }
722
723    #[test]
724    fn test_short_table_name_accepted_for_read_write() {
725        // ReadWrite context (the default for validate_table_name) only enforces min length 1,
726        // matching AWS's per-operation rules. CreateTable's min-length-3 lives behind
727        // table_name_constraint_errors with TableNameContext::CreateTable.
728        assert!(validate_table_name("ab").is_ok());
729        assert!(validate_table_name("a").is_ok());
730    }
731
732    #[test]
733    fn test_empty_table_name_rejected_for_read_write() {
734        let err = validate_table_name("").unwrap_err().to_string();
735        assert!(err.contains("Member must have length greater than or equal to 1"));
736        assert!(!err.contains("greater than or equal to 3"));
737    }
738
739    #[test]
740    fn test_invalid_table_name_bad_chars() {
741        assert!(validate_table_name("my table").is_err());
742        assert!(validate_table_name("my@table").is_err());
743    }
744
745    #[test]
746    fn test_create_table_context_keeps_min_length_3() {
747        let errs = table_name_constraint_errors(Some("ab"), TableNameContext::CreateTable);
748        assert!(
749            errs.iter()
750                .any(|e| e.contains("Member must have length greater than or equal to 3"))
751        );
752    }
753
754    #[test]
755    fn test_valid_key_schema() {
756        let schema = vec![hash_key("pk")];
757        assert!(validate_key_schema(&schema).is_ok());
758
759        let schema = vec![hash_key("pk"), range_key("sk")];
760        assert!(validate_key_schema(&schema).is_ok());
761    }
762
763    #[test]
764    fn test_invalid_key_schema_empty() {
765        assert!(validate_key_schema(&[]).is_err());
766    }
767
768    #[test]
769    fn test_invalid_key_schema_no_hash() {
770        let schema = vec![range_key("sk")];
771        assert!(validate_key_schema(&schema).is_err());
772    }
773
774    #[test]
775    fn test_valid_key_attributes_in_definitions() {
776        let schema = vec![hash_key("pk"), range_key("sk")];
777        let defs = vec![
778            attr_def("pk", ScalarAttributeType::S),
779            attr_def("sk", ScalarAttributeType::N),
780        ];
781        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_ok());
782    }
783
784    #[test]
785    fn test_missing_key_attribute_in_definitions() {
786        let schema = vec![hash_key("pk"), range_key("sk")];
787        let defs = vec![attr_def("pk", ScalarAttributeType::S)];
788        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_err());
789    }
790
791    #[test]
792    fn test_partition_key_name() {
793        let schema = vec![hash_key("pk"), range_key("sk")];
794        assert_eq!(partition_key_name(&schema), Some("pk"));
795    }
796
797    #[test]
798    fn test_sort_key_name() {
799        let schema = vec![hash_key("pk"), range_key("sk")];
800        assert_eq!(sort_key_name(&schema), Some("sk"));
801
802        let schema = vec![hash_key("pk")];
803        assert_eq!(sort_key_name(&schema), None);
804    }
805}