From c1175dfaa98d38daff048f17b4bd703fb85279a5 Mon Sep 17 00:00:00 2001 From: Jakob Truelsen Date: Tue, 4 Aug 2026 14:23:29 +0200 Subject: [PATCH 1/4] qusql-type: support row-constructor (a, b, ...) IN (subquery) matching Type row constructor lhs of IN element-wise against a subquery's columns (e.g. UNNEST($1::t[], $2::t[], ...)) or another row constructor's elements, instead of hitting a not-yet-implemented error and requiring the subquery to yield exactly one column. --- qusql-type/src/lib.rs | 47 ++++++++++++++++++++ qusql-type/src/type_expression.rs | 74 ++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/qusql-type/src/lib.rs b/qusql-type/src/lib.rs index bfa0f007..11addf8d 100644 --- a/qusql-type/src/lib.rs +++ b/qusql-type/src/lib.rs @@ -2171,6 +2171,53 @@ mod tests { } } + /// A row-constructor `IN` a subquery that yields matching columns via + /// `UNNEST($1::bigint[], $2::bigint[])`. Reported as a real-world usage that + /// should type-check. + #[test] + fn postgresql_row_in_unnest_subquery() { + let schema_src = " + CREATE TABLE t1 ( + id bigint NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + a bigint NOT NULL, + b bigint NOT NULL + ); + "; + + let opts = TypeOptions::new() + .dialect(SQLDialect::PostgreSQL) + .arguments(SQLArguments::Dollar); + let mut issues = Issues::new(schema_src); + let schema = parse_schemas(schema_src, &mut issues, &opts); + let mut errors = 0; + check_no_errors("schema", schema_src, issues.get(), &mut errors); + + let src = "SELECT id, a, b FROM t1 + WHERE (a, b) IN ( + SELECT * FROM UNNEST($1::bigint[], $2::bigint[]) + )"; + let mut issues = Issues::new(src); + let q = type_statement(&schema, src, &mut issues, &opts); + check_no_errors("query", src, issues.get(), &mut errors); + if let StatementType::Select { columns, arguments } = q { + if columns.len() != 3 { + println!("query: expected 3 columns, got {}", columns.len()); + errors += 1; + } + if arguments.len() != 2 { + println!("query: expected 2 arguments, got {}", arguments.len()); + errors += 1; + } + } else { + println!("query: expected Select"); + errors += 1; + } + + if errors != 0 { + panic!("{errors} errors in postgresql_row_in_unnest_subquery test"); + } + } + /// A writable CTE that unions a range into an `int8multirange` column and then /// `unnest()`s the result. Reported as a real-world usage that should type-check. #[test] diff --git a/qusql-type/src/type_expression.rs b/qusql-type/src/type_expression.rs index f12c17c3..f2604272 100644 --- a/qusql-type/src/type_expression.rs +++ b/qusql-type/src/type_expression.rs @@ -10,7 +10,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use alloc::{boxed::Box, format, string::ToString, sync::Arc, vec}; +use alloc::{boxed::Box, format, string::ToString, sync::Arc, vec, vec::Vec}; use qusql_parse::{Expression, Identifier, Spanned, UnaryOperator, Variable, issue_todo}; use crate::{ @@ -321,6 +321,78 @@ pub(crate) fn type_expression<'a>( flags }; + if let Expression::Row(row) = &e.lhs { + // Row constructor lhs: `(a, b, ...) IN (subquery yielding matching columns)` + // or `(a, b, ...) IN ((c, d, ...), ...)`. Compare element-wise by position. + let mut lhs_types: Vec = row + .elements + .iter() + .map(|el| type_expression(typer, el, f2, BaseType::Any)) + .collect(); + let mut not_null = lhs_types.iter().all(|t| t.not_null); + // Hack to allow null arguments on the right hand side of an in expression + // where the lhs is not null + for t in &mut lhs_types { + t.not_null = false; + } + for rhs in &e.rhs { + let rhs_types: Vec = match rhs { + Expression::Subquery(q) => { + let rhs_type = type_union_select(typer, &q.expression, false); + if rhs_type.columns.len() != lhs_types.len() { + typer.err( + format!( + "Row of {} columns compared to subquery yielding {} columns", + lhs_types.len(), + rhs_type.columns.len() + ), + &q.expression, + ); + } + rhs_type.columns.into_iter().map(|c| c.type_).collect() + } + Expression::Row(rhs_row) => { + if rhs_row.elements.len() != lhs_types.len() { + typer.err( + format!( + "Row of {} columns compared to row of {} columns", + lhs_types.len(), + rhs_row.elements.len() + ), + rhs, + ); + } + rhs_row + .elements + .iter() + .map(|el| { + type_expression( + typer, + el, + flags.without_values(), + BaseType::Any, + ) + }) + .collect() + } + _ => { + issue_todo!(typer.issues, rhs); + Vec::new() + } + }; + for (lhs_t, rhs_t) in lhs_types.iter().zip(rhs_types.iter()) { + not_null &= rhs_t.not_null; + if typer.matched_type(lhs_t, rhs_t).is_none() { + typer + .err("Incompatible types", &e.in_span) + .frag(lhs_t.t.to_string(), &e.lhs) + .frag(rhs_t.to_string(), rhs); + } + } + } + return FullType::new(BaseType::Bool, not_null); + } + let mut lhs_type = type_expression(typer, &e.lhs, f2, BaseType::Any); let mut not_null = lhs_type.not_null; // Hack to allow null arguments on the right hand side of an in expression From c5e532bdd7f30ca382da522d667bdb45b87fd529 Mon Sep 17 00:00:00 2001 From: Jakob Truelsen Date: Tue, 4 Aug 2026 14:25:43 +0200 Subject: [PATCH 2/4] qusql-type: infer array element type for uncast `= ANY($1)` args Thread the resolved lhs type of a comparison down as context for the rhs, and use that context to constrain a bare, uncast $N argument passed to ANY(...) to an array of the lhs's type, instead of leaving it untyped. --- qusql-type/src/lib.rs | 58 ++++++++++++++++++++++++ qusql-type/src/type_binary_expression.rs | 17 ++++++- qusql-type/src/type_expression.rs | 28 +++++++++--- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/qusql-type/src/lib.rs b/qusql-type/src/lib.rs index 11addf8d..72bc8888 100644 --- a/qusql-type/src/lib.rs +++ b/qusql-type/src/lib.rs @@ -2218,6 +2218,64 @@ mod tests { } } + /// `col = ANY($1)` with a plain, uncast argument (no `$1::bigint[]`) should infer the + /// argument as an array matching the column type. Reported as a real-world usage that + /// should type-check with exactly one (array-typed) argument. + #[test] + fn postgresql_eq_any_uncast_arg() { + let schema_src = " + CREATE TABLE t1 ( + a_id bigint NOT NULL, + b_id bigint NOT NULL + ); + CREATE TABLE t2 ( + id bigint NOT NULL PRIMARY KEY, + x bigint NOT NULL + ); + "; + + let opts = TypeOptions::new() + .dialect(SQLDialect::PostgreSQL) + .arguments(SQLArguments::Dollar); + let mut issues = Issues::new(schema_src); + let schema = parse_schemas(schema_src, &mut issues, &opts); + let mut errors = 0; + check_no_errors("schema", schema_src, issues.get(), &mut errors); + + let src = "SELECT a_id, b_id, x FROM t1, t2 WHERE a_id = ANY($1) AND b_id = t2.id"; + let mut issues = Issues::new(src); + let q = type_statement(&schema, src, &mut issues, &opts); + check_no_errors("query", src, issues.get(), &mut errors); + if let StatementType::Select { columns, arguments } = q { + if columns.len() != 3 { + println!("query: expected 3 columns, got {}", columns.len()); + errors += 1; + } + match arguments.first() { + Some((_, t)) if t.t == Type::Array(Box::new(BaseType::Integer.into())) => {} + Some((_, t)) => { + println!("query: expected argument 0 of type integer[] got {}", t.t); + errors += 1; + } + None => { + println!("query: expected an argument, got none"); + errors += 1; + } + } + if arguments.len() != 1 { + println!("query: expected 1 argument, got {}", arguments.len()); + errors += 1; + } + } else { + println!("query: expected Select"); + errors += 1; + } + + if errors != 0 { + panic!("{errors} errors in postgresql_eq_any_uncast_arg test"); + } + } + /// A writable CTE that unions a range into an `int8multirange` column and then /// `unnest()`s the result. Reported as a real-world usage that should type-check. #[test] diff --git a/qusql-type/src/type_binary_expression.rs b/qusql-type/src/type_binary_expression.rs index 099feef2..6f4daf22 100644 --- a/qusql-type/src/type_binary_expression.rs +++ b/qusql-type/src/type_binary_expression.rs @@ -160,7 +160,22 @@ pub(crate) fn type_binary_expression<'a>( }; let lhs_type = type_expression(typer, lhs, flags, context); - let rhs_type = type_expression(typer, rhs, flags, context); + // For comparisons, thread the lhs's resolved type down as context for the rhs so a + // bare, uncast `ANY($1)` can infer $1 is an array of the lhs's element type. + let rhs_context = match op { + BinaryOperator::Eq(_) + | BinaryOperator::Neq(_) + | BinaryOperator::GtEq(_) + | BinaryOperator::Gt(_) + | BinaryOperator::LtEq(_) + | BinaryOperator::Lt(_) + if lhs_type.base() != BaseType::Any => + { + lhs_type.base() + } + _ => context, + }; + let rhs_type = type_expression(typer, rhs, flags, rhs_context); match op { BinaryOperator::Or(_) => { typer.ensure_base(lhs, &lhs_type, BaseType::Bool); diff --git a/qusql-type/src/type_expression.rs b/qusql-type/src/type_expression.rs index f2604272..6c3591ac 100644 --- a/qusql-type/src/type_expression.rs +++ b/qusql-type/src/type_expression.rs @@ -818,12 +818,28 @@ pub(crate) fn type_expression<'a>( _ => { // Array operand: ANY($1) or ANY($1::type[]) let arr_type = type_expression(typer, &e.operand, flags, BaseType::Any); - let inner = if let Type::Array(inner) = arr_type.t { - *inner - } else { - Type::Base(BaseType::Any) - }; - FullType::new(inner, false) + match arr_type.t { + Type::Array(inner) => FullType::new(*inner, false), + Type::Args(base, args) => { + // Bare argument with no cast (e.g. `= ANY($1)`): the surrounding + // comparison's context tells us the element type, so constrain + // the argument to an array of that type instead of a scalar. + let elem = if context != BaseType::Any { + context + } else { + base + }; + for (idx, arg_type, _) in args.iter() { + typer.constrain_arg( + *idx, + arg_type, + &FullType::new(Type::Array(Box::new(Type::Base(elem))), false), + ); + } + FullType::new(elem, false) + } + _ => FullType::new(BaseType::Any, false), + } } }, Expression::FieldAccess(e) => { From 551471b0b4e2881075e75ecbfe71ce896c4e951b Mon Sep 17 00:00:00 2001 From: Jakob Truelsen Date: Tue, 4 Aug 2026 14:41:13 +0200 Subject: [PATCH 3/4] qusql-sqlx-type: support Vec<&[u8]> for array-of-bytea args Add an ArgIn impl for Vec<&[u8]> (borrowed byte slices) alongside the existing owned Vec> mapping, so a bytea[] arg bound with borrowed slices doesn't fail with an unsatisfied trait bound at the query!() call site. Add a qusql-type test confirming an INSERT ... SELECT * FROM UNNEST(...) with a bytea[] array type-checks, and a live-DB integration test in qusql-sqlx-type-test exercising the fixed Vec<&[u8]> binding. --- qusql-sqlx-type-test/sqlx-type-schema.sql | 7 +++ qusql-sqlx-type-test/src/test.rs | 25 ++++++++++ qusql-sqlx-type/src/lib.rs | 1 + qusql-type/src/lib.rs | 59 +++++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/qusql-sqlx-type-test/sqlx-type-schema.sql b/qusql-sqlx-type-test/sqlx-type-schema.sql index 00363062..d95712f2 100644 --- a/qusql-sqlx-type-test/sqlx-type-schema.sql +++ b/qusql-sqlx-type-test/sqlx-type-schema.sql @@ -20,3 +20,10 @@ CREATE TABLE IF NOT EXISTS partial_files ( last_modified timestamptz NOT NULL DEFAULT now() ); +CREATE TABLE IF NOT EXISTS bytea_array_items ( + a bigint NOT NULL, + b bigint NOT NULL, + data bytea NOT NULL, + PRIMARY KEY (a, b) +); + diff --git a/qusql-sqlx-type-test/src/test.rs b/qusql-sqlx-type-test/src/test.rs index b8a0e980..1b26148d 100644 --- a/qusql-sqlx-type-test/src/test.rs +++ b/qusql-sqlx-type-test/src/test.rs @@ -316,3 +316,28 @@ async fn test_int8multirange_update_returning_unnest(pool: PgPool) { let range: qusql_sqlx_type::PgRange = row.range.expect("unnest() row must be present"); assert_eq!(range, qusql_sqlx_type::PgRange::from(0..4_294_967_296i64)); } + +/// Reported real-world usage: `INSERT ... SELECT * FROM UNNEST($1::bigint[], $2::bigint[], +/// $3::bytea[]) ON CONFLICT DO NOTHING RETURNING ...` bound with a `Vec<&[u8]>` (borrowed +/// byte slices) for the `bytea[]` argument, rather than an owned `Vec>`. +#[sqlx::test] +async fn test_insert_unnest_bytea_array_borrowed_slices(pool: PgPool) { + setup(&pool).await; + let ins_a: Vec = vec![1, 1]; + let ins_b: Vec = vec![10, 20]; + let ins_data: Vec<&[u8]> = vec![b"one", b"two"]; + + let rows = query!( + "INSERT INTO bytea_array_items (a, b, data) + SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::bytea[]) + ON CONFLICT DO NOTHING + RETURNING a, b", + &ins_a, + &ins_b, + &ins_data + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(rows.len(), 2); +} diff --git a/qusql-sqlx-type/src/lib.rs b/qusql-sqlx-type/src/lib.rs index ca1c3777..e5fe2db5 100644 --- a/qusql-sqlx-type/src/lib.rs +++ b/qusql-sqlx-type/src/lib.rs @@ -170,6 +170,7 @@ arg_io!(Any, Vec); arg_io!(Any, Vec); arg_io!(Any, Vec); arg_io!(Any, Vec>); +arg_io!(Any, Vec<&[u8]>); arg_io!(Any, Vec); arg_io!(Any, Vec); diff --git a/qusql-type/src/lib.rs b/qusql-type/src/lib.rs index 72bc8888..75d4cd1b 100644 --- a/qusql-type/src/lib.rs +++ b/qusql-type/src/lib.rs @@ -2276,6 +2276,65 @@ mod tests { } } + /// `INSERT ... SELECT * FROM UNNEST(...)` where one of the unnested arrays is `bytea[]`. + /// Reported as a real-world usage that should type-check. + #[test] + fn postgresql_insert_unnest_bytea_array() { + let schema_src = " + CREATE TABLE t1 ( + a bigint NOT NULL, + b bigint NOT NULL, + payload bytea NOT NULL, + PRIMARY KEY (a, b) + ); + "; + + let opts = TypeOptions::new() + .dialect(SQLDialect::PostgreSQL) + .arguments(SQLArguments::Dollar); + let mut issues = Issues::new(schema_src); + let schema = parse_schemas(schema_src, &mut issues, &opts); + let mut errors = 0; + check_no_errors("schema", schema_src, issues.get(), &mut errors); + + let src = "INSERT INTO t1 (a, b, payload) + SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::bytea[]) + ON CONFLICT DO NOTHING + RETURNING a, b"; + let mut issues = Issues::new(src); + let q = type_statement(&schema, src, &mut issues, &opts); + check_no_errors("query", src, issues.get(), &mut errors); + if let StatementType::Insert { + arguments, + returning, + .. + } = q + { + match returning { + Some(columns) if columns.len() == 2 => {} + Some(columns) => { + println!("query: expected 2 returning columns, got {}", columns.len()); + errors += 1; + } + None => { + println!("query: expected returning columns, got none"); + errors += 1; + } + } + if arguments.len() != 3 { + println!("query: expected 3 arguments, got {}", arguments.len()); + errors += 1; + } + } else { + println!("query: expected Insert"); + errors += 1; + } + + if errors != 0 { + panic!("{errors} errors in postgresql_insert_unnest_bytea_array test"); + } + } + /// A writable CTE that unions a range into an `int8multirange` column and then /// `unnest()`s the result. Reported as a real-world usage that should type-check. #[test] From 4e8845ad89e16c93ff1913d322fc40b3459dbce1 Mon Sep 17 00:00:00 2001 From: Jakob Truelsen Date: Tue, 4 Aug 2026 14:45:24 +0200 Subject: [PATCH 4/4] bump versions --- qusql-sqlx-type-macro/Cargo.toml | 4 ++-- qusql-sqlx-type/Cargo.toml | 4 ++-- qusql-type/Cargo.toml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/qusql-sqlx-type-macro/Cargo.toml b/qusql-sqlx-type-macro/Cargo.toml index 46ab7055..848ec97c 100644 --- a/qusql-sqlx-type-macro/Cargo.toml +++ b/qusql-sqlx-type-macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qusql-sqlx-type-macro" -version = "0.4.3" +version = "0.4.4" authors = ["Jakob Truelsen "] edition = "2021" license = "Apache-2.0" @@ -27,7 +27,7 @@ proc-macro = true quote = "1" syn = { version = "3", features = ["full", "parsing"] } proc-macro2 = "1" -qusql-type = { path = "../qusql-type", version="0.8.0"} +qusql-type = { path = "../qusql-type", version="0.8.1"} ariadne = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/qusql-sqlx-type/Cargo.toml b/qusql-sqlx-type/Cargo.toml index cdaf689f..0e93812f 100644 --- a/qusql-sqlx-type/Cargo.toml +++ b/qusql-sqlx-type/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qusql-sqlx-type" -version = "0.4.3" +version = "0.4.4" authors = ["Jakob Truelsen "] edition = "2021" license = "Apache-2.0" @@ -25,7 +25,7 @@ missing_docs_in_private_items = "deny" sqlx = { version = "0.9", default-features = false, features = ["chrono", "runtime-tokio", "mysql", "postgres"] } [dependencies] -qusql-sqlx-type-macro = { version = "0.4.3", path = "../qusql-sqlx-type-macro"} +qusql-sqlx-type-macro = { version = "0.4.4", path = "../qusql-sqlx-type-macro"} chrono = "0.4" uuid = { version = "1", optional = true } serde_json = { version = "1", optional = true } diff --git a/qusql-type/Cargo.toml b/qusql-type/Cargo.toml index 08df0376..a4083202 100644 --- a/qusql-type/Cargo.toml +++ b/qusql-type/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "qusql-type" -version = "0.8.0" +version = "0.8.1" edition = "2024" authors = ["Jakob Truelsen "] keywords = [ "mysql", "postgesql", "sql", "typer" ]