Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod query_key_values;
mod read_helpers;
mod request_limits;
mod scan;
pub mod scan_request;
mod search_vectors;
pub mod stream_capture;
mod streams;
Expand Down Expand Up @@ -217,7 +218,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 {
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,
//! 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::{
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))
}

/// 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(
&[
("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")
&& 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) {
"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") {
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