Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
eb370a3
fix(query): check Query values against the key schema and the number …
LeeroyHannigan Oct 24, 2026
f0017ce
fix(query): run the request-time checks on the raw body, enums in the…
LeeroyHannigan Oct 25, 2026
e4f660d
docs(query): name the request-time function in the comments that poin…
LeeroyHannigan Oct 25, 2026
4a4e92f
fix(cassandra): pass the bare index name in IndexNotFound so the Quer…
LeeroyHannigan Oct 24, 2026
03caeed
fix(query): accept Unicode decimal digits in the number check and rep…
LeeroyHannigan Oct 25, 2026
bde92d3
fix(query): leave an unknown type descriptor to the request-time chec…
LeeroyHannigan Oct 25, 2026
3a633fd
test(query): say which Limit text the service uses beside an unknown …
LeeroyHannigan Oct 25, 2026
b2aa733
Merge origin/main into fix/query-key-value-types
LeeroyHannigan Nov 6, 2026
44b7b18
test(query): pass the RANGE attributes to resolve_multipart
LeeroyHannigan Nov 6, 2026
0b8e97d
fix(query): order the key value type check and the begins_with refusa…
LeeroyHannigan Nov 6, 2026
7b0924f
test(query): pin begins_with on a non-key attribute and correct the o…
LeeroyHannigan Nov 6, 2026
3663529
Merge origin/main into fix/query-key-value-types
LeeroyHannigan Nov 7, 2026
ce5c103
Merge origin/main into fix/query-key-value-types
LeeroyHannigan Nov 9, 2026
3d7e15f
Merge origin/main into fix/query-key-value-types
LeeroyHannigan Nov 9, 2026
cb2df0b
fix(query): check each value on its own and answer in the service's o…
LeeroyHannigan Nov 11, 2026
54150fe
fix(query): share the key value type check and update three comments
LeeroyHannigan Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions crates/core/src/expression/key_condition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,12 @@ impl KeyCondition {
/// [`Self::resolve_multipart_with`], which classifies every clause by name,
/// applies the service's rules for such a schema and calls `value_check`
/// where the service reports a value of the wrong type. On any other key
/// schema `value_check` runs on every clause first, which is where the
/// service reports it for such a schema (measured 2026-10-21), and then
/// [`Self::resolve_pk_sk`] picks the partition key clause.
/// schema `value_check` runs on every clause first, and then
/// [`Self::resolve_pk_sk`] picks the partition key clause. On a
/// single-attribute key schema the service reports a value of the wrong
/// type after the missing-key, filter, projection, unused-value and Select
/// checks (measured 2026-11-06), so the engine passes a `value_check` that
/// checks nothing there unless the request uses legacy `KeyConditions`.
///
/// The engine reads a `Query` key condition through this, and so does
/// authorization when it finds the partition key value for
Expand Down
146 changes: 146 additions & 0 deletions crates/engine/src/decimal_digits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: Apache-2.0

//! Unicode decimal digits (general category `Nd`) in the Basic Multilingual
//! Plane, folded to ASCII for the request-time number check.
//!
//! Measured 2026-10-25 (us-east-1, each request twice): the service reads a
//! fullwidth, Arabic-Indic or Devanagari digit in an `N` value as the digit it
//! stands for (`{"N": "\u{ff11}"}` in a `FilterExpression` answers OK; thirty
//! nine Devanagari ones answer `Attempting to store more than 38 significant
//! digits in a Number`), and refuses a superscript (`\u{b2}`), a vulgar
//! fraction (`\u{bd}`), a fullwidth minus (`\u{ff0d}`) and a fullwidth full
//! stop (`\u{ff0e}`) with `The parameter cannot be converted to a numeric
//! value: <value>`. The refused ones are `No` or `Pd` and `Po`.
//!
//! The service accepts a category `Nd` digit only below U+10000 (measured
//! 2026-11-11, each request twice): digit one of each of the 37 `Nd` runs up
//! to U+FF10 is accepted as a `FilterExpression` value, and digit one of each
//! of the 31 runs from U+104A0 up answers `The parameter cannot be converted to
//! a numeric value: <value>`.
//!
//! Every `Nd` run in Unicode 15.0 is ten consecutive code points starting at
//! its zero, so the table below holds the 37 zeros below U+10000 (generated
//! with Python's `unicodedata`, Unicode 15.0.0) and a digit's value is its
//! offset from the run's zero.
//!
//! The fold applies to the check and the text of its refusal only. The handler
//! and the server's `dynamodb:LeadingKeys` binding read the value as the
//! request wrote it, so both see the same text; reading a Unicode digit as the
//! digit has to change both together.

use std::borrow::Cow;

/// The zero of every `Nd` run in Unicode 15.0 below U+10000, ascending.
const ND_ZEROS: [u32; 37] = [
0x0030, 0x0660, 0x06F0, 0x07C0, 0x0966, 0x09E6, 0x0A66, 0x0AE6, 0x0B66, 0x0BE6, 0x0C66, 0x0CE6,
0x0D66, 0x0DE6, 0x0E50, 0x0ED0, 0x0F20, 0x1040, 0x1090, 0x17E0, 0x1810, 0x1946, 0x19D0, 0x1A80,
0x1A90, 0x1B50, 0x1BB0, 0x1C40, 0x1C50, 0xA620, 0xA8D0, 0xA900, 0xA9D0, 0xA9F0, 0xAA50, 0xABF0,
0xFF10,
];

/// The ASCII digit `c` stands for when `c` is a Unicode decimal digit
/// (general category `Nd`) below U+10000, or `None` for every other
/// character.
pub(crate) fn ascii_digit(c: char) -> Option<char> {
let cp = u32::from(c);
// The zero of the run that could hold `cp`: the last zero at or below it.
let run = ND_ZEROS
.partition_point(|&zero| zero <= cp)
.checked_sub(1)?;
let offset = cp - ND_ZEROS[run];
u8::try_from(offset)
.ok()
.filter(|offset| *offset < 10)
.map(|offset| char::from(b'0' + offset))
}

/// `s` with every Unicode decimal digit replaced by the ASCII digit it stands
/// for. Borrows `s` when it has no such digit outside ASCII.
pub(crate) fn fold_decimal_digits(s: &str) -> Cow<'_, str> {
if s.is_ascii() || !s.chars().any(|c| !c.is_ascii() && ascii_digit(c).is_some()) {
return Cow::Borrowed(s);
}
Cow::Owned(s.chars().map(|c| ascii_digit(c).unwrap_or(c)).collect())
}

#[cfg(test)]
mod tests {
use super::{ND_ZEROS, ascii_digit, fold_decimal_digits};

#[test]
fn measured_digits_fold_to_their_ascii_value() {
// Fullwidth one, Arabic-Indic one, Devanagari one and fullwidth seven,
// measured 2026-10-25.
assert_eq!(ascii_digit('\u{ff11}'), Some('1'));
assert_eq!(ascii_digit('\u{661}'), Some('1'));
assert_eq!(ascii_digit('\u{967}'), Some('1'));
assert_eq!(ascii_digit('\u{ff17}'), Some('7'));
assert_eq!(ascii_digit('0'), Some('0'));
assert_eq!(ascii_digit('9'), Some('9'));
}

#[test]
fn measured_controls_are_not_digits() {
// Superscript two, vulgar half, fullwidth minus, fullwidth full stop:
// refused by the service (measured 2026-10-25).
for c in ['\u{b2}', '\u{bd}', '\u{ff0d}', '\u{ff0e}'] {
assert_eq!(ascii_digit(c), None, "{c:?}");
}
// Neighbours of a run and non-digit characters.
assert_eq!(ascii_digit('\u{ff10}'), Some('0'));
assert_eq!(ascii_digit('\u{ff19}'), Some('9'));
assert_eq!(ascii_digit('\u{ff1a}'), None);
assert_eq!(ascii_digit('\u{ff0f}'), None);
assert_eq!(ascii_digit('a'), None);
assert_eq!(ascii_digit('.'), None);
assert_eq!(ascii_digit('e'), None);
assert_eq!(ascii_digit(char::MAX), None);
assert_eq!(ascii_digit('\u{0}'), None);
}

#[test]
fn digits_from_u_104a0_up_are_not_folded() {
// Measured 2026-11-11: digit one of each run from U+104A0 up is refused,
// and digit one of the last run below U+10000 is accepted.
for c in [
'\u{104A1}',
'\u{11067}',
'\u{1D7CF}',
'\u{1D7F7}',
'\u{1E951}',
'\u{1FBF1}',
] {
assert_eq!(ascii_digit(c), None, "{c:?}");
}
assert_eq!(ascii_digit('\u{ABF1}'), Some('1'));
assert_eq!(fold_decimal_digits("\u{ff11}\u{1D7CF}"), "1\u{1D7CF}");
}

#[test]
fn zeros_are_ascending_and_distinct_runs() {
for pair in ND_ZEROS.windows(2) {
assert!(pair[0] + 10 <= pair[1], "{pair:?}");
}
}

#[test]
fn folding_keeps_every_other_character() {
assert_eq!(fold_decimal_digits("\u{ff11}"), "1");
assert_eq!(fold_decimal_digits("1\u{ff12}"), "12");
assert_eq!(fold_decimal_digits("1e\u{ff12}"), "1e2");
assert_eq!(fold_decimal_digits(&"\u{967}".repeat(39)), "1".repeat(39));
assert_eq!(fold_decimal_digits("\u{ff0d}1"), "\u{ff0d}1");
assert_eq!(fold_decimal_digits("1\u{ff0e}5"), "1\u{ff0e}5");
assert_eq!(fold_decimal_digits("\u{b2}"), "\u{b2}");
assert_eq!(fold_decimal_digits("-1.5e3"), "-1.5e3");
assert_eq!(fold_decimal_digits(""), "");
assert!(matches!(
fold_decimal_digits("bob"),
std::borrow::Cow::Borrowed(_)
));
assert!(matches!(
fold_decimal_digits("\u{ff0d}"),
std::borrow::Cow::Borrowed(_)
));
}
}
2 changes: 2 additions & 0 deletions crates/engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod batch_get_item;
mod batch_write_item;
pub mod capacity_helpers;
mod create_table;
mod decimal_digits;
mod delete_item;
mod delete_table;
mod describe_endpoints;
Expand All @@ -37,6 +38,7 @@ mod list_tables;
mod partiql;
mod put_item;
mod query;
pub mod query_key_values;
mod read_helpers;
mod request_limits;
mod scan;
Expand Down
Loading
Loading