Skip to main content

dynoxide/actions/
execute_statement.rs

1use crate::errors::{DynoxideError, Result};
2use crate::partiql;
3use crate::storage_backend::StorageBackend;
4use crate::types::{AttributeValue, Item};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Default, Deserialize)]
8pub struct ExecuteStatementRequest {
9    #[serde(rename = "Statement")]
10    pub statement: String,
11    #[serde(
12        rename = "Parameters",
13        default,
14        deserialize_with = "crate::types::deserialize_parameters"
15    )]
16    pub parameters: Option<Vec<AttributeValue>>,
17    #[serde(rename = "Limit", default)]
18    pub limit: Option<usize>,
19    #[serde(rename = "NextToken", default)]
20    pub next_token: Option<String>,
21    /// Does not change which rows come back, because every read against SQLite
22    /// is already strongly consistent. It does change two things: the rate the
23    /// read is charged at, and whether a select qualified by a GSI is rejected
24    /// at all.
25    #[serde(rename = "ConsistentRead", default)]
26    pub consistent_read: Option<bool>,
27    #[serde(rename = "ReturnConsumedCapacity", default)]
28    pub return_consumed_capacity: Option<String>,
29    /// `ALL_OLD` hands back the item an UPDATE or DELETE failed its condition
30    /// on, on the `ConditionalCheckFailedException`; `NONE`, or leaving it out,
31    /// does not.
32    #[serde(rename = "ReturnValuesOnConditionCheckFailure", default)]
33    pub return_values_on_condition_check_failure: Option<String>,
34}
35
36#[derive(Debug, Default, Serialize)]
37pub struct ExecuteStatementResponse {
38    #[serde(
39        rename = "Items",
40        skip_serializing_if = "Option::is_none",
41        serialize_with = "crate::set_order::items_in_hash_order"
42    )]
43    // Number sets in Java hash-set order here, as DynamoDB returns them;
44    // pinned by set_order's every_response_item_field test.
45    pub items: Option<Vec<Item>>,
46    #[serde(rename = "NextToken", skip_serializing_if = "Option::is_none")]
47    pub next_token: Option<String>,
48    #[serde(rename = "ConsumedCapacity", skip_serializing_if = "Option::is_none")]
49    pub consumed_capacity: Option<crate::types::ConsumedCapacity>,
50}
51
52pub async fn execute<S: StorageBackend>(
53    storage: &S,
54    request: ExecuteStatementRequest,
55) -> Result<ExecuteStatementResponse> {
56    // Every member DynamoDB refuses is listed in one error, in DynamoDB's
57    // order: ReturnValuesOnConditionCheckFailure, Limit, the statement,
58    // ReturnConsumedCapacity, the parameters (captured eu-west-2 and
59    // us-east-1, 2026-10-10). All of them are checked before the statement
60    // is parsed. A zero limit would otherwise read nothing and mint no token,
61    // silently ending a paginated walk; its wording follows Scan's shape
62    // (value kept, lowercase 'limit'), not Query's, matching what
63    // ExecuteStatement itself returns (captured eu-west-2, 2026-07-29).
64    let violations: Vec<String> = [
65        crate::validation::return_values_on_condition_check_failure_rejection(
66            request.return_values_on_condition_check_failure.as_deref(),
67            "returnValuesOnConditionCheckFailure",
68        ),
69        (request.limit == Some(0)).then(|| {
70            "Value '0' at 'limit' failed to satisfy constraint: Member must have value \
71             greater than or equal to 1"
72                .to_string()
73        }),
74        crate::partiql::statement_length_violation(&request.statement, "statement"),
75        crate::validation::return_consumed_capacity_rejection(
76            request.return_consumed_capacity.as_deref(),
77        ),
78        crate::partiql::parameters_violation(request.parameters.as_deref(), "parameters"),
79    ]
80    .into_iter()
81    .flatten()
82    .collect();
83    if let Some(e) = crate::partiql::member_rejection(&violations) {
84        return Err(e);
85    }
86
87    // A parameter holding an empty attribute name is refused bare, before the
88    // statement is parsed or its table looked up (captured in ten regions).
89    if crate::empty_names::parameters_have_empty_name(request.parameters.as_deref()) {
90        return Err(crate::empty_names::bare());
91    }
92
93    // A parameter nested too deep is refused before the statement is parsed or
94    // its table looked up.
95    if crate::value_nesting::parameters_nest_too_deep(
96        request.parameters.as_deref().unwrap_or_default(),
97    ) {
98        return Err(crate::value_nesting::deep_parameter_error());
99    }
100
101    // Every parameter's numbers are checked before the statement is read,
102    // whether the statement uses the parameter or not, with the message any
103    // attribute value gets and no path after it (captured eu-west-2 and
104    // us-east-1, 2026-10-09).
105    for parameter in request.parameters.iter().flatten() {
106        partiql::executor::check_parameter_value(parameter)?;
107    }
108
109    // A COUNT projection is rejected before parsing, with DynamoDB's bare
110    // message rather than the wasn't-well-formed wrapper the parse errors
111    // below carry. ExecuteStatement is the captured surface for this shape.
112    // A read of a multi-attribute index is refused ahead of it (captured
113    // eu-west-2 and us-east-1, 2026-10-09).
114    if let Some(msg) = partiql::parser::count_projection_rejection(&request.statement) {
115        if let Some((table, Some(index))) = partiql::parser::select_target(&request.statement) {
116            partiql::executor::reject_multi_attribute_index_by_name(storage, &table, &index)
117                .await?;
118        }
119        return Err(DynoxideError::ValidationException(msg));
120    }
121
122    // The parser says which envelope its rejection takes: a malformed statement
123    // gets DynamoDB's "wasn't well formed" wrapper, while a rejection DynamoDB
124    // reports on its own terms is passed through bare.
125    let supplied = request.parameters.as_ref().map_or(0, Vec::len);
126    let stmt = partiql::parser::parse_with_parameters(&request.statement, supplied).map_err(
127        |failure| {
128            DynoxideError::ValidationException(match failure {
129                partiql::parser::ParseFailure::Statement(e) => e.into_message(),
130                partiql::parser::ParseFailure::Literal(message) => message,
131            })
132        },
133    )?;
134
135    let params = request.parameters.unwrap_or_default();
136    // The statement's text goes with it, so a continuation token is bound to
137    // the statement as written, as DynamoDB binds it.
138    let run = partiql::executor::execute_written_page(
139        storage,
140        &request.statement,
141        &stmt,
142        &params,
143        request.limit,
144        request.next_token.as_deref(),
145        request.consistent_read.unwrap_or(false),
146        request.return_consumed_capacity.as_deref(),
147        // A single statement has no preparation pass to share a resolution
148        // with, so the executor resolves the table itself, once.
149        None,
150        request.return_values_on_condition_check_failure.as_deref() == Some("ALL_OLD"),
151    );
152    // Base write, index fan-out and stream record are one atomic unit, as they
153    // are on every other write path. Without it a fan-out that failed part way
154    // left the base row committed and the indexes disagreeing with it. A read
155    // opens no transaction: it has nothing to roll back.
156    let page = if matches!(stmt, partiql::parser::Statement::Select { .. }) {
157        run.await?
158    } else {
159        super::helpers::with_write_transaction(storage, run).await?
160    };
161    let partiql::executor::StatementPage {
162        items,
163        size,
164        capacity,
165        next_token,
166        read_index,
167        base_read_units,
168    } = page;
169
170    // ConsumedCapacity is returned whenever ReturnConsumedCapacity is requested,
171    // unlike some emulators that omit it. A SELECT is charged read units (an
172    // eventually consistent read unless ConsistentRead is set) against the rows
173    // it walked, which is before its WHERE clause and its projection narrow
174    // them. A write is charged the base table arm plus a per-index arm,
175    // with no transactional factor: a capture against real DynamoDB reports a
176    // PartiQL INSERT of an item in two indexes as total 3, table 1, one unit per
177    // index, exactly as the equivalent `PutItem`.
178    let consumed_capacity = partiql::parser::table_name(&stmt).and_then(|table| {
179        let Some(capacity) = capacity else {
180            let units = crate::types::read_capacity_units_with_consistency(
181                size,
182                request.consistent_read.unwrap_or(false),
183            );
184            // A read served from an index is charged against that index's arm
185            // with the table arm at zero, the same shape Query and Scan already
186            // report. Captured eu-west-2 2026-08-15: a keyed GSI select is
187            // total 0.5, table 0, gsi 0.5, where dynoxide charged the table.
188            return match read_index {
189                Some(index) if index.is_lsi => {
190                    // A reach-back read the base item for each row, and those
191                    // land on the table arm, leaving the index arm to cover the
192                    // index read. Each one is charged on its own bytes at the
193                    // request's consistency and the charges are summed, which
194                    // the executor works out because only it sees the sizes.
195                    // Captured eu-west-2 2026-08-15: three small rows served
196                    // this way reported total 2, table 1.5, lsi 0.5. Captured
197                    // again 2026-08-17 across item sizes: the same three rows at
198                    // ~9KB apiece report table 4.5, and 9 under ConsistentRead.
199                    let lsi_units = std::collections::HashMap::from([(index.name, units)]);
200                    crate::types::consumed_capacity_with_secondary_indexes(
201                        table,
202                        base_read_units,
203                        &std::collections::HashMap::new(),
204                        &lsi_units,
205                        &request.return_consumed_capacity,
206                    )
207                }
208                Some(index) => {
209                    let gsi_units = std::collections::HashMap::from([(index.name, units)]);
210                    crate::types::consumed_capacity_with_indexes(
211                        table,
212                        0.0,
213                        &gsi_units,
214                        &request.return_consumed_capacity,
215                    )
216                }
217                None => {
218                    crate::types::consumed_capacity(table, units, &request.return_consumed_capacity)
219                }
220            };
221        };
222        crate::types::consumed_capacity_with_vector_indexes(
223            table,
224            crate::types::table_write_capacity_units(capacity.old_size, capacity.new_size),
225            &capacity.gsi_units,
226            &capacity.lsi_units,
227            &capacity.vector_bytes,
228            &request.return_consumed_capacity,
229        )
230    });
231
232    // A write without a RETURNING clause answers with an empty `Items` list,
233    // not without one (captured eu-west-2, us-east-1, eu-west-1 and
234    // us-west-2, 2026-10-09).
235    Ok(ExecuteStatementResponse {
236        items: Some(items.unwrap_or_default()),
237        next_token,
238        consumed_capacity,
239    })
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::storage::Storage;
246
247    /// Two GSIs, so the fan-out has a second insert to fail on.
248    async fn table_with_two_gsis(storage: &Storage) {
249        let req = serde_json::from_value(serde_json::json!({
250            "TableName": "pq_tx",
251            "KeySchema": [{"AttributeName": "pk", "KeyType": "HASH"}],
252            "AttributeDefinitions": [
253                {"AttributeName": "pk", "AttributeType": "S"},
254                {"AttributeName": "g1", "AttributeType": "S"},
255                {"AttributeName": "g2", "AttributeType": "S"}
256            ],
257            "BillingMode": "PAY_PER_REQUEST",
258            "GlobalSecondaryIndexes": [
259                {
260                    "IndexName": "gsi-one",
261                    "KeySchema": [{"AttributeName": "g1", "KeyType": "HASH"}],
262                    "Projection": {"ProjectionType": "ALL"}
263                },
264                {
265                    "IndexName": "gsi-two",
266                    "KeySchema": [{"AttributeName": "g2", "KeyType": "HASH"}],
267                    "Projection": {"ProjectionType": "ALL"}
268                }
269            ]
270        }))
271        .unwrap();
272        crate::actions::create_table::execute(storage, req)
273            .await
274            .unwrap();
275    }
276
277    fn s(v: &str) -> AttributeValue {
278        AttributeValue::S(v.to_string())
279    }
280
281    async fn val_of(storage: &Storage, pk: &str) -> String {
282        let req = crate::actions::get_item::GetItemRequest {
283            table_name: "pq_tx".to_string(),
284            key: [("pk".to_string(), s(pk))].into_iter().collect(),
285            ..Default::default()
286        };
287        let got = crate::actions::get_item::execute(storage, req)
288            .await
289            .unwrap();
290        match got.item.unwrap().get("val").unwrap() {
291            AttributeValue::S(v) => v.clone(),
292            other => panic!("unexpected val: {other:?}"),
293        }
294    }
295
296    #[tokio::test]
297    async fn a_failed_fan_out_rolls_the_base_row_back() {
298        // Every other write path wraps its base write and index fan-out in one
299        // transaction. These two did not, so a fan-out that failed part way
300        // left the base row committed and the indexes disagreeing with it, with
301        // nothing to signal it.
302        let storage = Storage::memory().unwrap();
303        table_with_two_gsis(&storage).await;
304
305        let put = crate::actions::put_item::PutItemRequest {
306            table_name: "pq_tx".to_string(),
307            item: [
308                ("pk".to_string(), s("a")),
309                ("g1".to_string(), s("x")),
310                ("g2".to_string(), s("y")),
311                ("val".to_string(), s("before")),
312            ]
313            .into_iter()
314            .collect(),
315            ..Default::default()
316        };
317        crate::actions::put_item::execute(&storage, put)
318            .await
319            .unwrap();
320        assert_eq!(val_of(&storage, "a").await, "before");
321
322        // Let the first index insert land, fail the second.
323        storage.fail_gsi_insert_after(1);
324        let err = execute(
325            &storage,
326            ExecuteStatementRequest {
327                statement: "UPDATE \"pq_tx\" SET val='after' WHERE pk='a'".to_string(),
328                ..Default::default()
329            },
330        )
331        .await
332        .expect_err("the injected failure must surface");
333        assert!(
334            format!("{err:?}").contains("injected GSI insert failure"),
335            "unexpected error: {err:?}"
336        );
337
338        assert_eq!(
339            val_of(&storage, "a").await,
340            "before",
341            "the base row must roll back with the fan-out that failed"
342        );
343    }
344}