Skip to main content

dynoxide/actions/
query.rs

1use crate::actions::helpers;
2use crate::errors::{DynoxideError, Result};
3use crate::expressions;
4use crate::storage_backend::StorageBackend;
5use crate::types::{AttributeValue, Item};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// 1MB response size limit for Query/Scan.
10const MAX_RESPONSE_SIZE: usize = 1_048_576;
11
12/// Internal deserialization struct for detecting missing fields.
13#[derive(Debug, Default, Deserialize)]
14struct QueryRequestRaw {
15    #[serde(rename = "TableName", default)]
16    table_name: Option<String>,
17    #[serde(rename = "KeyConditionExpression", default)]
18    key_condition_expression: Option<String>,
19    #[serde(rename = "FilterExpression", default)]
20    filter_expression: Option<String>,
21    #[serde(rename = "ProjectionExpression", default)]
22    projection_expression: Option<String>,
23    #[serde(rename = "ExpressionAttributeNames", default)]
24    expression_attribute_names: Option<HashMap<String, String>>,
25    #[serde(
26        rename = "ExpressionAttributeValues",
27        default,
28        deserialize_with = "crate::types::deserialize_named_empty_and_null_refusals"
29    )]
30    expression_attribute_values: Option<HashMap<String, AttributeValue>>,
31    #[serde(
32        rename = "ScanIndexForward",
33        default,
34        deserialize_with = "crate::types::deserialize_lenient_flag"
35    )]
36    scan_index_forward: Option<bool>,
37    #[serde(
38        rename = "Limit",
39        default,
40        deserialize_with = "helpers::deserialize_int32"
41    )]
42    limit: Option<helpers::Int32>,
43    #[serde(rename = "ExclusiveStartKey", default)]
44    exclusive_start_key: Option<serde_json::Value>,
45    #[serde(rename = "Select", default)]
46    select: Option<String>,
47    #[serde(
48        rename = "ConsistentRead",
49        default,
50        deserialize_with = "crate::types::deserialize_lenient_flag"
51    )]
52    consistent_read: Option<bool>,
53    #[serde(rename = "IndexName", default)]
54    index_name: Option<String>,
55    #[serde(rename = "ReturnConsumedCapacity", default)]
56    return_consumed_capacity: Option<String>,
57    #[serde(rename = "KeyConditions", default)]
58    key_conditions: Option<serde_json::Value>,
59    #[serde(rename = "AttributesToGet", default)]
60    attributes_to_get: Option<Vec<String>>,
61    #[serde(rename = "QueryFilter", default)]
62    query_filter: Option<serde_json::Value>,
63    #[serde(rename = "ConditionalOperator", default)]
64    conditional_operator: Option<String>,
65}
66
67#[derive(Debug, Default)]
68pub struct QueryRequest {
69    pub table_name: String,
70    pub key_condition_expression: Option<String>,
71    pub filter_expression: Option<String>,
72    pub projection_expression: Option<String>,
73    pub expression_attribute_names: Option<HashMap<String, String>>,
74    pub expression_attribute_values: Option<HashMap<String, AttributeValue>>,
75    pub scan_index_forward: bool,
76    pub limit: Option<usize>,
77    pub exclusive_start_key: Option<HashMap<String, AttributeValue>>,
78    pub select: Option<String>,
79    pub consistent_read: Option<bool>,
80    pub index_name: Option<String>,
81    pub return_consumed_capacity: Option<String>,
82    pub key_conditions: Option<serde_json::Value>,
83    pub attributes_to_get: Option<Vec<String>>,
84    pub query_filter: Option<serde_json::Value>,
85    pub conditional_operator: Option<String>,
86    /// Raw JSON for ExclusiveStartKey when deserialized from HTTP request.
87    /// Parsed lazily in `execute()` after other validations run.
88    /// When constructed directly (e.g. from MCP), this is `None` and
89    /// `exclusive_start_key` is used instead.
90    pub exclusive_start_key_raw: Option<serde_json::Value>,
91}
92
93/// The rejection for a `Select` outside the enum, or `None` when it is one
94/// DynamoDB accepts. The one copy of the set and the wording.
95pub(crate) fn select_rejection(select: &str) -> Option<String> {
96    if [
97        "ALL_ATTRIBUTES",
98        "ALL_PROJECTED_ATTRIBUTES",
99        "COUNT",
100        "SPECIFIC_ATTRIBUTES",
101    ]
102    .contains(&select)
103    {
104        return None;
105    }
106    Some(format!(
107        "Value '{select}' at 'select' failed to satisfy constraint: Member must satisfy \
108         enum value set: [SPECIFIC_ATTRIBUTES, COUNT, ALL_ATTRIBUTES, ALL_PROJECTED_ATTRIBUTES]"
109    ))
110}
111
112impl<'de> serde::Deserialize<'de> for QueryRequest {
113    fn deserialize<D: serde::Deserializer<'de>>(
114        deserializer: D,
115    ) -> std::result::Result<Self, D::Error> {
116        let raw = QueryRequestRaw::deserialize(deserializer)?;
117        use crate::validation::format_validation_errors;
118
119        // A BOOL or NULL in a legacy condition that doesn't read as a boolean
120        // makes the whole request malformed, ahead of everything else.
121        if let Some(message) =
122            crate::types::malformed_condition_boolean(raw.key_conditions.as_ref())
123                .or_else(|| crate::types::malformed_condition_boolean(raw.query_filter.as_ref()))
124        {
125            return Err(serde::de::Error::custom(message));
126        }
127
128        // A Limit below 1 and an empty name are the newer validation layer's
129        // own checks, reported together, Limit first, ahead of everything
130        // else. A name error and ReturnConsumedCapacity follow, together;
131        // Select waits until after the expression-mixing check in `execute`
132        // (captured 2026-10-09, six of seven regions). Scan words them the same
133        // way where the newer layer runs it (see `ScanMembers`).
134        //
135        // With no TableName at all, the request goes to the older layer, which
136        // reports every member error together: Select, ReturnConsumedCapacity,
137        // the missing name, then a Limit below 1 with its value echoed and the
138        // member spelled 'limit', as Scan does (captured eu-west-2 and
139        // us-east-1, 2026-10-09 and 2026-10-20).
140        //
141        // A Limit that saturated or lost a fraction as it was read (see
142        // `helpers::Int32`) is checked by the older layer the same way: the
143        // value as read, and an empty name in the older wording (captured
144        // eu-west-2, eu-central-1 and us-east-1, 2026-10-20).
145        let limit = raw.limit.map(|n| n.value);
146        let coerced = raw.limit.is_some_and(|n| n.coerced);
147        let limit_too_small = limit.filter(|l| *l < 1);
148        if raw.table_name.is_none() || coerced {
149            let mut errors = Vec::new();
150            errors.extend(raw.select.as_deref().and_then(select_rejection));
151            errors.extend(crate::validation::return_consumed_capacity_rejection(
152                raw.return_consumed_capacity.as_deref(),
153            ));
154            if matches!(raw.table_name.as_deref(), None | Some("")) {
155                errors.extend(crate::validation::table_name_constraint_errors(
156                    raw.table_name.as_deref(),
157                    crate::validation::TableNameContext::ReadWrite,
158                ));
159            }
160            // The missing operators' place is inferred from Scan's.
161            errors.extend(helpers::missing_comparison_operators(
162                raw.key_conditions.as_ref(),
163                "keyConditions",
164            ));
165            errors.extend(helpers::missing_comparison_operators(
166                raw.query_filter.as_ref(),
167                "queryFilter",
168            ));
169            if let Some(limit) = limit_too_small {
170                errors.push(format!(
171                    "Value '{limit}' at 'limit' failed to satisfy constraint: \
172                     Member must have value greater than or equal to 1"
173                ));
174            }
175            if let Some(msg) = format_validation_errors(&errors) {
176                return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
177            }
178        }
179        let mut first_pass = Vec::new();
180        if limit_too_small.is_some() {
181            first_pass.push(
182                "Value at 'Limit' failed to satisfy constraint: \
183                 Member must have value greater than or equal to 1"
184                    .to_string(),
185            );
186        }
187        if raw.table_name.as_deref() == Some("") {
188            first_pass.push(crate::validation::EMPTY_TABLE_NAME_REJECTION.to_string());
189        }
190        if let Some(msg) = format_validation_errors(&first_pass) {
191            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
192        }
193
194        let mut errors = crate::validation::item_table_name_errors(raw.table_name.as_deref());
195        let table_name = raw.table_name.unwrap_or_default();
196
197        // ReturnConsumedCapacity enum
198        if let Some(msg) = crate::validation::return_consumed_capacity_rejection(
199            raw.return_consumed_capacity.as_deref(),
200        ) {
201            errors.push(msg);
202        }
203
204        // A KeyConditions or QueryFilter condition with no ComparisonOperator,
205        // as a ScanFilter's is refused (inferred, as is the order).
206        errors.extend(helpers::missing_comparison_operators(
207            raw.key_conditions.as_ref(),
208            "keyConditions",
209        ));
210        errors.extend(helpers::missing_comparison_operators(
211            raw.query_filter.as_ref(),
212            "queryFilter",
213        ));
214
215        if let Some(msg) = format_validation_errors(&errors) {
216            return Err(serde::de::Error::custom(format!("VALIDATION:{}", msg)));
217        }
218
219        Ok(QueryRequest {
220            table_name,
221            key_condition_expression: raw.key_condition_expression,
222            filter_expression: raw.filter_expression,
223            projection_expression: raw.projection_expression,
224            expression_attribute_names: raw.expression_attribute_names,
225            expression_attribute_values: raw.expression_attribute_values,
226            // Left out or null, a Query reads forward (captured eu-west-2 and
227            // us-east-1, 2026-10-10).
228            scan_index_forward: raw.scan_index_forward.unwrap_or(true),
229            limit: limit.map(|l| l as usize),
230            exclusive_start_key: None,
231            select: raw.select,
232            consistent_read: raw.consistent_read,
233            index_name: raw.index_name,
234            return_consumed_capacity: raw.return_consumed_capacity,
235            key_conditions: raw.key_conditions,
236            attributes_to_get: raw.attributes_to_get,
237            query_filter: raw.query_filter,
238            conditional_operator: raw.conditional_operator,
239            exclusive_start_key_raw: raw.exclusive_start_key,
240        })
241    }
242}
243
244#[derive(Debug, Default, Serialize)]
245pub struct QueryResponse {
246    #[serde(rename = "Items", skip_serializing_if = "Option::is_none")]
247    // Number sets in numeric order here, as DynamoDB returns them;
248    // pinned by set_order's every_response_item_field test.
249    pub items: Option<Vec<Item>>,
250    #[serde(rename = "Count")]
251    pub count: usize,
252    #[serde(rename = "ScannedCount")]
253    pub scanned_count: usize,
254    #[serde(rename = "LastEvaluatedKey", skip_serializing_if = "Option::is_none")]
255    pub last_evaluated_key: Option<HashMap<String, AttributeValue>>,
256    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
257    pub consumed_capacity: Option<crate::types::ConsumedCapacity>,
258}
259
260pub async fn execute<S: StorageBackend>(
261    storage: &S,
262    mut request: QueryRequest,
263) -> Result<QueryResponse> {
264    // A Limit of 0 and an empty table name come first and together, Limit
265    // first, then any other table-name error, as the wire path reports them
266    // when it reads the body; a request built in code gets the same answer.
267    let mut first_pass = Vec::new();
268    if request.limit == Some(0) {
269        first_pass.push(
270            "Value at 'Limit' failed to satisfy constraint: \
271             Member must have value greater than or equal to 1"
272                .to_string(),
273        );
274    }
275    if request.table_name.is_empty() {
276        first_pass.push(crate::validation::EMPTY_TABLE_NAME_REJECTION.to_string());
277    }
278    if let Some(msg) = crate::validation::format_validation_errors(&first_pass) {
279        return Err(DynoxideError::ValidationException(msg));
280    }
281    crate::validation::validate_item_table_name(&request.table_name)?;
282
283    // A Limit past what a 32-bit integer holds is read as the largest one.
284    request.limit = request.limit.map(|l| l.min(i32::MAX as usize));
285
286    // An empty attribute name is refused bare, ahead of the table lookup and the
287    // expressions, wherever the request carries one: AttributesToGet, a
288    // QueryFilter value, the ExclusiveStartKey, the expression names and values
289    // (captured in ten regions). An empty name in QueryFilter itself is accepted.
290    if crate::empty_names::names_include_empty(request.attributes_to_get.as_ref())
291        || crate::empty_names::filter_values_have_empty_name(request.query_filter.as_ref())
292        || request
293            .exclusive_start_key
294            .as_ref()
295            .is_some_and(crate::empty_names::map_has_empty_name)
296        || request
297            .exclusive_start_key_raw
298            .as_ref()
299            .is_some_and(crate::empty_names::json_map_has_empty_name)
300    {
301        return Err(crate::empty_names::bare());
302    }
303    crate::empty_names::check_names(request.expression_attribute_names.as_ref())?;
304    if crate::empty_names::value_placeholder(request.expression_attribute_values.as_ref(), None)
305        .is_some()
306    {
307        return Err(crate::empty_names::bare());
308    }
309
310    // ---- Expression vs non-expression mixing validation ----
311    // DynamoDB checks this before anything else (except table name format and ESK values).
312    {
313        let mut non_expr = Vec::new();
314        let mut expr = Vec::new();
315        if request.attributes_to_get.is_some() {
316            non_expr.push("AttributesToGet");
317        }
318        if request.query_filter.is_some()
319            && request.query_filter.as_ref().is_some_and(|v| !v.is_null())
320        {
321            non_expr.push("QueryFilter");
322        }
323        if request.conditional_operator.is_some() {
324            non_expr.push("ConditionalOperator");
325        }
326        if request.key_conditions.is_some()
327            && request
328                .key_conditions
329                .as_ref()
330                .is_some_and(|v| !v.is_null())
331        {
332            non_expr.push("KeyConditions");
333        }
334        if request.projection_expression.is_some() {
335            expr.push("ProjectionExpression");
336        }
337        if request.filter_expression.is_some() {
338            expr.push("FilterExpression");
339        }
340        if request.key_condition_expression.is_some() {
341            expr.push("KeyConditionExpression");
342        }
343        let no_raw_eav: Option<serde_json::Value> = None;
344        let ctx = helpers::ExpressionParamContext {
345            non_expression_params: non_expr,
346            expression_params: expr,
347            all_expression_param_names: vec!["FilterExpression", "KeyConditionExpression"],
348            expression_attribute_names: &request.expression_attribute_names,
349            expression_attribute_values: &request.expression_attribute_values,
350            expression_attribute_values_raw: &no_raw_eav,
351            layer: crate::validation::ValueLayer::Current,
352        };
353        helpers::validate_expression_params(&ctx)?;
354    }
355
356    // ---- Select, after the mixing check (captured 2026-10-09) ----
357    if let Some(msg) = request.select.as_deref().and_then(select_rejection) {
358        return Err(DynoxideError::ValidationException(
359            crate::validation::envelope_message(&msg),
360        ));
361    }
362
363    // ---- ConditionalOperator only joins legacy QueryFilter conditions, so it
364    // is refused without two of them, whatever its value. Query reports it
365    // without the envelope (captured 2026-10-09, all seven regions). ----
366    if let Some(msg) = crate::validation::conditional_operator_misuse(
367        request.conditional_operator.as_deref(),
368        crate::validation::legacy_condition_count(request.query_filter.as_ref()),
369    ) {
370        return Err(DynoxideError::ValidationException(msg.to_string()));
371    }
372
373    // ---- Validate filter attribute values (before argument counts, matching DynamoDB) ----
374    // A QueryFilter or KeyConditions value nested too deep is refused first.
375    crate::value_nesting::check_query_conditions(request.query_filter.as_ref())?;
376    crate::value_nesting::check_query_conditions(request.key_conditions.as_ref())?;
377    helpers::validate_filter_conditions_raw(
378        request.query_filter.as_ref(),
379        "QueryFilter",
380        crate::validation::ValueLayer::Current,
381    )?;
382    helpers::validate_filter_conditions_raw(
383        request.key_conditions.as_ref(),
384        "KeyConditions",
385        crate::validation::ValueLayer::Current,
386    )?;
387
388    // ---- Validate filter argument counts and type compatibility ----
389    helpers::validate_filter_condition_args(request.query_filter.as_ref(), "queryFilter")?;
390    helpers::validate_filter_condition_args(request.key_conditions.as_ref(), "keyConditions")?;
391
392    // ---- Validate duplicate AttributesToGet ----
393    if let Some(ref attrs) = request.attributes_to_get {
394        helpers::validate_attributes_to_get_no_duplicates(attrs)?;
395    }
396
397    // ---- Parse ExclusiveStartKey from JSON value ----
398    let exclusive_start_key = if let Some(ref esk_val) = request.exclusive_start_key_raw {
399        Some(
400            helpers::parse_exclusive_start_key(esk_val, crate::validation::ValueLayer::Current)
401                .map_err(start_key_value_error)?,
402        )
403    } else {
404        request.exclusive_start_key.clone()
405    };
406    crate::value_nesting::check_query_start_key(exclusive_start_key.as_ref())?;
407
408    // ---- Validate expression syntax BEFORE table existence ----
409    // DynamoDB validates KeyConditionExpression, FilterExpression, and
410    // ProjectionExpression syntax before checking if the table exists.
411    if let Some(ref kce) = request.key_condition_expression
412        && kce.is_empty()
413    {
414        return Err(DynoxideError::ValidationException(
415            "Invalid KeyConditionExpression: The expression can not be empty;".to_string(),
416        ));
417    }
418    if let Some(ref fe) = request.filter_expression {
419        if fe.is_empty() {
420            if request.query_filter.is_none() || request.filter_expression.as_deref() == Some("") {
421                return Err(DynoxideError::ValidationException(
422                    "Invalid FilterExpression: The expression can not be empty;".to_string(),
423                ));
424            }
425        } else {
426            let parsed_fe = expressions::condition::parse(fe).map_err(|e| {
427                DynoxideError::ValidationException(format!("Invalid FilterExpression: {e}"))
428            })?;
429            // Validate that all #name references are defined in ExpressionAttributeNames
430            // (before table existence check, matching DynamoDB's validation ordering)
431            if let Err(e) = expressions::condition::validate_name_refs(
432                &parsed_fe,
433                &request.expression_attribute_names,
434            ) {
435                return Err(DynoxideError::ValidationException(format!(
436                    "Invalid FilterExpression: {e}"
437                )));
438            }
439            if let Err(e) = expressions::condition::validate_operand_semantics(
440                &parsed_fe,
441                &request.expression_attribute_names,
442                &request.expression_attribute_values,
443            ) {
444                return Err(DynoxideError::ValidationException(format!(
445                    "Invalid FilterExpression: {e}"
446                )));
447            }
448        }
449    }
450    if let Some(ref pe) = request.projection_expression
451        && pe.is_empty()
452    {
453        return Err(DynoxideError::ValidationException(
454            "Invalid ProjectionExpression: The expression can not be empty;".to_string(),
455        ));
456    }
457
458    // Select against the projection members and the index, in the envelope.
459    if let Some(msg) = helpers::select_projection_rejection(
460        request.select.as_deref(),
461        request.attributes_to_get.is_some(),
462        request.projection_expression.is_some(),
463        request.index_name.is_some(),
464    ) {
465        return Err(DynoxideError::ValidationException(msg));
466    }
467
468    // For KeyConditionExpression, validate syntax early too
469    if let Some(ref kce) = request.key_condition_expression
470        && !kce.is_empty()
471    {
472        // Create a temporary tracker for early syntax validation
473        let temp_tracker = crate::expressions::TrackedExpressionAttributes::new(
474            &request.expression_attribute_names,
475            &request.expression_attribute_values,
476        );
477        if let Err(e) = expressions::key_condition::parse(kce, &temp_tracker) {
478            return Err(DynoxideError::ValidationException(key_condition_error(
479                kce, e,
480            )));
481        }
482    }
483
484    let meta = helpers::require_table_for_item_op(storage, &request.table_name).await?;
485    let table_key_schema = helpers::parse_key_schema(&meta)?;
486
487    // Determine effective partition key name early so we can pass it to
488    // the legacy KeyConditions converter (ensures correct ordering when
489    // both hash and range keys use EQ).
490    let effective_pk_for_kc = if let Some(ref index_name) = request.index_name {
491        if let Some((pk, _)) = request
492            .index_name
493            .as_ref()
494            .and_then(|idx| super::lsi::parse_lsi_key_schema(&meta, idx).ok())
495        {
496            pk
497        } else if let Ok((pk, _)) = super::gsi::parse_gsi_key_schema(&meta, index_name) {
498            pk
499        } else {
500            table_key_schema.partition_key.clone()
501        }
502    } else {
503        table_key_schema.partition_key.clone()
504    };
505
506    // Convert legacy KeyConditions to KeyConditionExpression if no expression is set
507    let key_conditions: Option<HashMap<String, helpers::KeyCondition>> = match request
508        .key_conditions
509        .as_ref()
510    {
511        Some(kc_val) if request.key_condition_expression.is_none() && !kc_val.is_null() => Some(
512            helpers::read_legacy_conditions(kc_val, crate::validation::ValueLayer::Current)?,
513        ),
514        _ => None,
515    };
516    if let Some(kc) = key_conditions
517        && !kc.is_empty()
518    {
519        check_legacy_key_condition_types(&kc, &meta)?;
520        let converted = helpers::convert_key_conditions(&kc, Some(&effective_pk_for_kc))?;
521        request.key_condition_expression = Some(converted.expression);
522        let expr_values = request
523            .expression_attribute_values
524            .get_or_insert_with(HashMap::new);
525        expr_values.extend(converted.attribute_values);
526        let expr_names = request
527            .expression_attribute_names
528            .get_or_insert_with(HashMap::new);
529        expr_names.extend(converted.attribute_names);
530    }
531
532    // Convert legacy QueryFilter to FilterExpression if no expression is set
533    let query_filter: Option<HashMap<String, helpers::FilterCondition>> =
534        match request.query_filter.as_ref() {
535            Some(qf_val) if request.filter_expression.is_none() && !qf_val.is_null() => Some(
536                helpers::read_legacy_conditions(qf_val, crate::validation::ValueLayer::Current)?,
537            ),
538            _ => None,
539        };
540    if let Some(qf) = query_filter
541        && !qf.is_empty()
542    {
543        let converted =
544            helpers::convert_filter_conditions(&qf, request.conditional_operator.as_deref())?;
545        if !converted.expression.is_empty() {
546            request.filter_expression = Some(converted.expression);
547            let expr_values = request
548                .expression_attribute_values
549                .get_or_insert_with(HashMap::new);
550            expr_values.extend(converted.attribute_values);
551            let expr_names = request
552                .expression_attribute_names
553                .get_or_insert_with(HashMap::new);
554            expr_names.extend(converted.attribute_names);
555        }
556    }
557
558    // Convert legacy AttributesToGet to projection
559    let legacy_projection = if request.projection_expression.is_none() {
560        request
561            .attributes_to_get
562            .as_ref()
563            .map(|attrs| helpers::attributes_to_get_to_projection(attrs))
564    } else {
565        None
566    };
567
568    // Ensure KeyConditionExpression is present (required)
569    let key_condition_expression = request.key_condition_expression.as_deref().ok_or_else(|| {
570        DynoxideError::ValidationException(
571            "Either the KeyConditions or KeyConditionExpression parameter must be specified in the request."
572                .to_string(),
573        )
574    })?;
575    let key_condition_expression = key_condition_expression.to_string();
576
577    // Determine effective key schema (GSI, LSI, or base table)
578    let lsi_keys = request
579        .index_name
580        .as_ref()
581        .and_then(|idx| super::lsi::parse_lsi_key_schema(&meta, idx).ok());
582    let is_lsi = lsi_keys.is_some();
583
584    // ConsistentRead is not supported on GSIs (LSIs are fine)
585    if request.consistent_read.unwrap_or(false) && request.index_name.is_some() && !is_lsi {
586        return Err(DynoxideError::ValidationException(
587            "Consistent reads are not supported on global secondary indexes".to_string(),
588        ));
589    }
590
591    // Parse full index definition to get projection type
592    let index_projection_type = if let Some(ref index_name) = request.index_name {
593        if is_lsi {
594            super::lsi::parse_lsi_defs(&meta)?
595                .into_iter()
596                .find(|l| l.index_name == *index_name)
597                .map(|l| l.projection_type)
598        } else {
599            super::gsi::parse_gsi_defs(&meta)?
600                .into_iter()
601                .find(|g| g.index_name == *index_name)
602                .map(|g| g.projection_type)
603        }
604    } else {
605        None
606    };
607
608    let (effective_pk, effective_sk) = if let Some(ref index_name) = request.index_name {
609        if let Some(keys) = lsi_keys.clone() {
610            keys
611        } else {
612            // A vector index is listed by DescribeTable but no Query can read
613            // it, and DynamoDB refuses it by type rather than reporting the
614            // index missing (captured eu-west-2, 2026-08-19). Checked before
615            // the GSI lookup, which would otherwise answer "does not have the
616            // specified index" for an index the table does have.
617            reject_vector_index(&meta, index_name)?;
618            super::gsi::parse_gsi_key_schema(&meta, index_name)?
619        }
620    } else {
621        (
622            table_key_schema.partition_key.clone(),
623            table_key_schema.sort_key.clone(),
624        )
625    };
626
627    // Every partition key and sort key attribute, in key-schema order. Only a
628    // GSI can have more than one of each.
629    let (key_hash, key_range): (Vec<String>, Vec<String>) = match request.index_name {
630        Some(ref index_name) if !is_lsi => super::gsi::parse_gsi_key_attrs(&meta, index_name)?,
631        _ => (
632            vec![effective_pk.clone()],
633            effective_sk.iter().cloned().collect(),
634        ),
635    };
636
637    // ---- Validate ExclusiveStartKey structure against key schema ----
638    if let Some(ref esk) = exclusive_start_key {
639        // Stage 1+2: count check + index key type check
640        helpers::validate_esk_count_and_index_keys(
641            esk,
642            &meta,
643            request.index_name.as_deref(),
644            "The provided starting key is invalid",
645            false,
646        )
647        .map_err(bare_start_key_mismatch)?;
648        // Stage 3: table key type check
649        helpers::validate_esk_table_keys(esk, &meta).map_err(bare_start_key_mismatch)?;
650        // Stage 4: an empty string or binary key value has its own message.
651        check_start_key_not_empty(
652            esk,
653            &table_key_schema.partition_key,
654            table_key_schema.sort_key.as_deref(),
655        )?;
656    }
657
658    // Create tracker for unused expression attribute names/values
659    let tracker = crate::expressions::TrackedExpressionAttributes::new(
660        &request.expression_attribute_names,
661        &request.expression_attribute_values,
662    );
663
664    // Parse KeyConditionExpression and bind it to the key being read
665    let key_cond =
666        expressions::key_condition::parse(&key_condition_expression, &tracker).map_err(|e| {
667            DynoxideError::ValidationException(key_condition_error(&key_condition_expression, e))
668        })?;
669    let bound = expressions::key_condition::bind(
670        &key_cond,
671        expressions::key_condition::KeyAttributes {
672            hash: &key_hash,
673            range: &key_range,
674        },
675        &tracker,
676    )
677    .map_err(DynoxideError::ValidationException)?;
678    check_key_condition_types(&bound, &meta)?;
679
680    // Get pk string
681    let pk_str = bound.partition_key_string().ok_or_else(|| {
682        DynoxideError::ValidationException(
683            "Cannot convert partition key value to string".to_string(),
684        )
685    })?;
686
687    // Build sk SQL conditions
688    let (sk_condition_sql, sk_param_values) = bound.sort_key_sql(key_range.len());
689
690    // A starting key from another partition, or outside the sort key condition,
691    // is refused, on a table and on an index.
692    if let Some(ref esk) = exclusive_start_key {
693        let table_without_sort_key = request.index_name.is_none() && effective_sk.is_none();
694        check_start_key_within_condition(esk, &bound, table_without_sort_key)?;
695    }
696
697    // ---- Validate QueryFilter/FilterExpression don't reference primary key attrs ----
698    // Collect effective key attribute names
699    let effective_key_attrs: Vec<String> = key_hash.iter().chain(&key_range).cloned().collect();
700
701    // Check legacy QueryFilter
702    if let Some(ref qf_val) = request.query_filter
703        && let Some(obj) = qf_val.as_object()
704    {
705        for attr_name in obj.keys() {
706            if effective_key_attrs.contains(attr_name) {
707                return Err(DynoxideError::ValidationException(format!(
708                    "QueryFilter can only contain non-primary key attributes: \
709                         Primary key attribute: {attr_name}"
710                )));
711            }
712        }
713    }
714
715    // Check FilterExpression for key attribute references (only for user-supplied expressions,
716    // not those converted from QueryFilter - QueryFilter is checked separately above)
717    if request.query_filter.is_none()
718        && let Some(ref fe) = request.filter_expression
719        && let Ok(parsed_fe) = expressions::condition::parse(fe)
720    {
721        let top_attrs = expressions::condition::extract_top_level_attributes(
722            &parsed_fe,
723            &request.expression_attribute_names,
724        );
725        for attr in &top_attrs {
726            if effective_key_attrs.contains(attr) {
727                return Err(DynoxideError::ValidationException(format!(
728                    "Filter Expression can only contain non-primary key attributes: \
729                             Primary key attribute: {attr}"
730                )));
731            }
732        }
733        // Check for non-scalar key access in FilterExpression
734        // Build index key attribute lists
735        let mut index_key_attrs = Vec::new();
736        if request.index_name.is_some() {
737            // Index keys that are not also table keys
738            for k in &effective_key_attrs {
739                if ![table_key_schema.partition_key.clone()]
740                    .iter()
741                    .chain(table_key_schema.sort_key.iter())
742                    .any(|tk| tk == k)
743                {
744                    index_key_attrs.push(k.clone());
745                }
746            }
747        }
748        let base_key_attrs: Vec<String> = {
749            let mut v = vec![table_key_schema.partition_key.clone()];
750            if let Some(ref sk) = table_key_schema.sort_key {
751                v.push(sk.clone());
752            }
753            v
754        };
755        if let Some((attr, is_index)) = expressions::condition::check_non_scalar_key_access(
756            &parsed_fe,
757            &request.expression_attribute_names,
758            &base_key_attrs,
759            &index_key_attrs,
760        ) {
761            let prefix = if is_index { "IndexKey" } else { "Key" };
762            return Err(DynoxideError::ValidationException(format!(
763                "Key attributes must be scalars; \
764                         list random access '[]' and map lookup '.' are not allowed: {prefix}: {attr}"
765            )));
766        }
767    }
768
769    let is_index_query = request.index_name.is_some();
770
771    // Build ExclusiveStartKey sk value.
772    // For hash-only GSIs (no sort key), use empty string so the composite
773    // cursor (gsi_sk, table_pk, table_sk) can drive pagination.
774    let start_sk = if let Some(ref esk) = exclusive_start_key {
775        if !key_range.is_empty() {
776            let values: Option<Vec<&AttributeValue>> =
777                key_range.iter().map(|a| esk.get(a)).collect();
778            values.and_then(|v| super::gsi::composite_key_string(&v))
779        } else if is_index_query {
780            // Hash-only index: gsi_sk / lsi_sk is always ''
781            Some(String::new())
782        } else {
783            None
784        }
785    } else {
786        None
787    };
788
789    // For LSI and GSI queries, extract the base table keys from ExclusiveStartKey
790    // to enable composite cursor pagination.
791    let (start_base_pk, start_base_sk) = if is_index_query {
792        if let Some(ref esk) = exclusive_start_key {
793            let base_pk = esk
794                .get(&table_key_schema.partition_key)
795                .and_then(|v| v.to_key_string());
796            // When the base table is hash-only there is no base sort key, but the
797            // GSI/LSI row stores the empty-string default in its table_sk column.
798            // Default base_sk to "" (mirroring start_sk) so the composite cursor
799            // keeps its full width and disambiguates tied index keys by table_pk.
800            // Leaving it None collapses query_gsi_items to the 1-column gsi_sk
801            // cursor, which cannot advance past rows sharing the same index key
802            // and silently drops them. See scan.rs for the Scan-path counterpart.
803            let base_sk = if let Some(sk_name) = table_key_schema.sort_key.as_ref() {
804                esk.get(sk_name).and_then(|v| v.to_key_string())
805            } else {
806                Some(String::new())
807            };
808            (base_pk, base_sk)
809        } else {
810            (None, None)
811        }
812    } else {
813        (None, None)
814    };
815
816    // Validate Select=ALL_ATTRIBUTES against index projection type.
817    // For GSI with non-ALL projection, DynamoDB rejects ALL_ATTRIBUTES.
818    let is_select_all_attributes = request
819        .select
820        .as_deref()
821        .map(|s| s.eq_ignore_ascii_case("ALL_ATTRIBUTES"))
822        .unwrap_or(false);
823    let fetch_from_base_table = if is_select_all_attributes {
824        if let Some(ref proj_type) = index_projection_type {
825            if *proj_type != crate::types::ProjectionType::ALL {
826                if !is_lsi {
827                    return Err(DynoxideError::ValidationException(format!(
828                        "One or more parameter values were invalid: \
829                         Select type ALL_ATTRIBUTES is not supported for global secondary index {} \
830                         because its projection type is not ALL",
831                        request.index_name.as_deref().unwrap_or("")
832                    )));
833                }
834                // LSI with non-ALL projection: fetch full items from base table
835                true
836            } else {
837                false
838            }
839        } else {
840            false
841        }
842    } else {
843        false
844    };
845
846    let fetch_limit = request.limit;
847    let sk_params_refs: Vec<&str> = sk_param_values.iter().map(|s| s.as_str()).collect();
848
849    // Query either GSI table or base table
850    let query_params = crate::storage::QueryParams {
851        sk_condition: sk_condition_sql.as_deref(),
852        sk_params: &sk_params_refs,
853        forward: request.scan_index_forward,
854        limit: fetch_limit,
855        exclusive_start_sk: start_sk.as_deref(),
856        exclusive_start_base_pk: start_base_pk.as_deref(),
857        exclusive_start_base_sk: start_base_sk.as_deref(),
858    };
859    let rows = if let Some(ref index_name) = request.index_name {
860        if is_lsi {
861            storage
862                .query_lsi_items(&request.table_name, index_name, &pk_str, &query_params)
863                .await?
864        } else {
865            storage
866                .query_gsi_items(&request.table_name, index_name, &pk_str, &query_params)
867                .await?
868        }
869    } else {
870        storage
871            .query_items(&request.table_name, &pk_str, &query_params)
872            .await?
873    };
874
875    // Parse filter expression if present
876    let filter_expr = request
877        .filter_expression
878        .as_ref()
879        .map(|expr| expressions::condition::parse(expr))
880        .transpose()
881        .map_err(DynoxideError::ValidationException)?;
882
883    // Parse projection expression if present; fall back to legacy AttributesToGet
884    let projection = if let Some(ref proj_expr) = request.projection_expression {
885        Some(
886            expressions::projection::parse(proj_expr)
887                .map_err(DynoxideError::ValidationException)?,
888        )
889    } else {
890        legacy_projection.clone()
891    };
892
893    // Pre-register expression references so unused check works even with zero items
894    if let Some(ref filter) = filter_expr {
895        tracker.track_condition_expr(filter);
896    }
897    if let Some(ref proj) = projection {
898        tracker.track_projection_expr(proj);
899        // Reject undefined names and overlapping paths before the row loop, so a
900        // zero-match query still rejects.
901        expressions::projection::validate(proj, &tracker)
902            .map_err(DynoxideError::ValidationException)?;
903    }
904
905    // Untracked variant for the per-item hot loop: tracking already done above
906    let loop_tracker = crate::expressions::TrackedExpressionAttributes::without_tracking(
907        &request.expression_attribute_names,
908        &request.expression_attribute_values,
909    );
910
911    // Determine if SELECT COUNT
912    let is_count = request
913        .select
914        .as_deref()
915        .map(|s| s.eq_ignore_ascii_case("COUNT"))
916        .unwrap_or(false);
917
918    // Key attribute names for projection (use effective keys for GSI)
919    let mut key_attrs = vec![effective_pk.clone()];
920    if let Some(ref sk) = effective_sk {
921        key_attrs.push(sk.clone());
922    }
923    // Also include base table keys when querying a GSI
924    if request.index_name.is_some() {
925        if !key_attrs.contains(&table_key_schema.partition_key) {
926            key_attrs.push(table_key_schema.partition_key.clone());
927        }
928        if let Some(ref sk) = table_key_schema.sort_key
929            && !key_attrs.contains(sk)
930        {
931            key_attrs.push(sk.clone());
932        }
933    }
934
935    let mut items = Vec::new();
936    let mut scanned_count = 0;
937    let mut filtered_count = 0;
938    let mut cumulative_size = 0;
939    let mut last_evaluated_item: Option<Item> = None;
940    let mut truncated_by_size = false;
941
942    // Track sizes separately for ALL_ATTRIBUTES LSI queries where both
943    // index reads and base table reads contribute to ConsumedCapacity.
944    let mut base_table_cumulative_size = 0usize;
945    let mut index_cumulative_size = 0usize;
946
947    // A read of the table itself is charged on its items' billed size, a vector
948    // at four bytes per dimension (captured eu-west-2 and us-east-1,
949    // 2026-10-23). Index entries stay sized by their numbers, as their writes
950    // are, and the 1MB page limit stays on the stored size.
951    let vector_defs = super::vector_index::parse_vector_defs(&meta)?;
952    let mut billed_cumulative_size = 0usize;
953
954    for (_pk, _sk, item_json) in &rows {
955        let index_item: Item = serde_json::from_str(item_json).map_err(|e| {
956            DynoxideError::InternalServerError(format!("Bad item JSON in storage: {e}"))
957        })?;
958
959        // If Select=ALL_ATTRIBUTES on LSI with non-ALL projection, fetch full
960        // item from the base table for the response while using the index item
961        // for cursor tracking.
962        index_cumulative_size += crate::types::item_size(&index_item);
963        let item = if fetch_from_base_table {
964            let base_pk = index_item
965                .get(&table_key_schema.partition_key)
966                .and_then(|v| v.to_key_string())
967                .unwrap_or_default();
968            let base_sk = table_key_schema
969                .sort_key
970                .as_ref()
971                .and_then(|sk_name| index_item.get(sk_name))
972                .and_then(|v| v.to_key_string())
973                .unwrap_or_default();
974            if let Some(full_json) = storage
975                .get_item(&request.table_name, &base_pk, &base_sk)
976                .await?
977            {
978                let full_item: Item = serde_json::from_str(&full_json).map_err(|e| {
979                    DynoxideError::InternalServerError(format!("Bad item JSON: {e}"))
980                })?;
981                base_table_cumulative_size +=
982                    crate::types::billed_item_size(&full_item, &vector_defs);
983                full_item
984            } else {
985                index_item.clone()
986            }
987        } else {
988            index_item.clone()
989        };
990
991        scanned_count += 1;
992
993        // Check 1MB limit BEFORE filtering - DynamoDB counts all evaluated data
994        // towards the 1MB response size limit, not just items that pass the filter.
995        let item_size = crate::types::item_size(&item);
996        if cumulative_size + item_size > MAX_RESPONSE_SIZE && scanned_count > 1 {
997            truncated_by_size = true;
998            break;
999        }
1000        cumulative_size += item_size;
1001        if request.index_name.is_none() {
1002            billed_cumulative_size += crate::types::billed_item_size(&item, &vector_defs);
1003        }
1004
1005        // Apply filter
1006        if let Some(ref filter) = filter_expr {
1007            let passes = expressions::condition::evaluate(filter, &item, &loop_tracker)
1008                .map_err(DynoxideError::ValidationException)?;
1009            if !passes {
1010                last_evaluated_item = Some(index_item);
1011                continue;
1012            }
1013        }
1014
1015        filtered_count += 1;
1016
1017        // Apply projection -- do NOT auto-include key attributes when the
1018        // user explicitly specified ProjectionExpression or AttributesToGet.
1019        let result_item = if let Some(ref proj) = projection {
1020            let no_keys: &[String] = &[];
1021            expressions::projection::apply(&item, proj, &loop_tracker, no_keys)
1022                .map_err(DynoxideError::ValidationException)?
1023        } else {
1024            item
1025        };
1026
1027        last_evaluated_item = Some(index_item);
1028        if !is_count {
1029            items.push(result_item);
1030        }
1031    }
1032
1033    // Check for unused expression attribute names/values
1034    tracker.check_unused()?;
1035
1036    let count = if is_count {
1037        filtered_count
1038    } else {
1039        items.len()
1040    };
1041
1042    // Determine LastEvaluatedKey
1043    // We return LEK if: we hit the Limit, or we hit the 1MB limit
1044    let has_more = truncated_by_size
1045        || (fetch_limit.is_some() && scanned_count >= fetch_limit.unwrap_or(usize::MAX));
1046
1047    // For index queries, include the base table primary key in LastEvaluatedKey
1048    // alongside the effective (index) keys so the cursor can uniquely identify
1049    // the position. For LSIs, include the table sort key. For GSIs, include
1050    // both the table partition key and sort key.
1051    let is_gsi_query = request.index_name.is_some() && !is_lsi;
1052    let last_evaluated_key = if has_more {
1053        last_evaluated_item.map(|item| {
1054            let mut key = build_last_evaluated_key(&item, &key_hash, &key_range);
1055            // For LSI queries, add the table sort key if different from the index sort key
1056            if is_lsi
1057                && let Some(tsk) = table_key_schema.sort_key.as_deref()
1058                && !key.contains_key(tsk)
1059                && let Some(v) = item.get(tsk)
1060            {
1061                key.insert(tsk.to_string(), v.clone());
1062            }
1063            // For GSI queries, add the base table primary key (pk and sk)
1064            if is_gsi_query {
1065                if !key.contains_key(&table_key_schema.partition_key)
1066                    && let Some(v) = item.get(&table_key_schema.partition_key)
1067                {
1068                    key.insert(table_key_schema.partition_key.clone(), v.clone());
1069                }
1070                if let Some(ref tsk) = table_key_schema.sort_key
1071                    && !key.contains_key(tsk)
1072                    && let Some(v) = item.get(tsk)
1073                {
1074                    key.insert(tsk.clone(), v.clone());
1075                }
1076            }
1077            key
1078        })
1079    } else {
1080        None
1081    };
1082
1083    // Attribute read capacity to the index if querying one
1084    let is_gsi = is_gsi_query;
1085    let consistent = request.consistent_read.unwrap_or(false);
1086    let consumed_capacity = if is_gsi {
1087        let mut gsi_units = std::collections::HashMap::new();
1088        gsi_units.insert(
1089            request.index_name.as_ref().unwrap().clone(),
1090            crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
1091        );
1092        crate::types::consumed_capacity_with_indexes(
1093            &request.table_name,
1094            0.0,
1095            &gsi_units,
1096            &request.return_consumed_capacity,
1097        )
1098    } else if is_lsi {
1099        // When fetching from the base table (ALL_ATTRIBUTES on non-ALL LSI),
1100        // split capacity between the index read and the table read.
1101        let (table_cap, lsi_cap) = if fetch_from_base_table {
1102            let table_rcu = crate::types::read_capacity_units_with_consistency(
1103                base_table_cumulative_size,
1104                consistent,
1105            );
1106            let lsi_rcu = crate::types::read_capacity_units_with_consistency(
1107                index_cumulative_size,
1108                consistent,
1109            );
1110            (table_rcu, lsi_rcu)
1111        } else {
1112            (
1113                0.0,
1114                crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
1115            )
1116        };
1117        let mut lsi_units = std::collections::HashMap::new();
1118        lsi_units.insert(request.index_name.as_ref().unwrap().clone(), lsi_cap);
1119        crate::types::consumed_capacity_with_secondary_indexes(
1120            &request.table_name,
1121            table_cap,
1122            &std::collections::HashMap::new(),
1123            &lsi_units,
1124            &request.return_consumed_capacity,
1125        )
1126    } else {
1127        crate::types::consumed_capacity(
1128            &request.table_name,
1129            crate::types::read_capacity_units_with_consistency(billed_cumulative_size, consistent),
1130            &request.return_consumed_capacity,
1131        )
1132    };
1133
1134    Ok(QueryResponse {
1135        items: if is_count { None } else { Some(items) },
1136        count,
1137        scanned_count,
1138        last_evaluated_key,
1139        consumed_capacity,
1140    })
1141}
1142
1143/// The read key's attributes as the item holds them: every partition key and
1144/// sort key attribute of the table or index being read.
1145fn build_last_evaluated_key(
1146    item: &Item,
1147    hash: &[String],
1148    range: &[String],
1149) -> HashMap<String, AttributeValue> {
1150    hash.iter()
1151        .chain(range)
1152        .filter_map(|name| item.get(name).map(|v| (name.clone(), v.clone())))
1153        .collect()
1154}
1155
1156/// Refuse a key condition value whose type is not its key attribute's declared
1157/// type. DynamoDB checks this once the condition has bound to the key: a string
1158/// compared with a number sort key, or a boolean in an equality, fails here,
1159/// while an operand no key type could take (a number in begins_with) fails
1160/// earlier as an operand error. Captured eu-west-2, 2026-10-08 and October 2026.
1161fn check_key_condition_types(
1162    bound: &expressions::key_condition::BoundKeyCondition,
1163    meta: &crate::storage::TableMetadata,
1164) -> Result<()> {
1165    let defs: Vec<crate::types::AttributeDefinition> =
1166        serde_json::from_str(&meta.attribute_definitions).map_err(|e| {
1167            DynoxideError::InternalServerError(format!("Bad attribute definitions JSON: {e}"))
1168        })?;
1169    for (attr, value) in bound.compared_values() {
1170        if let Some(def) = defs.iter().find(|d| d.attribute_name == attr)
1171            && !helpers::attr_value_matches_scalar_type(value, &def.attribute_type)
1172        {
1173            return Err(DynoxideError::ValidationException(
1174                "One or more parameter values were invalid: Condition parameter type does not \
1175                 match schema type"
1176                    .to_string(),
1177            ));
1178        }
1179    }
1180    Ok(())
1181}
1182
1183/// Refuse a read against a vector index by type.
1184///
1185/// The wording differs between the two operations, full stop included: AWS
1186/// ends Query's with one where the other has none. Captured eu-west-2,
1187/// 2026-08-19.
1188fn reject_vector_index(
1189    meta: &crate::storage::TableMetadata,
1190    index_name: &str,
1191) -> crate::errors::Result<()> {
1192    if super::vector_index::parse_vector_defs(meta)?
1193        .iter()
1194        .any(|v| v.index_name == index_name)
1195    {
1196        return Err(DynoxideError::ValidationException(
1197            "Query operation not supported on this index type.".to_string(),
1198        ));
1199    }
1200    Ok(())
1201}
1202
1203/// A KeyConditionExpression parse failure as DynamoDB reports it. DynamoDB
1204/// reads the expression with the general expression grammar before applying
1205/// the key condition's own rules, so when the text breaks that grammar the
1206/// error is its `Syntax error; token: ..., near: ...` (or an unknown function
1207/// name). Size and redundant-parenthesis errors come first and keep their
1208/// wording, as does every failure of the key condition's own rules.
1209fn key_condition_error(expr: &str, err: String) -> String {
1210    if err.contains("Expression size has exceeded") || err.contains("redundant parentheses") {
1211        return err;
1212    }
1213    match expressions::condition::syntax_error(expr) {
1214        Some(syntax) => format!("Invalid KeyConditionExpression: {syntax}"),
1215        None => err,
1216    }
1217}
1218
1219/// A Query answers a starting key that doesn't fit the key schema with this
1220/// message alone, whatever the shape: a missing or extra attribute, a wrong
1221/// type, a map or set, NULL, or an index query missing the table's or the
1222/// index's keys. Captured in eu-west-2, us-east-1, us-west-2 and eu-central-1,
1223/// 2026-10-09. A Scan answers the same way where the newer validation layer
1224/// runs it, which is most regions since October 2026.
1225const START_KEY_INVALID: &str = "The provided starting key is invalid";
1226
1227/// The schema mismatch the shared starting-key checks report, with or without
1228/// the starting-key prefix, answered as [`START_KEY_INVALID`] alone. An index
1229/// key of the wrong type answers so too (captured eu-west-2, eu-central-1 and
1230/// us-east-1, 2026-10-20).
1231pub(crate) fn bare_start_key_mismatch(err: DynoxideError) -> DynoxideError {
1232    match err {
1233        DynoxideError::ValidationException(msg)
1234            if msg
1235                == "The provided starting key is invalid: \
1236                    The provided key element does not match the schema"
1237                || msg == "The provided key element does not match the schema" =>
1238        {
1239            DynoxideError::ValidationException(START_KEY_INVALID.to_string())
1240        }
1241        other => other,
1242    }
1243}
1244
1245/// A Query reports an invalid attribute value in its starting key (`NULL:
1246/// false`, an empty set) without the starting-key prefix a Scan can add. An
1247/// empty or multi-typed value keeps the prefix on both. Captured in eu-west-2
1248/// and us-east-1, 2026-10-09.
1249fn start_key_value_error(err: DynoxideError) -> DynoxideError {
1250    match err {
1251        DynoxideError::ValidationException(msg) => {
1252            match msg.strip_prefix("The provided starting key is invalid: ") {
1253                Some(rest) if rest.starts_with("One or more parameter values were invalid:") => {
1254                    DynoxideError::ValidationException(rest.to_string())
1255                }
1256                _ => DynoxideError::ValidationException(msg),
1257            }
1258        }
1259        other => other,
1260    }
1261}
1262
1263/// Refuse a starting key that the key condition could never have produced.
1264///
1265/// DynamoDB walks the key condition in order. A partition key attribute other
1266/// than the last that differs from the start key ends the check: "The query can
1267/// return at most one row and cannot be restarted". Then a sort key condition
1268/// the start key breaks: "does not match the range key predicate". Then a
1269/// different last partition key attribute: "at most one row" again when a sort
1270/// key condition follows it, "The provided exclusive start key is invalid" on a
1271/// table with no sort key, and "outside query boundaries based on provided
1272/// conditions" otherwise. The same on a table, an LSI, a GSI and a GSI with a
1273/// multi-attribute key. Captured in eu-west-2 and us-east-1, 2026-10-09, and
1274/// on tables in us-west-2 and eu-central-1 too.
1275fn check_start_key_within_condition(
1276    esk: &Item,
1277    bound: &expressions::key_condition::BoundKeyCondition,
1278    table_without_sort_key: bool,
1279) -> Result<()> {
1280    use crate::expressions::condition::{CompOp, compare_values};
1281
1282    const AT_MOST_ONE_ROW: &str = "The query can return at most one row and cannot be restarted";
1283    let refuse = |message: &str| Err(DynoxideError::ValidationException(message.to_string()));
1284    let holds = |name: &str, value: &AttributeValue| {
1285        esk.get(name)
1286            .is_some_and(|v| compare_values(v, &CompOp::Eq, value))
1287    };
1288
1289    let Some(((last_name, last_value), leading)) = bound.hash.split_last() else {
1290        return Ok(());
1291    };
1292    if leading.iter().any(|(name, value)| !holds(name, value)) {
1293        return refuse(AT_MOST_ONE_ROW);
1294    }
1295    let outside_equalities = bound
1296        .range_eq
1297        .iter()
1298        .any(|(name, value)| esk.contains_key(name) && !holds(name, value));
1299    let outside_last = bound.range_last.as_ref().is_some_and(|condition| {
1300        esk.get(condition.sk_name())
1301            .is_some_and(|value| !sort_key_satisfies(value, condition))
1302    });
1303    if outside_equalities || outside_last {
1304        return refuse("The provided starting key does not match the range key predicate");
1305    }
1306    if !holds(last_name, last_value) {
1307        if !bound.range_eq.is_empty() || bound.range_last.is_some() {
1308            return refuse(AT_MOST_ONE_ROW);
1309        }
1310        if table_without_sort_key {
1311            return refuse("The provided exclusive start key is invalid");
1312        }
1313        return refuse(
1314            "The provided starting key is outside query boundaries based on provided conditions",
1315        );
1316    }
1317    Ok(())
1318}
1319
1320fn sort_key_satisfies(
1321    value: &AttributeValue,
1322    condition: &expressions::key_condition::ResolvedSortKeyCondition,
1323) -> bool {
1324    use crate::expressions::condition::{CompOp, compare_values};
1325    use expressions::key_condition::ResolvedSortKeyCondition as Sk;
1326
1327    match condition {
1328        Sk::Eq(_, v) => compare_values(value, &CompOp::Eq, v),
1329        Sk::Lt(_, v) => compare_values(value, &CompOp::Lt, v),
1330        Sk::Le(_, v) => compare_values(value, &CompOp::Le, v),
1331        Sk::Gt(_, v) => compare_values(value, &CompOp::Gt, v),
1332        Sk::Ge(_, v) => compare_values(value, &CompOp::Ge, v),
1333        Sk::Between(_, low, high) => {
1334            compare_values(value, &CompOp::Ge, low) && compare_values(value, &CompOp::Le, high)
1335        }
1336        Sk::BeginsWith(_, prefix) => match (value, prefix) {
1337            (AttributeValue::S(s), AttributeValue::S(p)) => s.starts_with(p.as_str()),
1338            (AttributeValue::B(b), AttributeValue::B(p)) => b.starts_with(p),
1339            _ => false,
1340        },
1341    }
1342}
1343
1344/// A legacy KeyConditions value must be the key's schema type. Any other type
1345/// (a map, a list, a set, BOOL, NULL, or the wrong scalar) is refused with one
1346/// message, after the check that a BETWEEN's two values share a type. Captured
1347/// in eu-west-2, us-east-1, us-west-2 and eu-central-1, 2026-10-09.
1348///
1349/// Each check runs over every condition before the next starts, so the answer
1350/// doesn't depend on the order the conditions arrive in.
1351fn check_legacy_key_condition_types(
1352    conditions: &HashMap<String, helpers::KeyCondition>,
1353    meta: &crate::storage::TableMetadata,
1354) -> Result<()> {
1355    use crate::types::{AttributeDefinition, ScalarAttributeType};
1356
1357    let definitions: Vec<AttributeDefinition> = serde_json::from_str(&meta.attribute_definitions)
1358        .map_err(|e| {
1359        DynoxideError::InternalServerError(format!("Bad attribute definitions JSON: {e}"))
1360    })?;
1361    let lists = || {
1362        conditions
1363            .iter()
1364            .filter_map(|(name, condition)| Some((name, condition.attribute_value_list.as_ref()?)))
1365    };
1366    if lists().any(|(_, values)| {
1367        values
1368            .windows(2)
1369            .any(|pair| std::mem::discriminant(&pair[0]) != std::mem::discriminant(&pair[1]))
1370    }) {
1371        return Err(DynoxideError::ValidationException(
1372            "One or more parameter values were invalid: \
1373             AttributeValues inside AttributeValueList must be of same type"
1374                .to_string(),
1375        ));
1376    }
1377    for (name, values) in lists() {
1378        let Some(definition) = definitions.iter().find(|d| &d.attribute_name == name) else {
1379            continue;
1380        };
1381        let matches = |value: &AttributeValue| {
1382            matches!(
1383                (value, &definition.attribute_type),
1384                (AttributeValue::S(_), ScalarAttributeType::S)
1385                    | (AttributeValue::N(_), ScalarAttributeType::N)
1386                    | (AttributeValue::B(_), ScalarAttributeType::B)
1387            )
1388        };
1389        if !values.iter().all(matches) {
1390            return Err(DynoxideError::ValidationException(
1391                "One or more parameter values were invalid: \
1392                 Condition parameter type does not match schema type"
1393                    .to_string(),
1394            ));
1395        }
1396    }
1397    Ok(())
1398}
1399
1400/// A starting key whose table key value is an empty string or empty binary is
1401/// refused with the empty key-value message, ahead of the check against the key
1402/// condition. Captured for an empty partition key in eu-west-2, us-east-1,
1403/// us-west-2 and eu-central-1, 2026-10-09; the binary wording is the one item
1404/// keys already use.
1405fn check_start_key_not_empty(
1406    esk: &Item,
1407    partition_key: &str,
1408    sort_key: Option<&str>,
1409) -> Result<()> {
1410    for name in std::iter::once(partition_key).chain(sort_key) {
1411        let kind = match esk.get(name) {
1412            Some(AttributeValue::S(s)) if s.is_empty() => "string",
1413            Some(AttributeValue::B(b)) if b.is_empty() => "binary",
1414            _ => continue,
1415        };
1416        return Err(DynoxideError::KeyEmptyValueValidation(format!(
1417            "One or more parameter values are not valid. The AttributeValue for a key \
1418             attribute cannot contain an empty {kind} value. Key: {name}"
1419        )));
1420    }
1421    Ok(())
1422}