From 8c27c85396e7cebab83a08bb5c64abe3ee917f32 Mon Sep 17 00:00:00 2001 From: Gavin Brand <202810078+GavinPizza@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:02:42 -0700 Subject: [PATCH] Fix UNKNOWN parameter type error for params used in expressions Parameters used inside an expression (e.g. `WHERE id = $1 + 1`) could not have their type inferred by DuckDB at plan time, failing with "Could not convert DuckDB type: UNKNOWN". Deparse external parameters as CAST($n AS ) so DuckDB gets the type Postgres already resolved. Fixes #480. --- include/pgduckdb/pgduckdb_ruleutils.h | 1 + src/pgduckdb_ruleutils.cpp | 19 +++++++++ src/vendor/pg_ruleutils_14.c | 2 +- src/vendor/pg_ruleutils_15.c | 2 +- src/vendor/pg_ruleutils_16.c | 2 +- src/vendor/pg_ruleutils_17.c | 2 +- src/vendor/pg_ruleutils_18.c | 2 +- src/vendor/pg_ruleutils_19.c | 2 +- test/pycheck/prepared_test.py | 60 ++++++++++++++++++++++++++- 9 files changed, 85 insertions(+), 7 deletions(-) diff --git a/include/pgduckdb/pgduckdb_ruleutils.h b/include/pgduckdb/pgduckdb_ruleutils.h index 05ca704a..024f1170 100644 --- a/include/pgduckdb/pgduckdb_ruleutils.h +++ b/include/pgduckdb/pgduckdb_ruleutils.h @@ -21,6 +21,7 @@ bool pgduckdb_is_duckdb_row(Oid type_oid); bool pgduckdb_is_unresolved_type(Oid type_oid); bool pgduckdb_is_fake_type(Oid type_oid); bool pgduckdb_var_is_duckdb_row(Var *var); +void pgduckdb_get_param(StringInfo buf, Param *param); bool pgduckdb_func_returns_duckdb_row(RangeTblFunction *rtfunc); Var *pgduckdb_duckdb_subscript_var(Expr *expr); bool pgduckdb_reconstruct_star_step(StarReconstructionContext *ctx, ListCell *tle_cell); diff --git a/src/pgduckdb_ruleutils.cpp b/src/pgduckdb_ruleutils.cpp index a0167df8..384eafec 100644 --- a/src/pgduckdb_ruleutils.cpp +++ b/src/pgduckdb_ruleutils.cpp @@ -98,6 +98,25 @@ pgduckdb_is_fake_type(Oid type_oid) { return false; } +/* + * Deparse an external parameter as CAST($n AS ) instead of a bare $n. + * DuckDB can't always infer a parameter's type (e.g. inside "$1 + 1"), leaving + * it UNKNOWN and unconvertible to a Postgres type (issue #480); the cast hands + * it the type Postgres already resolved. Fake types are skipped (see above). + */ +void +pgduckdb_get_param(StringInfo buf, Param *param) { + Oid paramtype = param->paramtype; + + if (param->paramkind == PARAM_EXTERN && OidIsValid(paramtype) && paramtype != UNKNOWNOID && + !pgduckdb_is_fake_type(paramtype)) { + appendStringInfo(buf, "CAST($%d AS %s)", param->paramid, + format_type_with_typemod(paramtype, param->paramtypmod)); + } else { + appendStringInfo(buf, "$%d", param->paramid); + } +} + static bool pgduckdb_is_duckdb_subscript_type(Oid type_oid) { if (pgduckdb_is_unresolved_type(type_oid)) { diff --git a/src/vendor/pg_ruleutils_14.c b/src/vendor/pg_ruleutils_14.c index bb063dcd..553a4e74 100644 --- a/src/vendor/pg_ruleutils_14.c +++ b/src/vendor/pg_ruleutils_14.c @@ -8175,7 +8175,7 @@ get_parameter(Param *param, deparse_context *context) /* * Not PARAM_EXEC, or couldn't find referent: just print $N. */ - appendStringInfo(context->buf, "$%d", param->paramid); + pgduckdb_get_param(context->buf, param); } /* diff --git a/src/vendor/pg_ruleutils_15.c b/src/vendor/pg_ruleutils_15.c index 18b54194..b49f75fe 100644 --- a/src/vendor/pg_ruleutils_15.c +++ b/src/vendor/pg_ruleutils_15.c @@ -8378,7 +8378,7 @@ get_parameter(Param *param, deparse_context *context) /* * Not PARAM_EXEC, or couldn't find referent: just print $N. */ - appendStringInfo(context->buf, "$%d", param->paramid); + pgduckdb_get_param(context->buf, param); } /* diff --git a/src/vendor/pg_ruleutils_16.c b/src/vendor/pg_ruleutils_16.c index 200df3de..6779bf01 100644 --- a/src/vendor/pg_ruleutils_16.c +++ b/src/vendor/pg_ruleutils_16.c @@ -8313,7 +8313,7 @@ get_parameter(Param *param, deparse_context *context) /* * Not PARAM_EXEC, or couldn't find referent: just print $N. */ - appendStringInfo(context->buf, "$%d", param->paramid); + pgduckdb_get_param(context->buf, param); } /* diff --git a/src/vendor/pg_ruleutils_17.c b/src/vendor/pg_ruleutils_17.c index e666d2d7..e338c24d 100644 --- a/src/vendor/pg_ruleutils_17.c +++ b/src/vendor/pg_ruleutils_17.c @@ -8515,7 +8515,7 @@ get_parameter(Param *param, deparse_context *context) */ Assert(param->paramkind == PARAM_EXTERN); - appendStringInfo(context->buf, "$%d", param->paramid); + pgduckdb_get_param(context->buf, param); } /* diff --git a/src/vendor/pg_ruleutils_18.c b/src/vendor/pg_ruleutils_18.c index abb9bf2d..00eb5dc4 100644 --- a/src/vendor/pg_ruleutils_18.c +++ b/src/vendor/pg_ruleutils_18.c @@ -8863,7 +8863,7 @@ get_parameter(Param *param, deparse_context *context) */ Assert(param->paramkind == PARAM_EXTERN); - appendStringInfo(context->buf, "$%d", param->paramid); + pgduckdb_get_param(context->buf, param); } /* diff --git a/src/vendor/pg_ruleutils_19.c b/src/vendor/pg_ruleutils_19.c index 299c12b9..81307eca 100644 --- a/src/vendor/pg_ruleutils_19.c +++ b/src/vendor/pg_ruleutils_19.c @@ -9438,7 +9438,7 @@ get_parameter(Param *param, deparse_context *context) */ Assert(param->paramkind == PARAM_EXTERN); - appendStringInfo(context->buf, "$%d", param->paramid); + pgduckdb_get_param(context->buf, param); } /* diff --git a/test/pycheck/prepared_test.py b/test/pycheck/prepared_test.py index fbdbc98b..84076abd 100644 --- a/test/pycheck/prepared_test.py +++ b/test/pycheck/prepared_test.py @@ -42,6 +42,64 @@ def test_prepared(cur: Cursor): assert cur.sql(q2, (4,)) == 0 +def test_prepared_param_in_expression(cur: Cursor): + # Regression test for issue #480: a parameter used inside an expression + # (rather than a direct comparison) cannot have its type inferred by DuckDB. + # We deparse it with an explicit cast so DuckDB knows the type instead of + # reporting it as UNKNOWN. + cur.sql("CREATE TEMP TABLE test_table (id int) USING duckdb") + cur.sql("INSERT INTO test_table VALUES (1), (2), (3)") + + q = "SELECT count(*) FROM test_table WHERE id = %s + 1" + + cur.sql("SET plan_cache_mode = 'force_custom_plan'") + assert cur.sql(q, (1,), prepare=True) == 1 # matches id = 2 + assert cur.sql(q, (2,)) == 1 # matches id = 3 + assert cur.sql(q, (3,)) == 0 # id = 4 does not exist + + cur.sql("SET plan_cache_mode = 'force_generic_plan'") + assert cur.sql(q, (1,)) == 1 # creates generic plan + assert cur.sql(q, (2,)) == 1 + assert cur.sql(q, (3,)) == 0 + + +def test_prepared_param_cast_types(cur: Cursor): + # Regression test for issue #480: every external parameter now deparses as + # CAST($n AS ), so the emitted type name must be one DuckDB can parse. + # This covers more types than int, including the multi-word "timestamp with + # time zone". + cur.sql( + "CREATE TEMP TABLE test_table (t text, ts timestamptz, u uuid) USING duckdb" + ) + u = uuid.UUID("12345678-1234-5678-1234-567812345678") + cur.sql( + "INSERT INTO test_table VALUES (%s, %s, %s)", + ("foo", datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc), u), + ) + + for mode in ("force_custom_plan", "force_generic_plan"): + cur.sql(f"SET plan_cache_mode = '{mode}'") + # text: parameter concatenated + assert ( + cur.sql( + "SELECT count(*) FROM test_table WHERE t = %s || 'o'", + ("fo",), + prepare=True, + ) + == 1 + ) + # timestamptz: parameter used in interval arithmetic + assert ( + cur.sql( + "SELECT count(*) FROM test_table WHERE ts = %s + interval '1 day'", + (datetime.datetime(2019, 12, 31, tzinfo=datetime.timezone.utc),), + ) + == 1 + ) + # uuid: cast is emitted even for a plain equality + assert cur.sql("SELECT count(*) FROM test_table WHERE u = %s", (u,)) == 1 + + def test_extended(cur: Cursor): cur.sql(""" CREATE TABLE t( @@ -161,7 +219,7 @@ def test_prepared_ctas(cur: Cursor): # crash. with pytest.raises( psycopg.errors.InternalError, - match="Could not find parameter with identifier 1", + match="Not all parameters were bound", ): cur.sql( "CREATE TEMP TABLE t2 USING duckdb AS SELECT * FROM heapt where id = %s",