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    for (_pk, _sk, item_json) in &rows {
948        let index_item: Item = serde_json::from_str(item_json).map_err(|e| {
949            DynoxideError::InternalServerError(format!("Bad item JSON in storage: {e}"))
950        })?;
951
952        // If Select=ALL_ATTRIBUTES on LSI with non-ALL projection, fetch full
953        // item from the base table for the response while using the index item
954        // for cursor tracking.
955        index_cumulative_size += crate::types::item_size(&index_item);
956        let item = if fetch_from_base_table {
957            let base_pk = index_item
958                .get(&table_key_schema.partition_key)
959                .and_then(|v| v.to_key_string())
960                .unwrap_or_default();
961            let base_sk = table_key_schema
962                .sort_key
963                .as_ref()
964                .and_then(|sk_name| index_item.get(sk_name))
965                .and_then(|v| v.to_key_string())
966                .unwrap_or_default();
967            if let Some(full_json) = storage
968                .get_item(&request.table_name, &base_pk, &base_sk)
969                .await?
970            {
971                let full_item: Item = serde_json::from_str(&full_json).map_err(|e| {
972                    DynoxideError::InternalServerError(format!("Bad item JSON: {e}"))
973                })?;
974                base_table_cumulative_size += crate::types::item_size(&full_item);
975                full_item
976            } else {
977                index_item.clone()
978            }
979        } else {
980            index_item.clone()
981        };
982
983        scanned_count += 1;
984
985        // Check 1MB limit BEFORE filtering - DynamoDB counts all evaluated data
986        // towards the 1MB response size limit, not just items that pass the filter.
987        let item_size = crate::types::item_size(&item);
988        if cumulative_size + item_size > MAX_RESPONSE_SIZE && scanned_count > 1 {
989            truncated_by_size = true;
990            break;
991        }
992        cumulative_size += item_size;
993
994        // Apply filter
995        if let Some(ref filter) = filter_expr {
996            let passes = expressions::condition::evaluate(filter, &item, &loop_tracker)
997                .map_err(DynoxideError::ValidationException)?;
998            if !passes {
999                last_evaluated_item = Some(index_item);
1000                continue;
1001            }
1002        }
1003
1004        filtered_count += 1;
1005
1006        // Apply projection -- do NOT auto-include key attributes when the
1007        // user explicitly specified ProjectionExpression or AttributesToGet.
1008        let result_item = if let Some(ref proj) = projection {
1009            let no_keys: &[String] = &[];
1010            expressions::projection::apply(&item, proj, &loop_tracker, no_keys)
1011                .map_err(DynoxideError::ValidationException)?
1012        } else {
1013            item
1014        };
1015
1016        last_evaluated_item = Some(index_item);
1017        if !is_count {
1018            items.push(result_item);
1019        }
1020    }
1021
1022    // Check for unused expression attribute names/values
1023    tracker.check_unused()?;
1024
1025    let count = if is_count {
1026        filtered_count
1027    } else {
1028        items.len()
1029    };
1030
1031    // Determine LastEvaluatedKey
1032    // We return LEK if: we hit the Limit, or we hit the 1MB limit
1033    let has_more = truncated_by_size
1034        || (fetch_limit.is_some() && scanned_count >= fetch_limit.unwrap_or(usize::MAX));
1035
1036    // For index queries, include the base table primary key in LastEvaluatedKey
1037    // alongside the effective (index) keys so the cursor can uniquely identify
1038    // the position. For LSIs, include the table sort key. For GSIs, include
1039    // both the table partition key and sort key.
1040    let is_gsi_query = request.index_name.is_some() && !is_lsi;
1041    let last_evaluated_key = if has_more {
1042        last_evaluated_item.map(|item| {
1043            let mut key = build_last_evaluated_key(&item, &key_hash, &key_range);
1044            // For LSI queries, add the table sort key if different from the index sort key
1045            if is_lsi
1046                && let Some(tsk) = table_key_schema.sort_key.as_deref()
1047                && !key.contains_key(tsk)
1048                && let Some(v) = item.get(tsk)
1049            {
1050                key.insert(tsk.to_string(), v.clone());
1051            }
1052            // For GSI queries, add the base table primary key (pk and sk)
1053            if is_gsi_query {
1054                if !key.contains_key(&table_key_schema.partition_key)
1055                    && let Some(v) = item.get(&table_key_schema.partition_key)
1056                {
1057                    key.insert(table_key_schema.partition_key.clone(), v.clone());
1058                }
1059                if let Some(ref tsk) = table_key_schema.sort_key
1060                    && !key.contains_key(tsk)
1061                    && let Some(v) = item.get(tsk)
1062                {
1063                    key.insert(tsk.clone(), v.clone());
1064                }
1065            }
1066            key
1067        })
1068    } else {
1069        None
1070    };
1071
1072    // Attribute read capacity to the index if querying one
1073    let is_gsi = is_gsi_query;
1074    let consistent = request.consistent_read.unwrap_or(false);
1075    let consumed_capacity = if is_gsi {
1076        let mut gsi_units = std::collections::HashMap::new();
1077        gsi_units.insert(
1078            request.index_name.as_ref().unwrap().clone(),
1079            crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
1080        );
1081        crate::types::consumed_capacity_with_indexes(
1082            &request.table_name,
1083            0.0,
1084            &gsi_units,
1085            &request.return_consumed_capacity,
1086        )
1087    } else if is_lsi {
1088        // When fetching from the base table (ALL_ATTRIBUTES on non-ALL LSI),
1089        // split capacity between the index read and the table read.
1090        let (table_cap, lsi_cap) = if fetch_from_base_table {
1091            let table_rcu = crate::types::read_capacity_units_with_consistency(
1092                base_table_cumulative_size,
1093                consistent,
1094            );
1095            let lsi_rcu = crate::types::read_capacity_units_with_consistency(
1096                index_cumulative_size,
1097                consistent,
1098            );
1099            (table_rcu, lsi_rcu)
1100        } else {
1101            (
1102                0.0,
1103                crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
1104            )
1105        };
1106        let mut lsi_units = std::collections::HashMap::new();
1107        lsi_units.insert(request.index_name.as_ref().unwrap().clone(), lsi_cap);
1108        crate::types::consumed_capacity_with_secondary_indexes(
1109            &request.table_name,
1110            table_cap,
1111            &std::collections::HashMap::new(),
1112            &lsi_units,
1113            &request.return_consumed_capacity,
1114        )
1115    } else {
1116        crate::types::consumed_capacity(
1117            &request.table_name,
1118            crate::types::read_capacity_units_with_consistency(cumulative_size, consistent),
1119            &request.return_consumed_capacity,
1120        )
1121    };
1122
1123    Ok(QueryResponse {
1124        items: if is_count { None } else { Some(items) },
1125        count,
1126        scanned_count,
1127        last_evaluated_key,
1128        consumed_capacity,
1129    })
1130}
1131
1132/// The read key's attributes as the item holds them: every partition key and
1133/// sort key attribute of the table or index being read.
1134fn build_last_evaluated_key(
1135    item: &Item,
1136    hash: &[String],
1137    range: &[String],
1138) -> HashMap<String, AttributeValue> {
1139    hash.iter()
1140        .chain(range)
1141        .filter_map(|name| item.get(name).map(|v| (name.clone(), v.clone())))
1142        .collect()
1143}
1144
1145/// Refuse a key condition value whose type is not its key attribute's declared
1146/// type. DynamoDB checks this once the condition has bound to the key: a string
1147/// compared with a number sort key, or a boolean in an equality, fails here,
1148/// while an operand no key type could take (a number in begins_with) fails
1149/// earlier as an operand error. Captured eu-west-2, 2026-10-08 and October 2026.
1150fn check_key_condition_types(
1151    bound: &expressions::key_condition::BoundKeyCondition,
1152    meta: &crate::storage::TableMetadata,
1153) -> Result<()> {
1154    let defs: Vec<crate::types::AttributeDefinition> =
1155        serde_json::from_str(&meta.attribute_definitions).map_err(|e| {
1156            DynoxideError::InternalServerError(format!("Bad attribute definitions JSON: {e}"))
1157        })?;
1158    for (attr, value) in bound.compared_values() {
1159        if let Some(def) = defs.iter().find(|d| d.attribute_name == attr)
1160            && !helpers::attr_value_matches_scalar_type(value, &def.attribute_type)
1161        {
1162            return Err(DynoxideError::ValidationException(
1163                "One or more parameter values were invalid: Condition parameter type does not \
1164                 match schema type"
1165                    .to_string(),
1166            ));
1167        }
1168    }
1169    Ok(())
1170}
1171
1172/// Refuse a read against a vector index by type.
1173///
1174/// The wording differs between the two operations, full stop included: AWS
1175/// ends Query's with one where the other has none. Captured eu-west-2,
1176/// 2026-08-19.
1177fn reject_vector_index(
1178    meta: &crate::storage::TableMetadata,
1179    index_name: &str,
1180) -> crate::errors::Result<()> {
1181    if super::vector_index::parse_vector_defs(meta)?
1182        .iter()
1183        .any(|v| v.index_name == index_name)
1184    {
1185        return Err(DynoxideError::ValidationException(
1186            "Query operation not supported on this index type.".to_string(),
1187        ));
1188    }
1189    Ok(())
1190}
1191
1192/// A KeyConditionExpression parse failure as DynamoDB reports it. DynamoDB
1193/// reads the expression with the general expression grammar before applying
1194/// the key condition's own rules, so when the text breaks that grammar the
1195/// error is its `Syntax error; token: ..., near: ...` (or an unknown function
1196/// name). Size and redundant-parenthesis errors come first and keep their
1197/// wording, as does every failure of the key condition's own rules.
1198fn key_condition_error(expr: &str, err: String) -> String {
1199    if err.contains("Expression size has exceeded") || err.contains("redundant parentheses") {
1200        return err;
1201    }
1202    match expressions::condition::syntax_error(expr) {
1203        Some(syntax) => format!("Invalid KeyConditionExpression: {syntax}"),
1204        None => err,
1205    }
1206}
1207
1208/// A Query answers a starting key that doesn't fit the key schema with this
1209/// message alone, whatever the shape: a missing or extra attribute, a wrong
1210/// type, a map or set, NULL, or an index query missing the table's or the
1211/// index's keys. Captured in eu-west-2, us-east-1, us-west-2 and eu-central-1,
1212/// 2026-10-09. A Scan answers the same way where the newer validation layer
1213/// runs it, which is most regions since October 2026.
1214const START_KEY_INVALID: &str = "The provided starting key is invalid";
1215
1216/// The schema mismatch the shared starting-key checks report, with or without
1217/// the starting-key prefix, answered as [`START_KEY_INVALID`] alone. An index
1218/// key of the wrong type answers so too (captured eu-west-2, eu-central-1 and
1219/// us-east-1, 2026-10-20).
1220pub(crate) fn bare_start_key_mismatch(err: DynoxideError) -> DynoxideError {
1221    match err {
1222        DynoxideError::ValidationException(msg)
1223            if msg
1224                == "The provided starting key is invalid: \
1225                    The provided key element does not match the schema"
1226                || msg == "The provided key element does not match the schema" =>
1227        {
1228            DynoxideError::ValidationException(START_KEY_INVALID.to_string())
1229        }
1230        other => other,
1231    }
1232}
1233
1234/// A Query reports an invalid attribute value in its starting key (`NULL:
1235/// false`, an empty set) without the starting-key prefix a Scan can add. An
1236/// empty or multi-typed value keeps the prefix on both. Captured in eu-west-2
1237/// and us-east-1, 2026-10-09.
1238fn start_key_value_error(err: DynoxideError) -> DynoxideError {
1239    match err {
1240        DynoxideError::ValidationException(msg) => {
1241            match msg.strip_prefix("The provided starting key is invalid: ") {
1242                Some(rest) if rest.starts_with("One or more parameter values were invalid:") => {
1243                    DynoxideError::ValidationException(rest.to_string())
1244                }
1245                _ => DynoxideError::ValidationException(msg),
1246            }
1247        }
1248        other => other,
1249    }
1250}
1251
1252/// Refuse a starting key that the key condition could never have produced.
1253///
1254/// DynamoDB walks the key condition in order. A partition key attribute other
1255/// than the last that differs from the start key ends the check: "The query can
1256/// return at most one row and cannot be restarted". Then a sort key condition
1257/// the start key breaks: "does not match the range key predicate". Then a
1258/// different last partition key attribute: "at most one row" again when a sort
1259/// key condition follows it, "The provided exclusive start key is invalid" on a
1260/// table with no sort key, and "outside query boundaries based on provided
1261/// conditions" otherwise. The same on a table, an LSI, a GSI and a GSI with a
1262/// multi-attribute key. Captured in eu-west-2 and us-east-1, 2026-10-09, and
1263/// on tables in us-west-2 and eu-central-1 too.
1264fn check_start_key_within_condition(
1265    esk: &Item,
1266    bound: &expressions::key_condition::BoundKeyCondition,
1267    table_without_sort_key: bool,
1268) -> Result<()> {
1269    use crate::expressions::condition::{CompOp, compare_values};
1270
1271    const AT_MOST_ONE_ROW: &str = "The query can return at most one row and cannot be restarted";
1272    let refuse = |message: &str| Err(DynoxideError::ValidationException(message.to_string()));
1273    let holds = |name: &str, value: &AttributeValue| {
1274        esk.get(name)
1275            .is_some_and(|v| compare_values(v, &CompOp::Eq, value))
1276    };
1277
1278    let Some(((last_name, last_value), leading)) = bound.hash.split_last() else {
1279        return Ok(());
1280    };
1281    if leading.iter().any(|(name, value)| !holds(name, value)) {
1282        return refuse(AT_MOST_ONE_ROW);
1283    }
1284    let outside_equalities = bound
1285        .range_eq
1286        .iter()
1287        .any(|(name, value)| esk.contains_key(name) && !holds(name, value));
1288    let outside_last = bound.range_last.as_ref().is_some_and(|condition| {
1289        esk.get(condition.sk_name())
1290            .is_some_and(|value| !sort_key_satisfies(value, condition))
1291    });
1292    if outside_equalities || outside_last {
1293        return refuse("The provided starting key does not match the range key predicate");
1294    }
1295    if !holds(last_name, last_value) {
1296        if !bound.range_eq.is_empty() || bound.range_last.is_some() {
1297            return refuse(AT_MOST_ONE_ROW);
1298        }
1299        if table_without_sort_key {
1300            return refuse("The provided exclusive start key is invalid");
1301        }
1302        return refuse(
1303            "The provided starting key is outside query boundaries based on provided conditions",
1304        );
1305    }
1306    Ok(())
1307}
1308
1309fn sort_key_satisfies(
1310    value: &AttributeValue,
1311    condition: &expressions::key_condition::ResolvedSortKeyCondition,
1312) -> bool {
1313    use crate::expressions::condition::{CompOp, compare_values};
1314    use expressions::key_condition::ResolvedSortKeyCondition as Sk;
1315
1316    match condition {
1317        Sk::Eq(_, v) => compare_values(value, &CompOp::Eq, v),
1318        Sk::Lt(_, v) => compare_values(value, &CompOp::Lt, v),
1319        Sk::Le(_, v) => compare_values(value, &CompOp::Le, v),
1320        Sk::Gt(_, v) => compare_values(value, &CompOp::Gt, v),
1321        Sk::Ge(_, v) => compare_values(value, &CompOp::Ge, v),
1322        Sk::Between(_, low, high) => {
1323            compare_values(value, &CompOp::Ge, low) && compare_values(value, &CompOp::Le, high)
1324        }
1325        Sk::BeginsWith(_, prefix) => match (value, prefix) {
1326            (AttributeValue::S(s), AttributeValue::S(p)) => s.starts_with(p.as_str()),
1327            (AttributeValue::B(b), AttributeValue::B(p)) => b.starts_with(p),
1328            _ => false,
1329        },
1330    }
1331}
1332
1333/// A legacy KeyConditions value must be the key's schema type. Any other type
1334/// (a map, a list, a set, BOOL, NULL, or the wrong scalar) is refused with one
1335/// message, after the check that a BETWEEN's two values share a type. Captured
1336/// in eu-west-2, us-east-1, us-west-2 and eu-central-1, 2026-10-09.
1337///
1338/// Each check runs over every condition before the next starts, so the answer
1339/// doesn't depend on the order the conditions arrive in.
1340fn check_legacy_key_condition_types(
1341    conditions: &HashMap<String, helpers::KeyCondition>,
1342    meta: &crate::storage::TableMetadata,
1343) -> Result<()> {
1344    use crate::types::{AttributeDefinition, ScalarAttributeType};
1345
1346    let definitions: Vec<AttributeDefinition> = serde_json::from_str(&meta.attribute_definitions)
1347        .map_err(|e| {
1348        DynoxideError::InternalServerError(format!("Bad attribute definitions JSON: {e}"))
1349    })?;
1350    let lists = || {
1351        conditions
1352            .iter()
1353            .filter_map(|(name, condition)| Some((name, condition.attribute_value_list.as_ref()?)))
1354    };
1355    if lists().any(|(_, values)| {
1356        values
1357            .windows(2)
1358            .any(|pair| std::mem::discriminant(&pair[0]) != std::mem::discriminant(&pair[1]))
1359    }) {
1360        return Err(DynoxideError::ValidationException(
1361            "One or more parameter values were invalid: \
1362             AttributeValues inside AttributeValueList must be of same type"
1363                .to_string(),
1364        ));
1365    }
1366    for (name, values) in lists() {
1367        let Some(definition) = definitions.iter().find(|d| &d.attribute_name == name) else {
1368            continue;
1369        };
1370        let matches = |value: &AttributeValue| {
1371            matches!(
1372                (value, &definition.attribute_type),
1373                (AttributeValue::S(_), ScalarAttributeType::S)
1374                    | (AttributeValue::N(_), ScalarAttributeType::N)
1375                    | (AttributeValue::B(_), ScalarAttributeType::B)
1376            )
1377        };
1378        if !values.iter().all(matches) {
1379            return Err(DynoxideError::ValidationException(
1380                "One or more parameter values were invalid: \
1381                 Condition parameter type does not match schema type"
1382                    .to_string(),
1383            ));
1384        }
1385    }
1386    Ok(())
1387}
1388
1389/// A starting key whose table key value is an empty string or empty binary is
1390/// refused with the empty key-value message, ahead of the check against the key
1391/// condition. Captured for an empty partition key in eu-west-2, us-east-1,
1392/// us-west-2 and eu-central-1, 2026-10-09; the binary wording is the one item
1393/// keys already use.
1394fn check_start_key_not_empty(
1395    esk: &Item,
1396    partition_key: &str,
1397    sort_key: Option<&str>,
1398) -> Result<()> {
1399    for name in std::iter::once(partition_key).chain(sort_key) {
1400        let kind = match esk.get(name) {
1401            Some(AttributeValue::S(s)) if s.is_empty() => "string",
1402            Some(AttributeValue::B(b)) if b.is_empty() => "binary",
1403            _ => continue,
1404        };
1405        return Err(DynoxideError::KeyEmptyValueValidation(format!(
1406            "One or more parameter values are not valid. The AttributeValue for a key \
1407             attribute cannot contain an empty {kind} value. Key: {name}"
1408        )));
1409    }
1410    Ok(())
1411}