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. `ReadWrite` enforces a minimum of 1 character, with the regex
12/// pattern only firing on a non-empty invalid name. PutItem, GetItem,
13/// UpdateItem, DeleteItem, Query and Scan add a minimum of 3 for a non-empty
14/// name on top of it (see `item_table_name_errors`); the batch and transaction
15/// operations check their table names as members of the request body (see
16/// `member_table_name_errors`). SearchVectors keeps `ReadWrite` as it is: its
17/// short-name answer is uncaptured.
18#[derive(Copy, Clone, Debug)]
19pub enum TableNameContext {
20    /// CreateTable: regex pattern + minimum length 3.
21    CreateTable,
22    /// PutItem and friends: minimum length 1, regex pattern only on non-empty input.
23    ReadWrite,
24}
25
26/// Validate a DynamoDB table name for a read/write operation.
27///
28/// Equivalent to `table_name_constraint_errors(Some(name), TableNameContext::ReadWrite)`
29/// followed by formatting into the multi-error envelope. CreateTable callers must use
30/// `table_name_constraint_errors` directly with `TableNameContext::CreateTable` because
31/// CreateTable's full validation produces additional errors that need to be folded into
32/// a single envelope.
33pub fn validate_table_name(name: &str) -> Result<()> {
34    let errors = table_name_constraint_errors(Some(name), TableNameContext::ReadWrite);
35    if errors.is_empty() {
36        return Ok(());
37    }
38    let count = errors.len();
39    let msg = format!(
40        "{count} validation error{} detected: {}",
41        if count == 1 { "" } else { "s" },
42        errors.join("; ")
43    );
44    Err(DynoxideError::ValidationException(msg))
45}
46
47/// Collect table-name constraint errors for the multi-error validation format.
48///
49/// Returns a (possibly empty) list of error strings. If `table_name` is `None`,
50/// a "must not be null" error is emitted. If it is present but invalid, pattern
51/// and/or length errors are emitted, gated by `context`.
52pub fn table_name_constraint_errors(
53    table_name: Option<&str>,
54    context: TableNameContext,
55) -> Vec<String> {
56    let mut errors = Vec::new();
57    match table_name {
58        None => {
59            errors.push(
60                "Value null at 'tableName' failed to satisfy constraint: \
61                 Member must not be null"
62                    .to_string(),
63            );
64        }
65        Some(name) => match context {
66            TableNameContext::CreateTable => {
67                if name.is_empty()
68                    || !name
69                        .chars()
70                        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
71                {
72                    errors.push(format!(
73                        "Value '{}' at 'tableName' failed to satisfy constraint: \
74                         Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
75                        name
76                    ));
77                }
78                if name.len() < 3 {
79                    errors.push(format!(
80                        "Value '{}' at 'tableName' failed to satisfy constraint: \
81                         Member must have length greater than or equal to 3",
82                        name
83                    ));
84                }
85                if name.len() > 255 {
86                    errors.push(format!(
87                        "Value '{}' at 'tableName' failed to satisfy constraint: \
88                         Member must have length less than or equal to 255",
89                        name
90                    ));
91                }
92            }
93            TableNameContext::ReadWrite => {
94                if name.is_empty() {
95                    errors.push(
96                        "Value '' at 'tableName' failed to satisfy constraint: \
97                         Member must have length greater than or equal to 1"
98                            .to_string(),
99                    );
100                } else if !name
101                    .chars()
102                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
103                {
104                    errors.push(format!(
105                        "Value '{}' at 'tableName' failed to satisfy constraint: \
106                         Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
107                        name
108                    ));
109                }
110                if name.len() > 255 {
111                    errors.push(format!(
112                        "Value '{}' at 'tableName' failed to satisfy constraint: \
113                         Member must have length less than or equal to 255",
114                        name
115                    ));
116                }
117            }
118        },
119    }
120    errors
121}
122
123/// The newer validation layer's answer for an empty table name: no value echo,
124/// the member spelled `TableName`, reported on its own. PutItem, UpdateItem,
125/// DeleteItem, GetItem, Query and SearchVectors give it (captured on
126/// 2026-10-09 in at least five of seven regions for each), and Scan since the
127/// newer layer reached most regions (20 of 33 on 2026-10-20). The older layer
128/// gives the `ReadWrite` form, which a Scan still gets when a missing table
129/// name or operator, or a coerced integer, hands its request to that layer.
130pub(crate) const EMPTY_TABLE_NAME_REJECTION: &str = "Value at 'TableName' failed to satisfy \
131     constraint: Member must have length greater than or equal to 1";
132
133fn short_table_name_error(name: &str) -> String {
134    format!(
135        "Value '{name}' at 'tableName' failed to satisfy constraint: \
136         Member must have length greater than or equal to 3"
137    )
138}
139
140fn long_table_name_error(name: &str) -> String {
141    format!(
142        "Value '{name}' at 'tableName' failed to satisfy constraint: \
143         Member must have length less than or equal to 255"
144    )
145}
146
147fn table_name_pattern_error(name: &str) -> String {
148    format!(
149        "Value '{name}' at 'tableName' failed to satisfy constraint: \
150         Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"
151    )
152}
153
154/// The first constraint a non-empty table name breaks, as the newer layer
155/// reports it: one error per name, the length (3 to 255) before the pattern.
156/// A name both short and malformed (`a!`) gets the length error alone
157/// (captured 2026-10-09 on PutItem, DeleteItem, GetItem, Query and
158/// SearchVectors).
159pub(crate) fn first_table_name_error(name: &str) -> Option<String> {
160    if name.len() < 3 {
161        Some(short_table_name_error(name))
162    } else if name.len() > 255 {
163        Some(long_table_name_error(name))
164    } else if !table_name_pattern_holds(name) {
165        Some(table_name_pattern_error(name))
166    } else {
167        None
168    }
169}
170
171/// The table-name errors for PutItem, DeleteItem, GetItem and Query: a missing
172/// name as `ReadWrite` reports it, an empty one in the newer wording, and
173/// otherwise [`first_table_name_error`]. Callers report the empty name on its
174/// own before anything else; the other errors join the request's member
175/// errors.
176pub(crate) fn item_table_name_errors(table_name: Option<&str>) -> Vec<String> {
177    match table_name {
178        None => table_name_constraint_errors(None, TableNameContext::ReadWrite),
179        Some("") => vec![EMPTY_TABLE_NAME_REJECTION.to_string()],
180        Some(name) => first_table_name_error(name).into_iter().collect(),
181    }
182}
183
184/// [`validate_table_name`] for the operations that take
185/// [`item_table_name_errors`], so a request built in Rust gets the same answer
186/// as one decoded from a body.
187pub(crate) fn validate_item_table_name(name: &str) -> Result<()> {
188    match format_validation_errors(&item_table_name_errors(Some(name))) {
189        Some(msg) => Err(DynoxideError::ValidationException(msg)),
190        None => Ok(()),
191    }
192}
193
194/// UpdateItem's table-name errors in the newer layer: every constraint the
195/// name breaks, the pattern listed before the length, where the other
196/// single-item operations stop at the length (captured 2026-10-09, five of
197/// seven regions).
198pub(crate) fn update_item_table_name_errors(table_name: Option<&str>) -> Vec<String> {
199    let name = match table_name {
200        None => return table_name_constraint_errors(None, TableNameContext::ReadWrite),
201        Some("") => return vec![EMPTY_TABLE_NAME_REJECTION.to_string()],
202        Some(name) => name,
203    };
204    let mut errors = Vec::new();
205    if !table_name_pattern_holds(name) {
206        errors.push(table_name_pattern_error(name));
207    }
208    if name.len() < 3 {
209        errors.push(short_table_name_error(name));
210    } else if name.len() > 255 {
211        errors.push(long_table_name_error(name));
212    }
213    errors
214}
215
216/// The rejection for a `ReturnValuesOnConditionCheckFailure` outside
217/// `[ALL_OLD, NONE]`, at `path`: `returnValuesOnConditionCheckFailure` on the
218/// single-item operations and `ExecuteStatement`, `transactItems.N.member.<action>.`
219/// before it in a transaction, and `statements.N.member.` or
220/// `transactStatements.N.member.` on a PartiQL batch or transaction. An empty
221/// string is refused too. Captured in all seven regions tried, 2026-10-09, and
222/// for the PartiQL operations in eu-west-2 and us-east-1, 2026-10-10.
223pub(crate) fn return_values_on_condition_check_failure_rejection(
224    value: Option<&str>,
225    path: &str,
226) -> Option<String> {
227    let value = value?;
228    if ["ALL_OLD", "NONE"].contains(&value) {
229        return None;
230    }
231    Some(format!(
232        "Value '{value}' at '{path}' failed to satisfy constraint: \
233         Member must satisfy enum value set: [ALL_OLD, NONE]"
234    ))
235}
236
237/// What DynamoDB says about a `ConditionalOperator` once the request's members
238/// are valid. It only joins legacy conditions, so it is refused when there are
239/// none and when there is only one; `conditions` is the number of entries in
240/// `Expected`, `QueryFilter` or `ScanFilter`. The newer layer checks nothing
241/// else about it, so an unknown value is refused here too (captured
242/// 2026-10-09: PutItem, UpdateItem, DeleteItem and Query in at least five of
243/// seven regions, Scan in all seven for a valid value and, since 2026-10-20, in
244/// the 20 of 33 regions on the newer layer for any value).
245pub(crate) fn conditional_operator_misuse(
246    operator: Option<&str>,
247    conditions: usize,
248) -> Option<&'static str> {
249    let operator = operator?;
250    match conditions {
251        0 => Some("ConditionalOperator cannot be used without Filter or Expected"),
252        1 => Some(
253            "ConditionalOperator can only be used when Filter or Expected has two or more \
254             elements",
255        ),
256        // With conditions to join, a value other than AND or OR gets the
257        // generic enum message, DynamoDB's own `[ALL, OR]` list included
258        // (captured 2026-10-09: PutItem, UpdateItem and DeleteItem in five of
259        // seven regions, Query in six).
260        _ if !["AND", "OR"].contains(&operator) => {
261            Some("Failed to satisfy constraint: Member must satisfy enum value set: [ALL, OR]")
262        }
263        _ => None,
264    }
265}
266
267/// How many legacy conditions an `Expected`, `QueryFilter` or `ScanFilter`
268/// carries.
269pub(crate) fn legacy_condition_count(conditions: Option<&serde_json::Value>) -> usize {
270    conditions
271        .and_then(|value| value.as_object())
272        .map_or(0, |object| object.len())
273}
274
275/// Format a list of constraint validation errors into the DynamoDB multi-error format.
276///
277/// Returns `Some(message)` if there are errors, `None` if empty.
278pub fn format_validation_errors(errors: &[String]) -> Option<String> {
279    if errors.is_empty() {
280        return None;
281    }
282    if let [single] = errors {
283        return Some(envelope_message(single));
284    }
285    Some(format!(
286        "{} validation errors detected: {}",
287        errors.len(),
288        errors.join("; ")
289    ))
290}
291
292/// The single-error form of the request-validation envelope. This and
293/// `format_validation_errors` are the only producers of the prefix wording.
294pub(crate) fn envelope_message(msg: &str) -> String {
295    format!("1 validation error detected: {msg}")
296}
297
298/// The rejection for a `ReturnConsumedCapacity` outside the enum, or `None` when
299/// the value is one DynamoDB accepts.
300///
301/// The only copy of the enum and its message. `GetItem`, `PutItem`,
302/// `UpdateItem`, `DeleteItem`, `Query` and `Scan` each spelled it out during
303/// their own deserialisation, and the PartiQL surfaces derive their requests
304/// instead, so they had nowhere to put it and accepted anything. That was
305/// invisible while the value only chose whether a figure was reported; it
306/// stopped being invisible once an unrecognised value also meant the write path
307/// skipped sizing its indexes. Every operation that takes the parameter now
308/// asks here: those six, `SearchVectors`, the three PartiQL operations, and
309/// `BatchGetItem`, `BatchWriteItem`, `TransactGetItems` and
310/// `TransactWriteItems`, which check it in their `execute` once their own
311/// request-level constraints have passed.
312///
313/// Callers that collect several failures push the message onto their own errors
314/// vec, so the multi-error envelope they report is unchanged.
315pub(crate) fn return_consumed_capacity_rejection(mode: Option<&str>) -> Option<String> {
316    let mode = mode?;
317    if ["INDEXES", "TOTAL", "NONE"].contains(&mode) {
318        return None;
319    }
320    Some(format!(
321        "Value '{mode}' at 'returnConsumedCapacity' failed to satisfy constraint: \
322         Member must satisfy enum value set: [INDEXES, TOTAL, NONE]"
323    ))
324}
325
326/// [`return_consumed_capacity_rejection`] as an error, in the single-error
327/// envelope a wire client gets. For callers that check the mode outside a
328/// deserialiser, where there is no errors vec to push onto: the MCP tools.
329#[cfg(feature = "mcp-server")]
330pub(crate) fn reject_bad_capacity_mode(mode: Option<&str>) -> Result<()> {
331    match return_consumed_capacity_rejection(mode) {
332        Some(msg) => Err(DynoxideError::ValidationException(envelope_message(&msg))),
333        None => Ok(()),
334    }
335}
336
337/// The values `ReturnValues` accepts on a write.
338pub(crate) const RETURN_VALUES: [&str; 5] =
339    ["ALL_NEW", "UPDATED_OLD", "ALL_OLD", "NONE", "UPDATED_NEW"];
340
341/// DeleteItem's refusal of a `ReturnValues` the enum allows but it does not
342/// honour.
343pub(crate) const RETURN_VALUES_NOT_HONOURED: &str = "ReturnValues can only be ALL_OLD or NONE";
344
345/// The rejection for a `ReturnValues` outside the enum on PutItem, UpdateItem
346/// and DeleteItem: the validation framework's generic form, which names
347/// neither the value nor the member and lists the enum in DynamoDB's own
348/// order, its `UPDATE_NEW` typo included. Captured in eu-west-2, us-east-1 and
349/// two more regions on 2026-10-08; 31 of 33 regions answer this way, and
350/// eu-south-2 and me-central-1 still give the older per-value wording. Which
351/// values are accepted is unchanged: `UPDATED_NEW` is valid, `UPDATE_NEW` is
352/// only the message's spelling.
353pub(crate) const RETURN_VALUES_REJECTION: &str = "Failed to satisfy constraint: Member must \
354     satisfy enum value set: [ALL_OLD, UPDATED_OLD, ALL_NEW, UPDATE_NEW, NONE]";
355
356/// The older validation layer's rejection for a `ReturnValues` outside the
357/// enum, naming the value and the member. UpdateItem still gives it when the
358/// request has no key, in all seven regions captured on 2026-10-09.
359pub(crate) fn return_values_value_rejection(value: &str) -> String {
360    format!(
361        "Value '{value}' at 'returnValues' failed to satisfy constraint: \
362         Member must satisfy enum value set: [ALL_NEW, UPDATED_OLD, ALL_OLD, NONE, UPDATED_NEW]"
363    )
364}
365
366/// The rejection for a `ConditionalOperator` outside `[OR, AND]`, or `None`
367/// when the value is one DynamoDB accepts. Only the older validation layer
368/// checks it as an enum member, and a Scan reaches that layer only when a
369/// missing table name or operator, or a coerced integer, hands it the request. The newer layer, which
370/// every operation runs on in most regions, never reports the enum; see
371/// [`conditional_operator_misuse`].
372pub(crate) fn conditional_operator_rejection(operator: Option<&str>) -> Option<String> {
373    let operator = operator?;
374    if ["OR", "AND"].contains(&operator) {
375        return None;
376    }
377    Some(format!(
378        "Value '{operator}' at 'conditionalOperator' failed to satisfy constraint: \
379         Member must satisfy enum value set: [OR, AND]"
380    ))
381}
382
383/// The rejection for a `ReturnItemCollectionMetrics` outside the enum, or
384/// `None` when the value is one DynamoDB accepts. `BatchWriteItem` and
385/// `TransactWriteItems` ask here; PutItem, UpdateItem and DeleteItem spell the
386/// same check out in their own deserialisation.
387pub(crate) fn return_item_collection_metrics_rejection(mode: Option<&str>) -> Option<String> {
388    let mode = mode?;
389    if ["SIZE", "NONE"].contains(&mode) {
390        return None;
391    }
392    Some(format!(
393        "Value '{mode}' at 'returnItemCollectionMetrics' failed to satisfy constraint: \
394         Member must satisfy enum value set: [SIZE, NONE]"
395    ))
396}
397
398fn table_name_pattern_holds(name: &str) -> bool {
399    !name.is_empty()
400        && name
401            .chars()
402            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
403}
404
405/// Whether a table name meets the constraints on a `TableName` member of a
406/// request body: 3 to 255 characters from `[a-zA-Z0-9_.-]`. The single-item
407/// operations validate a top-level `tableName` more loosely (see
408/// [`TableNameContext::ReadWrite`]); the batch and transaction operations carry
409/// their table names inside the body and apply the full constraint.
410pub(crate) fn member_table_name_is_valid(name: &str) -> bool {
411    (3..=255).contains(&name.len()) && table_name_pattern_holds(name)
412}
413
414/// The constraint errors for a table name carried at `path` inside a
415/// transaction, such as `transactItems.2.member.update.tableName`: the length
416/// first, then the pattern, which is the order DynamoDB lists them in when a
417/// name breaks both (captured us-east-1 and three more regions, 2026-10-09;
418/// eu-west-2 lists the pattern first).
419pub(crate) fn member_table_name_errors(name: &str, path: &str) -> Vec<String> {
420    let mut errors = Vec::new();
421    if name.len() < 3 {
422        errors.push(format!(
423            "Value '{name}' at '{path}' failed to satisfy constraint: \
424             Member must have length greater than or equal to 3"
425        ));
426    } else if name.len() > 255 {
427        errors.push(format!(
428            "Value '{name}' at '{path}' failed to satisfy constraint: \
429             Member must have length less than or equal to 255"
430        ));
431    }
432    if !table_name_pattern_holds(name) {
433        errors.push(format!(
434            "Value '{name}' at '{path}' failed to satisfy constraint: \
435             Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"
436        ));
437    }
438    errors
439}
440
441/// The constraint a batch request's `RequestItems` map puts on its keys, the
442/// table names. DynamoDB reports it once for the whole map, echoing the map,
443/// whichever name broke it.
444pub(crate) const REQUEST_ITEMS_KEY_CONSTRAINT: &str = "Map keys must satisfy constraint: \
445     [Member must have length less than or equal to 255, Member must have length greater \
446     than or equal to 3, Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+]";
447
448/// Wrap an `EnvelopedValidation` error in the `1 validation error detected: `
449/// envelope, converting it to a plain `ValidationException`. Every other error
450/// passes through unchanged.
451///
452/// PutItem and UpdateItem apply this at their operation boundary.
453pub(crate) fn envelope_request_validation(err: DynoxideError) -> DynoxideError {
454    match err {
455        DynoxideError::EnvelopedValidation(msg) => {
456            DynoxideError::ValidationException(envelope_message(&msg))
457        }
458        other => other,
459    }
460}
461
462/// The layer a PutItem, UpdateItem or DeleteItem request's values are checked
463/// on: the older layer when the request was read on it (a wire seam sets that
464/// from the whole body, see [`crate::types::reads_on_older_layer`]) or when its
465/// legacy `Expected` would put it there, and the current layer otherwise.
466pub(crate) fn single_write_layer(expected: Option<&serde_json::Value>) -> ValueLayer {
467    if crate::types::current_value_layer() == ValueLayer::Older
468        || expected.is_some_and(crate::types::reads_on_older_layer)
469    {
470        ValueLayer::Older
471    } else {
472        ValueLayer::Current
473    }
474}
475
476/// Resolve the request-validation tag at a single-item write's boundary: in
477/// the envelope on the current layer, and bare on the older one, which never
478/// gives it (captured eu-west-2 and us-east-1, 2026-10-10).
479pub(crate) fn resolve_for_layer(layer: ValueLayer, err: DynoxideError) -> DynoxideError {
480    match (layer, err) {
481        (ValueLayer::Older, DynoxideError::EnvelopedValidation(msg)) => {
482            DynoxideError::ValidationException(msg)
483        }
484        (_, err) => envelope_request_validation(err),
485    }
486}
487
488/// Tag a plain `ValidationException` for the request-validation envelope, for
489/// a PutItem or UpdateItem call site whose shared helper reports it plain.
490/// Every other error passes through unchanged.
491pub(crate) fn tag_for_envelope(err: DynoxideError) -> DynoxideError {
492    match err {
493        DynoxideError::ValidationException(msg) => DynoxideError::EnvelopedValidation(msg),
494        other => other,
495    }
496}
497
498/// Convert the wire-invisible `EnvelopedValidation` tag back to a plain,
499/// unenveloped `ValidationException`. Every other error passes through
500/// unchanged.
501///
502/// Operations other than PutItem and UpdateItem report the request-validation
503/// family bare, and the tag must never reach the wire.
504///
505/// Compiled only for the surfaces that resolve the tag: the HTTP server, the
506/// MCP tools, the wasm engine API, and the unit tests.
507#[cfg(any(
508    feature = "http-server",
509    feature = "mcp-server",
510    feature = "wasm-sqlite",
511    test
512))]
513pub(crate) fn strip_request_validation_tag(err: DynoxideError) -> DynoxideError {
514    match err {
515        DynoxideError::EnvelopedValidation(msg) => DynoxideError::ValidationException(msg),
516        other => other,
517    }
518}
519
520/// Resolve the wire-invisible `EnvelopedValidation` tag for a named operation:
521/// PutItem, UpdateItem and DeleteItem wrap the request-validation family in the
522/// `1 validation error detected: ` envelope, every other operation reports it
523/// bare. Either way the tag never reaches the wire. Enveloping is idempotent
524/// for untagged errors, so applying this to an already-enveloped action error
525/// is safe.
526///
527/// Owner of the operation split for the HTTP and wasm dispatch seams, which
528/// both route through here. The same PutItem/UpdateItem/DeleteItem membership
529/// is encoded at the action boundaries (`put_item::execute`,
530/// `update_item::execute` and `delete_item::execute` apply the envelope for the
531/// in-process API) and in the MCP tools' choice of resolver, so a future
532/// enveloped operation must update all three places.
533///
534/// Compiled only for the dispatch seams that consume it: the HTTP server, the
535/// wasm engine API, and the unit tests.
536#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
537pub(crate) fn resolve_request_validation_tag(operation: &str, err: DynoxideError) -> DynoxideError {
538    match operation {
539        "PutItem" | "UpdateItem" | "DeleteItem" => envelope_request_validation(err),
540        _ => strip_request_validation_tag(err),
541    }
542}
543
544/// A validation failure from a shared validator, classified so PutItem and
545/// UpdateItem can tell which families DynamoDB wraps in the
546/// `1 validation error detected: ` envelope.
547///
548/// Every other caller converts it straight back to the original error via the
549/// `From` impl, so plain `?` propagation keeps their behaviour byte-identical.
550#[derive(Debug)]
551pub(crate) enum ClassifiedValidationError {
552    /// A family DynamoDB reports bare, or an error adopted from an
553    /// unclassified helper.
554    Bare(DynoxideError),
555    /// A family DynamoDB wraps in the request-validation envelope on PutItem
556    /// and UpdateItem.
557    Enveloped(String),
558}
559
560impl ClassifiedValidationError {
561    /// A family DynamoDB reports bare.
562    pub(crate) fn bare(message: impl Into<String>) -> Self {
563        Self::Bare(DynoxideError::ValidationException(message.into()))
564    }
565
566    /// A family DynamoDB wraps in the request-validation envelope on PutItem
567    /// and UpdateItem.
568    pub(crate) fn enveloped(message: impl Into<String>) -> Self {
569        Self::Enveloped(message.into())
570    }
571
572    /// Convert for a PutItem/UpdateItem call site: enveloped families become
573    /// the wire-invisible `EnvelopedValidation` tag (unwrapped at the
574    /// operation boundary), everything else passes through unchanged.
575    pub(crate) fn into_tagged(self) -> DynoxideError {
576        match self {
577            Self::Enveloped(msg) => DynoxideError::EnvelopedValidation(msg),
578            Self::Bare(err) => err,
579        }
580    }
581}
582
583impl From<ClassifiedValidationError> for DynoxideError {
584    fn from(e: ClassifiedValidationError) -> Self {
585        match e {
586            ClassifiedValidationError::Enveloped(msg) => DynoxideError::ValidationException(msg),
587            ClassifiedValidationError::Bare(err) => err,
588        }
589    }
590}
591
592impl From<DynoxideError> for ClassifiedValidationError {
593    /// Errors adopted from unclassified helpers stay bare; only call sites
594    /// that know a family is enveloped construct the enveloped form.
595    fn from(error: DynoxideError) -> Self {
596        Self::Bare(error)
597    }
598}
599
600/// Validate key schema: exactly one HASH key, optionally one RANGE key.
601///
602/// DynamoDB validates positionally: the first element must be HASH and, if a
603/// second element is present, it must be RANGE.
604pub fn validate_key_schema(key_schema: &[KeySchemaElement]) -> Result<()> {
605    if key_schema.is_empty() || key_schema.len() > 2 {
606        return Err(DynoxideError::ValidationException(
607            "1 validation error detected: Value null at 'keySchema' failed to satisfy constraint: \
608             Member must have length less than or equal to 2"
609                .to_string(),
610        ));
611    }
612
613    // First element must be HASH.
614    if key_schema[0].key_type != KeyType::HASH {
615        return Err(DynoxideError::ValidationException(
616            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
617        ));
618    }
619
620    // Check for duplicate attribute names (before type check, matching DynamoDB ordering).
621    if key_schema.len() == 2 && key_schema[0].attribute_name == key_schema[1].attribute_name {
622        return Err(DynoxideError::ValidationException(
623            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
624                .to_string(),
625        ));
626    }
627
628    // Second element, if present, must be RANGE.
629    if key_schema.len() == 2 && key_schema[1].key_type != KeyType::RANGE {
630        return Err(DynoxideError::ValidationException(
631            "Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
632        ));
633    }
634
635    Ok(())
636}
637
638/// Validate attribute definitions: types must be S, N, or B.
639pub fn validate_attribute_definitions(defs: &[AttributeDefinition]) -> Result<()> {
640    if defs.is_empty() {
641        return Err(DynoxideError::ValidationException(
642            "1 validation error detected: Value null at 'attributeDefinitions' failed to satisfy \
643             constraint: Member must have length greater than or equal to 1"
644                .to_string(),
645        ));
646    }
647
648    for def in defs {
649        match def.attribute_type {
650            ScalarAttributeType::S | ScalarAttributeType::N | ScalarAttributeType::B => {}
651        }
652    }
653
654    Ok(())
655}
656
657/// Attribute names in the order DynamoDB lists them in its index definition
658/// messages: the iteration order of the Java hash table it collects them in.
659///
660/// That order is deterministic for a given set of names: each name lands in
661/// bucket `(h ^ (h >>> 16)) & (capacity - 1)` of its `String.hashCode()` `h`,
662/// buckets are walked in turn, and names sharing a bucket keep the order they
663/// were added in. `sized_to_count` picks the table: one sized to the number of
664/// names (`new HashMap<>(n)`), or a default one with sixteen buckets
665/// (`new HashSet<>()`). Either grows by doubling once it is more than three
666/// quarters full. Captured eu-west-2 and us-east-1, 2026-10-09, over 24 lists of
667/// one to sixteen names.
668pub(crate) fn java_hash_order<'a>(names: &[&'a str], sized_to_count: bool) -> Vec<&'a str> {
669    let mut seen = std::collections::HashSet::with_capacity(names.len());
670    let unique: Vec<&str> = names
671        .iter()
672        .copied()
673        .filter(|name| seen.insert(*name))
674        .collect();
675    let initial = if sized_to_count {
676        unique.len().max(1).next_power_of_two()
677    } else {
678        16
679    };
680    let capacity = java_table_capacity(initial, unique.len());
681    let mut ordered: Vec<(usize, usize, &str)> = unique
682        .iter()
683        .enumerate()
684        .map(|(added, name)| (java_hash_bucket(name, capacity), added, *name))
685        .collect();
686    ordered.sort_unstable();
687    ordered.into_iter().map(|(_, _, name)| name).collect()
688}
689
690/// How many buckets a Java hash table that starts with `initial` (a power of
691/// two) holds once `len` names are in it: it doubles whenever it is more than
692/// three quarters full.
693pub(crate) fn java_table_capacity(initial: usize, len: usize) -> usize {
694    let mut capacity = initial;
695    while len * 4 > capacity * 3 {
696        capacity *= 2;
697    }
698    capacity
699}
700
701/// The bucket a name lands in in a Java hash table of `capacity` buckets (a
702/// power of two): `(h ^ (h >>> 16)) & (capacity - 1)` of its
703/// `String.hashCode()` `h`.
704pub(crate) fn java_hash_bucket(name: &str, capacity: usize) -> usize {
705    let h = name.encode_utf16().fold(0u32, |h, unit| {
706        h.wrapping_mul(31).wrapping_add(u32::from(unit))
707    });
708    ((h ^ (h >> 16)) as usize) & (capacity - 1)
709}
710
711/// The error for an index whose key schema names an attribute the request
712/// does not define, or `None` when every key attribute is defined.
713///
714/// The message lists the index's key attributes and the request's definitions
715/// when the request defines at least as many attributes as the index has key
716/// attributes, and is the generic one otherwise. CreateTable asks this of each
717/// index in turn and reports the first; UpdateTable asks it of a new index.
718/// Captured eu-west-2 and us-east-1, 2026-10-09.
719pub(crate) fn undefined_index_key_error(
720    key_schema: &[KeySchemaElement],
721    definitions: &[AttributeDefinition],
722) -> Option<String> {
723    let defined: Vec<&str> = definitions
724        .iter()
725        .map(|d| d.attribute_name.as_str())
726        .collect();
727    let keys: Vec<&str> = key_schema
728        .iter()
729        .map(|k| k.attribute_name.as_str())
730        .collect();
731    if keys.iter().all(|k| defined.contains(k)) {
732        return None;
733    }
734    let key_count = java_hash_order(&keys, true).len();
735    if defined.len() < key_count {
736        return Some("Invalid KeySchema: Some index key attribute have no definition".to_string());
737    }
738    Some(format!(
739        "One or more parameter values were invalid: Some index key attributes are not defined \
740         in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
741        java_hash_order(&keys, true).join(", "),
742        java_hash_order(&defined, true).join(", ")
743    ))
744}
745
746/// Validate that all key schema attributes are defined in attribute definitions.
747pub fn validate_key_attributes_in_definitions(
748    key_schema: &[KeySchemaElement],
749    definitions: &[AttributeDefinition],
750) -> Result<()> {
751    for key_elem in key_schema {
752        let found = definitions
753            .iter()
754            .any(|def| def.attribute_name == key_elem.attribute_name);
755        if !found {
756            return Err(DynoxideError::ValidationException(format!(
757                "One or more parameter values were invalid: Some index key attributes are not \
758                 defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
759                key_elem.attribute_name,
760                definitions
761                    .iter()
762                    .map(|d| d.attribute_name.as_str())
763                    .collect::<Vec<_>>()
764                    .join(", ")
765            )));
766        }
767    }
768
769    Ok(())
770}
771
772/// The shape rules for a global secondary index key, which can have up to four
773/// partition key (HASH) and four sort key (RANGE) attributes, HASH first, each
774/// named once. CreateTable and UpdateTable answer alike. Captured eu-west-2,
775/// 2026-10-09. An empty schema gets the length refusal both callers gave it.
776pub fn check_gsi_key_schema(key_schema: &[KeySchemaElement]) -> std::result::Result<(), String> {
777    const MAX_PER_KIND: usize = 4;
778    let Some(first) = key_schema.first() else {
779        return Err(
780            "1 validation error detected: Value null at 'keySchema' failed to satisfy \
781                    constraint: Member must have length less than or equal to 2"
782                .to_string(),
783        );
784    };
785    if first.key_type != KeyType::HASH {
786        return Err(
787            "Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
788        );
789    }
790    if let [hash, range] = key_schema
791        && range.key_type == KeyType::RANGE
792        && hash.attribute_name == range.attribute_name
793    {
794        return Err(
795            "Both the Hash Key and the Range Key element in the KeySchema have the same name"
796                .to_string(),
797        );
798    }
799    for (i, element) in key_schema.iter().enumerate() {
800        if key_schema[..i]
801            .iter()
802            .any(|earlier| earlier.attribute_name == element.attribute_name)
803        {
804            return Err(
805                "The KeySchema contains multiple key attributes with the same name".to_string(),
806            );
807        }
808    }
809    if key_schema
810        .windows(2)
811        .any(|w| w[0].key_type == KeyType::RANGE && w[1].key_type == KeyType::HASH)
812    {
813        return Err(
814            "One or more parameter values were invalid: All HASH key attributes must precede \
815             RANGE key attributes in the KeySchema"
816                .to_string(),
817        );
818    }
819    for (key_type, name) in [(KeyType::HASH, "HASH"), (KeyType::RANGE, "RANGE")] {
820        if key_schema.iter().filter(|k| k.key_type == key_type).count() > MAX_PER_KIND {
821            return Err(format!(
822                "One or more parameter values were invalid: The KeySchema exceeds the maximum \
823                 allowed number of {name} key attributes"
824            ));
825        }
826    }
827    Ok(())
828}
829
830/// Validate a Global Secondary Index definition.
831///
832/// `request_definitions` must be the AttributeDefinitions declared in the
833/// current request, not the table's merged stored set: DynamoDB requires a new
834/// index's key attributes to be (re)declared in the request itself.
835pub fn validate_gsi(
836    gsi: &GlobalSecondaryIndex,
837    request_definitions: &[AttributeDefinition],
838) -> Result<()> {
839    // Validate index name length
840    if gsi.index_name.len() < 3 || gsi.index_name.len() > 255 {
841        return Err(DynoxideError::ValidationException(format!(
842            "1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
843             failed to satisfy constraint: Member must have length greater than or equal to 3",
844            gsi.index_name
845        )));
846    }
847
848    // Validate index name character set (same as table names)
849    if !gsi
850        .index_name
851        .chars()
852        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
853    {
854        return Err(DynoxideError::ValidationException(format!(
855            "1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
856             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
857            gsi.index_name
858        )));
859    }
860
861    // Validate key schema
862    check_gsi_key_schema(&gsi.key_schema).map_err(DynoxideError::ValidationException)?;
863
864    // Validate projection
865    validate_projection(&gsi.projection, &gsi.index_name)?;
866
867    // Validate GSI key attributes exist in definitions
868    if let Some(message) = undefined_index_key_error(&gsi.key_schema, request_definitions) {
869        return Err(DynoxideError::ValidationException(message));
870    }
871
872    Ok(())
873}
874
875/// Validate a single vector index definition.
876///
877/// `member_path` is the request-model path of the index's member, threaded
878/// into the formulaic constraint messages: CreateTable passes
879/// `vectorIndexes.N.member` with the entry's real 1-based position, and
880/// UpdateTable passes `vectorIndexUpdates.1.member.create` (a call carries at
881/// most one create action). The UpdateTable path shape is captured from real
882/// DynamoDB (eu-west-2 and us-east-1, 2026-08-12).
883///
884/// `request_definitions` must be the AttributeDefinitions declared in the
885/// current request: SearchSchema attributes must be (re)declared there. The
886/// vector attribute itself must NOT be declared in AttributeDefinitions (it
887/// is not a key attribute; the list-level validation in CreateTable rejects
888/// a request that declares it there).
889pub fn validate_vector_index(
890    vix: &crate::types::VectorIndex,
891    request_definitions: &[AttributeDefinition],
892    member_path: &str,
893) -> Result<()> {
894    // Index name length (request-model constraint; minimum length 3)
895    if vix.index_name.len() < 3 {
896        return Err(DynoxideError::ValidationException(format!(
897            "1 validation error detected: Value '{}' at '{member_path}.indexName' \
898             failed to satisfy constraint: Member must have length greater than or equal to 3",
899            vix.index_name
900        )));
901    }
902    if vix.index_name.len() > 255 {
903        return Err(DynoxideError::ValidationException(format!(
904            "1 validation error detected: Value '{}' at '{member_path}.indexName' \
905             failed to satisfy constraint: Member must have length less than or equal to 255",
906            vix.index_name
907        )));
908    }
909
910    // Index name character set (same as table names)
911    if !vix
912        .index_name
913        .chars()
914        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
915    {
916        return Err(DynoxideError::ValidationException(format!(
917            "1 validation error detected: Value '{}' at '{member_path}.indexName' \
918             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
919            vix.index_name
920        )));
921    }
922
923    // Distance function enum (request-model constraint form)
924    match vix.distance_function.as_str() {
925        "COSINE" | "DOT_PRODUCT" | "EUCLIDEAN" => {}
926        other => {
927            return Err(DynoxideError::ValidationException(format!(
928                "1 validation error detected: Value '{other}' at \
929                 '{member_path}.distanceFunction' failed to satisfy constraint: \
930                 Member must satisfy enum value set: [COSINE, DOT_PRODUCT, EUCLIDEAN]"
931            )));
932        }
933    }
934
935    // Dimensions bounds. The 0 case rejects earlier at the request-model
936    // layer on the JSON path; this bare form covers the upper bound and the
937    // programmatic path. Captured from real DynamoDB (eu-west-2, 2026-08-11).
938    if vix.dimensions < 1 || vix.dimensions > 4096 {
939        return Err(DynoxideError::ValidationException(
940            "One or more parameter values were invalid: Number of dimensions must be between \
941             1 and 4096 inclusive."
942                .to_string(),
943        ));
944    }
945
946    // Projection structure
947    validate_projection(&vix.projection, &vix.index_name)?;
948
949    // SearchSchema element types and AttributeDefinitions membership. The
950    // missing-definition message is captured from real DynamoDB (eu-west-2,
951    // 2026-08-11).
952    if let Some(ref schema) = vix.search_schema {
953        for (elem_idx, elem) in schema.iter().enumerate() {
954            let elem_position = elem_idx + 1;
955            match elem.search_schema_element_type.as_str() {
956                "HASH" | "INLINE_FILTER" => {}
957                other => {
958                    return Err(DynoxideError::ValidationException(format!(
959                        "1 validation error detected: Value '{other}' at \
960                         '{member_path}.searchSchema.{elem_position}.member.searchSchemaElementType' \
961                         failed to satisfy constraint: Member must satisfy enum value set: \
962                         [HASH, INLINE_FILTER]"
963                    )));
964                }
965            }
966            if !request_definitions
967                .iter()
968                .any(|d| d.attribute_name == elem.attribute_name)
969            {
970                return Err(DynoxideError::ValidationException(
971                    "One or more parameter values were invalid: One element in SearchSchema \
972                     is not defined in attribute definitions"
973                        .to_string(),
974                ));
975            }
976        }
977
978        // At most one HASH element per SearchSchema; the reported value is
979        // the actual HASH count. Captured from real DynamoDB (eu-west-2 and
980        // us-east-1, 2026-08-12).
981        let hash_count = schema
982            .iter()
983            .filter(|e| e.search_schema_element_type == "HASH")
984            .count();
985        if hash_count > 1 {
986            return Err(DynoxideError::ValidationException(format!(
987                "One or more parameter values were invalid: Value '{hash_count}' at \
988                 'SearchSchema' failed to satisfy constraint: Member must have HASH count \
989                 less than or equal to 1"
990            )));
991        }
992
993        // No attribute may appear twice in one SearchSchema. Captured from
994        // real DynamoDB (eu-west-2 and us-east-1, 2026-08-12).
995        let mut seen = std::collections::HashSet::new();
996        for elem in schema {
997            if !seen.insert(elem.attribute_name.as_str()) {
998                return Err(DynoxideError::ValidationException(
999                    "One or more parameter values were invalid: SearchSchema contains a \
1000                     duplicate AttributeName"
1001                        .to_string(),
1002                ));
1003            }
1004        }
1005    }
1006
1007    Ok(())
1008}
1009
1010/// Validate a Projection (for GSI or LSI).
1011///
1012/// DynamoDB checks:
1013/// 1. ProjectionType must be present (not null)
1014/// 2. If NonKeyAttributes is specified, ProjectionType must be INCLUDE
1015pub fn validate_projection(projection: &crate::types::Projection, _index_name: &str) -> Result<()> {
1016    match &projection.projection_type {
1017        None => {
1018            return Err(DynoxideError::ValidationException(
1019                "One or more parameter values were invalid: Unknown ProjectionType: null"
1020                    .to_string(),
1021            ));
1022        }
1023        Some(pt) => {
1024            if let Some(ref nka) = projection.non_key_attributes {
1025                // NonKeyAttributes is present; check ProjectionType compatibility
1026                match pt {
1027                    crate::types::ProjectionType::ALL => {
1028                        return Err(DynoxideError::ValidationException(
1029                            "One or more parameter values were invalid: \
1030                             ProjectionType is ALL, but NonKeyAttributes is specified"
1031                                .to_string(),
1032                        ));
1033                    }
1034                    crate::types::ProjectionType::KEYS_ONLY => {
1035                        return Err(DynoxideError::ValidationException(
1036                            "One or more parameter values were invalid: \
1037                             ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified"
1038                                .to_string(),
1039                        ));
1040                    }
1041                    crate::types::ProjectionType::INCLUDE => {
1042                        // NonKeyAttributes with INCLUDE is valid, but must not be empty
1043                        if nka.is_empty() {
1044                            return Err(DynoxideError::ValidationException(
1045                                "One or more parameter values were invalid: \
1046                                 NonKeyAttributes must not be empty"
1047                                    .to_string(),
1048                            ));
1049                        }
1050                    }
1051                }
1052            }
1053        }
1054    }
1055    Ok(())
1056}
1057
1058/// Extract the partition key name from a key schema.
1059pub fn partition_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
1060    key_schema
1061        .iter()
1062        .find(|k| k.key_type == KeyType::HASH)
1063        .map(|k| k.attribute_name.as_str())
1064}
1065
1066/// DynamoDB caps document nesting at 32 levels, counting the top-level attribute
1067/// value as level 1. Values are validated 0-indexed (top-level = depth 0), so the
1068/// deepest permitted leaf sits at depth 31 and a value reaching depth 32 is rejected.
1069const MAX_NESTING_DEPTH: usize = 32;
1070
1071/// Real DynamoDB's verbatim message when document nesting exceeds the limit. Shared
1072/// by the stored-item and ExpressionAttributeValue checks so both match AWS.
1073pub(crate) const NESTING_LIMIT_MESSAGE: &str = "Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit";
1074
1075/// The short form of the nesting message, without the clause about the item's
1076/// attributes. UpdateItem sends it, bare, when the item an update would leave
1077/// behind is nested too deep, and a transaction's Update gives it as the
1078/// cancellation reason. Captured us-east-1 and eu-west-2, 2026-10-09.
1079pub(crate) const UPDATED_ITEM_NESTING_MESSAGE: &str =
1080    "Nesting Levels have exceeded supported limits";
1081
1082/// Refuse an updated item nested deeper than DynamoDB allows, with the short
1083/// message an update gets.
1084pub(crate) fn validate_updated_item_nesting(item: &Item) -> Result<()> {
1085    if item
1086        .values()
1087        .any(|value| validate_nesting_depth(value).is_err())
1088    {
1089        return Err(DynoxideError::ValidationException(
1090            UPDATED_ITEM_NESTING_MESSAGE.to_string(),
1091        ));
1092    }
1093    Ok(())
1094}
1095
1096/// Validate all attribute values in an item.
1097///
1098/// Rejects:
1099/// - Empty sets (`{"SS": []}`, `{"NS": []}`, `{"BS": []}`)
1100/// - Numbers that violate DynamoDB's precision/range constraints
1101/// - Nesting deeper than 32 levels
1102///
1103/// Validation is recursive: invalid values nested inside L (list) or M (map) are also rejected.
1104///
1105/// **Note:** Empty strings (`{"S": ""}`) and empty binary values (`{"B": ""}`) are
1106/// permitted in non-key attributes since DynamoDB's 2020 update. Key attributes
1107/// are validated separately in `helpers::validate_key_type`.
1108///
1109/// **Important:** This must only be called on items being persisted, NOT on
1110/// `ExpressionAttributeValues` (which may legitimately contain empty strings for comparisons).
1111pub fn validate_item_attribute_values(item: &Item) -> crate::Result<()> {
1112    validate_item_attribute_values_classified(item, ValueLayer::Older).map_err(Into::into)
1113}
1114
1115/// Classified form of [`validate_item_attribute_values`], read by `layer`'s
1116/// rules: the set families (empty and duplicate SS/NS/BS) are marked as
1117/// enveloped so PutItem and UpdateItem can wrap them in the request-validation
1118/// envelope, and so are the current layer's number errors; the older layer's
1119/// numbers and the second nesting check stay bare. Every other caller converts
1120/// back to the plain error unchanged.
1121pub(crate) fn validate_item_attribute_values_classified(
1122    item: &Item,
1123    layer: ValueLayer,
1124) -> std::result::Result<(), ClassifiedValidationError> {
1125    for value in item.values() {
1126        validate_attribute_value(value, 0, layer).map_err(|e| match e {
1127            // From 63 levels a second check answers first, with the same
1128            // message outside the envelope.
1129            ClassifiedValidationError::Enveloped(msg)
1130                if msg == NESTING_LIMIT_MESSAGE && nesting_depth(value) > SECOND_NESTING_LIMIT =>
1131            {
1132                ClassifiedValidationError::bare(msg)
1133            }
1134            other => other,
1135        })?;
1136    }
1137    Ok(())
1138}
1139
1140/// [`validate_item_attribute_values_classified`] for one value outside an
1141/// item, such as a legacy `Expected` condition's.
1142pub(crate) fn validate_value_classified(
1143    value: &AttributeValue,
1144    layer: ValueLayer,
1145) -> std::result::Result<(), ClassifiedValidationError> {
1146    validate_attribute_value(value, 0, layer)
1147}
1148
1149/// The first empty set, or set with a repeated member, anywhere in `value`,
1150/// lists and maps included, worded by `layer`; `None` when every set is sound.
1151pub(crate) fn find_set_error(value: &AttributeValue, layer: ValueLayer) -> Option<String> {
1152    const INVALID: &str = "One or more parameter values were invalid: ";
1153    match value {
1154        AttributeValue::SS(set) if set.is_empty() => {
1155            Some(format!("{INVALID}An string set  may not be empty"))
1156        }
1157        AttributeValue::NS(set) if set.is_empty() => {
1158            Some(format!("{INVALID}An number set  may not be empty"))
1159        }
1160        AttributeValue::BS(set) if set.is_empty() => {
1161            Some(format!("{INVALID}Binary sets should not be empty"))
1162        }
1163        AttributeValue::L(list) => list.iter().find_map(|v| find_set_error(v, layer)),
1164        AttributeValue::M(map) => map.values().find_map(|v| find_set_error(v, layer)),
1165        _ => duplicate_set_message(value, layer),
1166    }
1167}
1168
1169/// The first set with a repeated member anywhere in `value`, lists and maps
1170/// included, worded by `layer`; `None` when there is none.
1171pub(crate) fn find_duplicate_set(value: &AttributeValue, layer: ValueLayer) -> Option<String> {
1172    match value {
1173        AttributeValue::L(list) => list.iter().find_map(|v| find_duplicate_set(v, layer)),
1174        AttributeValue::M(map) => map.values().find_map(|v| find_duplicate_set(v, layer)),
1175        _ => duplicate_set_message(value, layer),
1176    }
1177}
1178
1179/// The depth past which a single-item request's nesting is refused by a
1180/// second check, in a different form: a stored item's message loses the
1181/// envelope, and an ExpressionAttributeValues entry is refused naming its key.
1182/// Values at 62 levels get the first check's answer and values at 63 the
1183/// second's. Captured us-east-1 and eu-west-2, 2026-10-09.
1184pub(crate) const SECOND_NESTING_LIMIT: usize = 62;
1185
1186/// How many lists and maps deep a value goes: a scalar is 0, a map holding a
1187/// scalar is 1.
1188pub(crate) fn nesting_depth(value: &AttributeValue) -> usize {
1189    match value {
1190        AttributeValue::L(list) => 1 + list.iter().map(nesting_depth).max().unwrap_or(0),
1191        AttributeValue::M(map) => 1 + map.values().map(nesting_depth).max().unwrap_or(0),
1192        _ => 0,
1193    }
1194}
1195
1196/// Check one PartiQL statement parameter as an item's values are checked
1197/// (unreadable numbers, empty and duplicated sets), leaving its depth to the
1198/// statement it fills in.
1199pub(crate) fn validate_parameter_value(value: &AttributeValue) -> crate::Result<()> {
1200    validate_attribute_value_within(value, 0, None, ValueLayer::Older).map_err(Into::into)
1201}
1202
1203fn validate_attribute_value(
1204    value: &AttributeValue,
1205    depth: usize,
1206    layer: ValueLayer,
1207) -> std::result::Result<(), ClassifiedValidationError> {
1208    validate_attribute_value_within(value, depth, Some(MAX_NESTING_DEPTH), layer)
1209}
1210
1211fn validate_attribute_value_within(
1212    value: &AttributeValue,
1213    depth: usize,
1214    nesting_limit: Option<usize>,
1215    layer: ValueLayer,
1216) -> std::result::Result<(), ClassifiedValidationError> {
1217    let number = |n: &str| {
1218        layer
1219            .check_number(n)
1220            .map_err(|e| layer.classify_number_error(e))
1221    };
1222    // PutItem wraps the nesting rejection in the envelope (us-east-1 and
1223    // eu-west-2, 2026-10-09); BatchWriteItem and a transaction's Put send it
1224    // bare, which the untagged callers keep.
1225    if nesting_limit.is_some_and(|limit| depth >= limit) {
1226        return Err(ClassifiedValidationError::enveloped(NESTING_LIMIT_MESSAGE));
1227    }
1228    match value {
1229        AttributeValue::SS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
1230            "One or more parameter values were invalid: An string set  may not be empty",
1231        )),
1232        AttributeValue::NS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
1233            "One or more parameter values were invalid: An number set  may not be empty",
1234        )),
1235        AttributeValue::BS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
1236            "One or more parameter values were invalid: Binary sets should not be empty",
1237        )),
1238        AttributeValue::SS(_) | AttributeValue::BS(_) => {
1239            match duplicate_set_message(value, layer) {
1240                Some(message) => Err(ClassifiedValidationError::enveloped(message)),
1241                None => Ok(()),
1242            }
1243        }
1244        AttributeValue::NS(set) => {
1245            for n in set {
1246                number(n)?;
1247            }
1248            match duplicate_set_message(value, layer) {
1249                Some(message) => Err(ClassifiedValidationError::enveloped(message)),
1250                None => Ok(()),
1251            }
1252        }
1253        AttributeValue::N(n) => {
1254            number(n)?;
1255            Ok(())
1256        }
1257        AttributeValue::L(list) => {
1258            for v in list {
1259                validate_attribute_value_within(v, depth + 1, nesting_limit, layer)?;
1260            }
1261            Ok(())
1262        }
1263        AttributeValue::M(map) => {
1264            for v in map.values() {
1265                validate_attribute_value_within(v, depth + 1, nesting_limit, layer)?;
1266            }
1267            Ok(())
1268        }
1269        _ => Ok(()),
1270    }
1271}
1272
1273/// Validate that a single `ExpressionAttributeValue` does not nest deeper than
1274/// DynamoDB allows.
1275///
1276/// Real DynamoDB rejects expression values whose document nesting exceeds 32 levels
1277/// up front, before the expression is evaluated, raising the same bare nesting
1278/// `ValidationException` it raises for over-deep stored items (no "ExpressionAttributeValues
1279/// contains invalid value" wrapper). Only the nesting depth is checked here; empty
1280/// strings and other shapes that are legal in comparisons are left untouched.
1281pub fn validate_nesting_depth(value: &AttributeValue) -> Result<()> {
1282    check_nesting_depth(value, 0)
1283}
1284
1285fn check_nesting_depth(value: &AttributeValue, depth: usize) -> Result<()> {
1286    if depth >= MAX_NESTING_DEPTH {
1287        return Err(DynoxideError::ValidationException(
1288            NESTING_LIMIT_MESSAGE.to_string(),
1289        ));
1290    }
1291    match value {
1292        AttributeValue::L(list) => list
1293            .iter()
1294            .try_for_each(|v| check_nesting_depth(v, depth + 1)),
1295        AttributeValue::M(map) => map
1296            .values()
1297            .try_for_each(|v| check_nesting_depth(v, depth + 1)),
1298        _ => Ok(()),
1299    }
1300}
1301
1302/// Validate Key attribute values before table-level checks.
1303///
1304/// This validates the attribute values in a Key map for:
1305/// - Invalid/empty numbers
1306/// - Empty sets, duplicate sets
1307/// - Multiple datatypes
1308///
1309/// These errors are returned with "One or more parameter values were invalid: " prefix.
1310pub fn validate_key_attribute_values(key: &Item) -> Result<()> {
1311    validate_key_values(key, ValueLayer::Older).map_err(Into::into)
1312}
1313
1314/// Check a key's values by `layer`'s rules. A duplicated set member, and on
1315/// the current layer a number it can't store, are marked enveloped so
1316/// UpdateItem and DeleteItem can wrap them in the request-validation envelope;
1317/// the empty-set families stay bare.
1318pub(crate) fn validate_key_values(
1319    key: &Item,
1320    layer: ValueLayer,
1321) -> std::result::Result<(), ClassifiedValidationError> {
1322    for value in key.values() {
1323        validate_key_attr_value(value, layer)?;
1324    }
1325    Ok(())
1326}
1327
1328fn validate_key_attr_value(
1329    value: &AttributeValue,
1330    layer: ValueLayer,
1331) -> std::result::Result<(), ClassifiedValidationError> {
1332    match value {
1333        AttributeValue::SS(set) if set.is_empty() => {
1334            return Err(ClassifiedValidationError::bare(
1335                "One or more parameter values were invalid: An string set  may not be empty",
1336            ));
1337        }
1338        AttributeValue::NS(set) if set.is_empty() => {
1339            return Err(ClassifiedValidationError::bare(
1340                "One or more parameter values were invalid: An number set  may not be empty",
1341            ));
1342        }
1343        AttributeValue::BS(set) if set.is_empty() => {
1344            return Err(ClassifiedValidationError::bare(
1345                "One or more parameter values were invalid: Binary sets should not be empty",
1346            ));
1347        }
1348        AttributeValue::SS(_) | AttributeValue::BS(_) => {
1349            if let Some(message) = duplicate_set_message(value, layer) {
1350                return Err(ClassifiedValidationError::enveloped(message));
1351            }
1352        }
1353        AttributeValue::NS(set) => {
1354            for n in set {
1355                layer
1356                    .check_number(n)
1357                    .map_err(|e| layer.classify_number_error(e))?;
1358            }
1359            if let Some(message) = duplicate_set_message(value, layer) {
1360                return Err(ClassifiedValidationError::enveloped(message));
1361            }
1362        }
1363        AttributeValue::N(n) => {
1364            layer
1365                .check_number(n)
1366                .map_err(|e| layer.classify_number_error(e))?;
1367        }
1368        _ => {}
1369    }
1370    Ok(())
1371}
1372
1373/// Which of DynamoDB's two attribute-value validation layers an operation
1374/// runs. They word a number they can't read, and a set with a repeated member,
1375/// differently. Captured in eu-west-2, us-east-1, eu-west-1 and us-west-2,
1376/// 2026-10-09; where the regions split (the transactions, which eu-west-2
1377/// already runs on the current layer, and Scan, which 19 of 33 regions read on
1378/// it by 2026-10-20), the majority's layer is used.
1379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1380pub(crate) enum ValueLayer {
1381    /// PutItem, UpdateItem and DeleteItem (inside the envelope), and GetItem,
1382    /// BatchGetItem, Query and Scan (bare). Numbers by the stored-number rule, an
1383    /// empty one ending `: `; duplicates quoted, in the order described at
1384    /// [`duplicate_set_message`]; expression values with no
1385    /// `ExpressionAttributeValues contains invalid value` wrapper.
1386    Current,
1387    /// BatchWriteItem, TransactWriteItems, TransactGetItems and the PartiQL
1388    /// operations. Numbers as Java's `BigDecimal` reads them, an empty
1389    /// one with no colon; duplicates unquoted, as sent; expression values
1390    /// inside the wrapper.
1391    Older,
1392}
1393
1394impl ValueLayer {
1395    /// The layer DynamoDB runs `operation` on, for the dispatch seams that
1396    /// read a request knowing only the operation's name.
1397    #[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
1398    pub(crate) fn for_operation(operation: &str) -> Self {
1399        match operation {
1400            "BatchWriteItem"
1401            | "TransactWriteItems"
1402            | "TransactGetItems"
1403            | "ExecuteStatement"
1404            | "BatchExecuteStatement"
1405            | "ExecuteTransaction" => Self::Older,
1406            _ => Self::Current,
1407        }
1408    }
1409
1410    /// Check a number by this layer's rule.
1411    pub(crate) fn check_number(self, n: &str) -> Result<()> {
1412        match self {
1413            Self::Current => crate::types::validate_current_number(n),
1414            Self::Older => crate::types::validate_parameter_number(n),
1415        }
1416    }
1417
1418    /// Classify a number this layer refuses: the current layer's go in the
1419    /// envelope on the single-item writes, the older layer's stay bare.
1420    pub(crate) fn classify_number_error(self, e: DynoxideError) -> ClassifiedValidationError {
1421        match (self, e) {
1422            (Self::Current, DynoxideError::ValidationException(m)) => {
1423                ClassifiedValidationError::enveloped(m)
1424            }
1425            (_, e) => ClassifiedValidationError::Bare(e),
1426        }
1427    }
1428}
1429
1430/// The message DynamoDB refuses a string, number or binary set with a repeated
1431/// member in, by `layer`'s wording, or `None` when every member is distinct.
1432///
1433/// The current layer quotes the members and lists them in an odd order: they
1434/// are read into a byte-ordered set until the first repeat, and the message
1435/// prints that set, the repeated member, then the members not yet read, as
1436/// sent. `["b", "a", "b", "C"]` reads `["a", "b", "b", "C"]`. Binary members
1437/// are base64, joined with no space. A number set names no members. The older
1438/// layer prints every member as sent, unquoted, and words a number set
1439/// without the prefix or the full stop. Captured in eu-west-2, us-east-1,
1440/// eu-west-1 and us-west-2, 2026-10-09.
1441pub(crate) fn duplicate_set_message(value: &AttributeValue, layer: ValueLayer) -> Option<String> {
1442    const INVALID: &str = "One or more parameter values were invalid: ";
1443    /// The current layer's order: the sorted members read before the first
1444    /// repeat, the repeat, then the rest as sent. `None` without a repeat.
1445    fn current_order<T: Ord + Clone>(members: &[T]) -> Option<Vec<T>> {
1446        let mut read = std::collections::BTreeSet::new();
1447        for (i, member) in members.iter().enumerate() {
1448            if !read.insert(member.clone()) {
1449                let mut out: Vec<T> = read.into_iter().collect();
1450                out.push(member.clone());
1451                out.extend(members[i + 1..].iter().cloned());
1452                return Some(out);
1453            }
1454        }
1455        None
1456    }
1457    fn has_repeat<T: std::hash::Hash + Eq>(members: impl IntoIterator<Item = T>) -> bool {
1458        let mut seen = std::collections::HashSet::new();
1459        members.into_iter().any(|m| !seen.insert(m))
1460    }
1461    use base64::Engine;
1462    let b64 = |b: &Vec<u8>| base64::engine::general_purpose::STANDARD.encode(b);
1463    match (value, layer) {
1464        (AttributeValue::SS(set), ValueLayer::Current) => current_order(set).map(|ordered| {
1465            let quoted: Vec<String> = ordered
1466                .iter()
1467                .map(|s| serde_json::to_string(s).unwrap_or_default())
1468                .collect();
1469            format!(
1470                "{INVALID}Input collection [{}] contains duplicates.",
1471                quoted.join(", ")
1472            )
1473        }),
1474        (AttributeValue::SS(set), ValueLayer::Older) => has_repeat(set.iter()).then(|| {
1475            format!(
1476                "{INVALID}Input collection [{}] contains duplicates.",
1477                set.join(", ")
1478            )
1479        }),
1480        (AttributeValue::BS(set), ValueLayer::Current) => current_order(set).map(|ordered| {
1481            let quoted: Vec<String> = ordered.iter().map(|b| format!("\"{}\"", b64(b))).collect();
1482            format!(
1483                "{INVALID}Input collection [{}]of type BS contains duplicates.",
1484                quoted.join(",")
1485            )
1486        }),
1487        (AttributeValue::BS(set), ValueLayer::Older) => has_repeat(set.iter()).then(|| {
1488            let shown: Vec<String> = set.iter().map(b64).collect();
1489            format!(
1490                "{INVALID}Input collection [{}]of type BS contains duplicates.",
1491                shown.join(", ")
1492            )
1493        }),
1494        (AttributeValue::NS(set), _) => {
1495            has_repeat(set.iter().map(|n| crate::types::normalize_dynamo_number(n))).then(|| {
1496                match layer {
1497                    ValueLayer::Current => {
1498                        format!("{INVALID}Input collection contains duplicates.")
1499                    }
1500                    ValueLayer::Older => "Input collection contains duplicates".to_string(),
1501                }
1502            })
1503        }
1504        _ => None,
1505    }
1506}
1507
1508/// Normalize sets within an item by deduplicating and sorting them in-place.
1509///
1510/// - SS: deduplicates by string value, then sorts by bytes
1511/// - NS: deduplicates by numeric value (e.g., "1.0" and "1" are the same),
1512///   then sorts by value
1513/// - BS: deduplicates by byte content, then sorts by bytes
1514///
1515/// The sorted order is the one DynamoDB stores and returns sets in.
1516///
1517/// Recursively normalizes sets inside L (list) and M (map) values.
1518pub fn normalize_item_sets(item: &mut Item) {
1519    for value in item.values_mut() {
1520        normalize_attribute_sets(value);
1521    }
1522}
1523
1524fn normalize_attribute_sets(value: &mut AttributeValue) {
1525    match value {
1526        AttributeValue::N(n) => {
1527            *n = crate::types::normalize_dynamo_number(n);
1528        }
1529        AttributeValue::SS(set) => {
1530            let mut seen = std::collections::HashSet::new();
1531            set.retain(|s| seen.insert(s.clone()));
1532            crate::set_order::sort_string_set(set);
1533        }
1534        AttributeValue::NS(set) => {
1535            let mut seen = std::collections::HashSet::new();
1536            set.retain(|n| seen.insert(normalize_number_for_dedup(n)));
1537            // Normalize each number in the set
1538            for n in set.iter_mut() {
1539                *n = crate::types::normalize_dynamo_number(n);
1540            }
1541            crate::set_order::sort_number_set(set);
1542        }
1543        AttributeValue::BS(set) => {
1544            let mut seen = std::collections::HashSet::new();
1545            set.retain(|b| seen.insert(b.clone()));
1546            crate::set_order::sort_binary_set(set);
1547        }
1548        AttributeValue::L(list) => {
1549            for v in list.iter_mut() {
1550                normalize_attribute_sets(v);
1551            }
1552        }
1553        AttributeValue::M(map) => {
1554            for v in map.values_mut() {
1555                normalize_attribute_sets(v);
1556            }
1557        }
1558        _ => {}
1559    }
1560}
1561
1562/// Produce a canonical string for a DynamoDB number for deduplication purposes.
1563/// Strips leading/trailing zeros and normalizes to a canonical form so that
1564/// "1.0", "1", "1.00", "01" all map to the same string.
1565fn normalize_number_for_dedup(n: &str) -> String {
1566    let trimmed = n.trim();
1567    let negative = trimmed.starts_with('-');
1568    let abs_str = if negative { &trimmed[1..] } else { trimmed };
1569
1570    let (digits, exponent) = crate::types::parse_number_parts(abs_str);
1571
1572    if digits.is_empty() {
1573        return "0".to_string();
1574    }
1575
1576    let mantissa: String = digits.iter().map(|&d| (b'0' + d) as char).collect();
1577    let sign = if negative { "-" } else { "" };
1578    format!("{sign}{mantissa}E{exponent}")
1579}
1580
1581/// Validate a Local Secondary Index definition.
1582pub fn validate_lsi(
1583    lsi: &crate::types::LocalSecondaryIndex,
1584    table_key_schema: &[KeySchemaElement],
1585    all_definitions: &[AttributeDefinition],
1586) -> Result<()> {
1587    // Validate index name length
1588    if lsi.index_name.len() < 3 || lsi.index_name.len() > 255 {
1589        return Err(DynoxideError::ValidationException(format!(
1590            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
1591             failed to satisfy constraint: Member must have length greater than or equal to 3",
1592            lsi.index_name
1593        )));
1594    }
1595
1596    // Validate index name character set
1597    if !lsi
1598        .index_name
1599        .chars()
1600        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
1601    {
1602        return Err(DynoxideError::ValidationException(format!(
1603            "1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
1604             failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
1605            lsi.index_name
1606        )));
1607    }
1608
1609    // Validate key schema
1610    validate_key_schema(&lsi.key_schema)?;
1611
1612    // Validate projection (DynamoDB checks this before hash key / sort key checks)
1613    validate_projection(&lsi.projection, &lsi.index_name)?;
1614
1615    // LSI must have a RANGE key (sort key)
1616    let lsi_pk = lsi
1617        .key_schema
1618        .iter()
1619        .find(|k| k.key_type == KeyType::HASH)
1620        .map(|k| k.attribute_name.as_str());
1621    let lsi_sk = lsi
1622        .key_schema
1623        .iter()
1624        .find(|k| k.key_type == KeyType::RANGE)
1625        .map(|k| k.attribute_name.as_str());
1626
1627    let table_pk = partition_key_name(table_key_schema);
1628    let table_sk = sort_key_name(table_key_schema);
1629
1630    // LSI partition key MUST match table partition key
1631    if lsi_pk != table_pk {
1632        return Err(DynoxideError::ValidationException(
1633            "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(),
1634        ));
1635    }
1636
1637    // LSI sort key must be different from table sort key
1638    if lsi_sk.is_some() && lsi_sk == table_sk {
1639        return Err(DynoxideError::ValidationException(
1640            "One or more parameter values were invalid: Index KeySchema: The index KeySchema must not be the same as the table KeySchema".to_string(),
1641        ));
1642    }
1643
1644    // LSI sort key must be in AttributeDefinitions
1645    validate_key_attributes_in_definitions(&lsi.key_schema, all_definitions)?;
1646
1647    Ok(())
1648}
1649
1650/// Extract the sort key name from a key schema (if present).
1651pub fn sort_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
1652    key_schema
1653        .iter()
1654        .find(|k| k.key_type == KeyType::RANGE)
1655        .map(|k| k.attribute_name.as_str())
1656}
1657
1658#[cfg(test)]
1659mod tests {
1660    use super::*;
1661
1662    /// An empty GSI key schema is refused by the shape check itself, in the
1663    /// words both callers used to give before calling it, rather than relying
1664    /// on each caller to guard the first element.
1665    #[test]
1666    fn an_empty_gsi_key_schema_is_refused_by_the_shape_check() {
1667        assert_eq!(
1668            check_gsi_key_schema(&[]),
1669            Err(
1670                "1 validation error detected: Value null at 'keySchema' failed to satisfy \
1671                 constraint: Member must have length less than or equal to 2"
1672                    .to_string()
1673            )
1674        );
1675    }
1676
1677    #[test]
1678    fn java_hash_order_matches_the_captured_lists() {
1679        // Definitions as requested, in DynamoDB's message order, eu-west-2 and
1680        // us-east-1, 2026-10-09: a table sized to the count, then a default one.
1681        assert_eq!(
1682            java_hash_order(&["PK", "h", "a", "unused"], true),
1683            ["h", "a", "unused", "PK"]
1684        );
1685        assert_eq!(
1686            java_hash_order(&["PK", "a", "b", "c", "d", "u1"], true),
1687            ["a", "b", "PK", "c", "d", "u1"]
1688        );
1689        assert_eq!(java_hash_order(&["h", "a", "zz"], true), ["h", "zz", "a"]);
1690        assert_eq!(
1691            java_hash_order(&["pk", "sk", "gpk", "gsk"], false),
1692            ["gpk", "sk", "pk", "gsk"]
1693        );
1694        assert_eq!(
1695            java_hash_order(&["orderId", "customerId", "createdAt", "status"], false),
1696            ["createdAt", "orderId", "customerId", "status"]
1697        );
1698    }
1699
1700    fn hash_key(name: &str) -> KeySchemaElement {
1701        KeySchemaElement {
1702            attribute_name: name.to_string(),
1703            key_type: KeyType::HASH,
1704        }
1705    }
1706
1707    fn range_key(name: &str) -> KeySchemaElement {
1708        KeySchemaElement {
1709            attribute_name: name.to_string(),
1710            key_type: KeyType::RANGE,
1711        }
1712    }
1713
1714    fn attr_def(name: &str, attr_type: ScalarAttributeType) -> AttributeDefinition {
1715        AttributeDefinition {
1716            attribute_name: name.to_string(),
1717            attribute_type: attr_type,
1718        }
1719    }
1720
1721    #[test]
1722    fn test_valid_table_name() {
1723        assert!(validate_table_name("MyTable").is_ok());
1724        assert!(validate_table_name("my-table.v2").is_ok());
1725        assert!(validate_table_name("a_b").is_ok());
1726    }
1727
1728    #[test]
1729    fn test_short_table_name_accepted_for_read_write() {
1730        // ReadWrite context (the default for validate_table_name) only enforces min length 1,
1731        // matching AWS's per-operation rules. CreateTable's min-length-3 lives behind
1732        // table_name_constraint_errors with TableNameContext::CreateTable.
1733        assert!(validate_table_name("ab").is_ok());
1734        assert!(validate_table_name("a").is_ok());
1735    }
1736
1737    #[test]
1738    fn test_empty_table_name_rejected_for_read_write() {
1739        let err = validate_table_name("").unwrap_err().to_string();
1740        assert!(err.contains("Member must have length greater than or equal to 1"));
1741        assert!(!err.contains("greater than or equal to 3"));
1742    }
1743
1744    #[test]
1745    fn test_invalid_table_name_bad_chars() {
1746        assert!(validate_table_name("my table").is_err());
1747        assert!(validate_table_name("my@table").is_err());
1748    }
1749
1750    #[test]
1751    fn test_create_table_context_keeps_min_length_3() {
1752        let errs = table_name_constraint_errors(Some("ab"), TableNameContext::CreateTable);
1753        assert!(
1754            errs.iter()
1755                .any(|e| e.contains("Member must have length greater than or equal to 3"))
1756        );
1757    }
1758
1759    #[test]
1760    fn test_valid_key_schema() {
1761        let schema = vec![hash_key("pk")];
1762        assert!(validate_key_schema(&schema).is_ok());
1763
1764        let schema = vec![hash_key("pk"), range_key("sk")];
1765        assert!(validate_key_schema(&schema).is_ok());
1766    }
1767
1768    #[test]
1769    fn test_invalid_key_schema_empty() {
1770        assert!(validate_key_schema(&[]).is_err());
1771    }
1772
1773    #[test]
1774    fn test_invalid_key_schema_no_hash() {
1775        let schema = vec![range_key("sk")];
1776        assert!(validate_key_schema(&schema).is_err());
1777    }
1778
1779    #[test]
1780    fn test_valid_key_attributes_in_definitions() {
1781        let schema = vec![hash_key("pk"), range_key("sk")];
1782        let defs = vec![
1783            attr_def("pk", ScalarAttributeType::S),
1784            attr_def("sk", ScalarAttributeType::N),
1785        ];
1786        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_ok());
1787    }
1788
1789    #[test]
1790    fn test_missing_key_attribute_in_definitions() {
1791        let schema = vec![hash_key("pk"), range_key("sk")];
1792        let defs = vec![attr_def("pk", ScalarAttributeType::S)];
1793        assert!(validate_key_attributes_in_definitions(&schema, &defs).is_err());
1794    }
1795
1796    #[test]
1797    fn test_partition_key_name() {
1798        let schema = vec![hash_key("pk"), range_key("sk")];
1799        assert_eq!(partition_key_name(&schema), Some("pk"));
1800    }
1801
1802    #[test]
1803    fn test_sort_key_name() {
1804        let schema = vec![hash_key("pk"), range_key("sk")];
1805        assert_eq!(sort_key_name(&schema), Some("sk"));
1806
1807        let schema = vec![hash_key("pk")];
1808        assert_eq!(sort_key_name(&schema), None);
1809    }
1810
1811    #[test]
1812    fn test_envelope_request_validation_wraps_tagged_error() {
1813        let msg = "Value '' at 'expressionAttributeNames' failed to satisfy constraint: \
1814                   Map value must satisfy constraint";
1815        let err = envelope_request_validation(DynoxideError::EnvelopedValidation(msg.to_string()));
1816        match err {
1817            DynoxideError::ValidationException(m) => {
1818                assert_eq!(m, format!("1 validation error detected: {msg}"));
1819            }
1820            other => panic!("expected ValidationException, got {other:?}"),
1821        }
1822    }
1823
1824    #[test]
1825    fn test_envelope_request_validation_passes_other_errors_through() {
1826        let plain = envelope_request_validation(DynoxideError::ValidationException("msg".into()));
1827        assert!(matches!(
1828            &plain,
1829            DynoxideError::ValidationException(m) if m == "msg"
1830        ));
1831
1832        let key_empty =
1833            envelope_request_validation(DynoxideError::KeyEmptyValueValidation("msg".into()));
1834        assert!(matches!(
1835            &key_empty,
1836            DynoxideError::KeyEmptyValueValidation(m) if m == "msg"
1837        ));
1838
1839        let not_found =
1840            envelope_request_validation(DynoxideError::ResourceNotFoundException("msg".into()));
1841        assert!(matches!(
1842            &not_found,
1843            DynoxideError::ResourceNotFoundException(m) if m == "msg"
1844        ));
1845    }
1846
1847    #[test]
1848    fn test_strip_request_validation_tag_untags_without_envelope() {
1849        let err = strip_request_validation_tag(DynoxideError::EnvelopedValidation("msg".into()));
1850        assert!(matches!(
1851            &err,
1852            DynoxideError::ValidationException(m) if m == "msg"
1853        ));
1854    }
1855
1856    #[test]
1857    fn test_strip_request_validation_tag_passes_other_errors_through() {
1858        let plain = strip_request_validation_tag(DynoxideError::ValidationException("msg".into()));
1859        assert!(matches!(
1860            &plain,
1861            DynoxideError::ValidationException(m) if m == "msg"
1862        ));
1863
1864        let not_found =
1865            strip_request_validation_tag(DynoxideError::ResourceNotFoundException("msg".into()));
1866        assert!(matches!(
1867            &not_found,
1868            DynoxideError::ResourceNotFoundException(m) if m == "msg"
1869        ));
1870    }
1871
1872    #[test]
1873    fn normalising_an_item_sorts_its_sets_in_place_at_every_depth() {
1874        // Read the stored Vecs directly: every other set-order test reads back
1875        // through Serialize, which sorts on its own and would hide a write that
1876        // stopped sorting.
1877        let mut item: Item = [
1878            (
1879                "top".to_string(),
1880                AttributeValue::NS(vec!["3".into(), "-1".into(), "2".into()]),
1881            ),
1882            (
1883                "m".to_string(),
1884                AttributeValue::M(
1885                    [(
1886                        "ss".to_string(),
1887                        AttributeValue::SS(vec!["b".into(), "a".into(), "c".into()]),
1888                    )]
1889                    .into(),
1890                ),
1891            ),
1892            (
1893                "l".to_string(),
1894                AttributeValue::L(vec![
1895                    AttributeValue::NS(vec!["10".into(), "2".into(), "1e2".into()]),
1896                    AttributeValue::BS(vec![vec![2], vec![0xff], vec![1]]),
1897                ]),
1898            ),
1899        ]
1900        .into();
1901        normalize_item_sets(&mut item);
1902
1903        assert_eq!(
1904            item["top"],
1905            AttributeValue::NS(vec!["-1".into(), "2".into(), "3".into()])
1906        );
1907        let AttributeValue::M(m) = &item["m"] else {
1908            panic!("m")
1909        };
1910        assert_eq!(
1911            m["ss"],
1912            AttributeValue::SS(vec!["a".into(), "b".into(), "c".into()])
1913        );
1914        let AttributeValue::L(l) = &item["l"] else {
1915            panic!("l")
1916        };
1917        assert_eq!(
1918            l[0],
1919            AttributeValue::NS(vec!["2".into(), "10".into(), "100".into()])
1920        );
1921        assert_eq!(l[1], AttributeValue::BS(vec![vec![1], vec![2], vec![0xff]]));
1922    }
1923}