Skip to content
Open
3 changes: 2 additions & 1 deletion crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub mod query_key_values;
mod read_helpers;
mod request_limits;
mod scan;
pub mod scan_request;
mod search_vectors;
mod start_key;
pub mod stream_capture;
Expand Down Expand Up @@ -248,7 +249,7 @@ pub(crate) const RETURN_VALUES_MEMBERS: &[&str] =
/// Pre-validate enum fields in a JSON body and return a combined error if
/// multiple are invalid, in the order listed. Whether an operation aggregates
/// its enum violations or stops at the first is per operation on the service
/// (PutItem/DeleteItem/Scan aggregate; UpdateItem and Query stop at the
/// (PutItem and DeleteItem aggregate; UpdateItem, Query and Scan stop at the
/// first), so callers choose by passing one list or several sequential calls.
pub(crate) fn validate_enum_fields(
body: &serde_json::Value,
Expand Down
4 changes: 2 additions & 2 deletions crates/engine/src/query_key_values.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,12 +181,12 @@ fn query_request_checks(body: &Value) -> Result<(), DynamoDbError> {

/// Whether `field` is in the body with a value other than `null`, as a typed
/// `Option` field would deserialize to `Some`.
fn present(body: &Value, field: &str) -> bool {
pub(crate) fn present(body: &Value, field: &str) -> bool {
body.get(field).is_some_and(|v| !v.is_null())
}

/// Whether `field` is a non-empty JSON object or array in the body.
fn non_empty(body: &Value, field: &str) -> bool {
pub(crate) fn non_empty(body: &Value, field: &str) -> bool {
match body.get(field) {
Some(Value::Object(map)) => !map.is_empty(),
Some(Value::Array(items)) => !items.is_empty(),
Expand Down
89 changes: 13 additions & 76 deletions crates/engine/src/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,58 +41,20 @@ pub async fn handle_scan(
body: Value,
ctx: &OperationContext,
) -> Result<DispatchResult, DynamoDbError> {
// Pre-scanned before typed deserialization. Scan AGGREGATES its invalid
// enums with Select's clause ahead of ReturnConsumedCapacity's, the
// opposite of Query on both counts (Query stops at the first, RCC-first).
// Measured 2026-08-24 (us-east-1): both invalid answers "2 validation
// errors detected: ... 'select' ...; ... 'returnConsumedCapacity' ...".
crate::validate_enum_fields(
&body,
&[
crate::EnumField {
json_name: "Select",
valid: &[
"SPECIFIC_ATTRIBUTES",
"COUNT",
"ALL_ATTRIBUTES",
"ALL_PROJECTED_ATTRIBUTES",
],
clause: crate::EnumClause::Named("select"),
},
crate::EnumField {
json_name: "ReturnConsumedCapacity",
valid: &["INDEXES", "TOTAL", "NONE"],
clause: crate::EnumClause::Named("returnConsumedCapacity"),
},
],
)?;
// Request-time checks on the raw body, in the service's order: the Limit,
// Segment and TotalSegments ranges, the expression and non-expression
// parameter mix, the ReturnConsumedCapacity enum, every number in
// ExpressionAttributeValues, the Select enum. They run before typed
// deserialization so a field that does not deserialize does not stop the
// ones before it; a field of the wrong JSON type anywhere in the body is
// reported ahead of them; see `scan_request`.
crate::scan_request::validate_scan_request_body(&body)?;
let input: ScanInput = serde_json::from_value(body).map_err(crate::deserialize_error)?;

// Validate Filter/Projection expressions before the existence
// check; index resolution, Segment/Limit/Select and key checks stay after.

// --- Legacy vs expression mutual exclusivity checks ---
let has_fe = input.filter_expression.is_some();
let has_sf = input.scan_filter.as_ref().is_some_and(|m| !m.is_empty());
let has_pe = input.projection_expression.is_some();
let has_atg = input
.attributes_to_get
.as_ref()
.is_some_and(|a| !a.is_empty());
// check; index resolution, Segment pairing, Select and key checks stay after.
let has_cond_op = input.conditional_operator.is_some();

extenddb_core::validation::validate_no_expression_param_mixing(
&[
("AttributesToGet", has_atg),
("ScanFilter", has_sf),
("ConditionalOperator", has_cond_op),
],
&[
("ProjectionExpression", has_pe),
("FilterExpression", has_fe),
],
)?;

let maps = build_expression_maps(
input.expression_attribute_names.as_ref(),
input.expression_attribute_values.as_ref(),
Expand Down Expand Up @@ -299,7 +261,9 @@ pub async fn handle_scan(
)?;
}

// Validate Segment/TotalSegments — DynamoDB returns different messages per direction
// Validate Segment/TotalSegments pairing. The range of each field is
// checked at request time in validate_scan_request_body; the service
// reports a missing partner with a message per direction.
match (input.segment, input.total_segments) {
(Some(_), None) => {
return Err(DynamoDbError::ValidationException(
Expand All @@ -314,26 +278,6 @@ pub async fn handle_scan(
));
}
(Some(seg), Some(total)) => {
if total < 1 {
return Err(DynamoDbError::ValidationException(
"The parameter TotalSegments should be greater than or equal to 1".to_owned(),
));
}
if total > 1_000_000 {
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{total}' at 'totalSegments' failed to satisfy constraint: Member must have value less than or equal to 1000000"
)));
}
if seg < 0 {
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{seg}' at 'segment' failed to satisfy constraint: Member must have value greater than or equal to 0"
)));
}
if seg > 999_999 {
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{seg}' at 'segment' failed to satisfy constraint: Member must have value less than or equal to 999999"
)));
}
if seg >= total {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test fails with this check disabled. TotalSegments: 1 accepted has no row either.

return Err(DynamoDbError::ValidationException(format!(
"The Segment parameter is zero-based and must be less than parameter TotalSegments: \
Expand All @@ -344,14 +288,7 @@ pub async fn handle_scan(
(None, None) => {}
}

// Validate Limit >= 1
if let Some(limit) = input.limit
&& limit < 1
{
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{limit}' at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1"
)));
}
// Limit >= 1 is checked at request time in validate_scan_request_body.

// For index scans, build a key_info that reflects the index's key schema.
let scan_key_info = if let Some(ref idx) = index_info {
Expand Down
170 changes: 170 additions & 0 deletions crates/engine/src/scan_request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
// Copyright 2026 ExtendDB contributors
// SPDX-License-Identifier: Apache-2.0

//! The `Scan` checks that run on the request body alone, before the body is
//! deserialized and before the table is looked up, in the service's order.
//!
//! Measured 2026-10-25 (us-east-1, each request twice):
//!
//! 1. the range constraints on `Limit` (at least 1), `Segment` (0 to 999999)
//! and `TotalSegments` (1 to 1000000), each checked when present, whether or
//! not the other segment field is. A `Limit` below 1 inside the int32 range
//! answers `1 validation error detected: Value at 'Limit' failed to satisfy
//! constraint: Member must have value greater than or equal to 1`; below
//! int32 the service saturates the value and answers the older form
//! `Value '-2147483648' at 'limit' ...`. The service aggregates several
//! range faults into one message; this module reports the first in the
//! order `Limit`, `Segment`, `TotalSegments`, with the `Segment` and
//! `TotalSegments` texts the previous release used;
//! 2. the expression and non-expression parameter mix;
//! 3. the `ReturnConsumedCapacity` enum;
//! 4. every number in `ExpressionAttributeValues`, `NS` members and values
//! nested in `L` and `M` included, whether a `FilterExpression` reads the
//! value, nothing reads it, or the request carries no expression at all; a
//! Unicode decimal digit counts as the digit it stands for;
//! 5. the `Select` enum. Both enums invalid answers the `ReturnConsumedCapacity`
//! error alone, so the two are not aggregated.
//!
//! Each of these is reported ahead of the next one, ahead of the table lookup,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DynamoDB reports the undefined placeholder, unused value and filter syntax checks ahead of Select: {"Select": "bad", "FilterExpression": "a = :undef"} answers Invalid FilterExpression: An expression attribute value used in expression is not defined; attribute value: :undef. DynamoDB also accepts ConsistentRead: "true", so not every wrong JSON type answers SerializationException.

//! and ahead of the checks the handler makes after deserializing the body
//! (Segment without TotalSegments and the reverse, Segment not below
//! TotalSegments, the undefined placeholder, unused value, filter syntax,
//! IndexName, ConsistentRead, Select with ProjectionExpression and
//! ExclusiveStartKey checks). A field of the wrong JSON type anywhere in the
//! body is reported ahead of all of them, as a `SerializationException`.

use std::collections::HashMap;

use extenddb_core::error::DynamoDbError;
use extenddb_core::types::{AttributeValue, ScanInput};
use serde_json::Value;

use crate::query_key_values::{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged with the current fix/query-key-value-types (54150fe), this file does not compile: error[E0603]: function validate_expression_attribute_value_numbers is private. That function now takes the raw map and checks each value on its own. Scan needs it, because the map deserialize at line 97 skips the check when any value does not deserialize. {"FilterExpression": "a = :a OR a = :n", "ExpressionAttributeValues": {":a": {}, ":n": {"N": "bob"}}} answers DynamoDB's ExpressionAttributeValues contains invalid value: The parameter cannot be converted to a numeric value: bob for key :n there; here it answers ... Supplied AttributeValue is empty ... for key :a.

non_empty, present, shape_fault_first, validate_expression_attribute_value_numbers,
};

/// The `Scan` checks that run on the raw request body, in the service's order:
/// the `Limit`, `Segment` and `TotalSegments` ranges, the expression and
/// non-expression parameter mix, the `ReturnConsumedCapacity` enum, every
/// number in `ExpressionAttributeValues`, then the `Select` enum.
///
/// The checks read the raw body, so a field that does not deserialize (an
/// invalid `Select`, for one) does not stop the ones before it. When a check
/// fails, the body is deserialized as a [`ScanInput`] and a
/// `SerializationException` from that (a field of the wrong JSON shape
/// anywhere in the body) is the answer instead, as the service reports a shape
/// fault ahead of every validation; see [`shape_fault_first`]. A body that
/// passes every check is not deserialized here.
///
/// # Errors
///
/// Returns `DynamoDbError::ValidationException` with the service's text, or
/// `DynamoDbError::SerializationException` for a body of the wrong shape.
pub fn validate_scan_request_body(body: &Value) -> Result<(), DynamoDbError> {
scan_request_checks(body).map_err(|check| shape_fault_first::<ScanInput>(body, check))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A JSON null where an AttributeValue belongs turns an enum error into SerializationException. Request: {"TableName": "t", "FilterExpression": "attribute_exists(a) OR a = :v", "ExpressionAttributeValues": {":v": null}, "Select": "bad"}. DynamoDB and main, twice: 1 validation error detected: Value 'bad' at 'select' failed to satisfy constraint: Member must satisfy enum value set: [SPECIFIC_ATTRIBUTES, COUNT, ALL_ATTRIBUTES, ALL_PROJECTED_ATTRIBUTES]. This branch: SerializationException: Start of structure or map found where not expected: invalid type: null, expected a DynamoDB AttributeValue map with exactly one type descriptor. {"L": [null]} and {"S": null} behave the same. The answer also depends on map order. {"Limit": 0, "ExpressionAttributeValues": {":u": {"X": "1"}}, "Select": 5} answers the Limit ValidationException on every backend; DynamoDB and main answer SerializationException. A body with an invalid Select ahead of a malformed B value answers the Select enum on MongoDB, which keeps request order, and SerializationException elsewhere and on DynamoDB.

}

/// The `Scan` checks in the service's order; see
/// [`validate_scan_request_body`].
fn scan_request_checks(body: &Value) -> Result<(), DynamoDbError> {
validate_ranges(body)?;

extenddb_core::validation::validate_no_expression_param_mixing(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service reports an invalid ReturnConsumedCapacity ahead of the parameter mix, as main does. Request: {"TableName": "t", "ScanFilter": {"a": {"ComparisonOperator": "EQ", "AttributeValueList": [{"S": "x"}]}}, "ReturnConsumedCapacity": "NOPE", "FilterExpression": "a = :n", "ExpressionAttributeValues": {":n": {"N": "1"}}}. DynamoDB, twice: 1 validation error detected: Value 'NOPE' at 'returnConsumedCapacity' failed to satisfy constraint: Member must satisfy enum value set: [INDEXES, TOTAL, NONE]. This branch: Can not use both expression and non-expression parameters in the same request: Non-expression parameters: {ScanFilter} Expression parameters: {FilterExpression}. The AttributesToGet and ConditionalOperator forms split the same way. The ReturnConsumedCapacity check moves ahead of this call.

&[
("AttributesToGet", non_empty(body, "AttributesToGet")),
("ScanFilter", non_empty(body, "ScanFilter")),
("ConditionalOperator", present(body, "ConditionalOperator")),
],
&[
(
"ProjectionExpression",
present(body, "ProjectionExpression"),
),
("FilterExpression", present(body, "FilterExpression")),
],
)?;

crate::validate_enum_fields(
body,
&[crate::EnumField {
json_name: "ReturnConsumedCapacity",
valid: &["INDEXES", "TOTAL", "NONE"],
clause: crate::EnumClause::Named("returnConsumedCapacity"),
}],
)?;

if let Some(raw) = body.get("ExpressionAttributeValues")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ExpressionAttributeNames checks run only in typed deserialization, and shape_fault_first keeps the number error over them. Request: {"TableName": "t", "ExpressionAttributeValues": {":n": {"N": "bob"}}, "ExpressionAttributeNames": {"a": "b"}}. DynamoDB and main, twice: ExpressionAttributeNames contains invalid key: Syntax error; key: "a". This branch: The parameter cannot be converted to a numeric value: bob. {} and {"#a": ""} split the same way. The same arm makes {"Select": "NOPE", "FilterExpression": "a = :d", "ExpressionAttributeValues": {":d": {"NS": ["1", "1"]}}} answer the Select enum, where DynamoDB answers One or more parameter values were invalid: Input collection contains duplicates.

&& let Ok(values) = serde_json::from_value::<HashMap<String, AttributeValue>>(raw.clone())
{
validate_expression_attribute_value_numbers(&values)?;
}

crate::validate_enum_fields(
body,
&[crate::EnumField {
json_name: "Select",
valid: &[
"SPECIFIC_ATTRIBUTES",
"COUNT",
"ALL_ATTRIBUTES",
"ALL_PROJECTED_ATTRIBUTES",
],
clause: crate::EnumClause::Named("select"),
}],
)
}

/// The range constraints on `Limit`, `Segment` and `TotalSegments`, the first
/// fault in that order.
///
/// `Limit` answers the service's text: inside the int32 range the field is
/// capitalised and the value is not echoed; below int32 the service saturates
/// the value and uses the older `Value '<n>' at 'limit'` form (measured
/// 2026-10-25, `Limit: -2147483649`). `Segment` and `TotalSegments` keep the
/// texts of the previous release.
fn validate_ranges(body: &Value) -> Result<(), DynamoDbError> {
let get = |field: &str| body.get(field).and_then(Value::as_i64);
if let Some(limit) = get("Limit")
&& limit < 1
{
let text = if limit >= i64::from(i32::MIN) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DynamoDB uses the older form when the map holds a value it cannot read. {"Limit": 0, "ExpressionAttributeValues": {":x": {"X": "1"}}}, eight runs: 1 validation error detected: Value '0' at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1. This branch: Value at 'Limit'. The same holds for {} and {"S": "a", "N": "1"}. An NS with duplicates or an empty NS keeps Value at 'Limit'. No Scan test sends this case.

"Value at 'Limit' failed to satisfy constraint: Member must have value greater than or equal to 1".to_owned()
} else {
format!(
"Value '{}' at 'limit' failed to satisfy constraint: Member must have value greater than or equal to 1",
i32::MIN
)
};
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: {text}"
)));
}
if let Some(segment) = get("Segment") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These checks now answer requests that main answered with the pairing text, so they need the service's texts. DynamoDB, twice each:

  • {"Segment": -1} answers 1 validation error detected: Value at 'Segment' failed to satisfy constraint: Member must have value greater than or equal to 0.
  • {"TotalSegments": 0} answers 1 validation error detected: Value at 'TotalSegments' failed to satisfy constraint: Member must have value greater than or equal to 1.
  • {"Segment": 2147483648} answers Value '2147483647' at 'segment' ....
  • {"Limit": 0, "Segment": -1} answers 2 validation errors detected: Value at 'Limit' ...; Value at 'Segment' ....
  • {"Limit": -2147483649, "ReturnConsumedCapacity": "bad"} answers 2 validation errors detected: Value 'bad' at 'returnConsumedCapacity' ...; Value '-2147483648' at 'limit' ....

This branch answers Value '-1' at 'segment', The parameter TotalSegments should be greater than or equal to 1, Value '2147483648', and one fault in each of the last two.

if segment < 0 {
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{segment}' at 'segment' failed to satisfy constraint: Member must have value greater than or equal to 0"
)));
}
if segment > 999_999 {
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{segment}' at 'segment' failed to satisfy constraint: Member must have value less than or equal to 999999"
)));
}
}
if let Some(total) = get("TotalSegments") {
if total < 1 {
return Err(DynamoDbError::ValidationException(
"The parameter TotalSegments should be greater than or equal to 1".to_owned(),
));
}
if total > 1_000_000 {
return Err(DynamoDbError::ValidationException(format!(
"1 validation error detected: Value '{total}' at 'totalSegments' failed to satisfy constraint: Member must have value less than or equal to 1000000"
)));
}
}
Ok(())
}

#[cfg(test)]
mod tests;
Loading
Loading