From 247eb1cae776105d41236ac1a96f542ca29738f3 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Fri, 3 Jul 2026 14:55:00 +0900 Subject: [PATCH 1/4] Document string query functions Assisted-by: Codex:gpt-5.5 --- spec/ir.md | 59 ++++++++++++++++++++++++------ spec/query.md | 75 +++++++++++++++++++++++++++++++-------- spec/sqlite-query-plan.md | 45 ++++++++++++++++++++--- 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/spec/ir.md b/spec/ir.md index 2c23abb..9ac32ca 100644 --- a/spec/ir.md +++ b/spec/ir.md @@ -217,11 +217,11 @@ contains the computed field. A computed field inside a nested link shape uses the nested shape source object, not the root select object. The first computed projection milestone accepts scalar numeric arithmetic -expressions over scalar paths and numeric literals. Boolean expressions, -membership expressions, link values, many-cardinality paths, `null`, function -calls, and subqueries are rejected before SQLite planning. Literal-only -computed projections are also rejected in this milestone because they do not -depend on the current row. +expressions and supported built-in value functions over scalar paths and scalar +literals. Boolean expressions, membership expressions, link values, +many-cardinality paths, `null`, unsupported function calls, and subqueries are +rejected before SQLite planning. Literal-only computed projections are also +rejected in this milestone because they do not depend on the current row. Output names must be unique within one `ResolvedShape`. This rule applies across schema-backed fields and computed fields. Nested shapes have independent @@ -281,6 +281,7 @@ Minimum variants: - arithmetic - unary arithmetic - cast +- string function - comparison - membership - boolean `and` @@ -295,7 +296,8 @@ 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, numeric cast values, and boolean predicate results. +arithmetic scalar values, numeric cast values, string function 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 @@ -435,6 +437,40 @@ 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. +### `StringFunctionExpr` + +Represents a resolved built-in string value function. + +Minimum fields: + +- function kind +- ordered argument expressions +- ordered argument scalar types +- result cardinality + +Supported function kinds in the first string function milestone: + +- `concat` +- `str` + +`concat` accepts two or more scalar `str` value expressions and returns `str`. +The resolver must reject non-string arguments, `null`, object values, link +values, and many-cardinality operands before IR construction. `concat` +cardinality is null-propagating: if any argument is optional, the result is +optional; otherwise the result is required. + +`str` accepts exactly one scalar value expression and returns `str`. The +accepted source scalar types are `str`, `int64`, `float64`, `bool`, `uuid`, and +`datetime`. The resolver must reject `null`, object values, link values, and +many-cardinality operands before IR construction. `str` cardinality follows the +operand cardinality. + +The source syntax for these operations is function-call syntax, but Semantic IR +stores the resolved operation as a string function expression rather than an +opaque function call or SQL fragment. Backend-specific text conversion rules, +including SQLite `CAST` or boolean rendering choices, belong to backend +planning and SQL generation. + ### `CompareExpr` Minimum fields: @@ -510,7 +546,8 @@ accepted Semantic IR: - `SubqueryExpr` 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 +nodes, such as `CastExpr` for `i64(expr)` and `f64(expr)` or +`StringFunctionExpr` for `concat(...)` and `str(expr)`. It must reject unsupported forms with diagnostics. Do not pass an unsupported expression through IR as an opaque node. @@ -523,10 +560,10 @@ Minimum fields: - value expression - direction: `asc` or `desc` -The order value must resolve to a scalar `ValueExpr`. Supported order values in -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. +The order value must resolve to a scalar `ValueExpr`. Supported order values +are resolved scalar paths, numeric arithmetic expressions over scalar paths and +numeric literals, unary arithmetic, numeric casts, and string functions 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 8d099a3..32fc46c 100644 --- a/spec/query.md +++ b/spec/query.md @@ -104,8 +104,8 @@ primary_expr := literal | path | "(" expr ")" MVP. Computed projection expressions are value expressions. The first computed -projection milestone accepts numeric arithmetic expressions over scalar paths -and numeric literals: +projection milestone accepts numeric arithmetic expressions and supported +built-in value functions over scalar paths and scalar literals: ```text select Post { @@ -137,8 +137,8 @@ milestone. They are output fields only. The resolver rejects boolean expressions, membership expressions, `null`, link values, many-cardinality paths, and literal-only computed projections before -SQLite planning. Function calls and subqueries remain reserved until their own -issues define resolver rules. +SQLite planning. Unsupported function calls and subqueries remain reserved +until their own issues define resolver rules. ### Filters @@ -283,9 +283,50 @@ 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. +the resolver only accepts the built-in numeric casts and string functions listed +in this document. Unsupported function names and unsupported arities are +rejected before Semantic IR construction. + +### String Functions + +String operations use named built-in functions. Gelite does not overload `+` +for string concatenation and does not expose SQLite `||` directly in query +syntax. + +Supported string value functions: + +- `concat(first, second, ...rest) -> str` +- `str(value) -> str` + +`concat` accepts two or more scalar string value expressions. Every argument +must resolve to `str`; numeric, boolean, uuid, datetime, `null`, object, link, +and many-cardinality arguments are rejected before SQLite planning. The result +is `str`. Cardinality is null-propagating: if any argument is optional, the +result is optional; otherwise the result is required. + +`str` accepts exactly one scalar value expression. It accepts `str`, `int64`, +`float64`, `bool`, `uuid`, and `datetime` operands and rejects `null`, object, +link, and many-cardinality operands before SQLite planning. `str` is an +explicit conversion; it does not enable implicit casts for comparisons, +membership checks, arithmetic, or concatenation. `str` of an optional value is +optional. `str` of a required value or scalar literal is required. + +`str` uses Gelite conversion semantics, not backend-default text coercion. +`str` of a `str`, `uuid`, or `datetime` value returns its canonical stored text +form. `str` of `int64` or `float64` returns the backend-rendered decimal text +for that numeric value. `str` of `bool` returns `"true"` or `"false"`. + +String functions 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, order expressions must refer to +the current row rather than being literal-only, and computed projections must +depend on the current row. + +Deferred string functions include `length`, `lower`, `upper`, `trim`, `ltrim`, +`rtrim`, `replace`, `substr`, `contains`, `starts_with`, and `ends_with`. +These names remain reserved until their resolver rules and backend lowering are +specified. ### Ordering @@ -296,6 +337,7 @@ Supported order values: - scalar paths - numeric arithmetic expressions over scalar paths, numeric literals, and numeric casts +- supported string functions that refer to the current row Examples: @@ -304,6 +346,7 @@ 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 by concat(.title, " draft") asc ``` Order expressions must resolve to scalar values. Boolean expressions such as @@ -354,15 +397,15 @@ expr_list := expr ("," expr)* subquery_expr := "(" select_stmt ")" ``` -Only path, literal, arithmetic, numeric cast function calls, comparison, +Only path, literal, arithmetic, supported built-in 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` 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. +ordering accepts scalar order values, and computed projection accepts value +expressions that refer to the current row. `function_call` is currently +accepted only for built-in numeric casts and supported string functions. 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 @@ -401,6 +444,7 @@ The MVP supports: - numeric arithmetic expressions used as comparison or membership operands - unary numeric arithmetic expressions - explicit numeric casts with `i64(expr)` and `f64(expr)` +- string functions with `concat(...)` and `str(expr)` - scalar membership checks against non-empty lists of non-null scalar value expressions - boolean composition @@ -413,8 +457,9 @@ The MVP does not support: - aggregation - `exists` - subquery `in` -- arbitrary function calls other than supported built-in numeric casts +- arbitrary function calls other than supported built-ins - implicit numeric casts +- implicit string casts - path scoping with aliases ## Insert @@ -611,7 +656,7 @@ These are intentionally out of scope until the end-to-end path is stable: - aggregation - grouping - pagination cursors -- arbitrary function calls beyond supported built-in numeric casts +- arbitrary function calls beyond supported built-ins - subqueries - query parameters - upsert diff --git a/spec/sqlite-query-plan.md b/spec/sqlite-query-plan.md index 4afeaf6..8d0065a 100644 --- a/spec/sqlite-query-plan.md +++ b/spec/sqlite-query-plan.md @@ -214,13 +214,14 @@ Predicates and later order/projection planning use structured SQLite value expressions. The plan should keep these expressions as data until `sqlite-query-sqlgen` serializes them. -Minimum supported forms for the arithmetic filter milestone: +Minimum supported forms for the current value-expression milestones: - column reference - literal - arithmetic expression - unary arithmetic expression - cast expression +- string function 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 @@ -298,6 +299,39 @@ SQL generation renders numeric casts as SQLite `CAST` expressions: The generated SQL should keep the cast expression structured until rendering. Do not store raw SQL fragments in the plan. +### `SQLiteStringFunctionExpr` + +Minimum fields: + +- function kind +- ordered SQLite value expression arguments +- ordered argument scalar types + +Supported function kinds in the first string function milestone: + +- `concat` +- `str` + +The SQLite planner receives only resolver-accepted string functions. It should +not repeat Gelite arity, type, or cardinality validation. Its responsibility is +to lower each argument to a SQLite value expression, preserve argument order and +bind order, and carry enough scalar type information for SQL generation to +render Gelite string conversion semantics. + +SQL generation renders `concat` as SQLite concatenation with `||`, using +parentheses to preserve grouping when needed. The query language does not expose +`||` directly. + +SQL generation renders `str` according to Gelite conversion semantics: + +- `str`, `uuid`, and `datetime` operands use their stored text expression +- `int64` and `float64` operands use `CAST( AS TEXT)` +- `bool` operands use a `CASE` expression that returns `'true'` for true and + `'false'` for false + +The generated SQL should keep string function expressions structured until +rendering. Do not store raw SQL fragments in the plan. + ## Ordering Model ### `SQLiteOrder` @@ -308,10 +342,11 @@ Minimum fields: - direction 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, unary numeric arithmetic -expressions, and numeric cast expressions. Ordering expressions should only -reference values already reachable through the planned join tree. +Supported order values in the arithmetic and string function milestones are +scalar column references, numeric arithmetic expressions, unary numeric +arithmetic expressions, numeric cast expressions, and supported string function +expressions. Ordering expressions should only reference values already +reachable through the planned join tree. ## Select Value Model From 65824e07db5cca2996d1015e4203dcd2ffc7ca1f Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Fri, 3 Jul 2026 15:08:00 +0900 Subject: [PATCH 2/4] Support query string functions Assisted-by: Codex:gpt-5.5 --- engine/query-ir/src/lib.rs | 63 +++++ engine/query-ir/src/tests/mod.rs | 42 +++- engine/query-parser/src/tests/mod.rs | 46 ++++ engine/query-resolver/src/lib.rs | 232 +++++++++++++++++- engine/query-resolver/src/tests/mod.rs | 140 +++++++++++ engine/sqlite-query-plan/src/lib.rs | 84 ++++++- engine/sqlite-query-plan/src/tests/mod.rs | 90 ++++++- engine/sqlite-query-sqlgen/Cargo.toml | 2 +- engine/sqlite-query-sqlgen/src/lib.rs | 48 +++- engine/sqlite-query-sqlgen/src/tests/mod.rs | 75 ++++++ .../query-pipeline/tests/select_execution.rs | 32 +++ 11 files changed, 845 insertions(+), 9 deletions(-) diff --git a/engine/query-ir/src/lib.rs b/engine/query-ir/src/lib.rs index 789f22b..8b9e04e 100644 --- a/engine/query-ir/src/lib.rs +++ b/engine/query-ir/src/lib.rs @@ -477,6 +477,69 @@ pub enum ValueExpr { Arithmetic(ArithmeticExpr), UnaryArithmetic(UnaryArithmeticExpr), Cast(CastExpr), + StringFunction(StringFunctionExpr), +} + +/// Resolved built-in string value function. +#[derive(Debug, Clone, PartialEq)] +pub struct StringFunctionExpr { + kind: StringFunctionKind, + args: Vec, + cardinality: Cardinality, +} + +impl StringFunctionExpr { + pub fn new( + kind: StringFunctionKind, + args: Vec, + cardinality: Cardinality, + ) -> Self { + Self { + kind, + args, + cardinality, + } + } + + pub fn kind(&self) -> StringFunctionKind { + self.kind + } + + pub fn args(&self) -> &[StringFunctionArg] { + &self.args + } + + pub fn cardinality(&self) -> Cardinality { + self.cardinality + } +} + +/// Built-in string value functions accepted by Semantic IR. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StringFunctionKind { + Concat, + Str, +} + +/// One resolved string function argument. +#[derive(Debug, Clone, PartialEq)] +pub struct StringFunctionArg { + value: ValueExpr, + scalar_type: ScalarType, +} + +impl StringFunctionArg { + pub fn new(value: ValueExpr, scalar_type: ScalarType) -> Self { + Self { value, scalar_type } + } + + pub fn value(&self) -> &ValueExpr { + &self.value + } + + pub fn scalar_type(&self) -> ScalarType { + self.scalar_type + } } /// Resolved explicit scalar cast value expression. diff --git a/engine/query-ir/src/tests/mod.rs b/engine/query-ir/src/tests/mod.rs index 6a1e6ff..76f3041 100644 --- a/engine/query-ir/src/tests/mod.rs +++ b/engine/query-ir/src/tests/mod.rs @@ -4,7 +4,8 @@ use crate::{ ArithmeticExpr, ArithmeticOp, CastExpr, CompareExpr, CompareOp, Expr, InExpr, InOp, Literal, OrderDirection, OrderExpr, ResolvedComputedField, ResolvedPath, ResolvedPathError, ResolvedPathStep, ResolvedPathStepKind, ResolvedShape, ResolvedShapeField, ResolvedShapeItem, - SelectQuery, UnaryArithmeticExpr, UnaryArithmeticOp, ValueExpr, + SelectQuery, StringFunctionArg, StringFunctionExpr, StringFunctionKind, UnaryArithmeticExpr, + UnaryArithmeticOp, ValueExpr, }; use alloc::boxed::Box; use alloc::string::ToString; @@ -113,6 +114,35 @@ fn cast_expr_can_store_operand_and_target_type() { assert_eq!(cast.target_type(), ScalarType::Float64); } +#[test] +fn string_function_expr_can_store_kind_arguments_types_and_cardinality() { + let expr = ValueExpr::StringFunction(StringFunctionExpr::new( + StringFunctionKind::Concat, + vec![ + StringFunctionArg::new( + ValueExpr::Literal(Literal::String("Hello".to_string())), + ScalarType::Str, + ), + StringFunctionArg::new(post_title_path_value(), ScalarType::Str), + ], + Cardinality::Required, + )); + + let ValueExpr::StringFunction(function) = expr else { + panic!("value expression should store a string function"); + }; + + assert_eq!(function.kind(), StringFunctionKind::Concat); + assert_eq!(function.cardinality(), Cardinality::Required); + assert_eq!(function.args().len(), 2); + assert_eq!(function.args()[0].scalar_type(), ScalarType::Str); + assert_eq!( + function.args()[0].value(), + &ValueExpr::Literal(Literal::String("Hello".to_string())) + ); + assert_eq!(function.args()[1].scalar_type(), ScalarType::Str); +} + #[test] fn resolved_shape_can_contain_link_field_with_child_shape() { let author_shape_field = ResolvedShapeField::new( @@ -330,6 +360,7 @@ fn resolved_select_query_can_store_order_by_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"), + ValueExpr::StringFunction(_) => panic!("order by should reference a resolved path"), } } @@ -367,6 +398,7 @@ fn resolved_select_query_can_store_filter_compare_expr() { panic!("filter left side should reference a resolved path") } ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), + ValueExpr::StringFunction(_) => panic!("filter left side should reference a resolved path"), } match compare.right() { @@ -378,6 +410,7 @@ fn resolved_select_query_can_store_filter_compare_expr() { 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"), + ValueExpr::StringFunction(_) => panic!("filter right side should store a literal"), } } @@ -412,6 +445,7 @@ fn resolved_select_query_can_store_filter_compare_int_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"), + ValueExpr::StringFunction(_) => panic!("filter right side should store a literal"), } } @@ -446,6 +480,7 @@ fn resolved_select_query_can_store_filter_compare_bool_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"), + ValueExpr::StringFunction(_) => panic!("filter right side should store a literal"), } } @@ -504,6 +539,7 @@ fn resolved_select_query_can_store_filter_is_null_expr() { panic!("filter left side should reference a resolved path") } ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), + ValueExpr::StringFunction(_) => panic!("filter left side should reference a resolved path"), } } @@ -538,6 +574,7 @@ fn resolved_select_query_can_store_filter_is_not_null_expr() { panic!("filter left side should reference a resolved path") } ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), + ValueExpr::StringFunction(_) => panic!("filter left side should reference a resolved path"), } } @@ -577,6 +614,7 @@ fn resolved_select_query_can_store_filter_in_expr() { panic!("filter left side should reference a resolved path") } ValueExpr::Cast(_) => panic!("filter left side should reference a resolved path"), + ValueExpr::StringFunction(_) => panic!("filter left side should reference a resolved path"), } assert_eq!(in_expr.op(), InOp::In); @@ -640,6 +678,7 @@ fn value_expr_can_reference_resolved_path() { panic!("value expression should reference a resolved path") } ValueExpr::Cast(_) => panic!("value expression should reference a resolved path"), + ValueExpr::StringFunction(_) => panic!("value expression should reference a resolved path"), } } @@ -656,6 +695,7 @@ fn value_expr_can_store_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"), + ValueExpr::StringFunction(_) => 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 25e2a05..1f23819 100644 --- a/engine/query-parser/src/tests/mod.rs +++ b/engine/query-parser/src/tests/mod.rs @@ -717,6 +717,52 @@ fn parser_preserves_function_call_argument_count() { } } +#[test] +fn parser_preserves_variadic_concat_arguments() { + let query = parse_select( + "select Post { title } filter concat(.title, \" - \", .subtitle) = \"Hello - Draft\"", + ) + .expect("query should parse"); + + let filter = query.filter().expect("query should have filter"); + + match filter { + Expr::Compare(compare) => { + let Expr::FunctionCall(function) = compare.left() else { + panic!("left side should be a function call"); + }; + + assert_eq!(function.name(), "concat"); + assert_eq!(function.args().len(), 3); + assert_path_expr(&function.args()[0], &["title"]); + assert_literal_expr(&function.args()[1], &Literal::String(" - ".to_string())); + assert_path_expr(&function.args()[2], &["subtitle"]); + } + _ => panic!("filter should be compare expression"), + } +} + +#[test] +fn parser_preserves_str_function_call_argument() { + let query = parse_select("select Post { title } filter str(.published) = \"true\"") + .expect("query should parse"); + + let filter = query.filter().expect("query should have filter"); + + match filter { + Expr::Compare(compare) => { + let Expr::FunctionCall(function) = compare.left() else { + panic!("left side should be a function call"); + }; + + assert_eq!(function.name(), "str"); + assert_eq!(function.args().len(), 1); + assert_path_expr(&function.args()[0], &["published"]); + } + _ => 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 15420f4..a2a1cc1 100644 --- a/engine/query-resolver/src/lib.rs +++ b/engine/query-resolver/src/lib.rs @@ -389,6 +389,9 @@ fn resolve_path_value_expr( query_ir::ValueExpr::Cast(_) => Err(ResolveError::UnsupportedExpr { expr_type: "null comparison value".to_string(), }), + query_ir::ValueExpr::StringFunction(_) => Err(ResolveError::UnsupportedExpr { + expr_type: "null comparison value".to_string(), + }), } } @@ -451,6 +454,21 @@ fn resolve_typed_function_call_expr( catalog: &schema_model::SchemaCatalog, source_object_type: &schema_model::ObjectTypeRef, function: &query_ast::FunctionCallExpr, +) -> Result { + match function.name() { + "i64" | "f64" => resolve_typed_numeric_cast_expr(catalog, source_object_type, function), + "concat" => resolve_typed_concat_expr(catalog, source_object_type, function), + "str" => resolve_typed_str_expr(catalog, source_object_type, function), + _ => Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }), + } +} + +fn resolve_typed_numeric_cast_expr( + catalog: &schema_model::SchemaCatalog, + source_object_type: &schema_model::ObjectTypeRef, + function: &query_ast::FunctionCallExpr, ) -> Result { let target_type = numeric_cast_target(function.name())?; @@ -474,6 +492,85 @@ fn resolve_typed_function_call_expr( }) } +fn resolve_typed_concat_expr( + catalog: &schema_model::SchemaCatalog, + source_object_type: &schema_model::ObjectTypeRef, + function: &query_ast::FunctionCallExpr, +) -> Result { + if function.args().len() < 2 { + return Err(ResolveError::UnsupportedExpr { + expr_type: "concat arity".to_string(), + }); + } + + let mut args = Vec::new(); + let mut cardinality = schema_model::Cardinality::Required; + + for arg in function.args() { + let typed = resolve_typed_value_expr(catalog, source_object_type, arg)?; + let scalar_type = source_scalar_type(typed.source); + + if value_expr_is_null_literal(&typed.value) { + return Err(ResolveError::UnsupportedExpr { + expr_type: "concat null argument".to_string(), + }); + } + + if scalar_type != schema_model::ScalarType::Str { + return Err(type_mismatch_error( + schema_model::ScalarType::Str, + scalar_type, + )); + } + + cardinality = combine_value_cardinality(cardinality, value_expr_cardinality(&typed.value)?); + args.push(query_ir::StringFunctionArg::new(typed.value, scalar_type)); + } + + Ok(TypedValueExpr { + value: query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Concat, + args, + cardinality, + )), + source: ValueSource::Computed(schema_model::ScalarType::Str), + }) +} + +fn resolve_typed_str_expr( + catalog: &schema_model::SchemaCatalog, + source_object_type: &schema_model::ObjectTypeRef, + function: &query_ast::FunctionCallExpr, +) -> Result { + if function.args().len() != 1 { + return Err(ResolveError::UnsupportedExpr { + expr_type: "str arity".to_string(), + }); + } + + let typed = resolve_typed_value_expr(catalog, source_object_type, &function.args()[0])?; + + if value_expr_is_null_literal(&typed.value) { + return Err(ResolveError::UnsupportedExpr { + expr_type: "str null operand".to_string(), + }); + } + + let scalar_type = source_scalar_type(typed.source); + let cardinality = value_expr_cardinality(&typed.value)?; + let mut args = Vec::new(); + args.push(query_ir::StringFunctionArg::new(typed.value, scalar_type)); + + Ok(TypedValueExpr { + value: query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Str, + args, + cardinality, + )), + source: ValueSource::Computed(schema_model::ScalarType::Str), + }) +} + fn numeric_cast_target(name: &str) -> Result { match name { "i64" => Ok(schema_model::ScalarType::Int64), @@ -682,6 +779,19 @@ fn resolve_membership_item(expr: &query_ast::Expr) -> Result Result { + match function.name() { + "i64" | "f64" => resolve_membership_numeric_cast(function), + "concat" => resolve_membership_concat(function), + "str" => resolve_membership_str(function), + _ => Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }), + } +} + +fn resolve_membership_numeric_cast( + function: &query_ast::FunctionCallExpr, ) -> Result { let target_type = numeric_cast_target(function.name())?; @@ -702,6 +812,78 @@ fn resolve_membership_function_call( }) } +fn resolve_membership_concat( + function: &query_ast::FunctionCallExpr, +) -> Result { + if function.args().len() < 2 { + return Err(ResolveError::UnsupportedExpr { + expr_type: "concat arity".to_string(), + }); + } + + let mut args = Vec::new(); + + for arg in function.args() { + let typed = resolve_membership_item(arg)?; + let scalar_type = source_scalar_type(typed.source); + + if value_expr_is_null_literal(&typed.value) { + return Err(ResolveError::UnsupportedExpr { + expr_type: "concat null argument".to_string(), + }); + } + + if scalar_type != schema_model::ScalarType::Str { + return Err(type_mismatch_error( + schema_model::ScalarType::Str, + scalar_type, + )); + } + + args.push(query_ir::StringFunctionArg::new(typed.value, scalar_type)); + } + + Ok(TypedValueExpr { + value: query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Concat, + args, + schema_model::Cardinality::Required, + )), + source: ValueSource::Computed(schema_model::ScalarType::Str), + }) +} + +fn resolve_membership_str( + function: &query_ast::FunctionCallExpr, +) -> Result { + if function.args().len() != 1 { + return Err(ResolveError::UnsupportedExpr { + expr_type: "str arity".to_string(), + }); + } + + let typed = resolve_membership_item(&function.args()[0])?; + + if value_expr_is_null_literal(&typed.value) { + return Err(ResolveError::UnsupportedExpr { + expr_type: "str null operand".to_string(), + }); + } + + let scalar_type = source_scalar_type(typed.source); + let mut args = Vec::new(); + args.push(query_ir::StringFunctionArg::new(typed.value, scalar_type)); + + Ok(TypedValueExpr { + value: query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Str, + args, + schema_model::Cardinality::Required, + )), + source: ValueSource::Computed(schema_model::ScalarType::Str), + }) +} + fn resolve_membership_unary_arithmetic( unary: &query_ast::UnaryArithmeticExpr, ) -> Result { @@ -1020,6 +1202,30 @@ fn ensure_order_value_is_single_cardinality( ensure_order_value_is_single_cardinality(unary.operand()) } query_ir::ValueExpr::Cast(cast) => ensure_order_value_is_single_cardinality(cast.operand()), + query_ir::ValueExpr::StringFunction(function) => { + for arg in function.args() { + ensure_order_value_is_single_cardinality(arg.value())?; + } + + Ok(()) + } + } +} + +fn combine_value_cardinality( + left: schema_model::Cardinality, + right: schema_model::Cardinality, +) -> schema_model::Cardinality { + match (left, right) { + (schema_model::Cardinality::Many, _) | (_, schema_model::Cardinality::Many) => { + schema_model::Cardinality::Many + } + (schema_model::Cardinality::Optional, _) | (_, schema_model::Cardinality::Optional) => { + schema_model::Cardinality::Optional + } + (schema_model::Cardinality::Required, schema_model::Cardinality::Required) => { + schema_model::Cardinality::Required + } } } @@ -1077,6 +1283,22 @@ fn value_expr_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 => { + Ok(cardinality) + } + } + } + query_ir::ValueExpr::StringFunction(function) => { + let cardinality = function + .args() + .iter() + .map(|arg| value_expr_cardinality(arg.value())) + .try_fold(schema_model::Cardinality::Required, |left, right| { + right.map(|right| combine_value_cardinality(left, right)) + })?; + match cardinality { schema_model::Cardinality::Many => Err(ResolveError::UnsupportedPath), schema_model::Cardinality::Optional | schema_model::Cardinality::Required => { @@ -1097,6 +1319,10 @@ fn value_expr_contains_path(value: &query_ir::ValueExpr) -> bool { } query_ir::ValueExpr::UnaryArithmetic(unary) => value_expr_contains_path(unary.operand()), query_ir::ValueExpr::Cast(cast) => value_expr_contains_path(cast.operand()), + query_ir::ValueExpr::StringFunction(function) => function + .args() + .iter() + .any(|arg| value_expr_contains_path(arg.value())), } } @@ -1112,11 +1338,15 @@ 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, + query_ir::ValueExpr::Cast(_) | query_ir::ValueExpr::StringFunction(_) => false, _ => false, } } +fn value_expr_is_null_literal(value: &query_ir::ValueExpr) -> bool { + matches!(value, query_ir::ValueExpr::Literal(query_ir::Literal::Null)) +} + fn resolve_order_expr( catalog: &schema_model::SchemaCatalog, source_object_type: &schema_model::ObjectTypeRef, diff --git a/engine/query-resolver/src/tests/mod.rs b/engine/query-resolver/src/tests/mod.rs index a861562..4bc1104 100644 --- a/engine/query-resolver/src/tests/mod.rs +++ b/engine/query-resolver/src/tests/mod.rs @@ -996,6 +996,9 @@ fn resolves_filter_compare_path_to_field_and_literal() { panic!("filter left side should resolve to a path") } query_ir::ValueExpr::Cast(_) => panic!("filter left side should resolve to a path"), + query_ir::ValueExpr::StringFunction(_) => { + panic!("filter left side should resolve to a path") + } } assert_eq!(compare.op(), query_ir::CompareOp::Eq); @@ -1377,6 +1380,123 @@ fn rejects_unknown_function_call() { ); } +#[test] +fn resolves_variadic_concat_filter_value_expr() { + let catalog = post_with_optional_subtitle_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr( + "concat", + vec![ + path_expr(&["title"]), + literal_string_expr(" - "), + path_expr(&["subtitle"]), + ], + ), + query_ast::CompareOp::Eq, + literal_string_expr("Hello - Draft"), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + let resolved = resolve_select(&catalog, &query).expect("query should resolve"); + + let query_ir::Expr::Compare(compare) = resolved.filter().expect("filter should exist") else { + panic!("filter should resolve to a compare expression"); + }; + let query_ir::ValueExpr::StringFunction(function) = compare.left() else { + panic!("left side should resolve to a string function"); + }; + + assert_eq!(function.kind(), query_ir::StringFunctionKind::Concat); + assert_eq!(function.cardinality(), schema_model::Cardinality::Optional); + assert_eq!(function.args().len(), 3); + assert_eq!( + function.args()[0].scalar_type(), + schema_model::ScalarType::Str + ); + assert_eq!( + function.args()[1].scalar_type(), + schema_model::ScalarType::Str + ); + assert_eq!( + function.args()[2].scalar_type(), + schema_model::ScalarType::Str + ); +} + +#[test] +fn resolves_str_bool_filter_value_expr() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("str", vec![path_expr(&["published"])]), + query_ast::CompareOp::Eq, + literal_string_expr("true"), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + let resolved = resolve_select(&catalog, &query).expect("query should resolve"); + + let query_ir::Expr::Compare(compare) = resolved.filter().expect("filter should exist") else { + panic!("filter should resolve to a compare expression"); + }; + let query_ir::ValueExpr::StringFunction(function) = compare.left() else { + panic!("left side should resolve to a string function"); + }; + + assert_eq!(function.kind(), query_ir::StringFunctionKind::Str); + assert_eq!(function.cardinality(), schema_model::Cardinality::Required); + assert_eq!(function.args().len(), 1); + assert_eq!( + function.args()[0].scalar_type(), + schema_model::ScalarType::Bool + ); +} + +#[test] +fn rejects_concat_with_non_string_argument() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr( + "concat", + vec![path_expr(&["title"]), path_expr(&["view_count"])], + ), + query_ast::CompareOp::Eq, + literal_string_expr("Hello1"), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::IncompatibleOperandTypes { + expected: "str".to_string(), + actual: "int64".to_string(), + }) + ); +} + +#[test] +fn rejects_concat_without_at_least_two_arguments() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("concat", vec![path_expr(&["title"])]), + query_ast::CompareOp::Eq, + literal_string_expr("Hello"), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "concat arity".to_string(), + }) + ); +} + #[test] fn rejects_numeric_cast_without_exactly_one_argument() { let catalog = post_with_scalar_fields_catalog(); @@ -1679,6 +1799,9 @@ fn resolves_filter_compare_null_literal_to_is_null_expr() { panic!("is null expression should reference a path") } query_ir::ValueExpr::Cast(_) => panic!("is null expression should reference a path"), + query_ir::ValueExpr::StringFunction(_) => { + panic!("is null expression should reference a path") + } } } @@ -1719,6 +1842,9 @@ fn resolves_filter_compare_left_null_literal_to_is_null_expr() { panic!("is null expression should reference a path") } query_ir::ValueExpr::Cast(_) => panic!("is null expression should reference a path"), + query_ir::ValueExpr::StringFunction(_) => { + panic!("is null expression should reference a path") + } } } @@ -1758,6 +1884,9 @@ fn resolves_filter_compare_not_null_literal_to_is_not_null_expr() { panic!("is not null expression should reference a path") } query_ir::ValueExpr::Cast(_) => panic!("is not null expression should reference a path"), + query_ir::ValueExpr::StringFunction(_) => { + panic!("is not null expression should reference a path") + } } } @@ -1797,6 +1926,9 @@ fn resolves_filter_compare_left_not_null_literal_to_is_not_null_expr() { panic!("is not null expression should reference a path") } query_ir::ValueExpr::Cast(_) => panic!("is not null expression should reference a path"), + query_ir::ValueExpr::StringFunction(_) => { + panic!("is not null expression should reference a path") + } } } @@ -1975,6 +2107,9 @@ fn resolves_filter_in_literal_list_to_in_expr() { panic!("in expression left side should be a path") } query_ir::ValueExpr::Cast(_) => panic!("in expression left side should be a path"), + query_ir::ValueExpr::StringFunction(_) => { + panic!("in expression left side should be a path") + } } assert_eq!(in_expr.op(), query_ir::InOp::In); @@ -2594,6 +2729,7 @@ fn resolves_order_path_to_resolved_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"), + query_ir::ValueExpr::StringFunction(_) => panic!("order by should resolve to a path"), } } @@ -3245,6 +3381,9 @@ fn resolves_filter_path_through_single_link_to_scalar_field() { panic!("filter left side should resolve to a path") } query_ir::ValueExpr::Cast(_) => panic!("filter left side should resolve to a path"), + query_ir::ValueExpr::StringFunction(_) => { + panic!("filter left side should resolve to a path") + } } match compare.right() { @@ -3329,5 +3468,6 @@ fn resolves_order_path_through_single_link_to_scalar_field() { 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"), + query_ir::ValueExpr::StringFunction(_) => 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 3beedb1..832fa4d 100644 --- a/engine/sqlite-query-plan/src/lib.rs +++ b/engine/sqlite-query-plan/src/lib.rs @@ -272,7 +272,8 @@ impl SQLiteFieldSelectValue { SQLiteValueExpr::Literal(_) | SQLiteValueExpr::Arithmetic(_) | SQLiteValueExpr::UnaryArithmetic(_) - | SQLiteValueExpr::Cast(_) => { + | SQLiteValueExpr::Cast(_) + | SQLiteValueExpr::StringFunction(_) => { unreachable!("field selected values are always columns") } } @@ -284,7 +285,8 @@ impl SQLiteFieldSelectValue { SQLiteValueExpr::Literal(_) | SQLiteValueExpr::Arithmetic(_) | SQLiteValueExpr::UnaryArithmetic(_) - | SQLiteValueExpr::Cast(_) => { + | SQLiteValueExpr::Cast(_) + | SQLiteValueExpr::StringFunction(_) => { unreachable!("field selected values are always columns") } } @@ -570,6 +572,11 @@ fn collect_root_path_aliases_from_value(value: &query_ir::ValueExpr, aliases: &m query_ir::ValueExpr::Cast(cast) => { collect_root_path_aliases_from_value(cast.operand(), aliases); } + query_ir::ValueExpr::StringFunction(function) => { + for arg in function.args() { + collect_root_path_aliases_from_value(arg.value(), aliases); + } + } } } @@ -967,6 +974,57 @@ pub enum SQLiteValueExpr { Arithmetic(SQLiteArithmeticExpr), UnaryArithmetic(SQLiteUnaryArithmeticExpr), Cast(SQLiteCastExpr), + StringFunction(SQLiteStringFunctionExpr), +} + +/// Backend-specific string value function expression. +#[derive(Debug, Clone, PartialEq)] +pub struct SQLiteStringFunctionExpr { + kind: SQLiteStringFunctionKind, + args: Vec, +} + +impl SQLiteStringFunctionExpr { + pub fn kind(&self) -> SQLiteStringFunctionKind { + self.kind + } + + pub fn args(&self) -> &[SQLiteStringFunctionArg] { + &self.args + } +} + +/// SQLite string function kinds emitted by the planner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SQLiteStringFunctionKind { + Concat, + Str, +} + +impl SQLiteStringFunctionKind { + fn from_ir(kind: query_ir::StringFunctionKind) -> Self { + match kind { + query_ir::StringFunctionKind::Concat => Self::Concat, + query_ir::StringFunctionKind::Str => Self::Str, + } + } +} + +/// One SQLite string function argument. +#[derive(Debug, Clone, PartialEq)] +pub struct SQLiteStringFunctionArg { + value: SQLiteValueExpr, + scalar_type: schema_model::ScalarType, +} + +impl SQLiteStringFunctionArg { + pub fn value(&self) -> &SQLiteValueExpr { + &self.value + } + + pub fn scalar_type(&self) -> schema_model::ScalarType { + self.scalar_type + } } /// Backend-specific scalar cast value expression. @@ -1365,6 +1423,28 @@ fn plan_value_expr( joins: operand.joins, } } + query_ir::ValueExpr::StringFunction(function) => { + let mut args = Vec::new(); + let mut joins = Vec::new(); + + for arg in function.args() { + let planned = + plan_value_expr(arg.value(), source_alias, source_nullable, join_aliases); + joins.extend(planned.joins); + args.push(SQLiteStringFunctionArg { + value: planned.value, + scalar_type: arg.scalar_type(), + }); + } + + PlannedValueExpr { + value: SQLiteValueExpr::StringFunction(SQLiteStringFunctionExpr { + kind: SQLiteStringFunctionKind::from_ir(function.kind()), + args, + }), + joins, + } + } } } diff --git a/engine/sqlite-query-plan/src/tests/mod.rs b/engine/sqlite-query-plan/src/tests/mod.rs index 964bbaa..f7e02bc 100644 --- a/engine/sqlite-query-plan/src/tests/mod.rs +++ b/engine/sqlite-query-plan/src/tests/mod.rs @@ -2,8 +2,8 @@ mod fixtures; use crate::{ SQLiteArithmeticOp, SQLiteCastTarget, SQLiteCompareOp, SQLiteInOp, SQLiteJoinKind, - SQLiteJoinReason, SQLiteLiteral, SQLiteOrder, SQLiteOrderDirection, SQLiteUnaryArithmeticOp, - SQLiteValueExpr, SQLiteValueRole, SQLiteWhereExpr, plan_select, + SQLiteJoinReason, SQLiteLiteral, SQLiteOrder, SQLiteOrderDirection, SQLiteStringFunctionKind, + SQLiteUnaryArithmeticOp, SQLiteValueExpr, SQLiteValueRole, SQLiteWhereExpr, plan_select, }; use alloc::boxed::Box; use alloc::string::ToString; @@ -36,6 +36,7 @@ fn assert_order_column(order: &SQLiteOrder, source_alias: &str, column_name: &st 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"), + SQLiteValueExpr::StringFunction(_) => panic!("order value should be a column"), } } @@ -49,6 +50,7 @@ fn assert_column_value(value: &SQLiteValueExpr, source_alias: &str, column_name: SQLiteValueExpr::Arithmetic(_) => panic!("value should be a column"), SQLiteValueExpr::UnaryArithmetic(_) => panic!("value should be a column"), SQLiteValueExpr::Cast(_) => panic!("value should be a column"), + SQLiteValueExpr::StringFunction(_) => panic!("value should be a column"), } } @@ -60,6 +62,7 @@ fn assert_int_literal_value(value: &SQLiteValueExpr, expected: i64) { SQLiteValueExpr::Arithmetic(_) => panic!("value should be a literal"), SQLiteValueExpr::UnaryArithmetic(_) => panic!("value should be a literal"), SQLiteValueExpr::Cast(_) => panic!("value should be a literal"), + SQLiteValueExpr::StringFunction(_) => panic!("value should be a literal"), } } @@ -1234,6 +1237,9 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_string_literal() { panic!("filter left side should be a column") } SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter left side should be a column") + } } assert_eq!(compare.op(), SQLiteCompareOp::Eq); @@ -1251,6 +1257,9 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_string_literal() { panic!("filter right side should be a literal") } SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter right side should be a literal") + } } } _ => panic!("expected compare filter"), @@ -1354,6 +1363,63 @@ fn sqlite_select_plan_can_filter_cast_expr_compared_to_float_literal() { } } +#[test] +fn sqlite_select_plan_can_filter_string_function_expr() { + let concat = query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Concat, + vec![ + query_ir::StringFunctionArg::new( + post_title_path_value(), + schema_model::ScalarType::Str, + ), + query_ir::StringFunctionArg::new( + query_ir::ValueExpr::Literal(Literal::String("!".to_string())), + schema_model::ScalarType::Str, + ), + ], + schema_model::Cardinality::Required, + )); + let filter = query_ir::CompareExpr::new( + concat, + query_ir::CompareOp::Eq, + query_ir::ValueExpr::Literal(Literal::String("Hello!".to_string())), + ); + + 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::StringFunction(function) = compare.left() else { + panic!("filter left side should be a string function"); + }; + + assert_eq!(function.kind(), SQLiteStringFunctionKind::Concat); + assert_eq!(function.args().len(), 2); + assert_eq!( + function.args()[0].scalar_type(), + schema_model::ScalarType::Str + ); + assert_column_value(function.args()[0].value(), "root", "title"); + assert_eq!( + function.args()[1].scalar_type(), + schema_model::ScalarType::Str + ); + match function.args()[1].value() { + SQLiteValueExpr::Literal(SQLiteLiteral::String(value)) => assert_eq!(value, "!"), + _ => panic!("second concat argument should be a string literal"), + } +} + #[test] fn sqlite_select_plan_can_filter_unary_arithmetic_expr_compared_to_int_literal() { let unary = query_ir::ValueExpr::UnaryArithmetic(UnaryArithmeticExpr::new( @@ -1536,6 +1602,7 @@ fn sqlite_select_plan_can_filter_single_link_scalar_path_equals_string_literal() 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"), + SQLiteValueExpr::StringFunction(_) => panic!("filter left side should be a column"), }, _ => panic!("expected compare filter"), } @@ -1619,6 +1686,9 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_int_literal() { panic!("filter right side should be a literal") } SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter right side should be a literal") + } } } _ => panic!("expected compare filter"), @@ -1662,6 +1732,9 @@ fn sqlite_select_plan_can_filter_root_scalar_field_equals_bool_literal() { panic!("filter right side should be a literal") } SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter right side should be a literal") + } } } _ => panic!("expected compare filter"), @@ -1704,6 +1777,9 @@ fn sqlite_select_plan_can_filter_root_scalar_field_in_literal_list() { panic!("filter left side should be a column") } SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter left side should be a column") + } } assert_eq!(in_expr.op(), SQLiteInOp::In); @@ -1799,6 +1875,9 @@ fn sqlite_select_plan_can_filter_single_link_scalar_path_not_in_literal_list() { panic!("filter left side should be a column") } SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter left side should be a column") + } } assert_eq!(in_expr.op(), SQLiteInOp::NotIn); @@ -1842,6 +1921,7 @@ fn sqlite_select_plan_can_filter_root_scalar_field_is_null() { 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"), + SQLiteValueExpr::StringFunction(_) => panic!("is null value should be a column"), }, _ => panic!("expected is null filter"), } @@ -2023,6 +2103,9 @@ fn sqlite_select_plan_can_filter_implicit_id_equals_string_literal() { panic!("filter left side should be a column") } SQLiteValueExpr::Cast(_) => panic!("filter left side should be a column"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter left side should be a column") + } } assert_eq!(compare.op(), SQLiteCompareOp::Eq); @@ -2040,6 +2123,9 @@ fn sqlite_select_plan_can_filter_implicit_id_equals_string_literal() { panic!("filter right side should be a literal") } SQLiteValueExpr::Cast(_) => panic!("filter right side should be a literal"), + SQLiteValueExpr::StringFunction(_) => { + panic!("filter right side should be a literal") + } } } _ => panic!("expected compare filter"), diff --git a/engine/sqlite-query-sqlgen/Cargo.toml b/engine/sqlite-query-sqlgen/Cargo.toml index af9232d..7039eb6 100644 --- a/engine/sqlite-query-sqlgen/Cargo.toml +++ b/engine/sqlite-query-sqlgen/Cargo.toml @@ -4,8 +4,8 @@ version.workspace = true edition.workspace = true [dependencies] +schema-model = { path = "../schema-model" } sqlite-query-plan = { path = "../sqlite-query-plan" } [dev-dependencies] query-ir = { path = "../query-ir" } -schema-model = { path = "../schema-model" } diff --git a/engine/sqlite-query-sqlgen/src/lib.rs b/engine/sqlite-query-sqlgen/src/lib.rs index 73fd7b4..2fd254d 100644 --- a/engine/sqlite-query-sqlgen/src/lib.rs +++ b/engine/sqlite-query-sqlgen/src/lib.rs @@ -18,8 +18,8 @@ use alloc::vec; use alloc::vec::Vec; use sqlite_query_plan::{ SQLiteArithmeticOp, SQLiteCastTarget, SQLiteCompareOp, SQLiteInOp, SQLiteJoinKind, - SQLiteLiteral, SQLiteOrderDirection, SQLiteSelectPlan, SQLiteUnaryArithmeticOp, - SQLiteValueExpr, SQLiteWhereExpr, + SQLiteLiteral, SQLiteOrderDirection, SQLiteSelectPlan, SQLiteStringFunctionKind, + SQLiteUnaryArithmeticOp, SQLiteValueExpr, SQLiteWhereExpr, }; fn quote_identifier(identifier: &str) -> String { @@ -263,6 +263,50 @@ fn render_value_expr(value: &SQLiteValueExpr, bind_values: &mut Vec match function.kind() { + SQLiteStringFunctionKind::Concat => { + let args = function + .args() + .iter() + .map(|arg| render_value_expr(arg.value(), bind_values)) + .collect::>() + .join(" || "); + + format!("({args})") + } + SQLiteStringFunctionKind::Str => { + let [arg] = function.args() else { + unreachable!("SQLite planner receives only resolver-accepted str arity"); + }; + + render_str_value_expr(arg.value(), arg.scalar_type(), bind_values) + } + }, + } +} + +fn render_str_value_expr( + value: &SQLiteValueExpr, + scalar_type: schema_model::ScalarType, + bind_values: &mut Vec, +) -> String { + match scalar_type { + schema_model::ScalarType::Str + | schema_model::ScalarType::Uuid + | schema_model::ScalarType::DateTime => render_value_expr(value, bind_values), + schema_model::ScalarType::Int64 | schema_model::ScalarType::Float64 => { + let value_sql = render_value_expr(value, bind_values); + + format!("CAST({value_sql} AS TEXT)") + } + schema_model::ScalarType::Bool => { + let null_check_sql = render_value_expr(value, bind_values); + let value_sql = render_value_expr(value, bind_values); + + format!( + "CASE WHEN {null_check_sql} IS NULL THEN NULL WHEN {value_sql} THEN 'true' ELSE 'false' END" + ) + } } } diff --git a/engine/sqlite-query-sqlgen/src/tests/mod.rs b/engine/sqlite-query-sqlgen/src/tests/mod.rs index ae73680..3efb803 100644 --- a/engine/sqlite-query-sqlgen/src/tests/mod.rs +++ b/engine/sqlite-query-sqlgen/src/tests/mod.rs @@ -347,6 +347,81 @@ fn sqlite_sqlgen_can_render_cast_filter_compared_to_float_literal() { assert_eq!(statement.bind_values(), &[SQLiteBindValue::Float64(10.5)]); } +#[test] +fn sqlite_sqlgen_can_render_concat_filter() { + let concat = query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Concat, + vec![ + query_ir::StringFunctionArg::new( + post_title_path_value(), + schema_model::ScalarType::Str, + ), + query_ir::StringFunctionArg::new( + query_ir::ValueExpr::Literal(query_ir::Literal::String("!".to_string())), + schema_model::ScalarType::Str, + ), + ], + schema_model::Cardinality::Required, + )); + let filter = query_ir::Expr::Compare(query_ir::CompareExpr::new( + concat, + query_ir::CompareOp::Eq, + query_ir::ValueExpr::Literal(query_ir::Literal::String("Hello!".to_string())), + )); + + 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 (\"root\".\"title\" || ?) = ?" + ); + assert_eq!( + statement.bind_values(), + &[ + SQLiteBindValue::String("!".to_string()), + SQLiteBindValue::String("Hello!".to_string()) + ] + ); +} + +#[test] +fn sqlite_sqlgen_can_render_str_bool_filter() { + let str_bool = query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Str, + vec![query_ir::StringFunctionArg::new( + query_ir::ValueExpr::Literal(query_ir::Literal::Bool(true)), + schema_model::ScalarType::Bool, + )], + schema_model::Cardinality::Required, + )); + let filter = query_ir::Expr::Compare(query_ir::CompareExpr::new( + str_bool, + query_ir::CompareOp::Eq, + query_ir::ValueExpr::Literal(query_ir::Literal::String("true".to_string())), + )); + + 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 CASE WHEN ? IS NULL THEN NULL WHEN ? THEN 'true' ELSE 'false' END = ?" + ); + assert_eq!( + statement.bind_values(), + &[ + SQLiteBindValue::Bool(true), + SQLiteBindValue::Bool(true), + SQLiteBindValue::String("true".to_string()) + ] + ); +} + #[test] fn sqlite_sqlgen_can_render_cast_arithmetic_filter_with_bind_order() { let cast = query_ir::ValueExpr::Cast(query_ir::CastExpr::new( diff --git a/tests/query-pipeline/tests/select_execution.rs b/tests/query-pipeline/tests/select_execution.rs index d88c422..adc49e0 100644 --- a/tests/query-pipeline/tests/select_execution.rs +++ b/tests/query-pipeline/tests/select_execution.rs @@ -264,6 +264,25 @@ fn select_pipeline_renders_numeric_cast_filter_from_query_text() { ); } +#[test] +fn select_pipeline_renders_string_function_filter_from_query_text() { + let statement = render_query( + r#"select Post { title } filter concat(.title, "!", str(.view_count)) = "Draft!5""#, + ); + + assert_eq!( + statement.sql(), + "SELECT \"root\".\"title\" FROM \"post\" AS \"root\" WHERE (\"root\".\"title\" || ? || CAST(\"root\".\"view_count\" AS TEXT)) = ?" + ); + assert_eq!( + statement.bind_values(), + &[ + SQLiteBindValue::String("!".to_string()), + SQLiteBindValue::String("Draft!5".to_string()), + ] + ); +} + #[test] fn select_pipeline_renders_computed_projection_from_query_text() { let statement = render_query(r#"select Post { score := .view_count + 1 }"#); @@ -427,6 +446,19 @@ fn select_pipeline_executes_root_scalar_numeric_cast_filter() { ); } +#[test] +fn select_pipeline_executes_string_function_filter() { + let result = execute_query( + r#"select Post { title } filter concat(.title, "!", str(.view_count)) = "Draft!5""#, + ); + + assert_eq!(result.columns(), &["title".to_string()]); + assert_eq!( + result.rows(), + &[vec![SQLiteCellValue::Text("Draft".to_string())]] + ); +} + #[test] fn select_pipeline_executes_single_link_unary_arithmetic_filter() { let result = From 7a61b30b4e37768e4f6ff3fd8fc74d755fb8bdd3 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Fri, 3 Jul 2026 15:35:56 +0900 Subject: [PATCH 3/4] Cover string function resolver branches Assisted-by: Codex:gpt-5.5 --- engine/query-resolver/src/tests/mod.rs | 435 ++++++++++++++++++++ engine/sqlite-query-sqlgen/src/tests/mod.rs | 35 ++ 2 files changed, 470 insertions(+) diff --git a/engine/query-resolver/src/tests/mod.rs b/engine/query-resolver/src/tests/mod.rs index 4bc1104..1866251 100644 --- a/engine/query-resolver/src/tests/mod.rs +++ b/engine/query-resolver/src/tests/mod.rs @@ -111,6 +111,41 @@ fn resolves_computed_projection_shape_item() { assert_eq!(arithmetic.op(), query_ir::ArithmeticOp::Add); } +#[test] +fn resolves_computed_projection_concat_expr() { + let catalog = post_with_scalar_fields_catalog(); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::computed( + "label", + function_call_expr( + "concat", + vec![path_expr(&["title"]), literal_string_expr("!")], + ), + )]), + 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(), "label"); + assert_eq!(computed.scalar_type(), schema_model::ScalarType::Str); + assert_eq!(computed.cardinality(), schema_model::Cardinality::Required); + + let query_ir::ValueExpr::StringFunction(function) = computed.value() else { + panic!("computed projection should store a string function expression"); + }; + assert_eq!(function.kind(), query_ir::StringFunctionKind::Concat); + assert_eq!(function.args().len(), 2); +} + #[test] fn resolves_computed_projection_runtime_division_as_optional() { let catalog = post_with_scalar_fields_catalog(); @@ -1453,6 +1488,89 @@ fn resolves_str_bool_filter_value_expr() { ); } +#[test] +fn rejects_str_without_exactly_one_argument() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("str", vec![]), + query_ast::CompareOp::Eq, + literal_string_expr(""), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "str arity".to_string(), + }) + ); +} + +#[test] +fn rejects_str_null_operand() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("str", vec![literal_null_expr()]), + query_ast::CompareOp::Eq, + literal_string_expr(""), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "str null operand".to_string(), + }) + ); +} + +#[test] +fn rejects_concat_null_argument() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr("concat", vec![path_expr(&["title"]), literal_null_expr()]), + query_ast::CompareOp::Eq, + literal_string_expr("Hello"), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "concat null argument".to_string(), + }) + ); +} + +#[test] +fn rejects_null_comparison_against_string_function_value() { + let catalog = post_with_scalar_fields_catalog(); + + let filter = Expr::Compare(CompareExpr::new( + function_call_expr( + "concat", + vec![path_expr(&["title"]), literal_string_expr("!")], + ), + query_ast::CompareOp::Eq, + literal_null_expr(), + )); + + let query = SelectQuery::new("Post", Shape::new(vec![]), Some(filter), vec![], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "null comparison value".to_string(), + }) + ); +} + #[test] fn rejects_concat_with_non_string_argument() { let catalog = post_with_scalar_fields_catalog(); @@ -2122,6 +2240,273 @@ fn resolves_filter_in_literal_list_to_in_expr() { ); } +#[test] +fn resolves_filter_in_concat_literal_item_to_value_expr() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr( + "concat", + vec![literal_string_expr("Dr"), literal_string_expr("aft")], + )], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + 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::StringFunction(function)] = in_expr.right() else { + panic!("membership item should resolve to a string function"); + }; + assert_eq!(function.kind(), query_ir::StringFunctionKind::Concat); + assert_eq!(function.args().len(), 2); +} + +#[test] +fn resolves_filter_in_str_literal_item_to_value_expr() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr("str", vec![literal_bool_expr(true)])], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + 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::StringFunction(function)] = in_expr.right() else { + panic!("membership item should resolve to a string function"); + }; + assert_eq!(function.kind(), query_ir::StringFunctionKind::Str); + assert_eq!( + function.args()[0].scalar_type(), + schema_model::ScalarType::Bool + ); +} + +#[test] +fn rejects_filter_in_unknown_function_item() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr( + "lower", + vec![literal_string_expr("draft")], + )], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + Some(filter), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "function call".to_string(), + }) + ); +} + +#[test] +fn rejects_filter_in_concat_item_without_at_least_two_arguments() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr( + "concat", + vec![literal_string_expr("Draft")], + )], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + Some(filter), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "concat arity".to_string(), + }) + ); +} + +#[test] +fn rejects_filter_in_concat_item_with_null_argument() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr( + "concat", + vec![literal_string_expr("Draft"), literal_null_expr()], + )], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + Some(filter), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "null membership item".to_string(), + }) + ); +} + +#[test] +fn rejects_filter_in_concat_item_with_non_string_argument() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr( + "concat", + vec![literal_string_expr("Draft"), literal_int_expr(1)], + )], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + Some(filter), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::IncompatibleOperandTypes { + expected: "str".to_string(), + actual: "int64".to_string(), + }) + ); +} + +#[test] +fn rejects_filter_in_str_item_without_exactly_one_argument() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr("str", vec![])], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + Some(filter), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "str arity".to_string(), + }) + ); +} + +#[test] +fn rejects_filter_in_str_null_item() { + let catalog = post_with_title_catalog(); + + let filter = Expr::In(InExpr::new( + path_expr(&["title"]), + query_ast::InOp::In, + vec![function_call_expr("str", vec![literal_null_expr()])], + )); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + Some(filter), + vec![], + None, + None, + ); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedExpr { + expr_type: "null membership item".to_string(), + }) + ); +} + #[test] fn resolves_filter_in_int_literal_list_to_in_expr() { let catalog = post_with_scalar_fields_catalog(); @@ -2733,6 +3118,56 @@ fn resolves_order_path_to_resolved_path() { } } +#[test] +fn resolves_order_concat_expr() { + let catalog = post_with_scalar_fields_catalog(); + + let order = query_ast::OrderExpr::new( + function_call_expr( + "concat", + vec![path_expr(&["title"]), literal_string_expr("!")], + ), + query_ast::OrderDirection::Asc, + ); + + let query = SelectQuery::new( + "Post", + Shape::new(vec![ShapeItem::new( + Path::new(vec![PathStep::new("title")]), + None, + )]), + None, + vec![order], + None, + None, + ); + + let resolved = resolve_select(&catalog, &query).expect("select query resolves"); + + let query_ir::ValueExpr::StringFunction(function) = resolved.order_by()[0].value() else { + panic!("order value should resolve to a string function"); + }; + assert_eq!(function.kind(), query_ir::StringFunctionKind::Concat); + assert_eq!(function.args().len(), 2); +} + +#[test] +fn rejects_order_str_expr_through_multi_link() { + let catalog = user_with_posts_catalog(); + + let order = query_ast::OrderExpr::new( + function_call_expr("str", vec![path_expr(&["posts", "view_count"])]), + query_ast::OrderDirection::Asc, + ); + + let query = SelectQuery::new("User", Shape::new(vec![]), None, vec![order], None, None); + + assert_eq!( + resolve_select(&catalog, &query), + Err(ResolveError::UnsupportedPath) + ); +} + #[test] fn resolves_order_numeric_arithmetic_expr() { let catalog = post_with_scalar_fields_catalog(); diff --git a/engine/sqlite-query-sqlgen/src/tests/mod.rs b/engine/sqlite-query-sqlgen/src/tests/mod.rs index 3efb803..fcbb497 100644 --- a/engine/sqlite-query-sqlgen/src/tests/mod.rs +++ b/engine/sqlite-query-sqlgen/src/tests/mod.rs @@ -422,6 +422,41 @@ fn sqlite_sqlgen_can_render_str_bool_filter() { ); } +#[test] +fn sqlite_sqlgen_can_render_str_uuid_filter_as_stored_text() { + let str_uuid = query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( + query_ir::StringFunctionKind::Str, + vec![query_ir::StringFunctionArg::new( + post_title_path_value(), + schema_model::ScalarType::Uuid, + )], + schema_model::Cardinality::Required, + )); + let filter = query_ir::Expr::Compare(query_ir::CompareExpr::new( + str_uuid, + query_ir::CompareOp::Eq, + query_ir::ValueExpr::Literal(query_ir::Literal::String( + "00000000-0000-0000-0000-000000000001".to_string(), + )), + )); + + 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 \"root\".\"title\" = ?" + ); + assert_eq!( + statement.bind_values(), + &[SQLiteBindValue::String( + "00000000-0000-0000-0000-000000000001".to_string() + )] + ); +} + #[test] fn sqlite_sqlgen_can_render_cast_arithmetic_filter_with_bind_order() { let cast = query_ir::ValueExpr::Cast(query_ir::CastExpr::new( From 89d5309d8578d5f3ce9befd6e07c0a733acc1857 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Fri, 3 Jul 2026 15:44:32 +0900 Subject: [PATCH 4/4] Use a vector literal to initialize the vector. --- engine/query-resolver/src/lib.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/engine/query-resolver/src/lib.rs b/engine/query-resolver/src/lib.rs index a2a1cc1..724f955 100644 --- a/engine/query-resolver/src/lib.rs +++ b/engine/query-resolver/src/lib.rs @@ -14,6 +14,7 @@ extern crate alloc; use alloc::boxed::Box; use alloc::string::{String, ToString}; +use alloc::vec; use alloc::vec::Vec; use query_ast::Path; @@ -558,8 +559,7 @@ fn resolve_typed_str_expr( let scalar_type = source_scalar_type(typed.source); let cardinality = value_expr_cardinality(&typed.value)?; - let mut args = Vec::new(); - args.push(query_ir::StringFunctionArg::new(typed.value, scalar_type)); + let args = vec![query_ir::StringFunctionArg::new(typed.value, scalar_type)]; Ok(TypedValueExpr { value: query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new( @@ -871,8 +871,7 @@ fn resolve_membership_str( } let scalar_type = source_scalar_type(typed.source); - let mut args = Vec::new(); - args.push(query_ir::StringFunctionArg::new(typed.value, scalar_type)); + let args = vec![query_ir::StringFunctionArg::new(typed.value, scalar_type)]; Ok(TypedValueExpr { value: query_ir::ValueExpr::StringFunction(query_ir::StringFunctionExpr::new(