Repository navigation
fix(expression): operator cap, syntax error wording, PutItem map keys - #558
Open
LeeroyHannigan wants to merge 8 commits into
Open
LeeroyHannigan wants to merge 8 commits into
LeeroyHannigan wants to merge 8 commits into
Conversation
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 25, 2026 13:37
This was referenced Oct 25, 2026
…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.
… keyword text and the legacy count
… 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
force-pushed
the
fix/expression-operator-limits
branch
from
November 1, 2026 09:47
98fb237 to
077d435
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,INand every function call (sizeincluded) counts one. Parentheses, paths, placeholders and the elements of anINlist count zero.BETWEEN :a AND :bcounts one; its innerANDis not counted. Examples from the measurements:attribute_not_exists(pk)followed by 300OR #v = :vterms answers 601; 160#v BETWEEN :v AND :vterms joined byORanswer 319; 230size(#v) = :vterms answer 689.It applies to
ConditionExpressionon PutItem, UpdateItem and DeleteItem, toFilterExpressionon Scan and Query, and toKeyConditionExpressionon Query. The legacyScanFilter,QueryFilterandExpectedparameters are not counted: the service accepts 160 conditions in each. AnUpdateExpressionhas no operator cap: a chain of arithmetic is a syntax error first, and 301SETassignments 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 fromNOTandORin a key condition, which the service answers withInvalid 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 = = :vanswerstoken: "=", near: "= = :v",#v==:vanswersnear: "==:v", and= #v = :vanswersnear: "= #v". When the expression ends where a token is required, the token is<EOF>and the window is the last token:#v = :v ANDanswerstoken: "<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 = :vanswersnear: " = #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 = :vanswerstoken: "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
ExpressionAttributeNamesorExpressionAttributeValueswas 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
ExpressionAttributeValuestext 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's1 validation error detected:prefix, which ExtendDB does not emit (see below).tests/test_legacy_filter_operator_count.py: a 160-conditionScanFilterandQueryFilterare answered normally and a 160-conditionExpectedis evaluated. These pass on main; they fail if the cap is applied to the legacy parameters.test_value_key_too_long: 82 passed (78, 3 and 1). Against main atf49c5f82: 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.crates/core/src/expressionandcrates/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 givingkey_condition_clause_operandsa copy of main's clause parser each fails a unit test.Gates
On
077d435d:cargo +1.97.0 fmt --all --checkclean;cargo +1.97.0 clippy --locked --workspace --all-targets -- -D warningsclean;cargo +1.97.0 check --locked --workspace --all-targetsandcargo +1.88.0 check --locked --workspaceclean;--libtests 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 filecrates/core/src/expression/key_condition_tests.rs. On SQLite, 50 files undertests/: the 51 that use an expression parameter or map, lesstests/test_gsi_async_queue.pyandtests/test_gsi_async_queue_sqlite.py, which start their own servers, plustests/test_legacy_filter_operator_count.py. With the sixlarge_backuptests oftests/test_backup_restore_fidelity.pydeselected: 1,227 passed, 10 skipped, 1 xfailed, 0 failed;tests/test_concurrency.py::TestParallelDeletes::test_parallel_deleteswas stopped before it finished and not rerun. The 15 files undertests/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
--libruns; the 36 files undertests/and 5 undertests/python/that the runs above leave out, on every backend; PostgreSQL on the other 38 of the 50 files and ontests/python/; the sixlarge_backuptests andtest_parallel_deleteson both backends; MongoDB and Cassandra.CI passed all 35 checks on
077d435d2.Related behaviour
1 validation error detected:. ExtendDB does not, on any check.begins_withoperand 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.FilterExpressioncount, and the service reports the count first. On a table with HASHpk(S) and RANGEsk(S) and a filter of 301 operators (151#f = :pterms joined byOR),pk = :p AND sk = :nwith:nan N value answersOne or more parameter values were invalid: Condition parameter type does not match schema type, andsk = :sanswersQuery condition missed key schema element: pk, on this branch and on main. The service answersInvalid FilterExpression: The expression contains too many operators; operator count: 301to 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.ORandNOTin a key condition: the service answerspk = :v OR sk = :v,NOT pk = :pandpk = :p AND NOT sk = :swithInvalid KeyConditionExpression: Invalid operator used in KeyConditionExpression(NOTmeasured 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 withonly AND is supported between key conditionsand the other two with the syntax error form,token: "NOT", near: "NOT pk"andtoken: "NOT", near: "AND NOT sk".)or(), at a placeholder or identifier where a connector is required, and at the end of input after(: the service answers theSyntax error; token:form, ExtendDB keeps its own texts, as on main. The same holds for several key condition, update and projection shapes, such aspk = :p sk = :s,SET #v #a = :v,DELETE #vand#v #w.NOTor 160 nested parenthesised groups: the service evaluates or counts them; ExtendDB stops at its nesting depth limit of 150 first, as on main.; expression size: <n>, the service does not.InternalServerError: the MongoDB filter compiler nests one level perORorAND, 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.rsgrew to 2,029 lines andprojection.rsto 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]patternevaluator_tests.rsuses.crates/engine/src/query.rswas 773 lines on main and is 780 here.Rebase on #429
Rebased on main at
f49c5f82(#429), which includes #511. Both changedcrates/core/src/expression/key_condition.rs, and #511 also changed thepub useblock ofcrates/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_conditionintoparse_clauses, whichparse_key_conditionand its newkey_condition_clause_operandsboth call.parse_clausesnow takes the expression text and builds the syntax error window, so both callers report a repeated or misplaced operator or keyword in theSyntax error; token:form.parse_key_condition_frompasses the request's text.parse_key_conditionandkey_condition_clause_operandspass none, so their window joins the token texts. A unit test checks thatkey_condition_clause_operandsrefuses the same expressions asparse_key_condition, with the same error. Authorization callskey_condition_clause_operandsto finddynamodb:LeadingKeysand 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. Inmod.rsthe 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_schemaandfor_each_clauseto theimpl KeyConditionblock, and #429 and #511 added 20 tests to the test module. The whole block andresolve_path_attrgo tokey_condition_resolve.rs, and the whole test module goes tokey_condition_tests.rs.SortKeyCondition::path, which #429 added, stays next toSortKeyConditioninkey_condition.rs.key_condition.rswas 2,029 lines before the move and is 621 after it.key_condition_resolve.rsis 458 lines andkey_condition_tests.rsis 997.crates/engine/src/query.rsmerged without a conflict.handle_querycallsparse_key_condition_expr, which runs the key condition count, beforeresolve_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. TheFilterExpressioncount 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.rswas 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.rsandcrates/engine/src/expression_helpers.rs. #484's edits to the key condition test module land inkey_condition_tests.rshere, and its other files merge without a conflict.key_condition.rs: this branch moves theimpl KeyConditionblock andresolve_path_attrtokey_condition_resolve.rs, and fix(core): refuse a second condition on a key in KeyConditionExpression #484 addsONE_CONDITION_PER_KEY_ERRandreject_duplicate_key_attrsbetween the two. The new items stay inkey_condition.rs, where the block was, since they read the privateRawClause. fix(core): refuse a second condition on a key in KeyConditionExpression #484's added sentence in theresolve_key_schema_withdoc goes tokey_condition_resolve.rs.key_condition.rs, the parse entry points:parse_key_condition_fromclassifies through fix(core): refuse a second condition on a key in KeyConditionExpression #484'sclassify_clauses.parse_key_condition_with_mapskeeps its signature and calls a newparse_key_condition_with_maps_from, which takes the expression text asparse_key_condition_fromdoes.key_condition_clause_operandsrunsparse_clauseswithout a source, then fix(core): refuse a second condition on a key in KeyConditionExpression #484's duplicate check.expression_helpers.rs:parse_key_condition_exprtakes fix(core): refuse a second condition on a key in KeyConditionExpression #484'smapsargument, runs this branch's operator count, then callsparse_key_condition_with_maps_fromwith the request's text and maps. A key condition gets the count first, then the clause grammar with theSyntax error; token:wording, then fix(core): refuse a second condition on a key in KeyConditionExpression #484's duplicate check, as on each branch alone. The test module keeps both sides' tests, and this branch's threeparse_key_condition_exprcalls pass empty maps.mod.rs: both sets of exports, plusparse_key_condition_with_maps_from.key_condition_tests.rs: fix(core): refuse a second condition on a key in KeyConditionExpression #484's edits to existing tests apply to the moved module, both sides' new tests are kept, and theuse crate::typesline takes fix(core): refuse a second condition on a key in KeyConditionExpression #484'sAttributeValuewith this branch'sKeySchemaElementandKeyType. Inclause_operands_reject_what_the_parser_rejects, this branch's check thatkey_condition_clause_operandsreturns the parser's error takes fix(core): refuse a second condition on a key in KeyConditionExpression #484's two map arguments.Two doc comments from #484 name
parse_key_condition_with_mapsas the function the engine parses with: its own doc and the sentence moved into theresolve_key_schema_withdoc. With both merged, the engine callsparse_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 --libpasses: 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.rsis 762 lines,key_condition_resolve.rs461 andcrates/engine/src/expression_helpers.rs645. The duplicate check (ONE_CONDITION_PER_KEY_ERR,reject_duplicate_key_attrsandraw_clause_values, about 80 lines) or the clause classification (classify_clauses,classify_single,classify_pair,classify_multiandraw_to_sk, about 130 lines) can move to a file of its own in whichever of #558 and #484 merges second.