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-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-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/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-parser/src/tests/mod.rs b/engine/query-parser/src/tests/mod.rs index 7e4d786..25e2a05 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,62 @@ 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_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 13a87a2..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(_) ) } @@ -249,6 +251,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(), + }), } } @@ -381,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(), + }), } } @@ -424,6 +432,9 @@ 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(), }), @@ -436,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, @@ -620,6 +668,7 @@ 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::And(_, _) @@ -631,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 { @@ -863,6 +934,9 @@ 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(), }), @@ -878,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, @@ -928,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()), } } @@ -975,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 => { @@ -994,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()), } } @@ -1009,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..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; @@ -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"), @@ -1310,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( @@ -1491,6 +1535,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 +1618,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 +1661,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 +1703,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 +1798,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 +1841,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 +2022,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 +2039,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})") + } } } 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/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 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 =