Repository navigation
fix(query): check Query values against the key schema and the number rules - #544
Open
LeeroyHannigan wants to merge 7 commits into
Open
LeeroyHannigan wants to merge 7 commits into
LeeroyHannigan wants to merge 7 commits into
Conversation
…rules
A Query with a KeyConditionExpression did not check a key condition value's
type against the key attribute's AttributeDefinitions type, and did not check
that an N value in ExpressionAttributeValues is a number. The storage layer
compared the value's inner text with the stored key and ignored the type tag,
so {"N": "bob"} on an S partition key read the bob partition while
authorization bound no dynamodb:LeadingKeys value to it, and a sort key value
of another type answered InternalServerError on SQLite.
Add the two checks the service applies before it reads a row, on the
expression path (crates/engine/src/query_key_values.rs):
- an N value in ExpressionAttributeValues that is not a number answers
"The parameter cannot be converted to a numeric value: <value>" (the
overflow and 38-digit texts for those cases; an empty value answers the
message with the colon). The check covers the whole map, including NS
members and values nested in L and M, whether the key condition, the
filter or nothing reads them, with the walk PutItem and UpdateItem already
use. It runs at request time after the Limit and expression parameter mix
checks and before the table lookup, which moves those two checks ahead of
the table lookup as well. The server runs the same request-time checks
ahead of the IAM decision, so a principal under any LeadingKeys fence form
gets ValidationException and no items;
- a key condition value whose type is not the key attribute's type answers
"One or more parameter values were invalid: Condition parameter type does
not match schema type", for the partition key, the sort key under every
comparison operator, both BETWEEN bounds, the begins_with prefix and
multi-attribute index keys, after the key schema, filter, projection,
unused value and Select checks and before the ExclusiveStartKey check. When
a compared placeholder is undefined the check returns without a verdict and
the undefined placeholder is reported.
Measured on the service on 2026-10-24 (113 requests, each sent twice). The
legacy KeyConditions path is unchanged; its checks land with #507. Scan is
unchanged. Unit tests at the checks and a dual-target wire test file,
tests/test_query_key_value_types.py, which fails on main.
… service's order The server ran its pre-IAM checks only when the body deserialized as QueryInput, and the engine checked the ReturnConsumedCapacity and Select enums before them, so a Query carrying an invalid Select and an N value that is not a number answered the Select message, or AccessDeniedException for a principal whose LeadingKeys fence needs a value. The checks now read the raw body in the service's order, measured 2026-10-24: Limit, the expression and non-expression parameter mix, ReturnConsumedCapacity, every number in ExpressionAttributeValues, then Select. The engine and the server run the same function.
LeeroyHannigan
requested review from
amrith,
c33howard,
jcshepherd,
pdf-amzn,
robinnsc and
yesyayen
as code owners
October 25, 2026 03:43
…y sentence matches the other backends
…ort a shape fault ahead of the request-time checks The request-time number check refused an N value written with a Unicode decimal digit (fullwidth, Arabic-Indic, Devanagari) that the service accepts as the digit it stands for, so a Query that returns rows on the service answered ValidationException here. The check now folds every general category Nd digit to ASCII before the number rules run and echoes the value as the request wrote it when the rules refuse it; the Nd zero code points of Unicode 15.0 live in a module of their own. The fold applies to the check only: the handler and the server's key binding read the value as written. The write paths are unchanged. The checks run on the raw body before deserialization, so a field of the wrong JSON type elsewhere in the body (a string ExpressionAttributeValues beside a Limit of 0, a string ExpressionAttributeNames beside a malformed N, a numeric Select) answered the check's ValidationException where the service and the previous release answered SerializationException. When a check fails, the body is now deserialized as the typed input and a SerializationException from that is returned instead; a ValidationException the deserializer raises does not preempt the check, and a body that passes every check is not deserialized twice. The server's call before dispatch goes through the same function. Measured against the service on 2026-10-25 (11:31 UTC) on tables with S/N and S/S keys, each request twice. Unit tests cover the digit table, the folded check and the shape-fault order; the wire file gains the measured rows for both.
…ks, and assert only acceptance for Unicode digits
The shape-fault pass returned the deserializer's SerializationException for an
AttributeValue with an unknown type descriptor, so Limit 0 beside {"X": "1"}
changed class. The service answers the Limit ValidationException for that
body (measured 2026-10-25), and the check's answer is returned again.
The Unicode digit tests asserted that a key condition or filter written with
a Unicode digit reads the item stored with the ASCII digit. This change makes
the request-time check accept such a value; it does not change how the
handler reads it, so the tests assert only that the value is accepted. A new
test checks that a partition key value written with Unicode digits returns no
item of a partition a ForAllValues LeadingKeys fence keeps the caller from.
This was referenced Oct 25, 2026
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.
fix(query): check Query values against the key schema and the number rules
What changes
A
Querywith aKeyConditionExpressiondid not check a key condition value's type against the key attribute'sAttributeDefinitionstype, and did not check that anNvalue inExpressionAttributeValuesis a number. The storage layer compared the value's inner text with the stored key and ignored the type tag: on a table whose partition key is an S attribute,pk = :uwith:u = {"N": "bob"}read thebobpartition,:u = {"N": "5"}read the5partition, and a sort key value of another type answeredInternalServerErroron SQLite. The service refuses these before it reads a row.Two checks are added on the expression path, in
crates/engine/src/query_key_values.rs:Nvalue inExpressionAttributeValuesthat is not a number answersThe parameter cannot be converted to a numeric value: <value>(the overflow, underflow and 38-digit texts for those cases; an empty value answers the message with the colon and nothing after it; noExpressionAttributeValues contains invalid valuewrapper, which the service adds onUpdateItemand not onQuery). The check covers the whole map, includingNSmembers and values nested inLandM, whether the key condition, theFilterExpressionor nothing reads them; the walk covers the same valuesPutItemandUpdateItemcheck, and accepts a Unicode decimal digit (general categoryNd, 68 runs of ten digits in Unicode 15.0, among them ASCII, fullwidth, Arabic-Indic and Devanagari) as the digit it stands for, as the service does (see the fold round below). The check runs at request time on the raw body, before it is deserialized and before the table lookup, in the service's order: theLimitcheck, the expression and non-expression parameter mix check, theReturnConsumedCapacityenum check, the numbers, then theSelectenum check. This movesLimitand the mix check ahead of the table lookup and of the two enum checks, and theSelectcheck after the numbers; because the checks read the raw body, a field that does not deserialize does not stop the ones before it. When a check fails, the body is deserialized as the typed input and aSerializationExceptionfrom that (a field of the wrong JSON type anywhere in the body) is returned instead, which is the service's order; a body that passes every check is deserialized once, by the handler. The server runs the same request-time checks ahead of the IAM decision, because authorization binds nodynamodb:LeadingKeysvalue to a number it cannot parse: without it a principal whose Allow carriesForAllValues:StringEquals dynamodb:LeadingKeys ["alice"]was authorized with no key bound and served thebobpartition, and a principal under aForAnyValue,Nullor plain-operator fence answeredAccessDeniedExceptionwhere the request's error isValidationException.One or more parameter values were invalid: Condition parameter type does not match schema type. The values checked are the partition key value, the sort key value under=,<,<=,>,>=and the reversed operand order, bothBETWEENbounds, thebegins_withprefix, and the extra conditions of a multi-attribute index key. The key schema is the queried table's or index's, so a GSI partition or range key and an LSI range key are checked against their own definitions. The check runs after the key schema,FilterExpression,ProjectionExpression, unused value andSelectchecks and before theExclusiveStartKeycheck. When a compared placeholder is undefined the check returns without a verdict and the undefined placeholder is reported, which is the service's order.The legacy
KeyConditionspath is not changed here; its checks land with #507.Scanis not changed here (see below).One line in
crates/storage-cassandra/src/data/index.rspasses the bare index name inIndexNotFound, so aQuerynaming an unknown index on Cassandra answersThe table does not have the specified index: <name>as the other backends do, where it read... index: Index <name> not found. The new test file asserts that text. The same commit is on #481 and #507, so the three merge without a conflict on that line.Measured on the service
us-east-1, admin principal, client retries off, each request sent twice, on tables with
pkS andskS (with an LSI and a GSI),pkS andskN (with a GSI), andbpkB andbskB.2026-10-24 15:10 UTC, the reported shapes:
pk = :u,:u = {"N": "bob"}ValidationExceptionThe parameter cannot be converted to a numeric value: bobpk = :u,:u = {"N": "5"}ValidationExceptionOne or more parameter values were invalid: Condition parameter type does not match schema typepk = :u,:ua B valuepk = :u,:u = {"BOOL": true}pk = :u AND sk = :s,:s = {"N": "5"}pk = :u AND pk > :v,:u = {"N": "bob"},:v = {"S": "alice"}The parameter cannot be converted to a numeric value: bobpk = :u AND pk > :v,:u = {"N": "5"},:v = {"S": "alice"}Invalid KeyConditionExpression: KeyConditionExpressions must only contain one condition per key2026-10-24 21:00 UTC, 77 requests, where the two checks fall against the other
Querychecks:IndexName, an undefined placeholder, an unused value, a syntax error,ConsistentReadon a GSI, a nested key path, a non-equality partition operator, an invalidExclusiveStartKey, theSelectandProjectionExpressionchecks and a reserved word in the filter; and for anNvalue that only theFilterExpressionreads or that nothing reads.Limit0 and the expression and non-expression parameter mix are reported ahead of it. An emptyNanswersThe parameter cannot be converted to a numeric value:;1e200answers the overflow message; 39 digits answer the 38-digit message.Query condition missed key schema element, a reserved word in the key condition, the unknown index,Limit0, the unused value, the undefined placeholder, the missing table,ConsistentReadon a GSI, abegins_withoperand error and a reserved word in the filter, aProjectionExpressionoverlap and theSelectchecks; and before an invalidExclusiveStartKey. It applies to the partition and the sort key, on the table, a GSI and an LSI, for the six comparison operators,BETWEENandbegins_with, forNULL,SS,L,M,BOOLandBvalues on an S key, S and N values on a B key, and S values on an N key.2026-10-24 21:06 UTC, 29 requests: an
Nvalue that is not a number insideL,M,NSorMinLinM, read by the filter or by nothing, answers the numeric message; a nested emptyNanswers the message with the colon; the numeric message is reported ahead of a missing table for a value nothing reads, ahead of the missing key condition refusal, and for a value beside legacyKeyConditions;Limit0 is reported ahead of a missing table, an unknown index,ConsistentReadon a GSI, the parameter mix and the numeric check; the parameter mix is reported ahead of a missing table.2026-10-24 23:42 UTC, the request-time order: an invalid
Selectbeside{"N": "bob"}answers the numeric message; an invalidReturnConsumedCapacitybeside it answers1 validation error detected: Value 'bad' at 'returnConsumedCapacity' failed to satisfy constraint: Member must satisfy enum value set: [INDEXES, TOTAL, NONE];Limit0 is reported ahead of an invalidSelectand an invalidReturnConsumedCapacity, and the parameter mix ahead of an invalidSelect;Limit-1 is reported ahead of the numeric message;Limit0 ahead of a type mismatch and a number that is not a number in one request.Not measured
Whether the service answers a principal fenced away from the value with
ValidationExceptionorAccessDeniedExceptionfor the schema type case cannot be measured on this host (no restricted principal can be created); the test file does not encode it. The order ofLimit0 against theSelectALL_ATTRIBUTESindex projection check was not measured; theLimitcheck now runs first.Differences that remain
Nvalues are invalid, the service reports one of them in an order that is neither the request's nor sorted (it matches JavaHashMapiteration order for the measured keys); ExtendDB reports the first in sorted placeholder order.pk > :uwith:u = {"N": "5"}on an S key: the service answers the schema type message; ExtendDB's parser refuses the shape (partition key condition must use equality (=)) before the key schema is known, as on main. fix(query): refuse a key condition with no HASH equality the way the service does #479 and fix(auth): bind LeadingKeys for a refused Query's partition operand #511 own that shape.BETWEENbounds order, thebegins_withoperand type and one condition per key in aKeyConditionExpressionbefore the schema type; main does not make those checks on the expression path (Count: 0,InternalServerError,Count: 0), so a request that carries both faults answers the schema type message here. fix(core): refuse a second condition on a key in KeyConditionExpression #484 adds the one-condition-per-key check. The service'sInvalid KeyConditionExpression:prefix on the undefined placeholder message, itsbegins_withtext for an N prefix on an N sort key, and its empty key value message (The AttributeValue for a key attribute cannot contain an empty string value. Key: pk) are also absent on main and unchanged here.Scanon the service applies the same numeric check to itsExpressionAttributeValues(top-level and nested, overflow, 39 digits, empty with the colon) before the table lookup, withLimit0 reported first; main'sScandoes not, and itsLimit0 message readsValue '0' at 'limit'where the service's readsValue at 'Limit'. Not changed in this branch.pk = :u AND other = :swith a non-reserved name outside the key schema is applied to the sort key column on SQLite on main and here.{"N": "5"}) as a number when it compares and stores the ASCII value. Here the number check accepts it, and everything after the check reads the value as the request wrote it, as on main: a key condition or filter written with a Unicode digit matches no item stored with the ASCII digit, and a numeric sort key orExclusiveStartKeywritten that way answersInternalServerErroron SQLite and PostgreSQL. The write paths (PutItem,UpdateItem, the batch and transaction writes) refuse such a value on main and are not changed here. Reading the digit as a number has to change the server'sdynamodb:LeadingKeysbinding and the engine together, and is left to a separate issue. A principal under adynamodb:LeadingKeysfence sending such a key value binds no number at the server; the engine then matches nothing, which a test pins. The service's answer for a fenced principal is not measured (no restricted principal on this host).Fold round (2026-10-25)
Two regressions the request-time design introduced, found on the
Scanbranch stacked on this one and fixed here first:{"N": "\uff11"}(fullwidth one) in a key condition, a filter or an unused value answeredThe parameter cannot be converted to a numeric value: 1where the service and the previous release return rows. Measured 2026-10-25 11:31 UTC on an S/N table holding an item withskN1andaN1, each request twice: a fullwidth, Arabic-Indic or Devanagari one as the sort key value returns that item (Count 1), as a filter value matchesa(Count 1), and as an unused value answers the unused-value text (the digit passed the number check); thirty nine Devanagari ones answerAttempting to store more than 38 significant digits in a Numberin all three positions;1\uff12and1e\uff12are accepted (Count 0); a fullwidth digit in anNSpasses the check; a superscript two, a vulgar half, a fullwidth minus and a fullwidth full stop are refused with the numeric message and the value echoed as sent.PutItemwithskN\uff11is accepted andGetItemby the fullwidth key and by the ASCII key both return the item withskN1: the service stores the ASCII value. The check now folds everyNddigit to ASCII before the number rules run (crates/engine/src/decimal_digits.rs: the 68 run zeros of Unicode 15.0, every run being ten consecutive code points, with a binary search) and echoes the value as written when the rules refuse it. The fold applies to the check only; the handler and the server's key binding read the value as written (see Differences that remain). No new dependency. The write paths are unchanged (their refusal is on main; a separate issue covers it).ValidationExceptionwhere the service and the previous release answerSerializationException. Measured 2026-10-25 11:31 UTC:Limit: 0withExpressionAttributeValues: "x"andExpressionAttributeNames: "x"with{"N": "bob"}answerSerializationException: Unexpected field type;Limit: "1"with{"N": "bob"}answersSTRING_VALUE cannot be converted to Integer;AttributesToGet: {"a": 1}withProjectionExpressionanswersStart of structure or map found where not expected;QueryFilter: [1]withFilterExpressionanswersSerializationExceptionwith no message;Select: 5with{"N": "bob"}andLimit: 0withSelect: 5answerNUMBER_VALUE cannot be converted to String;Limit: 0with{"N": "bob"}and no shape fault answers theLimittext. When a check fails,validate_query_request_bodynow deserializes the body asQueryInputand returns the deserializer'sSerializationExceptionif there is one; aValidationExceptionthe deserializer raises (an empty set, a duplicate, an invalidAttributeValue) does not preempt the check, since those orders are not measured. Neither does anAttributeValuewith an unknown type descriptor, which the deserializer reports as aSerializationException:Limit: 0beside{"X": "1"}answers theLimitValidationExceptionon the service (measured 2026-10-25 12:56 UTC), in its olderValue '0' at 'limit'form; ExtendDB answers itsValue at 'Limit'text, the form the service uses when noAttributeValueis malformed. The server's pre-dispatch call goes through the same function. The message texts of theSerializationExceptiondiffer between the service and ExtendDB (pre-existing); the tests assert the error type.Tests
crates/engine/src/query_key_values/tests.rs: 18 unit tests at the checks (the six comparison operators and the reversed operand order, bothBETWEENbounds, S, N and B keys, a GSI range key, a multi-attribute hash schema,#namereferences, the undefined placeholder stand-aside, the numeric messages, nestedNS,LandMvalues, values the key condition does not name, sorted order,Limitthen the parameter mix then the numbers at request time, and the two enum checks on either side of the numbers).crates/engine/src/decimal_digits.rshas 4 unit tests (the measured digits and controls, run boundaries, folding of text) andquery_key_values/tests.rsgains 3 (digits accepted, non-digits refused with the text as sent, shape faults first with the string-enum, deserializer-ValidationExceptionand unknown type descriptor controls).tests/test_query_key_value_types.pygains 21 rows (g1 to g8: the 38-digit message and the four refused characters in the key condition, filter and unused positions, five accepted forms and anNSas unused values),TestUnicodeDecimalDigitsPassTheCheck(6 tests: a Unicode digit, a mixed form and anNSmember accepted in the filter),TestShapeFaultsAreReportedFirst(7 raw signed rows plus a control) and, in the LeadingKeys class, a test that a partition key written with Unicode digits returns no item of a partition aForAllValuesfence keeps the caller from. At the previous head of this branch (4a4e92f) 22 of the new tests fail on SQLite; all 133 tests in the file pass at the new head on SQLite.tests/test_query_key_value_types.py(dual target): 94 parametrized rows, one per measured request above whose answer the branch produces, two controls, and a LeadingKeys class (extenddb only) sending four shapes (pk = :uwith:u = {"N": "bob"}; the same with an invalidSelect;pk = :uwith an S value and{"N": "bob"}only in the filter;pk = :uwith an S value and{"N": "bob"}unused) underForAllValues,ForAnyValue,Null false,StringEquals,StringLikeandStringNotEqualsfences and as the owner, expectingValidationExceptionand no items. On main (0.1.14, SQLite) 74 of the 97 tests fail and 23 pass: the two controls and the 21 rows whose service answer is a check main already makes ahead of the new ones (Limit, the parameter mix, the key schema, reserved words, the unknown index, the missing table,ConsistentReadon a GSI, the filter, projection andSelectchecks), which pin the order of the new checks against those. At the head all 97 pass on SQLite.Gates
At the head:
cargo +1.97.0 fmt --all -- --check: clean.cargo +1.97.0 clippy -p extenddb-engine -p extenddb-server --all-targets -- -D warnings: 0 warnings.cargo test -p extenddb-engine -p extenddb-server --lib: 194 and 202 passed, 0 failed.devtools/run-tests --extenddb --pytest --filter "test_query_key_value_types or test_query or test_fgac_leading_keys or test_key_condition or test_legacy"against a release binary built with thesqlitefeature only: 275 passed, 0 failed.On the first commit, before the last change moved the request-time checks onto the raw body (that change touches
crates/engineandcrates/serveronly):cargo +1.97.0 clippy --all-targets -- -D warnings: 0 warnings.cargo test --workspace --exclude extenddb-storage-cassandraon stable: 1782 passed, 0 failed (34 test binaries);cargo test -p extenddb-storage-cassandra --lib: 61 passed, 0 failed.cargo +1.88.0 test --workspace --exclude extenddb-storage-cassandra: 1782 passed, 0 failed.For the Cassandra line:
cargo test -p extenddb-storage-cassandra --lib61 passed, andcargo +1.97.0 clippy -p extenddb-storage-cassandra --all-targets -- -D warnings0 warnings.Cargo.lock unchanged.
Fold round, at the new head (SQLite release binary with the
sqlitefeature, port 18666):cargo +1.97.0 fmt --all -- --check: clean.cargo +1.97.0 clippy -p extenddb-engine -p extenddb-server --all-targets -- -D warnings: 0 warnings.cargo test -p extenddb-engine -p extenddb-server --lib: 201 and 202 passed, 0 failed.tests/test_query_key_value_types.py: 133 passed on the new head on SQLite and on PostgreSQL; 22 failed and 111 passed on the previous head 4a4e92f on SQLite.devtools/run-tests --extenddb --pytest --filter "test_query or test_fgac_leading_keys or test_key_condition or test_legacy": 311 passed, 0 failed.crates/engineand the test file only.Merge checks
git merge-tree --write-treeagainst the open PRs that touch the same files: #511, #484, #483, #479 and #429 merge clean. #481 conflicts incrates/engine/src/lib.rs(both branches add a module line next tomod query;; keep both) and incrates/engine/src/query.rswhere both insert a check between the filter operand checks and theExclusiveStartKeycheck: #481's comment places the type check above its key size check, so keep this branch's block first and #481's after it. #507 conflicts on the doc comment ofvalidate_attribute_numberincrates/core/src/validation/mod.rs(both branches make it public; either comment) and incrates/engine/src/query.rs, as does #399, where #399 moves the request-time checks ahead of the table lookup and #507 builds on it; this branch's request-time call lands directly beforeserde_json::from_valuein that layout (replacing the two enum calls ahead of it and theirLimitand mix checks after it) and the type check lands directly before theExclusiveStartKeycheck. The legacydesugar_key_conditionscall that #507 extends is not touched here.