Skip to content

fix(expression): operator cap, syntax error wording, PutItem map keys - #558

Open
LeeroyHannigan wants to merge 8 commits into
mainfrom
fix/expression-operator-limits
Open

LeeroyHannigan wants to merge 8 commits into
mainfrom
fix/expression-operator-limits

Conversation

@LeeroyHannigan

@LeeroyHannigan LeeroyHannigan commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #501.

Three expression checks now answer as DynamoDB does. Every expected response in the tests was measured on 2026-10-25 in us-east-1, each case sent twice with the same answer.

Operator cap

A condition, filter or key condition expression with more than 300 operators is refused with Invalid <Kind>: The expression contains too many operators; operator count: <n>, where <n> is the full count. 300 is accepted.

The count follows the service: every comparison operator, AND, OR, NOT, BETWEEN, IN and every function call (size included) counts one. Parentheses, paths, placeholders and the elements of an IN list count zero. BETWEEN :a AND :b counts one; its inner AND is not counted. Examples from the measurements: attribute_not_exists(pk) followed by 300 OR #v = :v terms answers 601; 160 #v BETWEEN :v AND :v terms joined by OR answer 319; 230 size(#v) = :v terms answer 689.

It applies to ConditionExpression on PutItem, UpdateItem and DeleteItem, to FilterExpression on Scan and Query, and to KeyConditionExpression on Query. The legacy ScanFilter, QueryFilter and Expected parameters are not counted: the service accepts 160 conditions in each. An UpdateExpression has no operator cap: a chain of arithmetic is a syntax error first, and 301 SET assignments are accepted. TransactWriteItems is unchanged: the service reported a different count there for the same text (301 where PutItem reports 599), and one shape is not enough to derive its rule.

On the write paths the count runs after the parse and the ordering operand type check, and before the unused name and value check and the table lookup. On UpdateItem the condition is checked before the update expression, so a condition over the cap is reported ahead of an update syntax error. On Query the key condition count runs ahead of the key grammar.

Syntax error wording

A repeated or misplaced operator or keyword answers Invalid <Kind>: Syntax error; token: "<t>", near: "<window>" on condition, filter, key condition, update and projection expressions, apart from NOT and OR in a key condition, which the service answers with Invalid operator used in KeyConditionExpression (see Related behaviour). The window starts at the token before the offending one and ends at the token after it, keeping the whitespace of the source: #v = = :v answers token: "=", near: "= = :v", #v==:v answers near: "==:v", and = #v = :v answers near: "= #v". When the expression ends where a token is required, the token is <EOF> and the window is the last token: #v = :v AND answers token: "<EOF>", near: "AND". When the faulty token is the first one, the window starts at the start of the expression and keeps the leading whitespace: = #v = :v answers near: " = #v".

The update parser built its window by searching the source for the token's text, so the window was wrong when the same text appeared earlier in the expression. It now uses the token positions.

The token is shown as the client wrote it, so #v = :v and and #v = :v answers token: "and", near: "and and #v".

Other syntax error texts are unchanged.

Too-long keys in the expression maps on PutItem

A key over 255 bytes in ExpressionAttributeNames or ExpressionAttributeValues was already refused by the shared deserializer, but PutItem classified deserializer errors with its own list and answered SerializationException. PutItem now uses the shared classifier and answers the ValidationException: ExpressionAttributeNames contains invalid key: The expression attribute map contains a key that is too long; size of key: 256. The bound is 255 bytes of UTF-8, and it is checked before any expression check.

The ExpressionAttributeValues text now carries the ; size of key: <n> suffix too. The service answered it on PutItem, Scan and Query in these measurements, and the existing test that asserted its absence is updated.

Tests

  • tests/test_expression_operator_limits.py: 78 tests covering the cap, its position, the key length and the syntax wording on PutItem, UpdateItem, DeleteItem, Scan, Query and GetItem. On the write paths the assertions compare the text after the service's 1 validation error detected: prefix, which ExtendDB does not emit (see below).
  • tests/test_legacy_filter_operator_count.py: a 160-condition ScanFilter and QueryFilter are answered normally and a 160-condition Expected is evaluated. These pass on main; they fail if the cap is applied to the legacy parameters.
  • Against this branch on SQLite and on PostgreSQL, the two files and the updated test_value_key_too_long: 82 passed (78, 3 and 1). Against main at f49c5f82: 73 fail. The 9 that pass are the three legacy tests and rows main already satisfies because it has no count: four PutItem shapes at or under the cap, and the two checks the service reports ahead of the count.
  • Unit tests at each check in crates/core/src/expression and crates/engine/src/expression_helpers.rs. Raising the cap to 301, removing the count from the condition path, parsing a key condition without its source text, or giving key_condition_clause_operands a copy of main's clause parser each fails a unit test.

Gates

On 077d435d: cargo +1.97.0 fmt --all --check clean; cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warnings clean; cargo +1.97.0 check --locked --workspace --all-targets and cargo +1.88.0 check --locked --workspace clean; --lib tests core 738 passed and 1 ignored, engine 188 passed, server 237 passed.

The wire runs used servers built from ce8d29ef, which differs from this head only in the unit test file crates/core/src/expression/key_condition_tests.rs. On SQLite, 50 files under tests/: the 51 that use an expression parameter or map, less tests/test_gsi_async_queue.py and tests/test_gsi_async_queue_sqlite.py, which start their own servers, plus tests/test_legacy_filter_operator_count.py. With the six large_backup tests of tests/test_backup_restore_fidelity.py deselected: 1,227 passed, 10 skipped, 1 xfailed, 0 failed; tests/test_concurrency.py::TestParallelDeletes::test_parallel_deletes was stopped before it finished and not rerun. The 15 files under tests/python/: 292 passed. On PostgreSQL 16, the twelve files closest to the change (test_expression_operator_limits.py, test_legacy_filter_operator_count.py, test_expression_attribute_map_validation.py, test_fgac_leading_keys.py, test_gsi_multi_range_query.py, test_query_scan.py, test_expression_param_usage.py, test_expression_error_prefix.py, test_validation_precedence.py, test_wording_parity_validation.py, test_conditional_operator.py, test_multipart_gsi.py): 394 passed and 1 skipped, the same per-file counts as on SQLite.

CI runs the rest: the Rust tests outside the three --lib runs; the 36 files under tests/ and 5 under tests/python/ that the runs above leave out, on every backend; PostgreSQL on the other 38 of the 50 files and on tests/python/; the six large_backup tests and test_parallel_deletes on both backends; MongoDB and Cassandra.

CI passed all 35 checks on 077d435d2.

Related behaviour

  • The service starts every expression ValidationException on PutItem, UpdateItem and DeleteItem with 1 validation error detected: . ExtendDB does not, on any check.
  • TransactWriteItems has no operator count here: the service reported 301 where PutItem reports 599 for the same text, and one shape is not enough to derive its rule.
  • On PutItem, DeleteItem, UpdateItem and Scan, ExtendDB reports an undefined placeholder and a begins_with operand type after the table lookup, and the service reports them before the operator count. With both faults in one request, the service answers the undefined placeholder and ExtendDB answers the count. The same position difference shows without any count.
  • On Query, the key value type check fix(query): classify and order key conditions on a multi-attribute GSI the way the service does #429 adds and the older missing key check run ahead of the FilterExpression count, and the service reports the count first. On a table with HASH pk (S) and RANGE sk (S) and a filter of 301 operators (151 #f = :p terms joined by OR), pk = :p AND sk = :n with :n an N value answers One or more parameter values were invalid: Condition parameter type does not match schema type, and sk = :s answers Query condition missed key schema element: pk, on this branch and on main. The service answers Invalid FilterExpression: The expression contains too many operators; operator count: 301 to both (measured 2026-10-31 and 2026-11-01). fix(query): check Query values against the key schema and the number rules #544 moves the type check after the filter checks; the missing key check is Query: the missing key refusal comes before the filter syntax and unused value checks when the key condition has an equality #712.
  • OR and NOT in a key condition: the service answers pk = :v OR sk = :v, NOT pk = :p and pk = :p AND NOT sk = :s with Invalid KeyConditionExpression: Invalid operator used in KeyConditionExpression (NOT measured 2026-11-01, and listed in Query: shapes the service refuses that ExtendDB accepts or words differently (measured 2026-10-23) #489). ExtendDB answers the first with only AND is supported between key conditions and the other two with the syntax error form, token: "NOT", near: "NOT pk" and token: "NOT", near: "AND NOT sk".
  • A projection path of 301 segments: the service answers a nesting levels message.
  • Syntax errors at punctuation (a doubled comma, a stray ) or (), at a placeholder or identifier where a connector is required, and at the end of input after (: the service answers the Syntax error; token: form, ExtendDB keeps its own texts, as on main. The same holds for several key condition, update and projection shapes, such as pk = :p sk = :s, SET #v #a = :v, DELETE #v and #v #w.
  • 160 stacked NOT or 160 nested parenthesised groups: the service evaluates or counts them; ExtendDB stops at its nesting depth limit of 150 first, as on main.
  • The expression size message: ExtendDB appends ; expression size: <n>, the service does not.
  • On MongoDB, an UpdateItem condition at the cap (a chain of 150 terms) answers InternalServerError: the MongoDB filter compiler nests one level per OR or AND, and the chain fails inside MongoDB. The test for that row is marked as an expected failure on MongoDB here; fix(mongodb): evaluate deeply nested conditions instead of answering 500 #571, stacked on this branch, evaluates such a condition in Rust and removes the marker.

Files

key_condition.rs grew to 2,029 lines and projection.rs to 754, so the last commit before the rustfmt commit is a pure move of the key condition resolution and the two test modules into their own files, using the #[path] pattern evaluator_tests.rs uses. crates/engine/src/query.rs was 773 lines on main and is 780 here.

Rebase on #429

Rebased on main at f49c5f82 (#429), which includes #511. Both changed crates/core/src/expression/key_condition.rs, and #511 also changed the pub use block of crates/core/src/expression/mod.rs, so the first commit conflicted in those two files and the move commit was redone.

#511 moved the clause loop of parse_key_condition into parse_clauses, which parse_key_condition and its new key_condition_clause_operands both call. parse_clauses now takes the expression text and builds the syntax error window, so both callers report a repeated or misplaced operator or keyword in the Syntax error; token: form. parse_key_condition_from passes the request's text. parse_key_condition and key_condition_clause_operands pass none, so their window joins the token texts. A unit test checks that key_condition_clause_operands refuses the same expressions as parse_key_condition, with the same error. Authorization calls key_condition_clause_operands to find dynamodb:LeadingKeys and reads the clauses it returns; of an error it reads only that one occurred, never the text. A key condition that does not parse binds no key there, as on main, and the client sees the engine's error with the source window. In mod.rs the merge keeps both sets of exports, and in the test module it keeps both sides' new tests.

#429's checks (one condition per key, the condition count, HASH coverage, the RANGE gap, the value type) run on the parsed key condition, after the parser, and keep their own texts.

The move commit moves the same items from the rebased file. #429 added resolve_multipart_with, resolve_key_schema_with, resolve_key_schema and for_each_clause to the impl KeyCondition block, and #429 and #511 added 20 tests to the test module. The whole block and resolve_path_attr go to key_condition_resolve.rs, and the whole test module goes to key_condition_tests.rs. SortKeyCondition::path, which #429 added, stays next to SortKeyCondition in key_condition.rs. key_condition.rs was 2,029 lines before the move and is 621 after it. key_condition_resolve.rs is 458 lines and key_condition_tests.rs is 997.

crates/engine/src/query.rs merged without a conflict. handle_query calls parse_key_condition_expr, which runs the key condition count, before resolve_key_schema_with, so the count is still reported ahead of the condition count, the missing HASH attribute, the RANGE gap and the value type check. The FilterExpression count is where it was, after the undefined name check and before the unused placeholder check, and after #429's key condition checks; the service reports the filter count ahead of #429's type check (see Related behaviour). query.rs was 773 lines on main and is 780 here.

Merging with #484

#484 and this branch conflict in crates/core/src/expression/key_condition.rs, crates/core/src/expression/mod.rs and crates/engine/src/expression_helpers.rs. #484's edits to the key condition test module land in key_condition_tests.rs here, and its other files merge without a conflict.

Two doc comments from #484 name parse_key_condition_with_maps as the function the engine parses with: its own doc and the sentence moved into the resolve_key_schema_with doc. With both merged, the engine calls parse_key_condition_with_maps_from.

rustfmt rewraps two of this branch's test calls and one of #484's test helpers on the merged tree. With both merged, cargo test -p extenddb-core -p extenddb-engine -p extenddb-server --lib passes: core 748 and 1 ignored (this branch's 738 and #484's 10), engine 189 (188 and #484's 1), server 238 (237 and #484's 1). key_condition.rs is 762 lines, key_condition_resolve.rs 461 and crates/engine/src/expression_helpers.rs 645. The duplicate check (ONE_CONDITION_PER_KEY_ERR, reject_duplicate_key_attrs and raw_clause_values, about 80 lines) or the clause classification (classify_clauses, classify_single, classify_pair, classify_multi and raw_to_sk, about 130 lines) can move to a file of its own in whichever of #558 and #484 merges second.

…ice does, and classify too-long map keys on PutItem

Condition, filter and key condition expressions now count operators the way
Amazon DynamoDB does: every comparison, AND, OR, NOT, BETWEEN, IN and
function call (size included) counts one; parentheses, paths, placeholders
and list elements count zero. A count above 300 is refused with
"Invalid <Kind>: The expression contains too many operators; operator
count: <n>". On the write paths the count runs after the ordering operand
type check and before the unused name and value check and the table lookup;
on Query it runs ahead of the key grammar. TransactWriteItems is unchanged.

A repeated or misplaced operator or keyword now answers
"Syntax error; token: \"<t>\", near: \"<window>\"" on condition, filter, key
condition, update and projection expressions. The window runs from the token
before the offending one to the token after it, with the source whitespace
kept; at the end of input the token is <EOF> and the window is the last
token. Other syntax error texts are unchanged.

PutItem classified deserializer errors itself and answered a too-long key in
ExpressionAttributeNames or ExpressionAttributeValues as SerializationException.
It now uses the shared classifier, which answers the ValidationException. The
ExpressionAttributeValues text carries the "; size of key: <n>" suffix, as
the service answers on PutItem, Scan and Query.

Measured on 2026-10-25 between 10:39 and 10:52 UTC, each case twice.

Fixes #501
… and the syntax error wording

tests/test_expression_operator_limits.py covers every row the change encodes,
from measurements on 2026-10-25 between 10:39 and 10:52 UTC. On the write
paths the assertions compare the text after the service's
"1 validation error detected: " prefix, which ExtendDB does not emit.
test_value_key_too_long now expects the size suffix.
…tion and projection tests into their own files

Pure move. key_condition.rs had grown to 2029 lines and projection.rs to 754;
the resolve methods go to key_condition_resolve.rs and the two test modules
to key_condition_tests.rs and projection_tests.rs, using the #[path] pattern
evaluator_tests.rs already uses.
…ve legacy filters out of the operator count

A syntax error at a lower-case keyword names the token as the client wrote
it: "#v = :v and and #v = :v" answers token "and", not "AND", on
FilterExpression, ConditionExpression and KeyConditionExpression (measured
2026-10-25). The token is now taken from the source text, as the window
already was.

Scan and Query counted the expression they build from a legacy ScanFilter or
QueryFilter, so 151 or more legacy conditions were refused as too many
operators. The service accepts 160 conditions in ScanFilter, QueryFilter and
Expected (measured 2026-10-25). The count now applies to a FilterExpression
only; Expected was not counted before this change.
… case as failing on MongoDB

On MongoDB, a 300-operator UpdateItem condition answers InternalServerError:
the MongoDB filter compiler nests one level per OR or AND, and a chain of 150
terms fails inside MongoDB. The compiler is not changed here. The refusal
above the cap stays an unconditional test; the evaluation at the cap is now a
test of its own with a strict expected failure on MongoDB.
…the first token

When the faulty token is the first one, the service starts the near window
at the start of the expression: `  = #v = :v` answers `near: "  = #v"` on
FilterExpression, KeyConditionExpression and ProjectionExpression (measured
2026-10-25 at 16:54 UTC, each request twice). The window started at the
token. A unit test and a wire test pin the measured rows.
@LeeroyHannigan
LeeroyHannigan force-pushed the fix/expression-operator-limits branch from 98fb237 to 077d435 Compare November 1, 2026 09:47

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Condition expressions: three shapes the service refuses or words differently (measured 2026-10-24)

1 participant