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