Skip to content

Commit ac4abf6

Browse files
yesyayenpdf-amzn
authored andcommitted
refactor: share update/condition expression parse helpers; expand transact tests
1 parent 3a4b1ae commit ac4abf6

4 files changed

Lines changed: 173 additions & 58 deletions

File tree

crates/engine/src/expression_helpers.rs

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use std::collections::HashMap;
77

88
use extenddb_core::error::DynamoDbError;
99
use extenddb_core::expression::{
10-
Expr, ExpressionKind, ExpressionMaps, PathElement, Token, parse_condition_with_depth_limit,
11-
parse_projection, tokenize_for, tokenize_with_limit, validate_no_reserved_words,
10+
Expr, ExpressionKind, ExpressionMaps, PathElement, Token, UpdateAction,
11+
parse_condition_with_depth_limit, parse_projection, parse_update_from, tokenize_for,
12+
tokenize_with_limit, validate_no_reserved_words,
1213
};
1314
use extenddb_core::limits::LimitsConfig;
1415
use extenddb_core::types::{AttributeValue, ConditionalOperator, ExpectedAttributeValue};
@@ -54,6 +55,44 @@ pub fn build_expression_maps(
5455
)
5556
}
5657

58+
/// Tokenize, reserved-word check, and parse a required `ConditionExpression`.
59+
///
60+
/// Errors carry the `ConditionExpression` prefix, matching Amazon DynamoDB.
61+
///
62+
/// # Errors
63+
///
64+
/// Returns `DynamoDbError::ValidationException` for syntax or reserved-word errors.
65+
pub fn parse_condition_expr(expr: &str, limits: &LimitsConfig) -> Result<Expr, DynamoDbError> {
66+
tokenize_expression(expr, limits)
67+
.and_then(|tokens| parse_condition_with_depth_limit(&tokens, limits.max_expression_depth))
68+
.map_err(|e| prefix_expression_error(e, ExpressionKind::Condition))
69+
}
70+
71+
/// Tokenize, reserved-word check, and parse an `UpdateExpression`.
72+
///
73+
/// Errors carry the `UpdateExpression` prefix, matching Amazon DynamoDB.
74+
///
75+
/// # Errors
76+
///
77+
/// Returns `DynamoDbError::ValidationException` for syntax or reserved-word errors.
78+
pub fn parse_update_expr(
79+
update_expr: &str,
80+
limits: &LimitsConfig,
81+
) -> Result<Vec<UpdateAction>, DynamoDbError> {
82+
tokenize_for(
83+
update_expr,
84+
limits.max_expression_tokens,
85+
ExpressionKind::Update,
86+
)
87+
.and_then(|update_tokens| {
88+
if limits.enforce_reserved_keywords {
89+
validate_no_reserved_words(&update_tokens)?;
90+
}
91+
parse_update_from(&update_tokens, update_expr)
92+
})
93+
.map_err(|e| prefix_expression_error(e, ExpressionKind::Update))
94+
}
95+
5796
/// Parse an optional condition expression string into an AST.
5897
///
5998
/// Returns `None` if the input is `None` or empty.
@@ -66,14 +105,7 @@ pub fn parse_optional_condition(
66105
limits: &LimitsConfig,
67106
) -> Result<Option<Expr>, DynamoDbError> {
68107
match expr {
69-
Some(s) if !s.is_empty() => {
70-
let parsed = tokenize_expression(s, limits).and_then(|tokens| {
71-
parse_condition_with_depth_limit(&tokens, limits.max_expression_depth)
72-
});
73-
parsed
74-
.map(Some)
75-
.map_err(|e| prefix_expression_error(e, ExpressionKind::Condition))
76-
}
108+
Some(s) if !s.is_empty() => parse_condition_expr(s, limits).map(Some),
77109
_ => Ok(None),
78110
}
79111
}

crates/engine/src/transact_write_items.rs

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ use crate::transact_write_helpers::{
1818
};
1919
use crate::{DispatchMetrics, DispatchResult};
2020
use extenddb_core::error::DynamoDbError;
21-
use extenddb_core::expression::{
22-
ExpressionKind, parse_update_from, tokenize_for, validate_no_reserved_words,
23-
};
2421
use extenddb_core::types::{TransactWriteItem, TransactWriteItemsInput, TransactWriteItemsOutput};
2522
use extenddb_core::validation::{
2623
validate_attribute_name_sizes, validate_attribute_values_nesting_depth,
@@ -296,20 +293,8 @@ async fn prepare_write_op(
296293
upd.expression_attribute_names.as_ref(),
297294
upd.expression_attribute_values.as_ref(),
298295
);
299-
let actions = tokenize_for(
300-
&upd.update_expression,
301-
ctx.limits.max_expression_tokens,
302-
ExpressionKind::Update,
303-
)
304-
.and_then(|update_tokens| {
305-
if ctx.limits.enforce_reserved_keywords {
306-
validate_no_reserved_words(&update_tokens)?;
307-
}
308-
parse_update_from(&update_tokens, &upd.update_expression)
309-
})
310-
.map_err(|e| {
311-
crate::expression_helpers::prefix_expression_error(e, ExpressionKind::Update)
312-
})?;
296+
let actions =
297+
crate::expression_helpers::parse_update_expr(&upd.update_expression, &ctx.limits)?;
313298
validate_no_key_updates(&actions, &key_info, &maps)?;
314299

315300
// Validate nesting depth of EAV values that get stored via SET actions.
@@ -366,16 +351,7 @@ async fn prepare_write_op(
366351
cc.expression_attribute_values.as_ref(),
367352
);
368353
let condition =
369-
crate::expression_helpers::tokenize_expression(&cc.condition_expression, &ctx.limits)
370-
.and_then(|tokens| {
371-
extenddb_core::expression::parse_condition_with_depth_limit(
372-
&tokens,
373-
ctx.limits.max_expression_depth,
374-
)
375-
})
376-
.map_err(|e| {
377-
crate::expression_helpers::prefix_expression_error(e, ExpressionKind::Condition)
378-
})?;
354+
crate::expression_helpers::parse_condition_expr(&cc.condition_expression, &ctx.limits)?;
379355
{
380356
let exprs: Vec<&extenddb_core::expression::Expr> = vec![&condition];
381357
extenddb_core::expression::validate_unused_attributes(

crates/engine/src/update_item.rs

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@ use std::collections::HashMap;
1111
use serde_json::Value;
1212

1313
use extenddb_core::error::DynamoDbError;
14-
use extenddb_core::expression::{
15-
ExpressionKind, ExpressionMaps, PathElement, UpdateAction, parse_update_from, tokenize_for,
16-
validate_no_reserved_words,
17-
};
14+
use extenddb_core::expression::{ExpressionKind, ExpressionMaps, PathElement, UpdateAction};
1815
use extenddb_core::types::{
1916
AttributeValue, Item, ReturnValues, TableKeyInfo, UpdateItemInput, UpdateItemOutput,
2017
item_size_bytes,
@@ -139,22 +136,9 @@ pub async fn handle_update_item(
139136
)?;
140137

141138
// No UpdateExpression and no AttributeUpdates: no-op upsert.
142-
// Some("") still errors via tokenize_for.
139+
// Some("") still errors via parse_update_expr.
143140
let actions = if let Some(update_expr) = effective_update_expr.as_deref() {
144-
tokenize_for(
145-
update_expr,
146-
ctx.limits.max_expression_tokens,
147-
ExpressionKind::Update,
148-
)
149-
.and_then(|update_tokens| {
150-
if ctx.limits.enforce_reserved_keywords {
151-
validate_no_reserved_words(&update_tokens)?;
152-
}
153-
parse_update_from(&update_tokens, update_expr)
154-
})
155-
.map_err(|e| {
156-
crate::expression_helpers::prefix_expression_error(e, ExpressionKind::Update)
157-
})?
141+
crate::expression_helpers::parse_update_expr(update_expr, &ctx.limits)?
158142
} else {
159143
Vec::new()
160144
};

tests/test_transact_expression_validation.py

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@
3636
"Invalid ConditionExpression: Attribute name is a reserved keyword; "
3737
"reserved keyword: status"
3838
)
39+
REDUNDANT_PARENS_CONDITION = (
40+
"Invalid ConditionExpression: The expression has redundant parentheses;"
41+
)
42+
UNUSED_NAME_MSG = (
43+
"Value provided in ExpressionAttributeNames unused in expressions: keys: {#n}"
44+
)
45+
UNUSED_VALUE_MSG = (
46+
"Value provided in ExpressionAttributeValues unused in expressions: keys: {:v}"
47+
)
3948

4049

4150
@pytest.fixture(scope="class")
@@ -137,6 +146,44 @@ def test_tgi_get_names_no_projection_accepted(self, dynamodb_client, hash_table)
137146
assert resp["Responses"][0]["Item"]["pk"]["S"] == "k1"
138147

139148

149+
class TestTransactReferencedExpressionAccepted:
150+
"""Names/values that ARE referenced by an expression are accepted."""
151+
152+
def test_twi_put_name_referenced_by_condition_accepted(
153+
self, dynamodb_client, hash_table
154+
):
155+
dynamodb_client.transact_write_items(
156+
TransactItems=[
157+
{
158+
"Put": {
159+
"TableName": hash_table,
160+
"Item": {"pk": {"S": "p-ref"}},
161+
"ConditionExpression": "attribute_not_exists(#n)",
162+
"ExpressionAttributeNames": {"#n": "pk"},
163+
}
164+
}
165+
]
166+
)
167+
got = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "p-ref"}})
168+
assert got["Item"]["pk"]["S"] == "p-ref"
169+
170+
def test_twi_update_value_referenced_accepted(self, dynamodb_client, hash_table):
171+
dynamodb_client.transact_write_items(
172+
TransactItems=[
173+
{
174+
"Update": {
175+
"TableName": hash_table,
176+
"Key": {"pk": {"S": "k1"}},
177+
"UpdateExpression": "SET foo = :v",
178+
"ExpressionAttributeValues": {":v": {"S": "set"}},
179+
}
180+
}
181+
]
182+
)
183+
got = dynamodb_client.get_item(TableName=hash_table, Key={"pk": {"S": "k1"}})
184+
assert got["Item"]["foo"]["S"] == "set"
185+
186+
140187
class TestTransactUnusedWithExpression:
141188
"""With an expression present, unused names/values are still rejected."""
142189

@@ -156,7 +203,45 @@ def test_twi_put_unused_name_with_condition_rejected(
156203
}
157204
]
158205
),
159-
"Value provided in ExpressionAttributeNames unused in expressions: keys: {#n}",
206+
UNUSED_NAME_MSG,
207+
)
208+
209+
def test_twi_delete_unused_value_with_condition_rejected(
210+
self, dynamodb_client, hash_table
211+
):
212+
_expect_validation(
213+
lambda: dynamodb_client.transact_write_items(
214+
TransactItems=[
215+
{
216+
"Delete": {
217+
"TableName": hash_table,
218+
"Key": {"pk": {"S": "k1"}},
219+
"ConditionExpression": "attribute_exists(pk)",
220+
"ExpressionAttributeValues": {":v": {"N": "1"}},
221+
}
222+
}
223+
]
224+
),
225+
UNUSED_VALUE_MSG,
226+
)
227+
228+
def test_twi_condition_check_unused_name_rejected(
229+
self, dynamodb_client, hash_table
230+
):
231+
_expect_validation(
232+
lambda: dynamodb_client.transact_write_items(
233+
TransactItems=[
234+
{
235+
"ConditionCheck": {
236+
"TableName": hash_table,
237+
"Key": {"pk": {"S": "k1"}},
238+
"ConditionExpression": "attribute_exists(pk)",
239+
"ExpressionAttributeNames": {"#n": "foo"},
240+
}
241+
}
242+
]
243+
),
244+
UNUSED_NAME_MSG,
160245
)
161246

162247
def test_tgi_get_unused_name_with_projection_rejected(
@@ -175,7 +260,7 @@ def test_tgi_get_unused_name_with_projection_rejected(
175260
}
176261
]
177262
),
178-
"Value provided in ExpressionAttributeNames unused in expressions: keys: {#n}",
263+
UNUSED_NAME_MSG,
179264
)
180265

181266

@@ -249,3 +334,41 @@ def test_twi_delete_condition_reserved_word_prefix(self, dynamodb_client, hash_t
249334
),
250335
RESERVED_CONDITION,
251336
)
337+
338+
def test_twi_update_syntax_error_prefix(self, dynamodb_client, hash_table):
339+
# Syntax error also carries the UpdateExpression prefix.
340+
with pytest.raises(ClientError) as exc_info:
341+
dynamodb_client.transact_write_items(
342+
TransactItems=[
343+
{
344+
"Update": {
345+
"TableName": hash_table,
346+
"Key": {"pk": {"S": "k1"}},
347+
"UpdateExpression": "SET foo = ",
348+
"ExpressionAttributeValues": {":v": {"N": "1"}},
349+
}
350+
}
351+
]
352+
)
353+
err = exc_info.value.response["Error"]
354+
assert err["Code"] == "ValidationException"
355+
assert err["Message"].startswith("Invalid UpdateExpression:")
356+
357+
def test_twi_condition_check_redundant_parens_prefix(
358+
self, dynamodb_client, hash_table
359+
):
360+
_expect_validation(
361+
lambda: dynamodb_client.transact_write_items(
362+
TransactItems=[
363+
{
364+
"ConditionCheck": {
365+
"TableName": hash_table,
366+
"Key": {"pk": {"S": "k1"}},
367+
"ConditionExpression": "((pk = :v))",
368+
"ExpressionAttributeValues": {":v": {"S": "k1"}},
369+
}
370+
}
371+
]
372+
),
373+
REDUNDANT_PARENS_CONDITION,
374+
)

0 commit comments

Comments
 (0)