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