diff --git a/changelog.d/u64-param-above-i64-max.breaking.md b/changelog.d/u64-param-above-i64-max.breaking.md new file mode 100644 index 000000000..7916526ed --- /dev/null +++ b/changelog.d/u64-param-above-i64-max.breaking.md @@ -0,0 +1,3 @@ +- Saved query plans now use envelope version 11, which carries a `U64` parameter above `i64::MAX` as its decimal text. Regenerate plans saved with earlier binaries; older binaries reject version 11 plans. See [saved plans][u64-param-above-i64-max-plans]. + +[u64-param-above-i64-max-plans]: ../docs/user/queries/explain.md#profile diff --git a/changelog.d/u64-param-above-i64-max.fixed.md b/changelog.d/u64-param-above-i64-max.fixed.md new file mode 100644 index 000000000..9eb72ad29 --- /dev/null +++ b/changelog.d/u64-param-above-i64-max.fixed.md @@ -0,0 +1,3 @@ +- A `U64` query parameter above `i64::MAX` now binds instead of being refused; send it as a decimal string to keep every digit. See [query parameters][query-parameters]. + +[query-parameters]: ../docs/user/queries/index.md#declarations-and-parameters diff --git a/crates/omnigraph-compiler/src/ir/coerce.rs b/crates/omnigraph-compiler/src/ir/coerce.rs index 4c2489241..e65538859 100644 --- a/crates/omnigraph-compiler/src/ir/coerce.rs +++ b/crates/omnigraph-compiler/src/ir/coerce.rs @@ -318,7 +318,7 @@ fn literal_round_trips(value: &Literal, source: Domain, target: Domain) -> bool }; float_round_trips(value, target) } else { - integer_round_trips(i128::from(*value), target) + integer_round_trips(*value, target) } } Literal::Float(value) => { @@ -460,8 +460,8 @@ mod tests { fn literal_narrowing_is_exact_and_keeps_a_cast() { use ScalarType::*; for (owner, literal, source, expected) in [ - (I32, Literal::Integer(i64::from(i32::MAX)), I64, I32), - (I32, Literal::Integer(i64::from(i32::MAX) + 1), I64, I64), + (I32, Literal::Integer(i128::from(i32::MAX)), I64, I32), + (I32, Literal::Integer(i128::from(i32::MAX) + 1), I64, I64), (U32, Literal::Integer(-1), I64, I64), (I64, Literal::Float(2_f64.powi(63)), F64, F64), (I64, Literal::Float(-2_f64.powi(63)), F64, I64), diff --git a/crates/omnigraph-compiler/src/ir/erase.rs b/crates/omnigraph-compiler/src/ir/erase.rs index db5f14d70..06111efc7 100644 --- a/crates/omnigraph-compiler/src/ir/erase.rs +++ b/crates/omnigraph-compiler/src/ir/erase.rs @@ -471,7 +471,7 @@ mod tests { PropType::scalar(ScalarType::String, false), ), ( - Literal::Integer(i64::MIN), + Literal::Integer(i128::from(i64::MIN)), PropType::scalar(ScalarType::I64, false), ), ( diff --git a/crates/omnigraph-compiler/src/ir/mod.rs b/crates/omnigraph-compiler/src/ir/mod.rs index b73e61703..b5adb6c6d 100644 --- a/crates/omnigraph-compiler/src/ir/mod.rs +++ b/crates/omnigraph-compiler/src/ir/mod.rs @@ -875,7 +875,7 @@ mod render_tests { fn int(value: i64) -> IRExpr { IRExpr::Literal( - Literal::Integer(value), + Literal::Integer(i128::from(value)), crate::types::ExprType::from_prop(&crate::types::PropType::scalar( crate::types::ScalarType::I64, false, diff --git a/crates/omnigraph-compiler/src/query/ast.rs b/crates/omnigraph-compiler/src/query/ast.rs index 3aa19f804..12a207b38 100644 --- a/crates/omnigraph-compiler/src/query/ast.rs +++ b/crates/omnigraph-compiler/src/query/ast.rs @@ -1021,7 +1021,10 @@ impl std::fmt::Display for AggFunc { pub enum Literal { Null, String(String), - Integer(i64), + /// An integer value, exact for every declared integer type: `I32`, `I64`, + /// `U32` and `U64` share the one spelling, and a value is narrowed only at + /// a typed boundary, from the declared type. + Integer(i128), Float(f64), Bool(bool), Date(String), diff --git a/crates/omnigraph-compiler/src/query/parser.rs b/crates/omnigraph-compiler/src/query/parser.rs index 239c5f0e0..b6ea71325 100644 --- a/crates/omnigraph-compiler/src/query/parser.rs +++ b/crates/omnigraph-compiler/src/query/parser.rs @@ -1567,7 +1567,7 @@ fn parse_literal(pair: pest::iterators::Pair) -> Result { .as_str() .parse() .map_err(|e| query_error_at(format!("invalid integer: {}", e), &inner))?; - Ok(Literal::Integer(n)) + Ok(Literal::Integer(i128::from(n))) } Rule::float_lit => { let f: f64 = inner diff --git a/crates/omnigraph-compiler/src/query/parser_tests.rs b/crates/omnigraph-compiler/src/query/parser_tests.rs index b485379f1..b3d3d751c 100644 --- a/crates/omnigraph-compiler/src/query/parser_tests.rs +++ b/crates/omnigraph-compiler/src/query/parser_tests.rs @@ -506,7 +506,7 @@ fn prop(variable: &str, property: &str) -> Expr { } fn int(value: i64) -> Expr { - Expr::Literal(Literal::Integer(value)) + Expr::Literal(Literal::Integer(i128::from(value))) } fn and(left: Expr, right: Expr) -> Expr { diff --git a/crates/omnigraph-compiler/src/query_input.rs b/crates/omnigraph-compiler/src/query_input.rs index 3e72b0385..93e2fbfb0 100644 --- a/crates/omnigraph-compiler/src/query_input.rs +++ b/crates/omnigraph-compiler/src/query_input.rs @@ -102,82 +102,65 @@ impl ToParam for bool { impl ToParam for i8 { fn to_param(self) -> crate::error::Result { - Ok(Literal::Integer(i64::from(self))) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for i16 { fn to_param(self) -> crate::error::Result { - Ok(Literal::Integer(i64::from(self))) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for i32 { fn to_param(self) -> crate::error::Result { - Ok(Literal::Integer(i64::from(self))) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for i64 { fn to_param(self) -> crate::error::Result { - Ok(Literal::Integer(self)) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for isize { fn to_param(self) -> crate::error::Result { - let value = i64::try_from(self).map_err(|_| { - CompilerError::Execution(format!( - "param value {} exceeds current engine range for numeric literals (max {})", - self, - i64::MAX - )) - })?; - Ok(Literal::Integer(value)) + // Why the cast: Rust has no `From for i128`, and the widening + // is exact. + Ok(Literal::Integer(self as i128)) } } impl ToParam for u8 { fn to_param(self) -> crate::error::Result { - Ok(Literal::Integer(i64::from(self))) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for u16 { fn to_param(self) -> crate::error::Result { - Ok(Literal::Integer(i64::from(self))) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for u32 { fn to_param(self) -> crate::error::Result { - Ok(Literal::Integer(i64::from(self))) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for u64 { fn to_param(self) -> crate::error::Result { - let value = i64::try_from(self).map_err(|_| { - CompilerError::Execution(format!( - "param value {} exceeds current engine range for numeric literals (max {})", - self, - i64::MAX - )) - })?; - Ok(Literal::Integer(value)) + Ok(Literal::Integer(i128::from(self))) } } impl ToParam for usize { fn to_param(self) -> crate::error::Result { - let value = i64::try_from(self).map_err(|_| { - CompilerError::Execution(format!( - "param value {} exceeds current engine range for numeric literals (max {})", - self, - i64::MAX - )) - })?; - Ok(Literal::Integer(value)) + // Why the cast: Rust has no `From for i128`, and the widening + // is exact. + Ok(Literal::Integer(self as i128)) } } @@ -392,7 +375,7 @@ fn json_value_to_literal_typed( let value = i32::try_from(value).map_err(|_| { RunInputError::message(format!("param '{}': value {} exceeds I32", key, value)) })?; - Ok(Literal::Integer(i64::from(value))) + Ok(Literal::Integer(i128::from(value))) } JsonParamMode::JavaScript => { let value = parse_i64_param(key, value, mode)?; @@ -402,10 +385,13 @@ fn json_value_to_literal_typed( key, value )) })?; - Ok(Literal::Integer(i64::from(value))) + Ok(Literal::Integer(i128::from(value))) } }, - "I64" => Ok(Literal::Integer(parse_i64_param(key, value, mode)?)), + "I64" => { + let value = parse_i64_param(key, value, mode)?; + Ok(Literal::Integer(i128::from(value))) + } "U32" => { let value = parse_u64_param(key, value, mode)?; let value = match mode { @@ -419,29 +405,11 @@ fn json_value_to_literal_typed( )) })?, }; - Ok(Literal::Integer(i64::from(value))) + Ok(Literal::Integer(i128::from(value))) } "U64" => { let value = parse_u64_param(key, value, mode)?; - let value = match mode { - JsonParamMode::Standard => i64::try_from(value).map_err(|_| { - RunInputError::message(format!( - "param '{}': value {} exceeds current engine range for U64 (max {})", - key, - value, - i64::MAX - )) - })?, - JsonParamMode::JavaScript => i64::try_from(value).map_err(|_| { - RunInputError::message(format!( - "param '{}': value {} exceeds current engine range for U64 parameters (max {})", - key, - value, - i64::MAX - )) - })?, - }; - Ok(Literal::Integer(value)) + Ok(Literal::Integer(i128::from(value))) } "F32" | "F64" => { let value = value.as_f64().ok_or_else(|| match mode { @@ -600,7 +568,7 @@ fn json_value_to_literal_inferred( Value::Number(number) => match mode { JsonParamMode::Standard => { if let Some(value) = number.as_i64() { - Ok(Literal::Integer(value)) + Ok(Literal::Integer(i128::from(value))) } else if let Some(value) = number.as_f64() { Ok(Literal::Float(value)) } else { @@ -618,7 +586,7 @@ fn json_value_to_literal_inferred( key, value ))); } - Ok(Literal::Integer(value)) + Ok(Literal::Integer(i128::from(value))) } else if let Some(value) = number.as_u64() { if value > JS_MAX_SAFE_INTEGER_U64 { return Err(RunInputError::message(format!( @@ -626,15 +594,7 @@ fn json_value_to_literal_inferred( key, value ))); } - let value = i64::try_from(value).map_err(|_| { - RunInputError::message(format!( - "param '{}': integer {} exceeds supported range (max {})", - key, - value, - i64::MAX - )) - })?; - Ok(Literal::Integer(value)) + Ok(Literal::Integer(i128::from(value))) } else if let Some(value) = number.as_f64() { Ok(Literal::Float(value)) } else { @@ -917,18 +877,39 @@ mod tests { assert!(matches!(date, Literal::Date(ref value) if value == "2026-03-06")); } + /// Issue 918: a `U64` parameter carries its whole range; the value is + /// narrowed only at a typed boundary, from the declared type. #[test] - fn to_param_rejects_unsigned_values_outside_engine_range() { - let error = u64::MAX.to_param().expect_err("oversized u64 should fail"); + fn to_param_keeps_unsigned_values_above_the_signed_range_issue_918() { + let literal = u64::MAX.to_param().expect("u64 param"); - assert_eq!( - error.to_string(), - format!( - "execution error: param value {} exceeds current engine range for numeric literals (max {})", - u64::MAX, - i64::MAX - ) - ); + assert!(matches!( + literal, + Literal::Integer(value) if value == i128::from(u64::MAX) + )); + } + + /// Issue 918: a `U64` parameter bound above `i64::MAX` binds, in the + /// decimal string form the wire uses for a 64-bit integer. + #[test] + fn a_u64_param_above_the_signed_range_binds_issue_918() { + let query = find_named_query( + "query find($id: U64) { match { $u: User } return { $u } }", + "find", + ) + .expect("query should parse"); + + let params = json_params_to_param_map( + Some(&json!({ "id": "18446744073709551615" })), + &query.params, + JsonParamMode::Standard, + ) + .expect("params should bind"); + + assert!(matches!( + params.get("id"), + Some(Literal::Integer(value)) if *value == i128::from(u64::MAX) + )); } #[test] diff --git a/crates/omnigraph-dst/src/fixtures.rs b/crates/omnigraph-dst/src/fixtures.rs index 8529f6085..1b57bbb4d 100644 --- a/crates/omnigraph-dst/src/fixtures.rs +++ b/crates/omnigraph-dst/src/fixtures.rs @@ -167,7 +167,7 @@ pub fn mixed_params(str_pairs: &[(&str, &str)], int_pairs: &[(&str, i64)]) -> Pa let mut map = params(str_pairs); for (k, v) in int_pairs { let key = k.strip_prefix('$').unwrap_or(k); - map.insert(key.to_string(), Literal::Integer(*v)); + map.insert(key.to_string(), Literal::Integer(i128::from(*v))); } map } diff --git a/crates/omnigraph-gqt/cases/issue_918_u64_param_above_i64_max.gqt b/crates/omnigraph-gqt/cases/issue_918_u64_param_above_i64_max.gqt new file mode 100644 index 000000000..b9449390e --- /dev/null +++ b/crates/omnigraph-gqt/cases/issue_918_u64_param_above_i64_max.gqt @@ -0,0 +1,75 @@ +# issue: 918 +# red_on: main @ 4ba904d3; before the fix a `U64` parameter bound to 18446744073709551615 is refused with `param 'v': value 18446744073709551615 exceeds current engine range for U64 (max 9223372036854775807)`. +# notes: A `U64` parameter carries its whole range; the value is narrowed only at a typed boundary, from the declared type. + +--- runner +timeout_ms: 10000 +environments: + - target: omnigraph-engine + storage: local-filesystem + +--- schema +node Item { + key: String @key + size: U64 +} + +--- seed +{"type":"Item","data":{"key":"a","size":18446744073709551615}} +{"type":"Item","data":{"key":"b","size":0}} + +--- query +query item_above_the_signed_range($v: U64) { + match { $i: Item $i.size = $v } + return { $i.key } +} + +--- params +{"v": 18446744073709551615} + +--- expect unordered +{"i.key": "a"} + +--- expect shape +i.key: String + +--- query +query item_above_the_signed_range_as_text($v: U64) { + match { $i: Item $i.size = $v } + return { $i.key } +} + +--- params +{"v": "18446744073709551615"} + +--- expect unordered +{"i.key": "a"} + +--- expect shape +i.key: String + +--- mutate +query insert_item_above_the_signed_range($v: U64) { + insert Item { key: "c", size: $v } +} + +--- params +{"v": 18446744073709551615} + +--- expect affected: nodes=1 edges=0 + +--- query +query inserted_item_keeps_the_whole_value($v: U64) { + match { $i: Item $i.size = $v } + return { $i.key } +} + +--- params +{"v": 18446744073709551615} + +--- expect unordered +{"i.key": "a"} +{"i.key": "c"} + +--- expect shape +i.key: String diff --git a/crates/omnigraph-gqt/cases/range_constraint_compares_integers_exactly.gqt b/crates/omnigraph-gqt/cases/range_constraint_compares_integers_exactly.gqt index 1a9f3b54e..0ad4c491a 100644 --- a/crates/omnigraph-gqt/cases/range_constraint_compares_integers_exactly.gqt +++ b/crates/omnigraph-gqt/cases/range_constraint_compares_integers_exactly.gqt @@ -4,7 +4,7 @@ # notes: 9007199254740992 to the same double, and above i64::MAX a U64 value and an I64 bound round to the same 2^63. # notes: A Float bound on an integer column, and an Integer bound on a float column, compare against the bound's exact value. # notes: A list property's @range bounds each element; the loader used to skip list columns without checking them. -# notes: U64 values above i64::MAX reach @range through a load; mutation parameters stop at i64::MAX. +# notes: U64 values above i64::MAX reach @range through a load and through a mutation parameter. # notes: Upper-only bounds are maxima for scalar and list properties; omitted minima permit negative values. # notes: The seed loads each boundary value; the final read shows that every refused write stored nothing. @@ -67,6 +67,12 @@ tables: unsigned: {kind: literal, value: 9223372036854775808} --- expect error: @range violation on Counter.unsigned: value 9223372036854775808 > max 9223372036854775807 +--- mutate +query unsigned_above_max_by_parameter($unsigned: U64) { insert Counter { name: "unsigned_above_by_param", unsigned: $unsigned } } +--- params +{"unsigned": 9223372036854775808} +--- expect error: @range violation on Counter.unsigned: value 9223372036854775808 > max 9223372036854775807 + --- mutate query small_below_float_min($small: I32) { insert Counter { name: "small_below", small: $small } } --- params diff --git a/crates/omnigraph-planner/src/bound.rs b/crates/omnigraph-planner/src/bound.rs index c8384eeb0..e8a0fdb1d 100644 --- a/crates/omnigraph-planner/src/bound.rs +++ b/crates/omnigraph-planner/src/bound.rs @@ -23,8 +23,9 @@ use crate::physical::{NodeId, PhysicalPlan}; /// coverage. Version 9 adds to version 8 every `AntiJoin`'s `strategy` /// (`correlated` or `key_set`). Version 10 adds to version 9 every table's /// `source` (`dataset` or `catalogue`) and the assumptions' catalogue -/// fingerprints. -pub const BOUND_PLAN_VERSION: u32 = 10; +/// fingerprints. Version 11 adds to version 10 a literal arm for an integer +/// outside the `i64` range, carried as its decimal text. +pub const BOUND_PLAN_VERSION: u32 = 11; /// The envelope omits top-level `plan` and `values` so unversioned readers reject it. #[derive(Serialize, Deserialize)] diff --git a/crates/omnigraph-planner/src/mirror.rs b/crates/omnigraph-planner/src/mirror.rs index d212af4b0..a5e0a128c 100644 --- a/crates/omnigraph-planner/src/mirror.rs +++ b/crates/omnigraph-planner/src/mirror.rs @@ -17,7 +17,8 @@ use omnigraph_compiler::types::Direction; use omnigraph_compiler::{ SYSTEM_COLUMNS_LEGACY, SYSTEM_COLUMNS_META, SYSTEM_COLUMNS_V3, SystemColumns, }; -use serde::{Deserialize, Serialize}; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::aggregate::AggregateSpec; use crate::bound::{BoundPlan, ValueTable}; @@ -1380,12 +1381,37 @@ impl From for Direction { } } +/// An integer outside the `i64` range, carried as its decimal text. +/// +/// The pinned serde buffers no `i128` value, so a wide integer travels as a +/// string. A text that is not an integer refuses the decode instead of +/// becoming another value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WideInteger(i128); + +impl Serialize for WideInteger { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> Deserialize<'de> for WideInteger { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + text.parse::() + .map(WideInteger) + .map_err(D::Error::custom) + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "literal", content = "value", rename_all = "snake_case")] pub enum LiteralMirror { Null, String(String), Integer(i64), + /// An integer outside the `i64` range, as its decimal text. + WideInteger(WideInteger), Float(f64), Bool(bool), Date(String), @@ -1398,7 +1424,10 @@ impl From<&Literal> for LiteralMirror { match literal { Literal::Null => Self::Null, Literal::String(text) => Self::String(text.clone()), - Literal::Integer(value) => Self::Integer(*value), + Literal::Integer(value) => match i64::try_from(*value) { + Ok(value) => Self::Integer(value), + Err(_) => Self::WideInteger(WideInteger(*value)), + }, Literal::Float(value) => Self::Float(*value), Literal::Bool(value) => Self::Bool(*value), Literal::Date(text) => Self::Date(text.clone()), @@ -1413,7 +1442,8 @@ impl From for Literal { match mirror { LiteralMirror::Null => Self::Null, LiteralMirror::String(text) => Self::String(text), - LiteralMirror::Integer(value) => Self::Integer(value), + LiteralMirror::Integer(value) => Self::Integer(i128::from(value)), + LiteralMirror::WideInteger(value) => Self::Integer(value.0), LiteralMirror::Float(value) => Self::Float(value), LiteralMirror::Bool(value) => Self::Bool(value), LiteralMirror::Date(text) => Self::Date(text), diff --git a/crates/omnigraph-planner/tests/integration/bound_plan.rs b/crates/omnigraph-planner/tests/integration/bound_plan.rs index 47df43c95..7d74e3f80 100644 --- a/crates/omnigraph-planner/tests/integration/bound_plan.rs +++ b/crates/omnigraph-planner/tests/integration/bound_plan.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use arrow_schema::{DataType, Field, Schema}; -use omnigraph_compiler::ir::{IRExpr, IROp, IROrdering, IRProjection, QueryIR}; +use omnigraph_compiler::ir::{IRExpr, IROp, IROrdering, IRProjection, ParamMap, QueryIR}; use omnigraph_compiler::query::ast::{CompOp, Literal}; use omnigraph_compiler::result::SearchFunction; use omnigraph_compiler::{ExprType, PropType, SYSTEM_COLUMNS_V3, ScalarType}; @@ -200,6 +200,23 @@ fn saved_plan_version_refuses_both_legacy_and_future_readers() { assert!(serde_json::from_value::(encoded).is_err()); } +#[test] +fn a_wide_bound_value_reads_back_exactly_issue_918() { + let bound = BoundPlan { + plan: plan(prop("d", "slug")), + values: ValueTable { + params: Arc::new(ParamMap::from([ + ("wide".to_string(), Literal::Integer(i128::from(u64::MAX))), + ("low".to_string(), Literal::Integer(i128::from(i64::MIN))), + ])), + vectors: BTreeMap::new(), + }, + }; + let text = serde_json::to_string(&bound).expect("the bound plan serializes"); + assert!(text.contains("18446744073709551615"), "{text}"); + assert_eq!(round_trip(&bound), bound); +} + #[test] fn fused_saved_node_refuses_a_reader_without_declared_row_keys() { let arm = IRExpr::Bm25 { diff --git a/crates/omnigraph-reference-engine/src/projection.rs b/crates/omnigraph-reference-engine/src/projection.rs index 80f60e2db..8b1d947e2 100644 --- a/crates/omnigraph-reference-engine/src/projection.rs +++ b/crates/omnigraph-reference-engine/src/projection.rs @@ -160,7 +160,10 @@ pub(super) fn literal_to_array(lit: &Literal, num_rows: usize) -> Result Arc::new(StringArray::from(vec![s.as_str(); num_rows])) as ArrayRef, Literal::Integer(n) => { // Try to match the most common integer types - Arc::new(Int64Array::from(vec![*n; num_rows])) as ArrayRef + let value = i64::try_from(*n).map_err(|_| { + OmniError::manifest("integer literal is outside the 64-bit range".to_string()) + })?; + Arc::new(Int64Array::from(vec![value; num_rows])) as ArrayRef } Literal::Float(f) => Arc::new(Float64Array::from(vec![*f; num_rows])) as ArrayRef, Literal::Bool(b) => Arc::new(BooleanArray::from(vec![*b; num_rows])) as ArrayRef, @@ -324,7 +327,14 @@ fn literal_list_to_array(items: &[Literal], num_rows: usize) -> Result for _ in 0..num_rows { for item in items { match item { - Literal::Integer(value) => builder.values().append_value(*value), + Literal::Integer(value) => { + let value = i64::try_from(*value).map_err(|_| { + OmniError::manifest( + "integer literal is outside the 64-bit range".to_string(), + ) + })?; + builder.values().append_value(value); + } _ => builder.values().append_null(), } } diff --git a/crates/omnigraph-reference-engine/src/query.rs b/crates/omnigraph-reference-engine/src/query.rs index 2e54abb65..6c3bd59d5 100644 --- a/crates/omnigraph-reference-engine/src/query.rs +++ b/crates/omnigraph-reference-engine/src/query.rs @@ -4460,12 +4460,13 @@ fn resolve_to_string(expr: &IRExpr, params: &ParamMap) -> Option { } } -/// Resolve an expression to an integer value (literal or param). +/// Resolve an expression to an integer value (literal or param). A value +/// outside v1's `i64` range has no v1 meaning and resolves to nothing. fn resolve_to_int(expr: &IRExpr, params: &ParamMap) -> Option { match expr { - IRExpr::Literal(Literal::Integer(n)) => Some(*n), + IRExpr::Literal(Literal::Integer(n)) => i64::try_from(*n).ok(), IRExpr::Param(name) => match params.get(name)? { - Literal::Integer(n) => Some(*n), + Literal::Integer(n) => i64::try_from(*n).ok(), _ => None, }, _ => None, @@ -4703,7 +4704,7 @@ fn literal_to_expr(lit: &Literal) -> Option { Some(match lit { Literal::Null => df_lit(datafusion::scalar::ScalarValue::Null), Literal::String(s) => df_lit(s.clone()), - Literal::Integer(n) => df_lit(*n), + Literal::Integer(n) => df_lit(i64::try_from(*n).ok()?), Literal::Float(f) => df_lit(*f), Literal::Bool(b) => df_lit(*b), // Date/DateTime pass through as strings here. Against a typed Date diff --git a/crates/omnigraph-reference-engine/tests/frozen.rs b/crates/omnigraph-reference-engine/tests/frozen.rs index cd7956a29..5412e96ee 100644 --- a/crates/omnigraph-reference-engine/tests/frozen.rs +++ b/crates/omnigraph-reference-engine/tests/frozen.rs @@ -46,11 +46,11 @@ const FROZEN: &[(&str, &str)] = &[ ), ( "projection.rs", - "be11d4ce201b212c0e8e2963a5ad5f0e3b8cd874e033eefef24874f02d6a4631", + "98c6599a042a8eb79a7cb19a25812b48135f2bbcf077c4bf9c4fb6141d25f9be", ), ( "query.rs", - "97ffde90e498a9b1568567cd1cc3dc2d284e9111360ce85abacb1a0b2341c1a1", + "1b70d771d32f1c3e2b848aa2f37f2c4c17c244e062db6f98743b0099c16c5602", ), ( "table_store.rs", diff --git a/crates/omnigraph/src/engine/constant.rs b/crates/omnigraph/src/engine/constant.rs index 3ce5304bd..928f2552f 100644 --- a/crates/omnigraph/src/engine/constant.rs +++ b/crates/omnigraph/src/engine/constant.rs @@ -87,14 +87,10 @@ fn literal_from_scalar(value: ScalarValue) -> Result { Ok(match value { ScalarValue::Boolean(Some(value)) => Literal::Bool(value), ScalarValue::Utf8(Some(value)) => Literal::String(value), - ScalarValue::Int32(Some(value)) => Literal::Integer(i64::from(value)), - ScalarValue::Int64(Some(value)) => Literal::Integer(value), - ScalarValue::UInt32(Some(value)) => Literal::Integer(i64::from(value)), - ScalarValue::UInt64(Some(value)) => { - Literal::Integer(i64::try_from(value).map_err(|_| { - OmniError::manifest("constant U64 result exceeds the public literal range") - })?) - } + ScalarValue::Int32(Some(value)) => Literal::Integer(i128::from(value)), + ScalarValue::Int64(Some(value)) => Literal::Integer(i128::from(value)), + ScalarValue::UInt32(Some(value)) => Literal::Integer(i128::from(value)), + ScalarValue::UInt64(Some(value)) => Literal::Integer(i128::from(value)), ScalarValue::Float32(Some(value)) => Literal::Float(f64::from(value)), ScalarValue::Float64(Some(value)) => Literal::Float(value), ScalarValue::Date32(Some(value)) => { @@ -273,7 +269,7 @@ mod tests { ty: exact.clone(), }; let signed = cast(IRExpr::Literal( - Literal::Integer(i64::MAX), + Literal::Integer(i128::from(i64::MAX)), scalar(ScalarType::I64, false), )); let unsigned = cast(IRExpr::Param("wide".into(), scalar(ScalarType::U64, false))); @@ -302,7 +298,7 @@ mod tests { let list = IRExpr::Cast { expr: Box::new(IRExpr::Literal( - Literal::List(vec![Literal::Integer(i64::MAX), Literal::Null]), + Literal::List(vec![Literal::Integer(i128::from(i64::MAX)), Literal::Null]), ExprType::from_prop(&PropType::list_of(ScalarType::I64, false)), )), ty: ExprType::ExactInteger { diff --git a/crates/omnigraph/src/engine/operators/subquery_aggregate.rs b/crates/omnigraph/src/engine/operators/subquery_aggregate.rs index 5f9232846..731279823 100644 --- a/crates/omnigraph/src/engine/operators/subquery_aggregate.rs +++ b/crates/omnigraph/src/engine/operators/subquery_aggregate.rs @@ -593,7 +593,7 @@ mod tests { let mut exact = SubqueryAggregate::new( &exact_predicate, plan_block_aggregate(&exact_predicate.left).unwrap(), - &ParamMap::from([("bound".into(), Literal::Integer(i64::MAX))]), + &ParamMap::from([("bound".into(), Literal::Integer(i128::from(i64::MAX)))]), 1, ) .unwrap(); diff --git a/crates/omnigraph/src/engine/plan_source/scan_access/planning_cost.rs b/crates/omnigraph/src/engine/plan_source/scan_access/planning_cost.rs index 12b18d4bd..aa1b92f57 100644 --- a/crates/omnigraph/src/engine/plan_source/scan_access/planning_cost.rs +++ b/crates/omnigraph/src/engine/plan_source/scan_access/planning_cost.rs @@ -360,7 +360,7 @@ async fn measure( ty: ty.clone(), }, CompOp::Eq, - IRExpr::Literal(Literal::Integer(value), ty), + IRExpr::Literal(Literal::Integer(i128::from(value)), ty), )]; let params = ParamMap::new(); let read = diff --git a/crates/omnigraph/src/engine/search.rs b/crates/omnigraph/src/engine/search.rs index dc267e4f9..74313addf 100644 --- a/crates/omnigraph/src/engine/search.rs +++ b/crates/omnigraph/src/engine/search.rs @@ -1412,7 +1412,7 @@ mod typed_search_value_tests { fn widened_integer(value: i64) -> IRExpr { IRExpr::Cast { expr: Box::new(IRExpr::Literal( - Literal::Integer(value), + Literal::Integer(i128::from(value)), scalar(ScalarType::I32, false), )), ty: scalar(ScalarType::I64, false), @@ -1480,7 +1480,7 @@ mod typed_search_value_tests { IRExpr::Literal(Literal::Null, scalar(ScalarType::I64, true)), IRExpr::Literal(Literal::Integer(-1), scalar(ScalarType::I64, false)), IRExpr::Literal( - Literal::Integer(i64::from(u32::MAX) + 1), + Literal::Integer(i128::from(u32::MAX) + 1), scalar(ScalarType::I64, false), ), IRExpr::Param("missing".into(), scalar(ScalarType::I64, false)), @@ -1518,7 +1518,7 @@ mod typed_search_value_tests { #[test] fn nearest_vector_conversion_preserves_the_recorded_list_domain() { - let value = (1_i64 << 62) + (1_i64 << 38) + 1; + let value = (1_i128 << 62) + (1_i128 << 38) + 1; let expected = 2_f32.powi(62); assert!(value as f32 > expected); let literal = Literal::List(vec![Literal::Integer(value)]); diff --git a/crates/omnigraph/src/engine/typed_value.rs b/crates/omnigraph/src/engine/typed_value.rs index 582aff51d..bc3df2cd9 100644 --- a/crates/omnigraph/src/engine/typed_value.rs +++ b/crates/omnigraph/src/engine/typed_value.rs @@ -224,7 +224,7 @@ fn typed_scalar(literal: &Literal, scalar: ScalarType) -> Result { _ => integer(-2_f64.powi(31), 2_f64.powi(31))? as i32, })), ScalarType::I64 => ScalarValue::Int64(Some(match literal { - Literal::Integer(value) => *value, + Literal::Integer(value) => i64::try_from(*value).map_err(|_| out_of_range())?, _ => integer(-2_f64.powi(63), 2_f64.powi(63))? as i64, })), ScalarType::U32 => ScalarValue::UInt32(Some(match literal { @@ -956,7 +956,7 @@ mod tests { #[test] fn direct_parameter_values_are_checked_even_without_a_consumer() { let invalid = [ - (ScalarType::I32, Literal::Integer(i64::from(i32::MAX) + 1)), + (ScalarType::I32, Literal::Integer(i128::from(i32::MAX) + 1)), (ScalarType::U32, Literal::Integer(-1)), (ScalarType::U64, Literal::Integer(-1)), (ScalarType::I64, Literal::Float(2_f64.powi(63))), @@ -995,9 +995,10 @@ mod tests { } } for (kind, value) in [ - (ScalarType::I64, Literal::Integer(i64::MAX)), - (ScalarType::I64, Literal::Integer(i64::MIN)), - (ScalarType::U32, Literal::Integer(i64::from(u32::MAX))), + (ScalarType::I64, Literal::Integer(i128::from(i64::MAX))), + (ScalarType::I64, Literal::Integer(i128::from(i64::MIN))), + (ScalarType::U32, Literal::Integer(i128::from(u32::MAX))), + (ScalarType::U64, Literal::Integer(i128::from(u64::MAX))), (ScalarType::F32, Literal::Float(f64::from(f32::MAX))), ] { assert!(typed_literal_to_array(&value, &scalar(kind, false), 0).is_ok()); diff --git a/crates/omnigraph/src/exec/mutation.rs b/crates/omnigraph/src/exec/mutation.rs index fef3f826a..0624f1fe9 100644 --- a/crates/omnigraph/src/exec/mutation.rs +++ b/crates/omnigraph/src/exec/mutation.rs @@ -57,7 +57,12 @@ fn literal_to_typed_array( })?; Arc::new(Int32Array::from(vec![value; num_rows])) } - (Literal::Integer(n), DataType::Int64) => Arc::new(Int64Array::from(vec![*n; num_rows])), + (Literal::Integer(n), DataType::Int64) => { + let value = i64::try_from(*n).map_err(|_| { + OmniError::manifest(format!("integer value {n} exceeds Int64 range")) + })?; + Arc::new(Int64Array::from(vec![value; num_rows])) + } (Literal::Integer(n), DataType::UInt32) => { let value = u32::try_from(*n).map_err(|_| { OmniError::manifest(format!("integer value {n} exceeds UInt32 range")) @@ -220,7 +225,15 @@ fn typed_list_literal_to_array( for _ in 0..num_rows { for item in items { match item { - Literal::Integer(value) => builder.values().append_value(*value), + Literal::Integer(value) => { + let value = i64::try_from(*value).map_err(|_| { + OmniError::manifest(format!( + "list value {} exceeds Int64 range", + value + )) + })?; + builder.values().append_value(value); + } _ => builder.values().append_null(), } } @@ -2082,21 +2095,19 @@ mod literal_narrowing_tests { #[test] fn scalar_narrowing_accepts_boundaries_and_rejects_wraparound() { - assert!( - literal_to_typed_array(&Literal::Integer(i32::MAX as i64), &DataType::Int32, 1).is_ok() - ); - assert!( - literal_to_typed_array(&Literal::Integer(i32::MAX as i64 + 1), &DataType::Int32, 1,) - .is_err() - ); - assert!( - literal_to_typed_array(&Literal::Integer(u32::MAX as i64), &DataType::UInt32, 1) - .is_ok() - ); - assert!( - literal_to_typed_array(&Literal::Integer(u32::MAX as i64 + 1), &DataType::UInt32, 1,) - .is_err() - ); + let int = |value: i128| Literal::Integer(value); + let int32_max = int(i32::MAX as i128); + let int32_over = int(i32::MAX as i128 + 1); + let uint32_max = int(u32::MAX as i128); + let uint32_over = int(u32::MAX as i128 + 1); + let uint64_max = int(u64::MAX as i128); + let uint64_over = int(u64::MAX as i128 + 1); + assert!(literal_to_typed_array(&int32_max, &DataType::Int32, 1).is_ok()); + assert!(literal_to_typed_array(&int32_over, &DataType::Int32, 1).is_err()); + assert!(literal_to_typed_array(&uint32_max, &DataType::UInt32, 1).is_ok()); + assert!(literal_to_typed_array(&uint32_over, &DataType::UInt32, 1).is_err()); + assert!(literal_to_typed_array(&uint64_max, &DataType::UInt64, 1).is_ok()); + assert!(literal_to_typed_array(&uint64_over, &DataType::UInt64, 1).is_err()); assert!(literal_to_typed_array(&Literal::Integer(-1), &DataType::UInt32, 1).is_err()); assert!(literal_to_typed_array(&Literal::Integer(-1), &DataType::UInt64, 1).is_err()); } diff --git a/crates/omnigraph/tests/helpers/mod.rs b/crates/omnigraph/tests/helpers/mod.rs index c39350972..9a9395261 100644 --- a/crates/omnigraph/tests/helpers/mod.rs +++ b/crates/omnigraph/tests/helpers/mod.rs @@ -709,7 +709,7 @@ pub fn int_params(pairs: &[(&str, i64)]) -> ParamMap { .iter() .map(|(k, v)| { let key = k.strip_prefix('$').unwrap_or(k); - (key.to_string(), Literal::Integer(*v)) + (key.to_string(), Literal::Integer(i128::from(*v))) }) .collect() } @@ -719,7 +719,7 @@ pub fn mixed_params(str_pairs: &[(&str, &str)], int_pairs: &[(&str, i64)]) -> Pa let mut map = params(str_pairs); for (k, v) in int_pairs { let key = k.strip_prefix('$').unwrap_or(k); - map.insert(key.to_string(), Literal::Integer(*v)); + map.insert(key.to_string(), Literal::Integer(i128::from(*v))); } map } diff --git a/crates/omnigraph/tests/integration/literal_filters.rs b/crates/omnigraph/tests/integration/literal_filters.rs index e493037db..7436b0651 100644 --- a/crates/omnigraph/tests/integration/literal_filters.rs +++ b/crates/omnigraph/tests/integration/literal_filters.rs @@ -671,7 +671,7 @@ async fn unused_mutation_parameters_are_checked_before_a_noop() { for (type_name, invalid, valid) in [ ( "I32", - Literal::Integer(i64::from(i32::MAX) + 1), + Literal::Integer(i128::from(i32::MAX) + 1), Literal::Integer(1), ), ( @@ -725,7 +725,7 @@ async fn mutation_assignment_parameter_is_checked_before_an_empty_scan_after_hea let source = r#"query assigned($value: I32) { update Metric set { count: $value } where name = "missing" }"#; - let params = ParamMap::from([("value".into(), Literal::Integer(i64::from(i32::MAX) + 1))]); + let params = ParamMap::from([("value".into(), Literal::Integer(i128::from(i32::MAX) + 1))]); let error = db .mutate_as_with_expected_head( "main", @@ -755,7 +755,7 @@ async fn mutation_assignment_parameter_is_checked_before_an_empty_scan_after_hea "main", source, "assigned", - &ParamMap::from([("value".into(), Literal::Integer(i64::from(i32::MAX)))]), + &ParamMap::from([("value".into(), Literal::Integer(i128::from(i32::MAX)))]), ) .await .unwrap(); diff --git a/docs/dev/execution.md b/docs/dev/execution.md index b850b985b..c65d153fc 100644 --- a/docs/dev/execution.md +++ b/docs/dev/execution.md @@ -147,7 +147,8 @@ when opened; empty input need not open it. Bound plans use a versioned envelope and require regeneration when the stored version is unsupported. Envelope version 7 requires aggregate expressions to carry their `distinct` flag; version 8 types each node's ordering and declares ranked scans' -eligibility and policy; version 9 records every `AntiJoin`'s `strategy`. +eligibility and policy; version 9 records every `AntiJoin`'s `strategy`; +version 11 carries an integer outside the `i64` range as its decimal text. Plans saved with earlier binaries must be regenerated before replay. ### Expand path selection diff --git a/docs/user/queries/explain.md b/docs/user/queries/explain.md index 14483ef5e..b08669c02 100644 --- a/docs/user/queries/explain.md +++ b/docs/user/queries/explain.md @@ -240,7 +240,7 @@ documents only before it) carry a pipeline `id` and `depends_on`, with `physical_plan` node ids; an operator's emission is set by the shape wrapper the engine builds its stream through, the one the planner's cut names, so the printed pipelines are the executed ones. Saved physical -plans use a `bound_plan_version: 10` envelope whose `body` contains `plan` and +plans use a `bound_plan_version: 11` envelope whose `body` contains `plan` and `values`. Unversioned plans and other versions are refused with a regeneration instruction, including plans without traversal nodes. diff --git a/docs/user/queries/index.md b/docs/user/queries/index.md index 06a578f24..a74a4e2a8 100644 --- a/docs/user/queries/index.md +++ b/docs/user/queries/index.md @@ -40,7 +40,8 @@ Parameter types use the schema scalar types. Every non-nullable declared parameter must be supplied; omission fails before query execution. A trailing `?` accepts `null` or an omitted value. `@description("...")` and `@instruction("...")` attach metadata for clients that expose stored queries -as tools. +as tools. An integer parameter carries the whole range of its declared type, +so a `U64` parameter takes every value from `0` to `18446744073709551615`. ## Match patterns @@ -164,9 +165,8 @@ query prolific($least: I64) { `NULL` never matches these predicates. `%` and `_` are ordinary characters, not wildcards. The predicates remain correct without an index; do not assume a -free-text String index accelerates exact prefix or substring filters. - -Use [full-text search](../search/index.md) for tokenization, fuzzy matching, and +free-text String index accelerates exact prefix or substring filters. Use +[full-text search](../search/index.md) for tokenization, fuzzy matching, and relevance ranking. ### System fields @@ -272,9 +272,7 @@ includes its `graph_commit_id`, pinned with the returned rows. Use that same-snapshot id with a later mutation's `--if-commit` option when implementing read-modify-write. On a newly created, unmodified branch, it is the head inherited from the source branch and is valid for the branch's first -conditional mutation. - -See [Branches, Commits, and History](../branching/index.md). +conditional mutation. See [Branches, Commits, and History](../branching/index.md). ## JSON result spelling @@ -293,9 +291,10 @@ its own. The spellings a consumer sees: (`1.0e20`, `1.0e-7`); a non-finite computed value is `null`. - `Vector(N)` and list properties are JSON arrays. -On input, a JSON number for an `F64` parses to the nearest `F64` value, the one -a GQ literal with the same digits names, so an `F64` value read from `rows` -loads back unchanged. A `Date` string is a calendar day, `"2024-01-01"`; a +Send a 64-bit integer above 2^53 as a decimal string, so a client that parses +JSON through a double keeps every digit. On input, a JSON number for an `F64` +parses to the nearest `F64` value, the one a GQ literal with the same digits +names, so an `F64` value read from `rows` loads back unchanged. A `Date` string is a calendar day, `"2024-01-01"`; a string that carries a time of day, such as `"2024-01-01T02:00:00+05:00"`, is refused as a load value, a param, or a `date(...)` literal, and an instant belongs in a `DateTime` property. A `DateTime` holds milliseconds: a string