From 5eb3f9f8cecfcd673c36c0dafdcf85d841baccda Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 2 Jul 2026 14:40:12 +0900 Subject: [PATCH 1/5] Document explicit numeric cast expressions Define i64(expr) and f64(expr) as built-in numeric casts parsed through function-call syntax and resolved into cast-specific IR and SQLite plan nodes. Assisted-by: Codex:gpt-5.5 --- spec/ir.md | 49 ++++++++++++++++++++--- spec/query.md | 84 ++++++++++++++++++++++++++++----------- spec/sqlite-query-plan.md | 32 +++++++++++++-- 3 files changed, 133 insertions(+), 32 deletions(-) diff --git a/spec/ir.md b/spec/ir.md index 0c65312..2c23abb 100644 --- a/spec/ir.md +++ b/spec/ir.md @@ -280,6 +280,7 @@ Minimum variants: - path - arithmetic - unary arithmetic +- cast - comparison - membership - boolean `and` @@ -294,7 +295,7 @@ Minimum metadata: The result type may be a scalar type, an object type for future subquery work, or a dedicated boolean type for predicates. The current implementation needs literal scalar values, resolved scalar paths, arithmetic scalar values, unary -arithmetic scalar values, and boolean predicate results. +arithmetic scalar values, numeric cast values, and boolean predicate results. The expression tree does not store SQLite SQL fragments. SQLite-specific operator spelling, parentheses, bind placeholders, and joins belong to SQLite @@ -399,6 +400,41 @@ operands are rejected before SQLite planning. Unary arithmetic has the same cardinality as its operand. It does not introduce additional `NULL` results by itself. +### `CastExpr` + +Represents a resolved explicit scalar cast. + +Minimum fields: + +- operand expression +- target scalar type +- result cardinality + +Supported target types in the numeric cast milestone: + +- `int64` +- `float64` + +Supported source and target combinations: + +- `int64 -> int64` +- `int64 -> float64` +- `float64 -> int64` +- `float64 -> float64` + +The source syntax for these casts is the built-in function-call form +`i64(expr)` or `f64(expr)`, but Semantic IR stores the resolved meaning as a +cast expression rather than as an opaque function call. The resolver must +reject unsupported function names, unsupported arities, non-numeric source +types, `null`, object values, link values, and many-cardinality operands before +IR construction. + +Cast cardinality follows the operand cardinality. A cast of an optional scalar +is optional; a cast of a required scalar or literal is required. Casts do not +introduce additional `NULL` results by themselves. SQLite runtime conversion +details are backend-specific lowering behavior and must not be encoded as SQL +fragments in Semantic IR. + ### `CompareExpr` Minimum fields: @@ -473,8 +509,10 @@ accepted Semantic IR: - `FunctionCallExpr` - `SubqueryExpr` -The resolver must reject unsupported forms with diagnostics. Do not pass an -unsupported expression through IR as an opaque node. +The resolver may lower accepted built-in function calls to specific Semantic IR +nodes, such as `CastExpr` for `i64(expr)` and `f64(expr)`. It must reject +unsupported forms with diagnostics. Do not pass an unsupported expression +through IR as an opaque node. ## Ordering Model @@ -486,8 +524,9 @@ Minimum fields: - direction: `asc` or `desc` The order value must resolve to a scalar `ValueExpr`. Supported order values in -the arithmetic order milestone are resolved scalar paths and numeric arithmetic -expressions over scalar paths and numeric literals, including unary arithmetic. +the arithmetic and numeric cast milestones are resolved scalar paths, numeric +arithmetic expressions over scalar paths and numeric literals, unary +arithmetic, and numeric casts that refer to the current row. Boolean expressions, membership expressions, and literal-only order values are rejected by the resolver before SQLite planning. diff --git a/spec/query.md b/spec/query.md index 9a555e0..8d099a3 100644 --- a/spec/query.md +++ b/spec/query.md @@ -241,19 +241,51 @@ Arithmetic is numeric only. The resolver accepts same-type numeric operands: - `int64 op int64` - `float64 op float64` -`float64` arithmetic may use declared `float64` fields and decimal float -literals. Integer literals are `int64` literals unless an explicit cast is used -in a later milestone. +`float64` arithmetic may use declared `float64` fields, decimal float literals, +and explicit `f64(expr)` casts. Integer literals are `int64` literals unless an +explicit cast is used. -Mixed numeric operands such as `int64 + float64` are rejected until explicit -cast expressions are supported. String, boolean, uuid, `null`, object, and link -operands are rejected before SQLite planning. `%` is accepted only for -`int64 % int64`. +Mixed numeric operands such as `int64 + float64` are rejected unless one side is +explicitly cast to the other numeric type. String, boolean, uuid, `null`, +object, and link operands are rejected before SQLite planning. `%` is accepted +only for `int64 % int64`. `int64 / int64` follows SQLite integer division semantics. If fractional -division is required, the query must use explicit casts once `f64(expr)` is -supported. Division by zero is not normalized by Gelite in this milestone; if a -runtime operand is zero, SQLite's result is used. +division is required, the query must use an explicit `f64(expr)` cast. Division +by zero is not normalized by Gelite in this milestone; if a runtime operand is +zero, SQLite's result is used. + +### Numeric Casts + +Numeric casts use the function-call syntax but are resolved as explicit cast +expressions, not as general user-defined functions: + +```text +filter f64(.view_count) / 2.0 >= 10.5 +filter i64(.score) % 10 = 0 +``` + +Supported cast functions: + +- `i64(expr) -> int64` +- `f64(expr) -> float64` + +Each cast accepts exactly one scalar numeric value expression. The resolver +accepts `int64` and `float64` sources and rejects string, boolean, uuid, +datetime, `null`, object, link, and many-cardinality operands before SQLite +planning. String-to-number casts are deferred because they would expose +SQLite-specific text coercion behavior. + +Numeric casts may appear anywhere a scalar value expression is allowed, +including filter comparisons, membership list items, order expressions, and +computed select projections. Context-specific restrictions still apply: +membership list items must be row-independent, and order expressions must refer +to the current row rather than being literal-only. + +The parser may represent any `IDENT "(" ... ")"` form as a function call, but +the resolver only accepts the built-in numeric casts listed above in this +milestone. Unsupported function names and unsupported arities are rejected +before Semantic IR construction. ### Ordering @@ -262,7 +294,8 @@ Order clauses use the value-expression subset of the shared expression grammar. Supported order values: - scalar paths -- numeric arithmetic expressions over scalar paths and numeric literals +- numeric arithmetic expressions over scalar paths, numeric literals, and + numeric casts Examples: @@ -270,6 +303,7 @@ Examples: order by .title asc order by .view_count + 1 desc order by (.view_count + 1) * 10 asc +order by f64(.view_count) / 2.0 asc ``` Order expressions must resolve to scalar values. Boolean expressions such as @@ -278,9 +312,10 @@ SQLite planning. Literal-only order values such as `order by 1` are also rejected in this milestone because they do not refer to data from the current row. -Arithmetic in order clauses follows the same numeric rules as arithmetic in -filters: operands must be same-type numeric values, `%` is accepted only for -`int64 % int64`, and division semantics are delegated to SQLite. +Arithmetic and casts in order clauses follow the same numeric rules as +arithmetic and casts in filters: arithmetic operands must be same-type numeric +values, `%` is accepted only for `int64 % int64`, and division semantics are +delegated to SQLite. ### Expression Grammar @@ -319,14 +354,15 @@ expr_list := expr ("," expr)* subquery_expr := "(" select_stmt ")" ``` -Only path, literal, arithmetic, comparison, bracketed-list `in`, -bracketed-list `not in`, boolean, and parenthesized expressions are accepted by -the resolver in the current expression milestones. Context determines which -subset is valid: filters accept boolean expressions, ordering accepts scalar -order values, and the first computed projection milestone accepts numeric +Only path, literal, arithmetic, numeric cast function calls, comparison, +bracketed-list `in`, bracketed-list `not in`, boolean, and parenthesized +expressions are accepted by the resolver in the current expression milestones. +Context determines which subset is valid: filters accept boolean expressions, +ordering accepts scalar order values, and computed projection accepts numeric arithmetic value expressions. -`function_call` and `subquery_expr` are reserved syntax positions. The parser -may produce AST nodes for them before the resolver accepts specific forms. +`function_call` is currently accepted only for built-in numeric casts: +`i64(expr)` and `f64(expr)`. Other function names remain reserved. `subquery_expr` +is also reserved until subquery expression scope is defined. The first accepted `in_rhs` form is a non-empty bracketed list. The parser may accept `null` as a list item because it is a literal expression, but the @@ -364,6 +400,7 @@ The MVP supports: - scalar comparisons against literals - numeric arithmetic expressions used as comparison or membership operands - unary numeric arithmetic expressions +- explicit numeric casts with `i64(expr)` and `f64(expr)` - scalar membership checks against non-empty lists of non-null scalar value expressions - boolean composition @@ -376,7 +413,7 @@ The MVP does not support: - aggregation - `exists` - subquery `in` -- function calls +- arbitrary function calls other than supported built-in numeric casts - implicit numeric casts - path scoping with aliases @@ -574,8 +611,7 @@ These are intentionally out of scope until the end-to-end path is stable: - aggregation - grouping - pagination cursors -- function calls -- explicit numeric casts +- arbitrary function calls beyond supported built-in numeric casts - subqueries - query parameters - upsert diff --git a/spec/sqlite-query-plan.md b/spec/sqlite-query-plan.md index 064d34c..4afeaf6 100644 --- a/spec/sqlite-query-plan.md +++ b/spec/sqlite-query-plan.md @@ -220,6 +220,7 @@ Minimum supported forms for the arithmetic filter milestone: - literal - arithmetic expression - unary arithmetic expression +- cast expression Membership list items use the same value expression structure. The SQLite planner receives only resolver-accepted list items, so list items are non-null @@ -272,6 +273,31 @@ operator spelling and parentheses to SQL generation. SQL generation should parenthesize unary operands whenever the operand is not already a single column reference or literal. +### `SQLiteCastExpr` + +Minimum fields: + +- operand SQLite value expression +- target scalar type + +Supported target types in the numeric cast milestone: + +- `int64` +- `float64` + +The SQLite planner receives only resolver-accepted casts. It should not repeat +Gelite cast type validation. Its responsibility is to lower the cast operand to +a SQLite value expression, preserve the operand tree shape and bind order, and +record the target type for SQL generation. + +SQL generation renders numeric casts as SQLite `CAST` expressions: + +- `int64` target: `CAST( AS INTEGER)` +- `float64` target: `CAST( AS REAL)` + +The generated SQL should keep the cast expression structured until rendering. +Do not store raw SQL fragments in the plan. + ## Ordering Model ### `SQLiteOrder` @@ -283,9 +309,9 @@ Minimum fields: The value expression uses the same SQLite value-expression model as predicates. Supported order values in the arithmetic order milestone are scalar column -references, numeric arithmetic expressions, and unary numeric arithmetic -expressions. Ordering expressions should only reference values already reachable -through the planned join tree. +references, numeric arithmetic expressions, unary numeric arithmetic +expressions, and numeric cast expressions. Ordering expressions should only +reference values already reachable through the planned join tree. ## Select Value Model From a414f47035801f85513330a1917b38fdf8b05a7c Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 2 Jul 2026 14:43:54 +0900 Subject: [PATCH 2/5] Add function call expression parser tests Capture the AST and parser surface needed for built-in numeric cast syntax before implementing function-call parsing. Assisted-by: Codex:gpt-5.5 --- engine/query-ast/src/tests/mod.rs | 23 +++++++++++-- engine/query-parser/src/tests/mod.rs | 48 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/engine/query-ast/src/tests/mod.rs b/engine/query-ast/src/tests/mod.rs index 6a3fd5b..1471053 100644 --- a/engine/query-ast/src/tests/mod.rs +++ b/engine/query-ast/src/tests/mod.rs @@ -1,6 +1,6 @@ use crate::{ - ArithmeticExpr, ArithmeticOp, CompareExpr, CompareOp, Expr, InExpr, InOp, Literal, - OrderDirection, OrderExpr, Path, PathStep, SelectQuery, Shape, ShapeItem, + ArithmeticExpr, ArithmeticOp, CompareExpr, CompareOp, Expr, FunctionCallExpr, InExpr, InOp, + Literal, OrderDirection, OrderExpr, Path, PathStep, SelectQuery, Shape, ShapeItem, }; use alloc::string::ToString; use alloc::vec; @@ -170,6 +170,25 @@ fn compare_expr_can_store_non_equality_operator() { assert_eq!(expr.op(), CompareOp::Ge); } +#[test] +fn function_call_expr_can_store_name_and_arguments() { + let expr = Expr::FunctionCall(FunctionCallExpr::new( + "f64", + vec![Expr::Path(Path::new(vec![PathStep::new("view_count")]))], + )); + + let Expr::FunctionCall(function) = expr else { + panic!("expected expression to be a function call"); + }; + + assert_eq!(function.name(), "f64"); + assert_eq!(function.args().len(), 1); + let Expr::Path(path) = &function.args()[0] else { + panic!("expected function argument to be a path"); + }; + assert_eq!(path.steps()[0].field_name(), "view_count"); +} + #[test] fn order_expr_can_reference_a_path() { let path = Expr::Path(Path::new(vec![PathStep::new("title")])); diff --git a/engine/query-parser/src/tests/mod.rs b/engine/query-parser/src/tests/mod.rs index 7e4d786..620684b 100644 --- a/engine/query-parser/src/tests/mod.rs +++ b/engine/query-parser/src/tests/mod.rs @@ -80,6 +80,25 @@ fn lexer_can_tokenize_arithmetic_operators() { assert_eq!(tokens[14].kind(), &TokenKind::Int("100".to_string())); } +#[test] +fn lexer_can_tokenize_function_call_expression() { + let tokens = lex("filter f64(.view_count) / 2.0 >= 10.5").expect("query should lex"); + + assert_eq!(tokens[0].kind(), &TokenKind::Keyword(Keyword::Filter)); + assert_eq!(tokens[1].kind(), &TokenKind::Ident("f64".to_string())); + assert_eq!(tokens[2].kind(), &TokenKind::LParen); + assert_eq!(tokens[3].kind(), &TokenKind::Dot); + assert_eq!( + tokens[4].kind(), + &TokenKind::Ident("view_count".to_string()) + ); + assert_eq!(tokens[5].kind(), &TokenKind::RParen); + assert_eq!(tokens[6].kind(), &TokenKind::Slash); + assert_eq!(tokens[7].kind(), &TokenKind::Float("2.0".to_string())); + assert_eq!(tokens[8].kind(), &TokenKind::Ge); + assert_eq!(tokens[9].kind(), &TokenKind::Float("10.5".to_string())); +} + #[test] fn lexer_can_tokenize_computed_shape_assignment() { let tokens = lex("select Post { score := .likes + 1 }").expect("query should lex"); @@ -642,6 +661,35 @@ fn parser_can_parse_filter_arithmetic_addition() { } } +#[test] +fn parser_can_parse_function_call_as_arithmetic_operand() { + let query = parse_select("select Post { title } filter f64(.view_count) / 2.0 >= 10.5") + .expect("query should parse"); + + let filter = query.filter().expect("query should have filter"); + + match filter { + Expr::Compare(compare) => { + let Expr::Arithmetic(arithmetic) = compare.left() else { + panic!("left side should be arithmetic expression"); + }; + assert_eq!(arithmetic.op(), ArithmeticOp::Div); + + let Expr::FunctionCall(function) = arithmetic.left() else { + panic!("arithmetic left operand should be a function call"); + }; + assert_eq!(function.name(), "f64"); + assert_eq!(function.args().len(), 1); + assert_path_expr(&function.args()[0], &["view_count"]); + + assert_literal_expr(arithmetic.right(), &Literal::Float64(2.0)); + assert_eq!(compare.op(), CompareOp::Ge); + assert_literal_expr(compare.right(), &Literal::Float64(10.5)); + } + _ => panic!("filter should be compare expression"), + } +} + #[test] fn parser_parses_unary_minus_integer_literal() { let query = From 847bc45eff4640eaf49eb7915eb3dc3a28c7964b Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 2 Jul 2026 14:45:37 +0900 Subject: [PATCH 3/5] Parse function call expressions Add an unresolved function-call AST node and parse IDENT(...) expressions while keeping resolver support closed until built-ins lower to semantic IR. Assisted-by: Codex:gpt-5.5 --- engine/query-ast/src/lib.rs | 25 ++++++++++++++++++++++ engine/query-parser/src/parser.rs | 35 +++++++++++++++++++++++++++++++ engine/query-resolver/src/lib.rs | 10 +++++++++ 3 files changed, 70 insertions(+) diff --git a/engine/query-ast/src/lib.rs b/engine/query-ast/src/lib.rs index 74e545c..645cac4 100644 --- a/engine/query-ast/src/lib.rs +++ b/engine/query-ast/src/lib.rs @@ -96,6 +96,31 @@ pub enum Expr { In(InExpr), Arithmetic(ArithmeticExpr), UnaryArithmetic(UnaryArithmeticExpr), + FunctionCall(FunctionCallExpr), +} + +/// Function call expression parsed from `name(args...)` syntax. +#[derive(Debug, Clone, PartialEq)] +pub struct FunctionCallExpr { + name: String, + args: Vec, +} + +impl FunctionCallExpr { + pub fn new(name: impl Into, args: Vec) -> Self { + Self { + name: name.into(), + args, + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn args(&self) -> &[Expr] { + &self.args + } } /// Membership expression parsed from an `in` or `not in` filter clause. diff --git a/engine/query-parser/src/parser.rs b/engine/query-parser/src/parser.rs index 80dc843..3bf3b57 100644 --- a/engine/query-parser/src/parser.rs +++ b/engine/query-parser/src/parser.rs @@ -389,6 +389,9 @@ impl<'a> Parser<'a> { Ok(Expr::Path(self.parse_path(true)?)) } Some(token) => match token.kind() { + TokenKind::Ident(_) if self.next_token_is_lparen() => { + self.parse_function_call_expr() + } TokenKind::Ident(_) => Ok(Expr::Path(self.parse_path(false)?)), TokenKind::Int(_) | TokenKind::Float(_) @@ -412,6 +415,32 @@ impl<'a> Parser<'a> { } } + fn parse_function_call_expr(&mut self) -> Result { + let name = self.expect_ident()?; + self.expect_token(TokenKind::LParen)?; + + let mut args = vec![]; + if self + .peek() + .is_some_and(|token| token.kind() == &TokenKind::RParen) + { + self.expect_rparen()?; + return Ok(Expr::FunctionCall(query_ast::FunctionCallExpr::new( + name, args, + ))); + } + + args.push(self.parse_expr()?); + while self.consume_comma_if_present() { + args.push(self.parse_expr()?); + } + + self.expect_rparen()?; + Ok(Expr::FunctionCall(query_ast::FunctionCallExpr::new( + name, args, + ))) + } + fn parse_order_clause(&mut self) -> Result, ParseError> { if !self .peek() @@ -812,6 +841,12 @@ impl<'a> Parser<'a> { } } + fn next_token_is_lparen(&self) -> bool { + self.tokens + .get(self.cursor + 1) + .is_some_and(|token| token.kind() == &TokenKind::LParen) + } + fn consume_i64_min_literal_if_present(&mut self) -> bool { match self.peek().map(Token::kind) { Some(TokenKind::Int(value)) if value == "9223372036854775808" => { diff --git a/engine/query-resolver/src/lib.rs b/engine/query-resolver/src/lib.rs index 13a87a2..dc13b97 100644 --- a/engine/query-resolver/src/lib.rs +++ b/engine/query-resolver/src/lib.rs @@ -249,6 +249,9 @@ fn resolve_expr( query_ast::Expr::UnaryArithmetic(_) => Err(ResolveError::UnsupportedExpr { expr_type: "unary arithmetic value".to_string(), }), + query_ast::Expr::FunctionCall(_) => Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }), } } @@ -427,6 +430,9 @@ fn resolve_typed_value_expr( query_ast::Expr::Compare(_) => Err(ResolveError::UnsupportedExpr { expr_type: "comparison value".to_string(), }), + query_ast::Expr::FunctionCall(_) => Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }), query_ast::Expr::And(_, _) | query_ast::Expr::Or(_, _) | query_ast::Expr::Not(_) @@ -622,6 +628,7 @@ fn resolve_membership_item(expr: &query_ast::Expr) -> Result resolve_membership_unary_arithmetic(unary), query_ast::Expr::Path(_) | query_ast::Expr::Compare(_) + | query_ast::Expr::FunctionCall(_) | query_ast::Expr::And(_, _) | query_ast::Expr::Or(_, _) | query_ast::Expr::Not(_) @@ -869,6 +876,9 @@ fn resolve_order_value_expr( query_ast::Expr::Compare(_) => Err(ResolveError::UnsupportedExpr { expr_type: "comparison value".to_string(), }), + query_ast::Expr::FunctionCall(_) => Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }), query_ast::Expr::And(_, _) | query_ast::Expr::Or(_, _) | query_ast::Expr::Not(_) From afd464f9f5b5b80ea28b1467ed40ffd13edd1387 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 2 Jul 2026 14:54:39 +0900 Subject: [PATCH 4/5] Resolve numeric cast function calls Add cast value expressions to Semantic IR and lower i64(expr) and f64(expr) function calls in resolver value contexts while preserving unsupported function and arity diagnostics. Assisted-by: Codex:gpt-5.5 --- engine/query-ir/src/lib.rs | 25 ++ engine/query-ir/src/tests/mod.rs | 27 +- engine/query-parser/src/tests/mod.rs | 27 ++ engine/query-resolver/src/lib.rs | 110 +++++++- engine/query-resolver/src/tests/fixtures.rs | 9 +- engine/query-resolver/src/tests/mod.rs | 283 +++++++++++++++++++- engine/sqlite-query-plan/src/lib.rs | 61 ++++- engine/sqlite-query-plan/src/tests/mod.rs | 13 + engine/sqlite-query-sqlgen/src/lib.rs | 19 +- 9 files changed, 554 insertions(+), 20 deletions(-) diff --git a/engine/query-ir/src/lib.rs b/engine/query-ir/src/lib.rs index f056569..789f22b 100644 --- a/engine/query-ir/src/lib.rs +++ b/engine/query-ir/src/lib.rs @@ -476,6 +476,31 @@ pub enum ValueExpr { Literal(Literal), Arithmetic(ArithmeticExpr), UnaryArithmetic(UnaryArithmeticExpr), + Cast(CastExpr), +} + +/// Resolved explicit scalar cast value expression. +#[derive(Debug, Clone, PartialEq)] +pub struct CastExpr { + operand: Box, + target_type: ScalarType, +} + +impl CastExpr { + pub fn new(operand: ValueExpr, target_type: ScalarType) -> Self { + Self { + operand: Box::new(operand), + target_type, + } + } + + pub fn operand(&self) -> &ValueExpr { + &self.operand + } + + pub fn target_type(&self) -> ScalarType { + self.target_type + } } /// Resolved arithmetic value expression. diff --git a/engine/query-ir/src/tests/mod.rs b/engine/query-ir/src/tests/mod.rs index b6f1513..6a1e6ff 100644 --- a/engine/query-ir/src/tests/mod.rs +++ b/engine/query-ir/src/tests/mod.rs @@ -1,7 +1,7 @@ mod fixtures; use crate::{ - ArithmeticExpr, ArithmeticOp, CompareExpr, CompareOp, Expr, InExpr, InOp, Literal, + ArithmeticExpr, ArithmeticOp, CastExpr, CompareExpr, CompareOp, Expr, InExpr, InOp, Literal, OrderDirection, OrderExpr, ResolvedComputedField, ResolvedPath, ResolvedPathError, ResolvedPathStep, ResolvedPathStepKind, ResolvedShape, ResolvedShapeField, ResolvedShapeItem, SelectQuery, UnaryArithmeticExpr, UnaryArithmeticOp, ValueExpr, @@ -98,6 +98,21 @@ fn unary_arithmetic_expr_can_store_operand_and_operator() { assert_eq!(unary.scalar_type(), ScalarType::Int64); } +#[test] +fn cast_expr_can_store_operand_and_target_type() { + let expr = ValueExpr::Cast(CastExpr::new( + ValueExpr::Literal(Literal::Int64(1)), + ScalarType::Float64, + )); + + let ValueExpr::Cast(cast) = expr else { + panic!("value expression should store a cast expression"); + }; + + assert_eq!(cast.operand(), &ValueExpr::Literal(Literal::Int64(1))); + assert_eq!(cast.target_type(), ScalarType::Float64); +} + #[test] fn resolved_shape_can_contain_link_field_with_child_shape() { let author_shape_field = ResolvedShapeField::new( @@ -314,6 +329,7 @@ fn resolved_select_query_can_store_order_by_path() { ValueExpr::Literal(_) => panic!("order by should reference a resolved path"), ValueExpr::Arithmetic(_) => panic!("order by should reference a resolved path"), ValueExpr::UnaryArithmetic(_) => panic!("order by should reference a resolved path"), + ValueExpr::Cast(_) => panic!("order by should reference a resolved path"), } } @@ -350,6 +366,7 @@ fn resolved_select_query_can_store_filter_compare_expr() { ValueExpr::UnaryArithmetic(_) => { panic!("filter left side should reference a resolved path") } + ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), } match compare.right() { @@ -360,6 +377,7 @@ fn resolved_select_query_can_store_filter_compare_expr() { ValueExpr::Path(_) => panic!("filter right side should store a literal"), ValueExpr::Arithmetic(_) => panic!("filter right side should store a literal"), ValueExpr::UnaryArithmetic(_) => panic!("filter right side should store a literal"), + ValueExpr::Cast(_) => panic!("filter right side should store a literal"), } } @@ -393,6 +411,7 @@ fn resolved_select_query_can_store_filter_compare_int_literal() { ValueExpr::Path(_) => panic!("filter right side should store a literal"), ValueExpr::Arithmetic(_) => panic!("filter right side should store a literal"), ValueExpr::UnaryArithmetic(_) => panic!("filter right side should store a literal"), + ValueExpr::Cast(_) => panic!("filter right side should store a literal"), } } @@ -426,6 +445,7 @@ fn resolved_select_query_can_store_filter_compare_bool_literal() { ValueExpr::Path(_) => panic!("filter right side should store a literal"), ValueExpr::Arithmetic(_) => panic!("filter right side should store a literal"), ValueExpr::UnaryArithmetic(_) => panic!("filter right side should store a literal"), + ValueExpr::Cast(_) => panic!("filter right side should store a literal"), } } @@ -483,6 +503,7 @@ fn resolved_select_query_can_store_filter_is_null_expr() { ValueExpr::UnaryArithmetic(_) => { panic!("filter left side should reference a resolved path") } + ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), } } @@ -516,6 +537,7 @@ fn resolved_select_query_can_store_filter_is_not_null_expr() { ValueExpr::UnaryArithmetic(_) => { panic!("filter left side should reference a resolved path") } + ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), } } @@ -554,6 +576,7 @@ fn resolved_select_query_can_store_filter_in_expr() { ValueExpr::UnaryArithmetic(_) => { panic!("filter left side should reference a resolved path") } + ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), } assert_eq!(in_expr.op(), InOp::In); @@ -616,6 +639,7 @@ fn value_expr_can_reference_resolved_path() { ValueExpr::UnaryArithmetic(_) => { panic!("value expression should reference a resolved path") } + ValueExpr::Cast(_) => panic!("value expression should reference a resolved path"), } } @@ -631,6 +655,7 @@ fn value_expr_can_store_literal() { ValueExpr::Path(_) => panic!("value expression should store a literal"), ValueExpr::Arithmetic(_) => panic!("value expression should store a literal"), ValueExpr::UnaryArithmetic(_) => panic!("value expression should store a literal"), + ValueExpr::Cast(_) => panic!("value expression should store a literal"), } } diff --git a/engine/query-parser/src/tests/mod.rs b/engine/query-parser/src/tests/mod.rs index 620684b..25e2a05 100644 --- a/engine/query-parser/src/tests/mod.rs +++ b/engine/query-parser/src/tests/mod.rs @@ -690,6 +690,33 @@ fn parser_can_parse_function_call_as_arithmetic_operand() { } } +#[test] +fn parser_preserves_function_call_argument_count() { + let query = parse_select("select Post { title } filter concat() = concat(.title, \"!\")") + .expect("query should parse"); + + let filter = query.filter().expect("query should have filter"); + + match filter { + Expr::Compare(compare) => { + let Expr::FunctionCall(left) = compare.left() else { + panic!("left side should be a function call"); + }; + assert_eq!(left.name(), "concat"); + assert_eq!(left.args().len(), 0); + + let Expr::FunctionCall(right) = compare.right() else { + panic!("right side should be a function call"); + }; + assert_eq!(right.name(), "concat"); + assert_eq!(right.args().len(), 2); + assert_path_expr(&right.args()[0], &["title"]); + assert_literal_expr(&right.args()[1], &Literal::String("!".to_string())); + } + _ => panic!("filter should be compare expression"), + } +} + #[test] fn parser_parses_unary_minus_integer_literal() { let query = diff --git a/engine/query-resolver/src/lib.rs b/engine/query-resolver/src/lib.rs index dc13b97..15420f4 100644 --- a/engine/query-resolver/src/lib.rs +++ b/engine/query-resolver/src/lib.rs @@ -210,7 +210,9 @@ fn resolve_computed_shape_item( fn computed_projection_expr_is_supported(expr: &query_ast::Expr) -> bool { matches!( expr, - query_ast::Expr::Arithmetic(_) | query_ast::Expr::UnaryArithmetic(_) + query_ast::Expr::Arithmetic(_) + | query_ast::Expr::UnaryArithmetic(_) + | query_ast::Expr::FunctionCall(_) ) } @@ -384,6 +386,9 @@ fn resolve_path_value_expr( query_ir::ValueExpr::UnaryArithmetic(_) => Err(ResolveError::UnsupportedExpr { expr_type: "null comparison value".to_string(), }), + query_ir::ValueExpr::Cast(_) => Err(ResolveError::UnsupportedExpr { + expr_type: "null comparison value".to_string(), + }), } } @@ -427,12 +432,12 @@ fn resolve_typed_value_expr( query_ast::Expr::UnaryArithmetic(unary) => { resolve_typed_unary_arithmetic_expr(catalog, source_object_type, unary) } + query_ast::Expr::FunctionCall(function) => { + resolve_typed_function_call_expr(catalog, source_object_type, function) + } query_ast::Expr::Compare(_) => Err(ResolveError::UnsupportedExpr { expr_type: "comparison value".to_string(), }), - query_ast::Expr::FunctionCall(_) => Err(ResolveError::UnsupportedExpr { - expr_type: "function call".to_string(), - }), query_ast::Expr::And(_, _) | query_ast::Expr::Or(_, _) | query_ast::Expr::Not(_) @@ -442,6 +447,43 @@ fn resolve_typed_value_expr( } } +fn resolve_typed_function_call_expr( + catalog: &schema_model::SchemaCatalog, + source_object_type: &schema_model::ObjectTypeRef, + function: &query_ast::FunctionCallExpr, +) -> Result { + let target_type = numeric_cast_target(function.name())?; + + if function.args().len() != 1 { + return Err(ResolveError::UnsupportedExpr { + expr_type: "numeric cast arity".to_string(), + }); + } + + let operand = resolve_typed_value_expr(catalog, source_object_type, &function.args()[0])?; + let operand_type = source_scalar_type(operand.source); + + ensure_numeric_arithmetic_operand(operand_type)?; + if value_expr_cardinality(&operand.value)? == schema_model::Cardinality::Many { + return Err(ResolveError::UnsupportedPath); + } + + Ok(TypedValueExpr { + value: query_ir::ValueExpr::Cast(query_ir::CastExpr::new(operand.value, target_type)), + source: ValueSource::Computed(target_type), + }) +} + +fn numeric_cast_target(name: &str) -> Result { + match name { + "i64" => Ok(schema_model::ScalarType::Int64), + "f64" => Ok(schema_model::ScalarType::Float64), + _ => Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }), + } +} + fn resolve_typed_arithmetic_expr( catalog: &schema_model::SchemaCatalog, source_object_type: &schema_model::ObjectTypeRef, @@ -626,9 +668,9 @@ fn resolve_membership_item(expr: &query_ast::Expr) -> Result resolve_membership_literal(literal), query_ast::Expr::Arithmetic(arithmetic) => resolve_membership_arithmetic(arithmetic), query_ast::Expr::UnaryArithmetic(unary) => resolve_membership_unary_arithmetic(unary), + query_ast::Expr::FunctionCall(function) => resolve_membership_function_call(function), query_ast::Expr::Path(_) | query_ast::Expr::Compare(_) - | query_ast::Expr::FunctionCall(_) | query_ast::Expr::And(_, _) | query_ast::Expr::Or(_, _) | query_ast::Expr::Not(_) @@ -638,6 +680,28 @@ fn resolve_membership_item(expr: &query_ast::Expr) -> Result Result { + let target_type = numeric_cast_target(function.name())?; + + if function.args().len() != 1 { + return Err(ResolveError::UnsupportedExpr { + expr_type: "numeric cast arity".to_string(), + }); + } + + let operand = resolve_membership_item(&function.args()[0])?; + let operand_type = source_scalar_type(operand.source); + + ensure_numeric_arithmetic_operand(operand_type)?; + + Ok(TypedValueExpr { + value: query_ir::ValueExpr::Cast(query_ir::CastExpr::new(operand.value, target_type)), + source: ValueSource::Computed(target_type), + }) +} + fn resolve_membership_unary_arithmetic( unary: &query_ast::UnaryArithmeticExpr, ) -> Result { @@ -870,15 +934,15 @@ fn resolve_order_value_expr( query_ast::Expr::UnaryArithmetic(unary) => { resolve_order_unary_arithmetic_expr(catalog, source_object_type, unary) } + query_ast::Expr::FunctionCall(function) => { + resolve_order_function_call_expr(catalog, source_object_type, function) + } query_ast::Expr::Literal(_) => Err(ResolveError::UnsupportedExpr { expr_type: "order value".to_string(), }), query_ast::Expr::Compare(_) => Err(ResolveError::UnsupportedExpr { expr_type: "comparison value".to_string(), }), - query_ast::Expr::FunctionCall(_) => Err(ResolveError::UnsupportedExpr { - expr_type: "function call".to_string(), - }), query_ast::Expr::And(_, _) | query_ast::Expr::Or(_, _) | query_ast::Expr::Not(_) @@ -888,6 +952,23 @@ fn resolve_order_value_expr( } } +fn resolve_order_function_call_expr( + catalog: &schema_model::SchemaCatalog, + source_object_type: &schema_model::ObjectTypeRef, + function: &query_ast::FunctionCallExpr, +) -> Result { + let typed = resolve_typed_function_call_expr(catalog, source_object_type, function)?; + + if !value_expr_contains_path(&typed.value) { + return Err(ResolveError::UnsupportedExpr { + expr_type: "order value".to_string(), + }); + } + ensure_order_value_is_single_cardinality(&typed.value)?; + + Ok(typed.value) +} + fn resolve_order_arithmetic_expr( catalog: &schema_model::SchemaCatalog, source_object_type: &schema_model::ObjectTypeRef, @@ -938,6 +1019,7 @@ fn ensure_order_value_is_single_cardinality( query_ir::ValueExpr::UnaryArithmetic(unary) => { ensure_order_value_is_single_cardinality(unary.operand()) } + query_ir::ValueExpr::Cast(cast) => ensure_order_value_is_single_cardinality(cast.operand()), } } @@ -985,6 +1067,16 @@ fn value_expr_cardinality( query_ir::ValueExpr::UnaryArithmetic(unary) => { let cardinality = value_expr_cardinality(unary.operand())?; + match cardinality { + schema_model::Cardinality::Many => Err(ResolveError::UnsupportedPath), + schema_model::Cardinality::Optional | schema_model::Cardinality::Required => { + Ok(cardinality) + } + } + } + query_ir::ValueExpr::Cast(cast) => { + let cardinality = value_expr_cardinality(cast.operand())?; + match cardinality { schema_model::Cardinality::Many => Err(ResolveError::UnsupportedPath), schema_model::Cardinality::Optional | schema_model::Cardinality::Required => { @@ -1004,6 +1096,7 @@ fn value_expr_contains_path(value: &query_ir::ValueExpr) -> bool { || value_expr_contains_path(arithmetic.right()) } query_ir::ValueExpr::UnaryArithmetic(unary) => value_expr_contains_path(unary.operand()), + query_ir::ValueExpr::Cast(cast) => value_expr_contains_path(cast.operand()), } } @@ -1019,6 +1112,7 @@ fn is_nonzero_numeric_literal(value: &query_ir::ValueExpr) -> bool { query_ir::ValueExpr::Literal(query_ir::Literal::Int64(value)) => *value != 0, query_ir::ValueExpr::Literal(query_ir::Literal::Float64(value)) => *value != 0.0, query_ir::ValueExpr::UnaryArithmetic(unary) => is_nonzero_numeric_literal(unary.operand()), + query_ir::ValueExpr::Cast(_) => false, _ => false, } } diff --git a/engine/query-resolver/src/tests/fixtures.rs b/engine/query-resolver/src/tests/fixtures.rs index 2ab06a6..5565c53 100644 --- a/engine/query-resolver/src/tests/fixtures.rs +++ b/engine/query-resolver/src/tests/fixtures.rs @@ -1,8 +1,9 @@ use alloc::string::String; use alloc::vec; +use alloc::vec::Vec; use query_ast::{ - ArithmeticExpr, ArithmeticOp, CompareExpr, CompareOp, Expr, InExpr, InOp, Literal, Path, - PathStep, + ArithmeticExpr, ArithmeticOp, CompareExpr, CompareOp, Expr, FunctionCallExpr, InExpr, InOp, + Literal, Path, PathStep, }; use schema_model::{Field, LinkField, ObjectType, ScalarField, ScalarType, SchemaCatalog}; @@ -164,6 +165,10 @@ pub fn arithmetic_expr(left: Expr, op: ArithmeticOp, right: Expr) -> Expr { Expr::Arithmetic(ArithmeticExpr::new(left, op, right)) } +pub fn function_call_expr(name: &str, args: Vec) -> Expr { + Expr::FunctionCall(FunctionCallExpr::new(name, args)) +} + pub fn filter_eq_string(path: &[&str], value: &str) -> Expr { Expr::Compare(CompareExpr::new( path_expr(path), diff --git a/engine/query-resolver/src/tests/mod.rs b/engine/query-resolver/src/tests/mod.rs index d025d0c..a861562 100644 --- a/engine/query-resolver/src/tests/mod.rs +++ b/engine/query-resolver/src/tests/mod.rs @@ -8,10 +8,10 @@ use fixtures::{ arithmetic_expr, filter_compare_int, filter_eq_bool, filter_eq_int, filter_eq_null, filter_eq_string, filter_in_bools, filter_in_empty, filter_in_floats, filter_in_ints, filter_in_null, filter_in_path_item, filter_in_strings, filter_lt_null, filter_ne_null, - filter_not_in_strings, filter_null_eq, filter_null_ne, literal_float_expr, literal_int_expr, - literal_null_expr, literal_string_expr, path_expr, post_only_catalog, post_with_author_catalog, - post_with_optional_subtitle_catalog, post_with_scalar_fields_catalog, post_with_title_catalog, - user_with_posts_catalog, + filter_not_in_strings, filter_null_eq, filter_null_ne, function_call_expr, literal_float_expr, + literal_int_expr, literal_null_expr, literal_string_expr, path_expr, post_only_catalog, + post_with_author_catalog, post_with_optional_subtitle_catalog, post_with_scalar_fields_catalog, + post_with_title_catalog, user_with_posts_catalog, }; use query_ast::{ ArithmeticExpr, CompareExpr, @@ -298,6 +298,37 @@ fn resolves_computed_projection_unary_arithmetic_path() { } } +#[test] +fn resolves_computed_projection_numeric_cast_path() { + let catalog = post_with_scalar_fields_catalog(); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::computed( + "view_count_float", + function_call_expr("f64", vec![path_expr(&["view_count"])]), + )]), + None, + vec![], + None, + None, + ); + + let resolved = resolve_select(&catalog, &query).expect("select query resolves"); + let query_ir::ResolvedShapeItem::Computed(computed) = &resolved.shape().items()[0] else { + panic!("shape item should resolve to a computed projection"); + }; + + assert_eq!(computed.output_name(), "view_count_float"); + assert_eq!(computed.scalar_type(), ScalarType::Float64); + assert_eq!(computed.cardinality(), Cardinality::Required); + + let ValueExpr::Cast(cast) = computed.value() else { + panic!("computed projection should resolve to a cast value expression"); + }; + assert_eq!(cast.target_type(), ScalarType::Float64); +} + #[test] fn resolves_filter_compare_unary_arithmetic_path() { let catalog = post_with_scalar_fields_catalog(); @@ -964,6 +995,7 @@ fn resolves_filter_compare_path_to_field_and_literal() { query_ir::ValueExpr::UnaryArithmetic(_) => { panic!("filter left side should resolve to a path") } + query_ir::ValueExpr::Cast(_) => panic!("filter left side should resolve to a path"), } assert_eq!(compare.op(), query_ir::CompareOp::Eq); @@ -1111,6 +1143,84 @@ fn resolves_filter_compare_float_arithmetic_expr() { ); } +#[test] +fn resolves_filter_compare_explicit_float_cast_arithmetic_expr() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + arithmetic_expr( + function_call_expr("f64", vec![path_expr(&["view_count"])]), + query_ast::ArithmeticOp::Div, + literal_float_expr(2.0), + ), + query_ast::CompareOp::Ge, + literal_float_expr(10.5), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + let resolved = resolve_select(&catalog, &query).expect("select query resolved"); + let query_ir::Expr::Compare(compare) = resolved.filter().expect("filter should resolve") else { + panic!("filter should resolve to a compare expression"); + }; + + let query_ir::ValueExpr::Arithmetic(arithmetic) = compare.left() else { + panic!("comparison left side should resolve to an arithmetic value expression"); + }; + + assert_eq!(arithmetic.op(), query_ir::ArithmeticOp::Div); + assert_eq!(arithmetic.scalar_type(), schema_model::ScalarType::Float64); + + let query_ir::ValueExpr::Cast(cast) = arithmetic.left() else { + panic!("arithmetic left side should resolve to a cast value expression"); + }; + assert_eq!(cast.target_type(), schema_model::ScalarType::Float64); + + let query_ir::ValueExpr::Path(path) = cast.operand() else { + panic!("cast operand should resolve to a path"); + }; + assert_eq!(path.steps()[0].field().name(), "view_count"); + + assert_eq!( + arithmetic.right(), + &query_ir::ValueExpr::Literal(query_ir::Literal::Float64(2.0)) + ); +} + +#[test] +fn resolves_filter_compare_explicit_int_cast_modulo_expr() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + arithmetic_expr( + function_call_expr("i64", vec![path_expr(&["rating"])]), + query_ast::ArithmeticOp::Mod, + literal_int_expr(10), + ), + query_ast::CompareOp::Eq, + literal_int_expr(0), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + let resolved = resolve_select(&catalog, &query).expect("select query resolved"); + let query_ir::Expr::Compare(compare) = resolved.filter().expect("filter should resolve") else { + panic!("filter should resolve to a compare expression"); + }; + + let query_ir::ValueExpr::Arithmetic(arithmetic) = compare.left() else { + panic!("comparison left side should resolve to an arithmetic value expression"); + }; + + assert_eq!(arithmetic.op(), query_ir::ArithmeticOp::Mod); + assert_eq!(arithmetic.scalar_type(), schema_model::ScalarType::Int64); + + let query_ir::ValueExpr::Cast(cast) = arithmetic.left() else { + panic!("arithmetic left side should resolve to a cast value expression"); + }; + assert_eq!(cast.target_type(), schema_model::ScalarType::Int64); +} + #[test] fn rejects_arithmetic_expr_as_filter_root() { let catalog = post_with_scalar_fields_catalog(); @@ -1247,6 +1357,111 @@ fn rejects_arithmetic_expr_with_string_left_operand() { ); } +#[test] +fn rejects_unknown_function_call() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("round", vec![path_expr(&["rating"])]), + query_ast::CompareOp::Eq, + literal_float_expr(1.0), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }) + ); +} + +#[test] +fn rejects_numeric_cast_without_exactly_one_argument() { + let catalog = post_with_scalar_fields_catalog(); + + let no_args = Expr::Compare(CompareExpr::new( + function_call_expr("f64", vec![]), + query_ast::CompareOp::Eq, + literal_float_expr(1.0), + )); + let query = SelectQuery::new( + "Post", + Shape::new(vec![]), + Some(no_args), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "numeric cast arity".to_string(), + }) + ); + + let too_many_args = Expr::Compare(CompareExpr::new( + function_call_expr("f64", vec![path_expr(&["view_count"]), literal_int_expr(1)]), + query_ast::CompareOp::Eq, + literal_float_expr(1.0), + )); + let query = SelectQuery::new( + "Post", + Shape::new(vec![]), + Some(too_many_args), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "numeric cast arity".to_string(), + }) + ); +} + +#[test] +fn rejects_numeric_cast_non_numeric_operand() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("f64", vec![path_expr(&["title"])]), + query_ast::CompareOp::Eq, + literal_float_expr(1.0), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::NonNumericArithmeticOperand { + actual: "str".to_string(), + }) + ); +} + +#[test] +fn rejects_numeric_cast_through_multi_link() { + let catalog = user_with_posts_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("f64", vec![path_expr(&["posts", "view_count"])]), + query_ast::CompareOp::Eq, + literal_float_expr(1.0), + )); + + let query = SelectQuery::new("User", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedPath) + ); +} + #[test] fn rejects_numeric_arithmetic_result_compared_to_string_literal() { let catalog = post_with_scalar_fields_catalog(); @@ -1463,6 +1678,7 @@ fn resolves_filter_compare_null_literal_to_is_null_expr() { query_ir::ValueExpr::UnaryArithmetic(_) => { panic!("is null expression should reference a path") } + query_ir::ValueExpr::Cast(_) => panic!("is null expression should reference a path"), } } @@ -1502,6 +1718,7 @@ fn resolves_filter_compare_left_null_literal_to_is_null_expr() { query_ir::ValueExpr::UnaryArithmetic(_) => { panic!("is null expression should reference a path") } + query_ir::ValueExpr::Cast(_) => panic!("is null expression should reference a path"), } } @@ -1540,6 +1757,7 @@ fn resolves_filter_compare_not_null_literal_to_is_not_null_expr() { query_ir::ValueExpr::UnaryArithmetic(_) => { panic!("is not null expression should reference a path") } + query_ir::ValueExpr::Cast(_) => panic!("is not null expression should reference a path"), } } @@ -1578,6 +1796,7 @@ fn resolves_filter_compare_left_not_null_literal_to_is_not_null_expr() { query_ir::ValueExpr::UnaryArithmetic(_) => { panic!("is not null expression should reference a path") } + query_ir::ValueExpr::Cast(_) => panic!("is not null expression should reference a path"), } } @@ -1755,6 +1974,7 @@ fn resolves_filter_in_literal_list_to_in_expr() { query_ir::ValueExpr::UnaryArithmetic(_) => { panic!("in expression left side should be a path") } + query_ir::ValueExpr::Cast(_) => panic!("in expression left side should be a path"), } assert_eq!(in_expr.op(), query_ir::InOp::In); @@ -1878,6 +2098,34 @@ fn resolves_filter_in_arithmetic_literal_item_to_value_expr() { ); } +#[test] +fn resolves_filter_in_numeric_cast_literal_item_to_value_expr() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::In(query_ast::InExpr::new( + path_expr(&["rating"]), + query_ast::InOp::In, + vec![function_call_expr("f64", vec![literal_int_expr(1)])], + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + let resolved = resolve_select(&catalog, &query).expect("select query resolved"); + let query_ir::Expr::In(in_expr) = resolved.filter().expect("filter should resolve") else { + panic!("filter should resolve to an in expression"); + }; + + let [query_ir::ValueExpr::Cast(cast)] = in_expr.right() else { + panic!("membership item should resolve to a cast value expression"); + }; + + assert_eq!(cast.target_type(), schema_model::ScalarType::Float64); + assert_eq!( + cast.operand(), + &query_ir::ValueExpr::Literal(query_ir::Literal::Int64(1)) + ); +} + #[test] fn resolves_filter_in_overflowing_arithmetic_literal_item_without_folding() { let catalog = post_with_scalar_fields_catalog(); @@ -2345,6 +2593,7 @@ fn resolves_order_path_to_resolved_path() { query_ir::ValueExpr::Literal(_) => panic!("order by should resolve to a path"), query_ir::ValueExpr::Arithmetic(_) => panic!("order by should resolve to a path"), query_ir::ValueExpr::UnaryArithmetic(_) => panic!("order by should resolve to a path"), + query_ir::ValueExpr::Cast(_) => panic!("order by should resolve to a path"), } } @@ -2403,6 +2652,30 @@ fn resolves_order_numeric_arithmetic_expr() { ); } +#[test] +fn resolves_order_numeric_cast_expr() { + let catalog = post_with_scalar_fields_catalog(); + + let order = query_ast::OrderExpr::new( + function_call_expr("f64", vec![path_expr(&["view_count"])]), + query_ast::OrderDirection::Asc, + ); + + let query = SelectQuery::new("Post", Shape::new(vec![]), None, vec![order], None, None); + + let resolved = resolve_select(&catalog, &query).expect("select query resolves"); + + let query_ir::ValueExpr::Cast(cast) = resolved.order_by()[0].value() else { + panic!("order by should resolve to a cast value expression"); + }; + + assert_eq!(cast.target_type(), schema_model::ScalarType::Float64); + let query_ir::ValueExpr::Path(path) = cast.operand() else { + panic!("cast operand should resolve to a path"); + }; + assert_eq!(path.steps()[0].field().name(), "view_count"); +} + #[test] fn resolves_order_parenthesized_numeric_arithmetic_expr() { let catalog = post_with_scalar_fields_catalog(); @@ -2971,6 +3244,7 @@ fn resolves_filter_path_through_single_link_to_scalar_field() { query_ir::ValueExpr::UnaryArithmetic(_) => { panic!("filter left side should resolve to a path") } + query_ir::ValueExpr::Cast(_) => panic!("filter left side should resolve to a path"), } match compare.right() { @@ -3054,5 +3328,6 @@ fn resolves_order_path_through_single_link_to_scalar_field() { query_ir::ValueExpr::Literal(_) => panic!("order by should resolve to a path"), query_ir::ValueExpr::Arithmetic(_) => panic!("order by should resolve to a path"), query_ir::ValueExpr::UnaryArithmetic(_) => panic!("order by should resolve to a path"), + query_ir::ValueExpr::Cast(_) => panic!("order by should resolve to a path"), } } diff --git a/engine/sqlite-query-plan/src/lib.rs b/engine/sqlite-query-plan/src/lib.rs index b13d334..3beedb1 100644 --- a/engine/sqlite-query-plan/src/lib.rs +++ b/engine/sqlite-query-plan/src/lib.rs @@ -271,7 +271,8 @@ impl SQLiteFieldSelectValue { SQLiteValueExpr::Column(column) => column.source_alias(), SQLiteValueExpr::Literal(_) | SQLiteValueExpr::Arithmetic(_) - | SQLiteValueExpr::UnaryArithmetic(_) => { + | SQLiteValueExpr::UnaryArithmetic(_) + | SQLiteValueExpr::Cast(_) => { unreachable!("field selected values are always columns") } } @@ -282,7 +283,8 @@ impl SQLiteFieldSelectValue { SQLiteValueExpr::Column(column) => column.column_name(), SQLiteValueExpr::Literal(_) | SQLiteValueExpr::Arithmetic(_) - | SQLiteValueExpr::UnaryArithmetic(_) => { + | SQLiteValueExpr::UnaryArithmetic(_) + | SQLiteValueExpr::Cast(_) => { unreachable!("field selected values are always columns") } } @@ -565,6 +567,9 @@ fn collect_root_path_aliases_from_value(value: &query_ir::ValueExpr, aliases: &m query_ir::ValueExpr::UnaryArithmetic(unary) => { collect_root_path_aliases_from_value(unary.operand(), aliases); } + query_ir::ValueExpr::Cast(cast) => { + collect_root_path_aliases_from_value(cast.operand(), aliases); + } } } @@ -961,6 +966,46 @@ pub enum SQLiteValueExpr { Literal(SQLiteLiteral), Arithmetic(SQLiteArithmeticExpr), UnaryArithmetic(SQLiteUnaryArithmeticExpr), + Cast(SQLiteCastExpr), +} + +/// Backend-specific scalar cast value expression. +#[derive(Debug, Clone, PartialEq)] +pub struct SQLiteCastExpr { + operand: Box, + target: SQLiteCastTarget, +} + +impl SQLiteCastExpr { + pub fn operand(&self) -> &SQLiteValueExpr { + &self.operand + } + + pub fn target(&self) -> SQLiteCastTarget { + self.target + } +} + +/// SQLite cast targets emitted by the planner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SQLiteCastTarget { + Int64, + Float64, +} + +impl SQLiteCastTarget { + fn from_scalar_type(scalar_type: schema_model::ScalarType) -> Self { + match scalar_type { + schema_model::ScalarType::Int64 => Self::Int64, + schema_model::ScalarType::Float64 => Self::Float64, + schema_model::ScalarType::Str + | schema_model::ScalarType::Bool + | schema_model::ScalarType::Uuid + | schema_model::ScalarType::DateTime => { + unreachable!("SQLite planner receives only resolver-accepted numeric casts") + } + } + } } /// Backend-specific arithmetic value expression. @@ -1308,6 +1353,18 @@ fn plan_value_expr( joins: operand.joins, } } + query_ir::ValueExpr::Cast(cast) => { + let operand = + plan_value_expr(cast.operand(), source_alias, source_nullable, join_aliases); + + PlannedValueExpr { + value: SQLiteValueExpr::Cast(SQLiteCastExpr { + operand: Box::new(operand.value), + target: SQLiteCastTarget::from_scalar_type(cast.target_type()), + }), + joins: operand.joins, + } + } } } diff --git a/engine/sqlite-query-plan/src/tests/mod.rs b/engine/sqlite-query-plan/src/tests/mod.rs index 0cc1922..079d247 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -35,6 +35,7 @@ fn assert_order_column(order: &SQLiteOrder, source_alias: &str, column_name: &st SQLiteValueExpr::Literal(_) => panic!("order value should be a column"), SQLiteValueExpr::Arithmetic(_) => panic!("order value should be a column"), SQLiteValueExpr::UnaryArithmetic(_) => panic!("order value should be a column"), + SQLiteValueExpr::Cast(_) => panic!("order value should be a column"), } } @@ -47,6 +48,7 @@ fn assert_column_value(value: &SQLiteValueExpr, source_alias: &str, column_name: SQLiteValueExpr::Literal(_) => panic!("value should be a column"), SQLiteValueExpr::Arithmetic(_) => panic!("value should be a column"), SQLiteValueExpr::UnaryArithmetic(_) => panic!("value should be a column"), + SQLiteValueExpr::Cast(_) => panic!("value should be a column"), } } @@ -57,6 +59,7 @@ fn assert_int_literal_value(value: &SQLiteValueExpr, expected: i64) { SQLiteValueExpr::Column(_) => panic!("value should be a literal"), SQLiteValueExpr::Arithmetic(_) => panic!("value should be a literal"), SQLiteValueExpr::UnaryArithmetic(_) => panic!("value should be a literal"), + SQLiteValueExpr::Cast(_) => panic!("value should be a literal"), } } @@ -1230,6 +1233,7 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_string_literal() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter left side should be a column") } + SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), } assert_eq!(compare.op(), SQLiteCompareOp::Eq); @@ -1246,6 +1250,7 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_string_literal() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter right side should be a literal") } + SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), } } _ => panic!("expected compare filter"), @@ -1491,6 +1496,7 @@ fn sqlite_select_plan_can_filter_single_link_scalar_path_equals_string_literal() SQLiteValueExpr::Literal(_) => panic!("filter left side should be a column"), SQLiteValueExpr::Arithmetic(_) => panic!("filter left side should be a column"), SQLiteValueExpr::UnaryArithmetic(_) => panic!("filter left side should be a column"), + SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), }, _ => panic!("expected compare filter"), } @@ -1573,6 +1579,7 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_int_literal() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter right side should be a literal") } + SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), } } _ => panic!("expected compare filter"), @@ -1615,6 +1622,7 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_bool_literal() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter right side should be a literal") } + SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), } } _ => panic!("expected compare filter"), @@ -1656,6 +1664,7 @@ fn sqlite_select_plan_can_filter_root_scalar_field_in_literal_list() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter left side should be a column") } + SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), } assert_eq!(in_expr.op(), SQLiteInOp::In); @@ -1750,6 +1759,7 @@ fn sqlite_select_plan_can_filter_single_link_scalar_path_not_in_literal_list() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter left side should be a column") } + SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), } assert_eq!(in_expr.op(), SQLiteInOp::NotIn); @@ -1792,6 +1802,7 @@ fn sqlite_select_plan_can_filter_root_scalar_field_is_null() { SQLiteValueExpr::Literal(_) => panic!("is null value should be a column"), SQLiteValueExpr::Arithmetic(_) => panic!("is null value should be a column"), SQLiteValueExpr::UnaryArithmetic(_) => panic!("is null value should be a column"), + SQLiteValueExpr::Cast(_) => panic!("is null value should be a column"), }, _ => panic!("expected is null filter"), } @@ -1972,6 +1983,7 @@ fn sqlite_select_plan_can_filter_implicit_id_equals_string_literal() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter left side should be a column") } + SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), } assert_eq!(compare.op(), SQLiteCompareOp::Eq); @@ -1988,6 +2000,7 @@ fn sqlite_select_plan_can_filter_implicit_id_equals_string_literal() { SQLiteValueExpr::UnaryArithmetic(_) => { panic!("filter right side should be a literal") } + SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), } } _ => panic!("expected compare filter"), diff --git a/engine/sqlite-query-sqlgen/src/lib.rs b/engine/sqlite-query-sqlgen/src/lib.rs index d667ea2..73fd7b4 100644 --- a/engine/sqlite-query-sqlgen/src/lib.rs +++ b/engine/sqlite-query-sqlgen/src/lib.rs @@ -17,9 +17,9 @@ use alloc::string::{String, ToString}; use alloc::vec; use alloc::vec::Vec; use sqlite_query_plan::{ - SQLiteArithmeticOp, SQLiteCompareOp, SQLiteInOp, SQLiteJoinKind, SQLiteLiteral, - SQLiteOrderDirection, SQLiteSelectPlan, SQLiteUnaryArithmeticOp, SQLiteValueExpr, - SQLiteWhereExpr, + SQLiteArithmeticOp, SQLiteCastTarget, SQLiteCompareOp, SQLiteInOp, SQLiteJoinKind, + SQLiteLiteral, SQLiteOrderDirection, SQLiteSelectPlan, SQLiteUnaryArithmeticOp, + SQLiteValueExpr, SQLiteWhereExpr, }; fn quote_identifier(identifier: &str) -> String { @@ -217,6 +217,13 @@ fn render_unary_arithmetic_op(op: SQLiteUnaryArithmeticOp) -> &'static str { } } +fn render_cast_target(target: SQLiteCastTarget) -> &'static str { + match target { + SQLiteCastTarget::Int64 => "INTEGER", + SQLiteCastTarget::Float64 => "REAL", + } +} + fn render_value_expr(value: &SQLiteValueExpr, bind_values: &mut Vec) -> String { match value { SQLiteValueExpr::Column(column) => { @@ -250,6 +257,12 @@ fn render_value_expr(value: &SQLiteValueExpr, bind_values: &mut Vec { + let operand = render_value_expr(cast.operand(), bind_values); + let target = render_cast_target(cast.target()); + + format!("CAST({operand} AS {target})") + } } } From be1955ec01561f1521d25d3824922328257cfe1c Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 2 Jul 2026 14:57:33 +0900 Subject: [PATCH 5/5] Cover SQLite numeric cast lowering Add SQLite plan, SQL generation, and query pipeline coverage for explicit numeric casts lowered from Semantic IR. Assisted-by: Codex:gpt-5.5 --- engine/sqlite-query-plan/src/tests/mod.rs | 45 +++++++++++++- engine/sqlite-query-sqlgen/src/tests/mod.rs | 62 +++++++++++++++++++ .../query-pipeline/tests/select_execution.rs | 33 ++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/engine/sqlite-query-plan/src/tests/mod.rs b/engine/sqlite-query-plan/src/tests/mod.rs index 079d247..964bbaa 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -1,9 +1,9 @@ mod fixtures; use crate::{ - SQLiteArithmeticOp, SQLiteCompareOp, SQLiteInOp, SQLiteJoinKind, SQLiteJoinReason, - SQLiteLiteral, SQLiteOrder, SQLiteOrderDirection, SQLiteUnaryArithmeticOp, SQLiteValueExpr, - SQLiteValueRole, SQLiteWhereExpr, plan_select, + SQLiteArithmeticOp, SQLiteCastTarget, SQLiteCompareOp, SQLiteInOp, SQLiteJoinKind, + SQLiteJoinReason, SQLiteLiteral, SQLiteOrder, SQLiteOrderDirection, SQLiteUnaryArithmeticOp, + SQLiteValueExpr, SQLiteValueRole, SQLiteWhereExpr, plan_select, }; use alloc::boxed::Box; use alloc::string::ToString; @@ -1315,6 +1315,45 @@ fn sqlite_select_plan_can_filter_arithmetic_expr_compared_to_int_literal() { } } +#[test] +fn sqlite_select_plan_can_filter_cast_expr_compared_to_float_literal() { + let cast = query_ir::ValueExpr::Cast(query_ir::CastExpr::new( + post_view_count_path_value(), + schema_model::ScalarType::Float64, + )); + let filter = query_ir::CompareExpr::new( + cast, + query_ir::CompareOp::Ge, + query_ir::ValueExpr::Literal(Literal::Float64(10.5)), + ); + + let ir = SelectQuery::new( + post_type(), + ResolvedShape::new(post_type(), vec![]), + Some(query_ir::Expr::Compare(filter)), + vec![], + None, + None, + ); + + let plan = plan_select(&ir); + + let Some(SQLiteWhereExpr::Compare(compare)) = plan.filter() else { + panic!("expected compare filter"); + }; + + let SQLiteValueExpr::Cast(cast) = compare.left() else { + panic!("filter left side should be a cast expression"); + }; + assert_eq!(cast.target(), SQLiteCastTarget::Float64); + assert_column_value(cast.operand(), "root", "view_count"); + + match compare.right() { + SQLiteValueExpr::Literal(SQLiteLiteral::Float64(value)) => assert_eq!(*value, 10.5), + _ => panic!("comparison right side should be a float literal"), + } +} + #[test] fn sqlite_select_plan_can_filter_unary_arithmetic_expr_compared_to_int_literal() { let unary = query_ir::ValueExpr::UnaryArithmetic(UnaryArithmeticExpr::new( diff --git a/engine/sqlite-query-sqlgen/src/tests/mod.rs b/engine/sqlite-query-sqlgen/src/tests/mod.rs index a708965..ae73680 100644 --- a/engine/sqlite-query-sqlgen/src/tests/mod.rs +++ b/engine/sqlite-query-sqlgen/src/tests/mod.rs @@ -322,6 +322,68 @@ fn sqlite_sqlgen_can_render_arithmetic_filter_compared_to_float_literal() { ); } +#[test] +fn sqlite_sqlgen_can_render_cast_filter_compared_to_float_literal() { + let cast = query_ir::ValueExpr::Cast(query_ir::CastExpr::new( + post_view_count_path_value(), + schema_model::ScalarType::Float64, + )); + let filter = query_ir::Expr::Compare(query_ir::CompareExpr::new( + cast, + query_ir::CompareOp::Ge, + query_ir::ValueExpr::Literal(query_ir::Literal::Float64(10.5)), + )); + + let ir = post_query_with_filter(filter); + let plan = sqlite_query_plan::plan_select(&ir); + + let statement = render_select(&plan); + + assert_eq!( + statement.sql(), + "SELECT \"root\".\"title\" FROM \"post\" AS \"root\" WHERE CAST(\"root\".\"view_count\" AS REAL) >= ?" + ); + + assert_eq!(statement.bind_values(), &[SQLiteBindValue::Float64(10.5)]); +} + +#[test] +fn sqlite_sqlgen_can_render_cast_arithmetic_filter_with_bind_order() { + let cast = query_ir::ValueExpr::Cast(query_ir::CastExpr::new( + post_view_count_path_value(), + schema_model::ScalarType::Float64, + )); + let arithmetic = query_ir::ValueExpr::Arithmetic(query_ir::ArithmeticExpr::new( + cast, + query_ir::ArithmeticOp::Div, + query_ir::ValueExpr::Literal(query_ir::Literal::Float64(2.0)), + schema_model::ScalarType::Float64, + )); + let filter = query_ir::Expr::Compare(query_ir::CompareExpr::new( + arithmetic, + query_ir::CompareOp::Ge, + query_ir::ValueExpr::Literal(query_ir::Literal::Float64(10.5)), + )); + + let ir = post_query_with_filter(filter); + let plan = sqlite_query_plan::plan_select(&ir); + + let statement = render_select(&plan); + + assert_eq!( + statement.sql(), + "SELECT \"root\".\"title\" FROM \"post\" AS \"root\" WHERE (CAST(\"root\".\"view_count\" AS REAL) / ?) >= ?" + ); + + assert_eq!( + statement.bind_values(), + &[ + SQLiteBindValue::Float64(2.0), + SQLiteBindValue::Float64(10.5) + ] + ); +} + #[test] fn sqlite_sqlgen_can_render_arithmetic_filter_with_joined_operand() { let arithmetic = query_ir::ValueExpr::Arithmetic(query_ir::ArithmeticExpr::new( diff --git a/tests/query-pipeline/tests/select_execution.rs b/tests/query-pipeline/tests/select_execution.rs index 381fa6b..d88c422 100644 --- a/tests/query-pipeline/tests/select_execution.rs +++ b/tests/query-pipeline/tests/select_execution.rs @@ -247,6 +247,23 @@ fn select_pipeline_renders_arithmetic_order_from_query_text() { ); } +#[test] +fn select_pipeline_renders_numeric_cast_filter_from_query_text() { + let statement = render_query(r#"select Post { title } filter f64(.view_count) / 2.0 >= 10.5"#); + + assert_eq!( + statement.sql(), + "SELECT \"root\".\"title\" FROM \"post\" AS \"root\" WHERE (CAST(\"root\".\"view_count\" AS REAL) / ?) >= ?" + ); + assert_eq!( + statement.bind_values(), + &[ + SQLiteBindValue::Float64(2.0), + SQLiteBindValue::Float64(10.5), + ] + ); +} + #[test] fn select_pipeline_renders_computed_projection_from_query_text() { let statement = render_query(r#"select Post { score := .view_count + 1 }"#); @@ -394,6 +411,22 @@ fn select_pipeline_executes_root_scalar_arithmetic_filter() { ); } +#[test] +fn select_pipeline_executes_root_scalar_numeric_cast_filter() { + let result = execute_query( + r#"select Post { title } filter f64(.view_count) / 2.0 >= 10.0 order by .title asc"#, + ); + + assert_eq!(result.columns(), &["title".to_string()]); + assert_eq!( + result.rows(), + &[ + vec![SQLiteCellValue::Text("Archived".to_string())], + vec![SQLiteCellValue::Text("Published".to_string())], + ] + ); +} + #[test] fn select_pipeline_executes_single_link_unary_arithmetic_filter() { let result =