Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
164c915
fix: add NUMERIC prepared statement parameter support (#892)
cburyta Feb 12, 2026
fb3b2bb
fix: add array prepared statement parameter support (#892)
cburyta Feb 12, 2026
ae07bbc
test(prepared): add unsupported param type failure test, minor file c…
cburyta Feb 12, 2026
44db497
fix: map DuckDB UNKNOWN type to Postgres TEXT for result columns
cburyta Feb 17, 2026
6e874db
fix(prepared): stabilize select-list parameter schema across prepare …
cburyta Feb 17, 2026
f4bbc95
fix(prepared): handle unresolved parquet param typing in conversion a…
cburyta Feb 18, 2026
73ca890
fix(sql): add int4 assignment cast for duckdb.unresolved_type
cburyta Feb 18, 2026
9020c1c
test(prepared): add parquet untyped and native bind regressions
cburyta Feb 18, 2026
519e828
chore(debug): enrich column mismatch diagnostics
cburyta Feb 20, 2026
6292b11
fix(executor): retry direct execute on schema drift
cburyta Feb 20, 2026
f521fe4
fix(executor): inline-parameter fallback for shape drift
cburyta Feb 20, 2026
61ce657
fix(build): declare querydef symbol locally
cburyta Feb 20, 2026
6f39595
chore(debug): log CreatePlan result types from DuckDB prepare
cburyta Feb 21, 2026
a4a7adc
fix(deparse): expand ScalarArrayOpExpr as IN instead of ANY(ARRAY)
cburyta Feb 21, 2026
3d8cee2
fix(numeric): prevent uint8_t overflow in NUMERIC precision calculation
cburyta Apr 8, 2026
67ed76d
style: fix clang-format and ruff lint violations
cburyta Apr 9, 2026
566a1bc
fix(test): correct test assertions and types in prepared_test.py
cburyta Apr 9, 2026
bfc9676
refactor(executor): remove speculative retry/fallback for schema drift
cburyta Apr 9, 2026
e999048
fix(test): remove untestable NUMERIC[] array and unsupported type sce…
cburyta Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sql/pg_duckdb--1.0.0--1.1.0.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
-- Add MAP functions support
-- Allow native PREPARE/EXECUTE integer literals to bind against unresolved parquet predicates.
-- The unresolved type lives in the duckdb schema.
CREATE CAST (integer AS duckdb.unresolved_type) WITH INOUT AS ASSIGNMENT;

-- Extract value from map using key
CREATE FUNCTION @extschema@.map_extract(map_col duckdb.map, key "any")
RETURNS duckdb.unresolved_type AS 'MODULE_PATHNAME', 'duckdb_only_function' LANGUAGE C;
Expand Down
11 changes: 11 additions & 0 deletions src/pgduckdb_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,17 @@ Duckdb_ExecCustomScan_Cpp(CustomScanState *node) {
MemoryContextReset(duckdb_scan_state->css.ss.ps.ps_ExprContext->ecxt_per_tuple_memory);
ExecClearTuple(slot);

const auto slot_column_count = static_cast<duckdb::idx_t>(slot->tts_tupleDescriptor->natts);
const auto custom_scan_column_count =
static_cast<duckdb::idx_t>(list_length(duckdb_scan_state->custom_scan->custom_scan_tlist));
if (duckdb_scan_state->column_count != slot_column_count) {
elog(ERROR,
"(PGDuckDB/ExecuteQuery) Number of columns returned by DuckDB query changed between planning and "
"execution, expected slot=%zu custom_scan_tlist=%zu got %zu",
static_cast<size_t>(slot_column_count), static_cast<size_t>(custom_scan_column_count),
static_cast<size_t>(duckdb_scan_state->column_count));
}

/* MemoryContext used for allocation */
old_context = MemoryContextSwitchTo(duckdb_scan_state->css.ss.ps.ps_ExprContext->ecxt_per_tuple_memory);

Expand Down
6 changes: 6 additions & 0 deletions src/pgduckdb_planner.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ CreatePlan(Query *query, bool throw_error) {

auto &prepared_result_types = prepared_query->GetTypes();

elog(DEBUG2, "(PGDuckDB/CreatePlan) DuckDB Prepare returned %zu result column(s)", prepared_result_types.size());
for (size_t j = 0; j < prepared_result_types.size(); j++) {
elog(DEBUG2, "(PGDuckDB/CreatePlan) col[%zu] = %s name=%s", j, prepared_result_types[j].ToString().c_str(),
prepared_query->GetNames()[j].c_str());
}

for (size_t i = 0; i < prepared_result_types.size(); i++) {
Oid postgresColumnOid = pgduckdb::GetPostgresDuckDBType(prepared_result_types[i], throw_error);

Expand Down
179 changes: 179 additions & 0 deletions src/pgduckdb_types.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,8 @@ ConvertDuckToPostgresValue(TupleTableSlot *slot, duckdb::Value &value, idx_t col
case BPCHAROID:
case TEXTOID:
case JSONOID:
case UNKNOWNOID:
case 0: /* InvalidOid - for UNKNOWN columns where tuple descriptor has no type */
case VARCHAROID: {
slot->tts_values[col] = ConvertToStringDatum(value);
break;
Expand Down Expand Up @@ -1514,6 +1516,8 @@ GetPostgresArrayDuckDBType(const duckdb::LogicalType &type, bool throw_error) {
return pgduckdb::DuckdbUnionArrayOid();
case duckdb::LogicalTypeId::MAP:
return pgduckdb::DuckdbMapArrayOid();
case duckdb::LogicalTypeId::UNKNOWN:
return TEXTARRAYOID;
default: {
if (throw_error) {
throw duckdb::NotImplementedException("Unsupported DuckDB `LIST` subtype: " + type.ToString());
Expand Down Expand Up @@ -1613,6 +1617,9 @@ GetPostgresDuckDBType(const duckdb::LogicalType &type, bool throw_error) {
return pgduckdb::DuckdbUnionOid();
case duckdb::LogicalTypeId::MAP:
return pgduckdb::DuckdbMapOid();
case duckdb::LogicalTypeId::UNKNOWN:
/* Used for parameter expressions and unresolved types; map to text so CreatePlan succeeds. */
return TEXTOID;
case duckdb::LogicalTypeId::ENUM:
return VARCHAROID;
default: {
Expand Down Expand Up @@ -1793,6 +1800,56 @@ ConvertDecimal(const NumericVar &numeric) {
return numeric.sign == NUMERIC_NEG ? -base_res : base_res;
}

// Issue #892: Helper function for converting NUMERIC parameters to DuckDB DECIMAL
static duckdb::Value
ConvertNumericParameterToDuckValue(Datum value) {
auto numeric = DatumGetNumeric(value);
auto numeric_var = FromNumeric(numeric);

// Check for special values (NaN, Infinity)
if (numeric_var.sign == NUMERIC_NAN) {
elog(ERROR, "Cannot convert NaN NUMERIC to DuckDB DECIMAL");
}
#if PG_VERSION_NUM >= 140000
if (numeric_var.sign == NUMERIC_PINF || numeric_var.sign == NUMERIC_NINF) {
elog(ERROR, "Cannot convert Infinity NUMERIC to DuckDB DECIMAL");
}
#endif

// Calculate precision from the numeric value
// Precision = number of digits before decimal + scale
// weight is in base-NBASE (10000) units, so multiply by DEC_DIGITS (4)
int integral_digits = (numeric_var.weight + 1) * DEC_DIGITS;
if (integral_digits < 1) {
integral_digits = 1; // At minimum 1 digit for the integral part
}

// Clamp using int to avoid uint8_t overflow (e.g. 256 digits wraps to 0)
int raw_precision = integral_digits + static_cast<int>(numeric_var.dscale);
if (raw_precision > 38) {
elog(WARNING, "NUMERIC precision %d exceeds DuckDB maximum (38), truncating", raw_precision);
raw_precision = 38;
}
uint8_t precision = static_cast<uint8_t>(raw_precision);
uint8_t scale = static_cast<uint8_t>(std::min(static_cast<int>(numeric_var.dscale), raw_precision));
if (scale > precision) {
elog(DEBUG1, "NUMERIC scale (%d) > precision (%d), clamping", scale, precision);
scale = precision;
}

// Choose the appropriate physical type based on precision
if (precision <= 4) {
return duckdb::Value::DECIMAL(ConvertDecimal<int16_t>(numeric_var), precision, scale);
} else if (precision <= 9) {
return duckdb::Value::DECIMAL(ConvertDecimal<int32_t>(numeric_var), precision, scale);
} else if (precision <= 18) {
return duckdb::Value::DECIMAL(ConvertDecimal<int64_t>(numeric_var), precision, scale);
} else {
return duckdb::Value::DECIMAL(ConvertDecimal<hugeint_t, DecimalConversionHugeint>(numeric_var), precision,
scale);
}
}

/*
* Convert a Postgres Datum to a DuckDB Value. This is meant to be used to
* covert query parameters in a prepared statement to its DuckDB equivalent.
Expand All @@ -1813,6 +1870,7 @@ ConvertPostgresParameterToDuckValue(Datum value, Oid postgres_type) {
case BPCHAROID:
case TEXTOID:
case JSONOID:
case UNKNOWNOID:
case VARCHAROID: {
// FIXME: TextDatumGetCstring allocates so it needs a
// guard, but it's a macro not a function, so our current gaurd
Expand Down Expand Up @@ -1841,7 +1899,128 @@ ConvertPostgresParameterToDuckValue(Datum value, Oid postgres_type) {
return duckdb::Value::DOUBLE(DatumGetFloat8(value));
case UUIDOID:
return duckdb::Value::UUID(DatumGetUUID(value));
case NUMERICOID:
// Issue #892: Support NUMERIC in prepared statement parameters
return ConvertNumericParameterToDuckValue(value);
case INT2ARRAYOID:
case INT4ARRAYOID:
case INT8ARRAYOID:
case FLOAT4ARRAYOID:
case FLOAT8ARRAYOID:
case TEXTARRAYOID:
case VARCHARARRAYOID:
case BPCHARARRAYOID:
case BOOLARRAYOID:
case DATEARRAYOID:
case TIMESTAMPARRAYOID:
case TIMESTAMPTZARRAYOID:
case UUIDARRAYOID: {
// Issue #892: Support array types in prepared statement parameters
auto array = DatumGetArrayTypeP(value);
auto elem_type = ARR_ELEMTYPE(array);

int16 typlen;
bool typbyval;
char typalign;
PostgresFunctionGuard(get_typlenbyvalalign, elem_type, &typlen, &typbyval, &typalign);

int nelems;
Datum *elems;
bool *nulls;
PostgresFunctionGuard(deconstruct_array, array, elem_type, typlen, typbyval, typalign, &elems, &nulls, &nelems);

// Determine the DuckDB element type based on postgres element type
duckdb::LogicalType child_type;
switch (elem_type) {
case INT2OID:
child_type = duckdb::LogicalType::SMALLINT;
break;
case INT4OID:
child_type = duckdb::LogicalType::INTEGER;
break;
case INT8OID:
child_type = duckdb::LogicalType::BIGINT;
break;
case FLOAT4OID:
child_type = duckdb::LogicalType::FLOAT;
break;
case FLOAT8OID:
child_type = duckdb::LogicalType::DOUBLE;
break;
case TEXTOID:
case VARCHAROID:
case BPCHAROID:
child_type = duckdb::LogicalType::VARCHAR;
break;
case BOOLOID:
child_type = duckdb::LogicalType::BOOLEAN;
break;
case DATEOID:
child_type = duckdb::LogicalType::DATE;
break;
case TIMESTAMPOID:
child_type = duckdb::LogicalType::TIMESTAMP;
break;
case TIMESTAMPTZOID:
child_type = duckdb::LogicalType::TIMESTAMP_TZ;
break;
case UUIDOID:
child_type = duckdb::LogicalType::UUID;
break;
default:
elog(ERROR, "Unsupported array element type: %d", elem_type);
}

// Convert each element
duckdb::vector<duckdb::Value> values;
values.reserve(nelems);
for (int i = 0; i < nelems; i++) {
if (nulls[i]) {
values.push_back(duckdb::Value(child_type));
} else {
values.push_back(ConvertPostgresParameterToDuckValue(elems[i], elem_type));
}
}

return duckdb::Value::LIST(child_type, std::move(values));
}
case NUMERICARRAYOID: {
// Issue #892: Support NUMERIC[] - special case due to per-element precision
// NUMERIC arrays require individual element conversion since each value
// may have different precision/scale
auto array = DatumGetArrayTypeP(value);
auto elem_type = ARR_ELEMTYPE(array);

int16 typlen;
bool typbyval;
char typalign;
PostgresFunctionGuard(get_typlenbyvalalign, elem_type, &typlen, &typbyval, &typalign);

int nelems;
Datum *elems;
bool *nulls;
PostgresFunctionGuard(deconstruct_array, array, elem_type, typlen, typbyval, typalign, &elems, &nulls, &nelems);

// Convert each NUMERIC element individually
duckdb::vector<duckdb::Value> values;
values.reserve(nelems);
for (int i = 0; i < nelems; i++) {
if (nulls[i]) {
// Use a reasonable default DECIMAL type for nulls
values.push_back(duckdb::Value(duckdb::LogicalType::DECIMAL(38, 0)));
} else {
values.push_back(ConvertNumericParameterToDuckValue(elems[i]));
}
}

// Use DECIMAL(38,0) as the list element type since individual elements
// may have varying precision
return duckdb::Value::LIST(duckdb::LogicalType::DECIMAL(38, 0), std::move(values));
}
default:
if (postgres_type == pgduckdb::DuckdbUnresolvedTypeOid()) {
return duckdb::Value(TextDatumGetCString(value));
}
elog(ERROR, "Could not convert Postgres parameter of type: %d to DuckDB type", postgres_type);
}
}
Expand Down
96 changes: 69 additions & 27 deletions src/vendor/pg_ruleutils_14.c
Original file line number Diff line number Diff line change
Expand Up @@ -8175,6 +8175,27 @@ get_parameter(Param *param, deparse_context *context)
/*
* Not PARAM_EXEC, or couldn't find referent: just print $N.
*/
if (param->paramkind == PARAM_EXTERN &&
OidIsValid(param->paramtype) &&
param->paramtype != UNKNOWNOID &&
!pgduckdb_is_fake_type(param->paramtype))
{
const char *param_type_name;

/*
* Keep the deparsed parameter typed so DuckDB does not drop projection
* parameter columns from prepared result schemas.
*/
param_type_name = format_type_with_typemod(param->paramtype,
param->paramtypmod);
if (param_type_name)
{
appendStringInfo(context->buf, "($%d)::%s", param->paramid,
param_type_name);
return;
}
}

appendStringInfo(context->buf, "$%d", param->paramid);
}

Expand Down Expand Up @@ -8704,36 +8725,57 @@ get_rule_expr(Node *node, deparse_context *context,
Node *arg1 = (Node *) linitial(args);
Node *arg2 = (Node *) lsecond(args);

if (!PRETTY_PAREN(context))
appendStringInfoChar(buf, '(');
get_rule_expr_paren(arg1, context, true, node);
appendStringInfo(buf, " %s %s (",
generate_operator_name(expr->opno,
exprType(arg1),
get_base_element_type(exprType(arg2))),
expr->useOr ? "ANY" : "ALL");
get_rule_expr_paren(arg2, context, true, node);

/*
* There's inherent ambiguity in "x op ANY/ALL (y)" when y is
* a bare sub-SELECT. Since we're here, the sub-SELECT must
* be meant as a scalar sub-SELECT yielding an array value to
* be used in ScalarArrayOpExpr; but the grammar will
* preferentially interpret such a construct as an ANY/ALL
* SubLink. To prevent misparsing the output that way, insert
* a dummy coercion (which will be stripped by parse analysis,
* so no inefficiency is added in dump and reload). This is
* indeed most likely what the user wrote to get the construct
* accepted in the first place.
* When the RHS is an explicit ARRAY[] constructor and the
* expression uses OR semantics (i.e., original SQL was
* IN (...)), deparse as IN (...) instead of = ANY (ARRAY[...]).
* DuckDB's prepared statement engine handles IN syntax
* correctly but can mishandle the ANY(ARRAY[...]) form.
*/
if (IsA(arg2, SubLink) &&
((SubLink *) arg2)->subLinkType == EXPR_SUBLINK)
appendStringInfo(buf, "::%s",
format_type_with_typemod(exprType(arg2),
exprTypmod(arg2)));
appendStringInfoChar(buf, ')');
if (!PRETTY_PAREN(context))
if (expr->useOr && IsA(arg2, ArrayExpr))
{
ArrayExpr *arrexpr = (ArrayExpr *) arg2;
ListCell *lc;
bool first = true;

if (!PRETTY_PAREN(context))
appendStringInfoChar(buf, '(');
get_rule_expr_paren(arg1, context, true, node);
appendStringInfoString(buf, " IN (");
foreach(lc, arrexpr->elements)
{
Node *elem = (Node *) lfirst(lc);

if (!first)
appendStringInfoString(buf, ", ");
get_rule_expr(elem, context, false);
first = false;
}
appendStringInfoChar(buf, ')');
if (!PRETTY_PAREN(context))
appendStringInfoChar(buf, ')');
}
else
{
if (!PRETTY_PAREN(context))
appendStringInfoChar(buf, '(');
get_rule_expr_paren(arg1, context, true, node);
appendStringInfo(buf, " %s %s (",
generate_operator_name(expr->opno,
exprType(arg1),
get_base_element_type(exprType(arg2))),
expr->useOr ? "ANY" : "ALL");
get_rule_expr_paren(arg2, context, true, node);

if (IsA(arg2, SubLink) &&
((SubLink *) arg2)->subLinkType == EXPR_SUBLINK)
appendStringInfo(buf, "::%s",
format_type_with_typemod(exprType(arg2),
exprTypmod(arg2)));
appendStringInfoChar(buf, ')');
if (!PRETTY_PAREN(context))
appendStringInfoChar(buf, ')');
}
}
break;

Expand Down
Loading
Loading