Repository navigation
fix(query): classify and order key conditions on a multi-attribute GSI the way the service does - #429
Conversation
b3343a7 to
4ec5f66
Compare
|
Measured on the service on 2026-10-23 while working #427 (each row twice, identical answers), for the placement of the type check this PR adds:
#479 conflicts with this branch in |
… reach Closes #440. A Query ExclusiveStartKey from another partition, or outside the sort key condition, was accepted and answered an empty page. The service refuses it, with a sentence chosen by where the key falls. Measured on 2026-10-23 and 2026-10-24, every row repeated with identical answers, page-end rows ten times each. After the shape check, the service first asks whether a later key in the scan direction can still satisfy the sort key condition, for either partition: - On a base table a later key must differ from the start key's sort value: ">" and "<" at the bound are refused, ">=" at the bound is accepted, "<=" at the bound refused, BETWEEN at the upper bound refused and at the lower bound accepted, begins_with with a key that does not carry the prefix refused; the reverse direction mirrors each. - On an LSI or GSI a repeat of the start key's sort value counts as a later key, because index keys repeat: "<=", BETWEEN at either bound, ">=" in reverse, begins_with in reverse at the prefix and "=" with the key itself are all accepted; ">" and "<" at the bound are refused. - The sentence is "The provided starting key does not match the range key predicate", with two base-table exceptions in the same partition: "=" with the key's own value: "The provided exclusive start key is invalid"; begins_with in reverse with the key exactly the prefix: "The provided starting key is outside query boundaries based on provided conditions". Then the partition, when the key's partition differs from the condition's (table, LSI, GSI, legacy KeyConditions, either direction): - no sort key condition: "The provided starting key is outside query boundaries based on provided conditions"; - any sort key condition: "The query can return at most one row and cannot be restarted". A hash-only base table refuses every ExclusiveStartKey with "The provided exclusive start key is invalid", the queried partition included, and its pages never carry a key. Partition values compare by value for numbers (1, 1.0, 1e0, 01, +1 are equal); strings by UTF-8 byte order; binary by byte order. Page end: when a base table page ends on the last key that can satisfy the sort key condition in the scan direction, the service returns no LastEvaluatedKey (ten of ten for "=", BETWEEN at the upper bound, "<=" at the bound, reverse ">=" at the bound). ExtendDB now drops the key there, so a client that feeds every returned key back is never refused. Index pages are unchanged. Ordering, measured: filter and projection faults, unused names and values, ConsistentRead on a GSI, Limit, a missing table or index, the key size check and the start key shape check (missing or extra attributes, wrong types) are reported first; the Select checks after. The checks live in crates/engine/src/start_key.rs, called from handle_query after the shape check. The sort key predicate is evaluated with the expression evaluator on the start key's sort value, so every operator and type follows the same comparison as the condition itself. Tests: engine unit tests in start_key_tests.rs and start_key_range_tests.rs; tests/python/test_query_start_key_partition.py, test_query_start_key_range.py and test_query_start_key_index.py (162 rows, the hash-only page row ExtendDB only) pass against the service and on SQLite and PostgreSQL, with test_read_paging.py and the Query wire files. Pagination walks with Limit 1 to 3 in both directions feed every returned key back without a refusal. Pre-existing, measured, not changed here: a sort key bound of another type than the sort key answers InternalServerError (the type check in #429); the service omits the trailing key when the next row exists but fails the condition ("sk < z" with Limit equal to the matches), which needs a storage look-ahead and is filed separately.
robinnsc
left a comment
There was a problem hiding this comment.
The classification and ordering look right. On local SQLite and PostgreSQL, a reference model built from the rules in the description matched every page over a few thousand checks: all RANGE attributes, both directions, Limit 1/2/3/7 across full-key ties, LastEvaluatedKey, COUNT, filters, legacy KeyConditions, and random clause orders and aliases. Main failed nearly all of the same checks. MongoDB and Cassandra behaved as documented. Performance is better: SQLite and PostgreSQL now read the ordering index with no sort, and hash-only reverse queries with Limit 10 on a 20k-item partition went from about 17 ms to about 3 ms. Base-table and single-attribute queries are unchanged.
The one thing I'd hold this for is the LeadingKeys interaction in the first inline comment. Authorization still picks the partition clause the old way, and this PR makes the engine serve clause orders it used to refuse.
Smaller notes, not blocking:
- Cassandra, pre-existing: on a two-HASH index with one RANGE attribute (
h1,h2;r),data/query.rs:264reads the cursor's sort key asquery_key_schema[1], which ish2. Pagingh1 = :x AND h2 = :yrepeats rows after the first page, and with a condition onrthe second page answers 500 (Column "sk_n" cannot be restricted by both an equality and an inequality relation). Probably worth its own issue. The new differences row reads as if single-RANGE indexes page fine. - Legacy
KeyConditionswith two or more non-EQ conditions return the RANGE-gap orQuery key condition not supportedbefore the count, HASH and non-key rules, e.g.h LT, d GEgets the RANGE message rather than the HASH one. Only the wording differs, and the service is measured for just one shape in that class. - Nothing pins the count refusal ahead of the missing-HASH and RANGE-gap refusals. Moving the count check after both still passes all 37
key_conditiontests, because every count case has every key attribute.a = :a AND b = :b AND c = :c AND z1 = :z AND z2 = :y(h missing) would pin it. - Description: the regression file set gave 309 passed on PostgreSQL here rather than 180.
| // A wrong-typed value is reported after the structural rules and | ||
| // before the non-key rule (measured 2026-10-21), so the type check | ||
| // runs inside the classifier. | ||
| key_condition.resolve_multipart_with( |
There was a problem hiding this comment.
I think this needs a matching change in authorization before it goes in. query_leading_keys (crates/server/src/leading_keys.rs:253-262) still finds the LeadingKeys value with parse_key_condition + resolve_pk_sk and then checks pk_path == first HASH attribute. On main that was safe, because the engine ran the same check and refused anything authorization couldn't read. Here multi-attribute schemas skip it and classify by name, so for some 3+ clause orders authorization finds no key while the engine serves the query. Under ForAllValues:StringEquals dynamodb:LeadingKeys an absent key passes.
Reproduced on SQLite and PostgreSQL head, with a principal fenced to ["alice"] on the table and /index/* and every query naming bob:
- GSI (g1, g2 HASH; gs RANGE):
g2 = :g AND g1 = :b AND gs = :x,gs = :x AND g1 = :b AND g2 = :g,g2 = :g AND g1 = :b AND gs > :lo, and the#namevariants return bob's items. - GSI (h HASH; a, b RANGE):
a = :a AND h = :b AND b = :c,b = :c AND h = :b AND a = :a,a = :a AND h = :b AND b > :lo,b > :lo AND a = :a AND h = :b, andbegins_with(b, :p) AND a = :a AND h = :breturn bob's items. - On main all of these are ValidationException. Two-clause orders, the base table, LSIs, single-RANGE GSIs and legacy
KeyConditionsstill bind correctly, andForAnyValueor aNullguard denies.
Possible fix: when is_multipart_key_schema holds, have query_leading_keys call resolve_multipart(&hash_attrs, &range_attrs, &names) (no type check) instead of resolve_pk_sk. It leaves the first HASH attribute in pk_path, so the existing check and value_of bind the right value. Sharing one "classify for this key schema" function between the engine and authorization would keep them from drifting again. QUERY_ACCEPTED in tests/test_fgac_leading_keys.py only has two-clause orders on a HASH-only index, so adding the shapes above there should fail today and pass with the fix.
| .position(|(n, _)| *n == name) | ||
| .map(|pos| (pos, all_sks[pos].1)) | ||
| }; | ||
| if let Some(sk_cond) = &key_condition.sk_condition |
There was a problem hiding this comment.
Pre-existing, but since these lines are rewritten: on a single-attribute schema a condition on a non-key attribute falls through to the first RANGE column. On pk S / sk N, pk = :p AND zz = :v with :v N 4 returns the item whose sk is 4. On a GSI (h; a), h = :h AND pk = :v filters on a. With mismatched types it's a 500. PostgreSQL does the same at query_scan.rs:190. The multi-attribute path now refuses these with Query condition missed key schema element, so the two paths disagree. Could consider applying the same count and non-key rules on the single-attribute path in query.rs too.
| use extenddb_core::expression::Expr; | ||
| use extenddb_core::types::{AttributeValue, ScalarAttributeType}; | ||
|
|
||
| let Some(attr) = resolve_path_attr_name(path, &maps.names) else { |
There was a problem hiding this comment.
query_key_info.attribute_definitions is the table-wide list for an index query (query.rs:158), so a clause on an attribute that is defined but isn't in the queried index's key schema also gets type-checked here. On GSI (h; a), h = :h AND sk = :s (the base table's N sort key, S value) answers the type sentence, where the stated rules would give Query condition missed key schema element: a. The service isn't measured for that case, so this might be fine, but filtering the definitions to the queried key schema would line it up with the doc comment.
| // value ahead of the missing-key check (measured 2026-10-21: `pk = :n` | ||
| // on an S key with a second clause on a non-key attribute reports the | ||
| // type, not the missing key). | ||
| for_each_key_clause(&key_condition, |path, value| { |
There was a problem hiding this comment.
nit: the type check now runs before the unused-value check (query.rs:430) and filter parsing. So pk = :p AND sk > :s (S value on an N sort key) plus an unused :u answers the type sentence where main answered Value provided in ExpressionAttributeValues unused, and the same goes for a bad FilterExpression. The #544 paragraph in the description covers where the service puts it, so this is probably known. Just noting it shows up on this branch alone too.
| FIRST_RANGE_ONLY_MARK = "applies a key condition to the first RANGE attribute" | ||
|
|
||
|
|
||
| @pytest.fixture(scope="module") |
There was a problem hiding this comment.
first_range_only decides from the server's reply whether to skip the 22 served-result tests. If SQLite or PostgreSQL ever started answering with the MongoDB refusal, the module would report 39 passed / 22 skipped and stay green. Might be worth taking the branch from EXTENDDB_TEST_BACKEND like tests/test_ttl.py does, and failing when the reply doesn't match the backend.
|
Thanks for the review. Pushed four commits:
Filed separately: the Cassandra two-HASH cursor as #673 and the single-attribute non-key fall-through as #674, with the measurement above in the second. On Not changed: the type check running before the unused-value check, which the #544 paragraph covers, and the legacy |
46a66b5 to
3893bf6
Compare
|
Rebased onto main to clear a conflict in |
…I the way the service does
On a global secondary index with one HASH attribute and several RANGE attributes, Query ordered results by the first RANGE attribute and then the table key, refused every key condition with three or more clauses as "Query condition missed key schema element: h", and accepted a condition that skipped the first RANGE attribute while returning no items. KeyCondition::resolve_multipart returned early when the schema had one HASH attribute, so the parser's assignment stood: the first equality became the partition key and the rest sat where the engine's HASH-count check refused them.
Measured against the service on 2026-10-21, with the issue's schema (HASH h, RANGE a, b, c, d) and with two HASH attributes:
- Results are ordered over every RANGE attribute in key-schema order, then the base table key; ScanIndexForward = false is the exact mirror. LastEvaluatedKey carries every key attribute.
- A key condition is served when the HASH attributes all have an equality and the RANGE conditions cover a leading run of the RANGE attributes with an equality on every one before the last; the last may use <, <=, >, >=, BETWEEN or begins_with. Clause order in the expression is free.
- Refusals, in the order the service applies them: a second condition on one key attribute ("KeyConditionExpressions must only contain one condition per key"); more conditions than key attributes ("The number of query conditions (6) exceeds the number of key attributes defined in the schema (5)"); a HASH attribute without an equality ("HASH key attributes h1, h2 must have equality conditions specified in the query", every missing one named); a RANGE gap ("RANGE key attributes a must have equality conditions specified in the query because a condition is present on key attribute b"); a value whose type differs from the attribute definition ("One or more parameter values were invalid: Condition parameter type does not match schema type"); a condition on an attribute outside the key schema, named by the first RANGE attribute without a condition ("Query condition missed key schema element: a"). begins_with on a number attribute is "Incorrect operand type for operator or function; operator or function: begins_with, operand type: N". The single-attribute wording "Query condition missed key schema element" stays on single-attribute schemas.
resolve_multipart now classifies every clause by name against the HASH and RANGE attribute lists and applies those rules in that order; the value type check runs inside it through resolve_multipart_with, with the engine supplying the attribute definitions. The engine's single-attribute path gets the same type check ahead of its missing-key check, which turns a 500 on a wrong-typed sort key value into the service's refusal. Legacy KeyConditions on a multi-attribute schema go through the same classifier. The SQLite and PostgreSQL backends apply every RANGE condition to its column and order and page over all RANGE columns; the cursor keeps its shape. The MongoDB and Cassandra backends filter the first RANGE attribute only and now refuse a condition on a later one instead of applying it to the wrong column. On Cassandra every Query on an index with several RANGE attributes answered 500 before this change: the index table clusters on every RANGE column and CQL rejects an ORDER BY that skips one, so the ORDER BY now names every index RANGE column in declared order, and a Query with an ExclusiveStartKey on such an index is refused instead of answering 500, because the cursor compares the first RANGE attribute only. docs/differences-from-dynamodb.md records both backends.
tests/test_gsi_multi_range_query.py pins 60 cases, all measured on the service: order, pages and LastEvaluatedKey, every accepted shape, every refusal and its text, legacy KeyConditions, and the MongoDB and Cassandra refusal. 60 pass on the service and on SQLite and PostgreSQL; on MongoDB 39 pass and the 22 served-result cases skip; on Cassandra the same 22 skip. On main 30 of the 30 then-existing cases failed. Unit tests in core cover the classifier rules; the storage plan and cursor tests cover the ORDER BY and cursor columns.
Closes #381
…fication On a key schema with several HASH or several RANGE attributes the engine assigns every key condition clause to its key attribute by name, so it serves clause orders such as `g2 = :g AND g1 = :v AND gs = :x` and `b > :lo AND a = :a AND h = :v`. Authorization still read the partition equality with `resolve_pk_sk`, found no partition key in those orders and left dynamodb:LeadingKeys absent, so a ForAllValues:StringEquals fence let the query read any partition of the index. `KeyCondition::resolve_key_schema_with` holds the choice between the multi-attribute classifier and `resolve_pk_sk`. The engine calls it, and so does `equality_pk_value`, which `query_leading_keys` calls for a condition with an equality on the partition attribute. When a clause compares the first HASH attribute by equality, authorization binds that attribute's value for every clause order the engine serves, and a condition the classifier refuses leaves the key absent. For `g1 = :a` on HASH `g1`, `g2` that gives the answer the service gives every principal, ValidationException (measured 2026-10-21). A single clause on the partition attribute under an operator the engine rejects still binds that clause's operand, as it does on main since #511. tests/test_fgac_leading_keys.py adds the three-clause orders from the review on a GSI with HASH `g1`, `g2` and RANGE `gs` and a GSI with HASH `h` and RANGE `a`, `b`, and expects ValidationException for the missing-g2 shape from the principal fenced away from the value. That shape was the only entry in the list of codes that still differed from the service, so the list is removed. It and tests/test_ttl.py read the backend through `configured_backend()` in tests/conftest.py, so this file can expect the MongoDB and Cassandra refusal of a condition on `b`.
…GE checks The service reports more conditions than key attributes ahead of which key attribute is missing (measured 2026-10-21), and the classifier checks the count before the missing-HASH and RANGE-gap rules. Every count case in the tests had every key attribute, so moving the count check after both rules still passed them. The new cases have six conditions with `h` missing and six with a RANGE gap on HASH `h` and RANGE `a`, `b`, `c`, `d`, and five conditions on HASH `h1`, `h2` and RANGE `r`. The same five clauses on the five key attributes of `h` and `a` to `d` are not more than the key attribute count, so the missing HASH attribute answers them.
…test `first_range_only` decided from the server's reply whether to skip the served-result tests, so a SQLite or PostgreSQL server that answered a condition on `b` with the MongoDB and Cassandra refusal would have reported 39 passed and 22 skipped and stayed green. The fixture now reads the backend from `EXTENDDB_TEST_BACKEND` or the server's config file, as tests/test_ttl.py does, expects the refusal on MongoDB and Cassandra only, and fails when the reply does not match the backend.
…te index For an index query the attribute definitions are the table's whole list, which also holds the base table key and the other indexes' key attributes. A clause on one of those attributes with a value of another type got `Condition parameter type does not match schema type`, where the classifier's non-key rule gives `Query condition missed key schema element: <attribute>`, the first RANGE attribute without a condition. On a multi-attribute key schema the type check now reads the definitions of the queried key schema only, as its doc comment says. Measured on the service on 2026-10-30, on a GSI with HASH `GSI2PK` and RANGE `GSI2SK` of a table keyed on `PK` (S): `GSI2PK = :h AND PK = :k` answers `Query condition missed key schema element: GSI2SK` with `:k` an S and with `:k` an N, two samples each. The service's type check does not see an attribute outside the queried key schema. A single-attribute key schema keeps the table's definitions. Its path has no non-key rule: a clause on another attribute reaches storage, which applies it to the first RANGE attribute, and the type check is what refuses a value of the wrong type there. Reading only the key schema's definitions on that path would turn the N case above into a 500, and the S case would still be applied to the first RANGE attribute. That path's divergence from the measurement is older than this change and is tracked separately.
3893bf6 to
a7e14d8
Compare
|
Rebased onto main to clear the conflict with #511. The resolution is the one the overlap note in the description gave, and the new "Rebase on #511" section has the details and the runs. The review commits are now 5f7452a (LeadingKeys), 853d20b (count order), 2269f00 ( |
…service does Closes #427. A Query whose KeyConditionExpression has no equality clause on the HASH attribute was refused with a parser sentence of ExtendDB's own. The service chooses the sentence from the key schema. Measured on 2026-10-23, every row repeated with identical answers: - Single HASH attribute, any clause names it (so the clause on it is a non-equality): "Query key condition not supported". Rows: pk > :x, pk BETWEEN :x AND :y, begins_with(pk, :x), pk > :x AND sk = :y, sk = :x AND pk > :y, zz = :x AND pk > :y, and the GSI forms. - Single HASH attribute, no clause names it: "Query condition missed key schema element: <HASH>". Rows: sk > :x, zz > :x, sk > :x AND zz > :y, zz = :x AND yy = :y, and the GSI forms naming the index HASH attribute. - Several key attributes (two HASH attributes, or one HASH with several RANGE attributes): "HASH key attributes h1, h2 must have equality conditions specified in the query", naming every HASH attribute, for every operator and for an N value on the HASH attribute. - Two conditions on one attribute: "Invalid KeyConditionExpression: KeyConditionExpressions must only contain one condition per key". - An undefined #name or :value in the key condition is refused first, with the "Invalid KeyConditionExpression: " prefix. The rows include pk = :x with no values. - Single HASH attribute, a value of the wrong type: pk > :n with an N value on an S key answers "One or more parameter values were invalid: Condition parameter type does not match schema type", and begins_with(pk, :n) on an N key answers "Invalid KeyConditionExpression: Incorrect operand type for operator or function; operator or function: begins_with, operand type: N". - Legacy KeyConditions is desugared with every reference defined and an equality clause, or refused there; no change. Measured on 2026-11-01, two samples each, on a single HASH attribute schema: two conditions on one attribute answer the one condition per key sentence whatever their value types (pk > :x AND pk < :n). A wrong-typed value is reported only in the clause on the HASH attribute: pk > :n AND sk > :y, sk = :x AND pk > :n and pk BETWEEN :n AND :m answer the type sentence, and pk > :x AND sk > :n answers "Query key condition not supported". With no clause on the HASH attribute no value is checked: sk > :n answers "Query condition missed key schema element: pk", and on an index with HASH gsi1_pk and RANGE SK, PK > :n (PK the table's HASH attribute) and SK > :n answer "Query condition missed key schema element: gsi1_pk". Ordering, measured: unused values, filter syntax, projection syntax, the Select conflicts and undefined key references are reported before this refusal; an ExclusiveStartKey fault and an oversized key value after it. An unused value and an invalid FilterExpression are also reported before the type sentence for pk > :n (measured again on 2026-10-31). The refusal is in crates/core/src/expression/key_condition/no_hash_equality.rs, with KeyCondition::lacks_hash_equality, refuse_two_conditions_on_one_attribute, operands_naming and refuse_without_hash_equality, and KeyCondition::validate_references is in key_condition/references.rs. The parser now records one or two clauses with no equality instead of refusing them, so the engine can choose the sentence. handle_query skips the classification from #429 for a key condition with no equality clause, and on a single HASH attribute schema for an equality on another attribute paired with a non-equality on the HASH attribute (sk = :x AND pk > :y). Every other key condition goes through that classification as before. After validate_select_projection, handle_query calls the engine's refuse_without_hash_equality. It checks the references of every key condition and refuses the ones the classification skipped. It refuses two conditions on one attribute first. Then, on a single HASH attribute schema and only when a clause names the HASH attribute, it runs the type check and the begins_with check from #429 on that clause, against that attribute's definition. Then it refuses with the key schema's wording. On a multi-attribute key schema no value is checked, because the service answers h > :n with the HASH sentence. LeadingKeys keeps the binding from #511. query_kce_without_a_partition_key_equality_binds_only_a_partition_operand asserts the values it binds for the shapes the parser now records, and the_engine_still_refuses_every_shape_whose_operand_is_now_bound asserts that the core refusal answers "Query key condition not supported" for each shape it binds. Tests: unit tests in no_hash_equality/tests.rs, references.rs and crates/engine/src/query/no_hash_equality_tests.rs. The wire file tests/python/test_query_lone_non_equality.py has 81 rows. 67 of them passed against the service as a file; the other 14 repeat requests measured on 2026-10-23 (427-8c, row 6, and rows 5a and 5b with the N value) and on 2026-11-01 (the ten rows of TestValueTypes, two of them on the file's single-idx in place of the measured index). All 81 pass on SQLite and PostgreSQL 16 builds of this commit, and a SQLite build of main at f49c5f8 fails 74 of them. Related behaviour, measured: "<>", NOT, OR, IN and contains in a key condition get "Invalid operator used in KeyConditionExpression" on the service; pk = :x AND z > :x gets "missed key schema element: sk" on the service and 200 here; the Select ALL_PROJECTED_ATTRIBUTES text lacks the "1 validation error detected: " prefix here. A key condition with an equality clause and a HASH attribute without one (sk = :x on a table, g1 = :x on an index with HASH g1, g2) is refused by the key schema checks handle_query runs before it parses the FilterExpression. The service reports an unused value or an invalid FilterExpression first (measured 2026-10-31).
…service does Closes #427. A Query whose KeyConditionExpression has no equality clause on the HASH attribute was refused with a parser sentence of ExtendDB's own. The service chooses the sentence from the key schema. Measured on 2026-10-23, every row repeated with identical answers: - Single HASH attribute, any clause names it (so the clause on it is a non-equality): "Query key condition not supported". Rows: pk > :x, pk BETWEEN :x AND :y, begins_with(pk, :x), pk > :x AND sk = :y, sk = :x AND pk > :y, zz = :x AND pk > :y, and the GSI forms. - Single HASH attribute, no clause names it: "Query condition missed key schema element: <HASH>". Rows: sk > :x, zz > :x, sk > :x AND zz > :y, zz = :x AND yy = :y, and the GSI forms naming the index HASH attribute. - Several key attributes (two HASH attributes, or one HASH with several RANGE attributes): "HASH key attributes h1, h2 must have equality conditions specified in the query", naming every HASH attribute, for every operator and for an N value on the HASH attribute. - Two conditions on one attribute: "Invalid KeyConditionExpression: KeyConditionExpressions must only contain one condition per key". - An undefined #name or :value in the key condition is refused first, with the "Invalid KeyConditionExpression: " prefix. The rows include pk = :x with no values. - Single HASH attribute, a value of the wrong type: pk > :n with an N value on an S key answers "One or more parameter values were invalid: Condition parameter type does not match schema type", and begins_with(pk, :n) on an N key answers "Invalid KeyConditionExpression: Incorrect operand type for operator or function; operator or function: begins_with, operand type: N". - Legacy KeyConditions is desugared with every reference defined and an equality clause, or refused there; no change. Measured on 2026-11-01, two samples each, on a single HASH attribute schema: two conditions on one attribute answer the one condition per key sentence whatever their value types (pk > :x AND pk < :n). A wrong-typed value is reported only in the clause on the HASH attribute: pk > :n AND sk > :y, sk = :x AND pk > :n and pk BETWEEN :n AND :m answer the type sentence, and pk > :x AND sk > :n answers "Query key condition not supported". With no clause on the HASH attribute no value is checked: sk > :n answers "Query condition missed key schema element: pk", and on an index with HASH gsi1_pk and RANGE SK, PK > :n (PK the table's HASH attribute) and SK > :n answer "Query condition missed key schema element: gsi1_pk". On a table with HASH pk (N) and RANGE sk (S), begins_with(pk, :n) AND sk > :x and sk > :x AND begins_with(pk, :n) answer the begins_with operand type sentence, as begins_with(pk, :n) alone does. Ordering, measured: unused values, filter syntax, projection syntax, the Select conflicts and undefined key references are reported before this refusal; an ExclusiveStartKey fault and an oversized key value after it. An unused value and an invalid FilterExpression are also reported before the type sentence for pk > :n (measured again on 2026-10-31). The refusal is in crates/core/src/expression/key_condition/no_hash_equality.rs, with KeyCondition::lacks_hash_equality, refuse_two_conditions_on_one_attribute, operands_naming and refuse_without_hash_equality, and KeyCondition::validate_references is in key_condition/references.rs. The parser now records one or two clauses with no equality instead of refusing them, so the engine can choose the sentence. When only the second of two such clauses is a begins_with, the parser keeps that clause whole as the sort key condition, which the begins_with check reads. handle_query skips the classification from #429 for a key condition with no equality clause, and on a single HASH attribute schema for an equality on another attribute paired with a non-equality on the HASH attribute (sk = :x AND pk > :y). Every other key condition goes through that classification as before. After validate_select_projection, handle_query calls the engine's refuse_without_hash_equality. It checks the references of every key condition and refuses the ones the classification skipped. It refuses two conditions on one attribute first. Then, on a single HASH attribute schema and only when a clause names the HASH attribute, it runs the type check and the begins_with check from #429 on that clause, against that attribute's definition. Then it refuses with the key schema's wording. On a multi-attribute key schema no value is checked, because the service answers h > :n with the HASH sentence. LeadingKeys keeps the binding from #511. query_kce_without_a_partition_key_equality_binds_only_a_partition_operand asserts the values it binds for the shapes the parser now records, and the_engine_still_refuses_every_shape_whose_operand_is_now_bound asserts that the core refusal answers "Query key condition not supported" for each shape it binds. Tests: unit tests in no_hash_equality/tests.rs, references.rs and crates/engine/src/query/no_hash_equality_tests.rs. The wire file tests/python/test_query_lone_non_equality.py has 83 rows. 67 of them passed against the service as a file; the other 16 repeat requests measured on 2026-10-23 (427-8c, row 6, and rows 5a and 5b with the N value) and on 2026-11-01 (the ten rows of TestValueTypes, two of them on the file's single-idx in place of the measured index, and the two begins_with rows on a table with HASH pk (N) and RANGE sk). All 83 pass on SQLite and PostgreSQL 16 builds of this commit, and a SQLite build of main at f49c5f8 fails 76 of them. Related behaviour, measured: "<>", NOT, OR, IN and contains in a key condition get "Invalid operator used in KeyConditionExpression" on the service; pk = :x AND z > :x gets "missed key schema element: sk" on the service and 200 here; the Select ALL_PROJECTED_ATTRIBUTES text lacks the "1 validation error detected: " prefix here. A key condition with an equality clause and a HASH attribute without one (sk = :x on a table, g1 = :x on an index with HASH g1, g2) is refused by the key schema checks handle_query runs before it parses the FilterExpression. The service reports an unused value or an invalid FilterExpression first (measured 2026-10-31).
…Lite DynamoDB returns a Query on a local secondary index ordered by the index sort key, with the items that share an index sort key value in one deterministic order, and a reverse Query as the exact mirror of the forward one (measured 2026-10-21; the order inside such a group differs from ExtendDB's byte order and is tracked in #425). ExtendDB orders an LSI partition by the index sort key, then the base table sort key, and pages through it with a cursor over the same columns. Every index table whose index has a sort key has an ordering index on (pk, sk, base_pk, base_sk) on the PostgreSQL and SQLite backends, with one sk column per RANGE attribute. A GSI Query orders by its RANGE columns in key-schema order, then base_pk and base_sk, which is that index without the constant pk, so both databases walk the index and stop at the page limit. An LSI Query ordered by (sk, base_sk) and its cursor compared the same two columns. Neither planner can tell that base_pk is constant inside an LSI partition, so the index served the index sort key only. PostgreSQL planned an Incremental Sort over the index scan when the key condition left several index sort key values and a Sort when an equality left one; SQLite used a temporary B-tree for the last term of the ORDER BY, and seeked a resumed page on the index sort key alone. On an LSI partition of 50,000 items with 50 items per index sort key value, a PostgreSQL page with Limit 100 read 151 rows and 154 buffers to return 101, and a page resumed inside a group read 130 rows and 133 buffers. The LSI ORDER BY and cursor now list base_pk between the index sort key and the base sort key, the same columns a GSI with one RANGE attribute uses, so the ORDER BY is the ordering index without pk. Every row of an LSI partition stores the queried partition key as base_pk. The cursor binds the queried partition key for it, not the start key's partition key, so base_pk compares equal on every row and the base sort key orders the rows as before. The rows, their order, the page boundaries and the LastEvaluatedKey and ExclusiveStartKey shapes do not change. On SQLite the statement builder moves out of query_impl into query_statement so the plan tests can explain the statement a Query sends. Measured on servers built from this branch and from main, on the same 52,000 items: the LSI pages plan as an Index Scan or Index Scan Backward on the ordering index with the cursor in the Index Cond and no sort node, in both directions, in the custom and the generic plan, reading 101 rows and 103 to 105 buffers. SQLite plans SEARCH USING INDEX idx_order_* with no temporary B-tree and seeks a resumed page on (sk_s, base_pk, base_sk_s). The GSI plans and buffer counts are unchanged. The items and LastEvaluatedKey of every measured page are identical before and after on both backends. crates/storage-postgres/tests/lsi_query_plan.rs reads the statement the engine sent from pg_stat_activity and asserts that its generic and custom plans have no Sort or Incremental Sort node and no Filter under the index scan, for a first page and a resumed page with the partition alone, with lsk = :l, and with lsk BETWEEN, in both directions. It also checks the page rows and that a start key naming another partition returns the same page. It skips below PostgreSQL 16, which added EXPLAIN (GENERIC_PLAN). CI runs it in the PostgreSQL storage-level step. The SQLite plan tests (crates/storage-sqlite/src/data/query_scan/plan_tests.rs) assert no temporary B-tree for the same LSI shapes and for GSI pages, and page through an LSI in both directions. The cursor unit tests on both backends assert the new LSI fragment and bind. A GSI with several RANGE attributes orders by every RANGE column (#429). crates/storage-postgres/tests/lsi_query_plan/multi_range_gsi.rs, a module of the lsi_query_plan test, and a fourth SQLite plan_tests case run such a GSI with RANGE attributes a, b and c for the partition alone, an equality on a, an equality on a with b BETWEEN, and equalities on all three, first and resumed pages in both directions, and assert the same index walk; both also check the rows of the pages. Their plan checks pass on main. With the ORDER BY cut to the first RANGE column, the SQLite case sorts in a temporary B-tree and the PostgreSQL case returns the first page out of order. With a condition applied to the first RANGE column instead of the one it names, the SQLite case returns no rows for a = :a AND b BETWEEN. With base_pk removed from the LSI ORDER BY, the PostgreSQL plan test and the SQLite LSI plan test fail on the sort node; the SQLite GSI plan test and paging test pass, as the rows do not change. With base_pk removed from the LSI cursor comparison, the PostgreSQL plan test fails on the Filter under the index scan and the SQLite cursor unit tests fail on the fragment; the paging tests pass. With the start key's partition key bound instead of the queried one, the PostgreSQL other-partition page check and the SQLite bind test fail. tests/test_query_scan.py and tests/test_multipart_gsi.py pass on both backends. docs/design/04-component-storage.md describes the LSI cursor and ORDER BY and the sort step they avoid. Closes #366
Closes #381.
What the service does
Measured on 2026-10-21 against live DynamoDB with the issue's schema (GSI with HASH
hand RANGEa,b,c,d) and a two-HASH index:ScanIndexForward = falsereturns the exact mirror.LastEvaluatedKeycarries every key attribute.<,<=,>,>=,BETWEENorbegins_with. Clause order is free.KeyConditionExpressions must only contain one condition per key); more conditions than key attributes (The number of query conditions (6) exceeds the number of key attributes defined in the schema (5)); a HASH attribute without an equality (HASH key attributes h1, h2 must have equality conditions specified in the query); a RANGE gap (RANGE key attributes a must have equality conditions specified in the query because a condition is present on key attribute b); a value whose type differs from the attribute definition (One or more parameter values were invalid: Condition parameter type does not match schema type); a condition on an attribute outside the key schema, named by the first RANGE attribute without a condition (Query condition missed key schema element: a).begins_withon a number attribute isIncorrect operand type for operator or function; operator or function: begins_with, operand type: N.dynamodb:LeadingKeyson a Query of a GSI with two HASH attributes is the first HASH attribute's value (measured 2026-10-20).g1 = :aon HASHg1,g2answersValidationExceptionto every principal, including one fenced away from the value (measured 2026-10-21).GSI2PK(S) and RANGEGSI2SK(S) of a table keyed onPK(S):GSI2PK = :h AND PK = :kanswersQuery condition missed key schema element: GSI2SKwith:kan S, the type the table defines forPK, and with:kan N, two samples each.What ExtendDB did
KeyCondition::resolve_multipartreturned early for one HASH attribute, so the parser's assignment stood: results were ordered by the first RANGE attribute then the table key, every key condition with three or more clauses was refused asQuery condition missed key schema element: h, andh = :h AND b = :bwas accepted and returned nothing.Changes
crates/core/src/expression/key_condition.rs:resolve_multipartclassifies every clause by name and applies the rules above in the service's order.resolve_multipart_withtakes a per-clause value check, which the engine supplies with the attribute definitions, so a wrong-typed value is reported after the structural rules and before the non-key rule, as measured.resolve_key_schema_withchooses between the classifier andresolve_pk_skfor the key schema the query reads, and both the engine and authorization call it.crates/engine/src/query.rs: the key condition goes throughresolve_key_schema_withwith the type check. On a multi-attribute key schema the type check reads the queried key schema's attribute definitions only, so a clause on another attribute gets the non-key rule, as measured. The single-attribute path runs the type check, against the table's definitions, ahead of its missing-key check (a wrong-typed sort key value answered 500 before). That path has no non-key rule yet (see the known divergences below), so a clause on another attribute reaches storage, and the table's definitions are what refuse a value of the wrong type for it.begins_withon a number attribute is refused with the measured wording.crates/server/src/leading_keys.rs:query_leading_keysassigns the clauses withresolve_key_schema, the engine's classification. Withresolve_pk_skit found no partition key in three-clause orders such asg2 = :g AND g1 = :v AND gs = :xorb > :lo AND a = :a AND h = :v, which the engine now serves, so aForAllValues:StringEqualsLeadingKeys fence let the query read any partition of the index.crates/engine/src/legacy_filter.rs:KeyConditionson a multi-attribute schema go through the same classifier.crates/storage-sqliteandcrates/storage-postgres: every RANGE condition is applied to its column;ORDER BYand the row-comparison cursor cover all RANGE columns.crates/storage/src/util/key.rs,crates/storage-mongodb,crates/storage-cassandra: these two backends filter the first RANGE attribute of such an index only. A condition on a later RANGE attribute is refused with aValidationExceptionnaming the attribute and the backend, instead of being applied to the wrong column.crates/storage-cassandra/src/data/query.rs: onmain, everyQueryon a Cassandra index with two or more RANGE attributes answers 500 (measured onec81e9d4: hash-only, first-RANGE equality and first-RANGE range conditions all returnOrder by currently only supports the ordering of columns following their declared order in the PRIMARY KEYfrom Cassandra). The index table clusters on every RANGE column ahead ofbase_pk, so theORDER BYnow names every index RANGE column in declared order; those queries answer 200 with the page in clustering order. AQuerywith anExclusiveStartKeyon such an index also answered 500 and is now refused with aValidationException(ExclusiveStartKey on index gsi: the Cassandra backend pages a Query over an index with one RANGE attribute only), because the cursor compares the first RANGE attribute only.docs/differences-from-dynamodb.mdrecords both backends.Changes from the review of 30 October
fix(auth): bind LeadingKeys through the engine's key condition classification: authorization reads a multi-attribute key condition the way the engine does, so the three-clause orders from the review are denied to a principal fenced away from the first HASH attribute's value, andg1 = :aon HASHg1,g2answersValidationExceptionto every principal, as the service does.test(core): pin the condition count refusal ahead of the HASH and RANGE checks: unit cases with the HASH attribute missing or a RANGE gap and more conditions than key attributes.test(query): take the first-RANGE-only branch from the backend under test:tests/test_gsi_multi_range_query.pyreads the backend fromEXTENDDB_TEST_BACKENDor the server's config file and fails when the server's reply does not match it, instead of skipping the served-result cases.fix(query): type-check only the queried key schema on a multi-attribute index: a value of another type on an attribute the table defines for its base key or another index getsQuery condition missed key schema elementin place of the type sentence, which is the service's answer.Verification
tests/test_gsi_multi_range_query.py, 64 cases: order, pages andLastEvaluatedKey, each accepted shape, each refusal and its text, legacyKeyConditions, the value type rule, and the MongoDB and Cassandra refusal. Every expectation in the first 61 cases was measured on the service. On 2026-10-30 the whole file at3893bf63ran against the service and passed, 63 passed and 1 skipped (the MongoDB and Cassandra refusal, which runs only on a target that refuses), so the three cases for a value on an attribute outside the queried key schema are measured on indexes with several key attributes too.3893bf63on 2026-10-3046a66b5346a66b53. The first push of this branch errored at the module fixture (61 errors, the hash-onlyQueryanswered 500 as onmain); theORDER BYchange fixed thatmainatec81e9d4, SQLite2b05987b(before the review fixes), SQLite and PostgreSQLtests/test_fgac_leading_keys.pyon servers built from this branch: 29 passed on SQLite, PostgreSQL and MongoDB, and 28 passed on Cassandra, wheretest_oversized_requests_fail_validation_before_authorizationwas not run. On servers built from2b05987bthe Query shape test fails on both: each of the eleven three-clause orders returns rows to the principal fenced away from the partition, andg1 = :aon the two-HASH index answersAccessDeniedExceptionto that principal where the service answersValidationException. The new orders combine two measured rules, LeadingKeys is the first HASH attribute's value and clause order is free; the shapes themselves were run against ExtendDB only.Regression on the branch servers:
test_multipart_gsi.py,test_query_scan.py,test_conditional_operator.py,test_expression_param_usage.pyandtest_item_operations.py, 309 passed on SQLite and 309 passed on PostgreSQL. A 67-request probe comparing the branch with the service case by case leaves 13 differences, all present onmainand outside this change: the parser's wording for two non-equality clauses (#427), the 2048-byte hash key and 1024-byte aggregate range key limits on Query, and an extra page with aLastEvaluatedKeywhenLimitlands on the last item.Negative controls on the committed tree, each restored with
git checkout: the eight classifier mutations (missing-HASH order, RANGE duplicate detection, non-equality duplicate detection, RANGE gap window, non-equality excluded from the gap, early return widened, non-key refusal disabled, count refusal disabled) each fail their unit test; the count check moved after the missing-HASH and RANGE-gap checks fails the new count ordering test and passes the other 39key_conditiontests; the type check disabled fails 6 wire cases; thebegins_withrule disabled fails 1; the MongoDB refusal disabled fails 18 wire cases on wrong items or order. WithEXTENDDB_TEST_BACKEND=mongodbagainst a SQLite server,tests/test_gsi_multi_range_query.pyfrom2b05987bpasses (60 passed, 1 skipped) and the new file fails with 23 errors.Gates:
cargo fmt --check;cargo +1.97.0 clippy --all-targets -D warningson core, engine, storage and the four storage crates, and on core, engine and server for the review changes;cargo +1.88.0 buildon the same, andcargo +1.88.0 checkon core, engine and server; unit tests core 537, engine 178 and server 204 on this revision, and storage 72, storage-sqlite 101, storage-postgres 46, storage-mongodb 96 and storage-cassandra 52 on the earlier push (the review changes do not touch the storage crates). No dependency change.Not in this change
Pre-existing, filed separately: #427 (the parser refuses a lone non-equality, or two non-equalities, before the key schema is known, so the wording differs from the service); Query does not enforce the key value size limits the service applies (
Size of hashkey has exceeded the maximum size limit of2048 bytes,Aggregated size of all range keys has exceeded the size limit of 1024 bytes); aLimitthat lands on the last item returns aLastEvaluatedKeyand one more empty page where the service returns none. Full support for several RANGE attributes on the MongoDB and Cassandra backends (typed fields and clustering for every RANGE attribute) is a separate change.Known divergences found in review, present on
main, filed as #673 (Cassandra) and #674 (single-attribute key schema):h1,h2and RANGErreadsh2as the cursor's sort key (crates/storage-cassandra/src/data/query.rs,query_key_schema[1]). Page 2 ofh1 = :x AND h2 = :yrepeats page 1 whenh2is N and answers 500 when it is S. On any GSI with a RANGE attribute, a key condition on that attribute together with anExclusiveStartKeyanswers 500 on page 2. The new row indocs/differences-from-dynamodb.mdcovers indexes with several RANGE attributes only.pk = :p AND zz = :vreturns the item whose sort key equals:v, and a value of another type than the sort key answers 500. The service refuses such a clause withQuery condition missed key schema element: <RANGE attribute>whatever the value's type (measured 2026-10-30, above), and the multi-attribute path here does the same. Until the single-attribute path has that rule, its type check keeps reading the table's definitions, so ExtendDB answers the measuredGSI2PK = :h AND PK = :kwith the type sentence when:kis an N, and with 200 and the condition applied toGSI2SKwhen:kis an S.Also not changed here: the type check runs before the unused-value check, so
pk = :p AND sk > :swith a wrong-typed:sand an unused:ugets the type sentence, wheremainreports the unused value; the #544 paragraph below covers where the service reports it. LegacyKeyConditionswith two or more non-EQ conditions report the RANGE-gap message, orQuery key condition not supported, before the count, HASH and non-key rules; only the wording differs, and the service is measured for one shape in that class.Overlap with other open PRs
Condition parameter type does not match schema typerefusal on the expression path, at the position measured on 2026-10-24 (after the filter, projection, unused value andSelectchecks, before theExclusiveStartKeycheck).check_key_value_typehere runs earlier, at key condition classification, so with both merged a request that carries both faults would get the type sentence where the service reports the other fault. Whichever merges second drops this copy and keeps fix(query): check Query values against the key schema and the number rules #544's.parse_key_conditionandresolve_pk_skfor every key schema. The duplicate check in the multi-attribute classifier here is then needed only for the alias case (#a = :x AND h = :ywith#anamingh).KeyConditionstype check inlegacy_filter.rs. The two PRs conflict inlegacy_filter.rsandquery.rs, so the legacy path is settled in the same rebase.Rebase on #395
Rebased on main at
3051e500(#395) on 30 October, after the review commits. Main'stests/conftest.pyalready hasconfigured_backend(), so the LeadingKeys commit now reads the backend through it intests/test_fgac_leading_keys.pyandtests/test_ttl.pyinstead of adding its own copy. That was the only conflict in the review commits; the first commit had one more, a row indocs/differences-from-dynamodb.md.46a66b53is the head before the rebase, with the same changes, and the MongoDB and Cassandra rows above are from it.On
3893bf63: workspace clippy with-D warnings;--libtests core 713, engine 183 and server 222;tests/test_fgac_leading_keys.py,tests/test_gsi_multi_range_query.pyandtests/test_ttl.pyon a SQLite server, 103 passed and 1 skipped; andtests/test_gsi_multi_range_query.pyagainst the service, 63 passed and 1 skipped.Rebase on #511
Rebased on main at
1c0523c9(#511) on 30 October. #511 binds LeadingKeys to the partition operand of a key condition the engine refuses, and it changedquery_leading_keysandkey_condition.rsas well, so the two conflicted. #511's path through the clauses stays. When a clause compares the partition attribute by equality,equality_pk_valuereads the condition throughresolve_key_schemawith the queried key schema in place ofresolve_pk_sk, and a single clause under an operator the engine rejects binds its operand. The legacyKeyConditionspath keeps #511's operator list.multi hash gsi missing g2now answersValidationExceptionto every principal, as the service does, soQUERY_REJECTED_CODE_DIVERGES, which held only that entry, is removed fromtests/test_fgac_leading_keys.py. Inkey_condition.rsboth sides added tests at the end of the test module, and both are kept.On
a7e14d8e:cargo fmt --check; workspace clippy with-D warnings;--libtests core 715, engine 183 and server 237;tests/test_fgac_leading_keys.py,tests/test_gsi_multi_range_query.pyandtests/test_ttl.pyon a SQLite server, 105 passed and 1 skipped, and on a PostgreSQL 16 server, 105 passed and 1 skipped. The two extra passes are #511's tests. The run against the service is the one at3893bf63and was not repeated on this head.