Skip to content

Commit 6436704

Browse files
authored
Harden our CustomScan against unexpected type changes (#759)
This potential issue has been bothering me for a while now. It's possible for DuckDB to change its mind about the columns that it returns between the Postgres planning and execution phase. This change makes sure we detect such cases with a clear error, instead of having Postgres return weird results or crash.
1 parent 695eef4 commit 6436704

File tree

2 files changed

+53
-1
lines changed

2 files changed

+53
-1
lines changed

src/pgduckdb_node.cpp

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ static CustomExecMethods duckdb_scan_exec_methods;
3333

3434
typedef struct DuckdbScanState {
3535
CustomScanState css; /* must be first field */
36+
const CustomScan *custom_scan;
3637
const Query *query;
3738
ParamListInfo params;
3839
duckdb::Connection *duckdb_connection;
@@ -72,6 +73,7 @@ static Node *
7273
Duckdb_CreateCustomScanState(CustomScan *cscan) {
7374
DuckdbScanState *duckdb_scan_state = (DuckdbScanState *)newNode(sizeof(DuckdbScanState), T_CustomScanState);
7475
CustomScanState *custom_scan_state = &duckdb_scan_state->css;
76+
duckdb_scan_state->custom_scan = cscan;
7577

7678
duckdb_scan_state->query = (const Query *)linitial(cscan->custom_private);
7779
custom_scan_state->methods = &duckdb_scan_exec_methods;
@@ -84,7 +86,9 @@ Duckdb_BeginCustomScan_Cpp(CustomScanState *cscanstate, EState *estate, int /*ef
8486

8587
StringInfo explain_prefix = makeStringInfo();
8688

87-
if (ActivePortal && ActivePortal->commandTag == CMDTAG_EXPLAIN) {
89+
bool is_explain_query = ActivePortal && ActivePortal->commandTag == CMDTAG_EXPLAIN;
90+
91+
if (is_explain_query) {
8892
appendStringInfoString(explain_prefix, "EXPLAIN ");
8993

9094
if (NEED_JSON_PLAN(duckdb_explain_format))
@@ -114,6 +118,33 @@ Duckdb_BeginCustomScan_Cpp(CustomScanState *cscanstate, EState *estate, int /*ef
114118
"DuckDB re-planning failed: " + prepared_query->GetError());
115119
}
116120

121+
if (!is_explain_query) {
122+
auto &prepared_result_types = prepared_query->GetTypes();
123+
124+
size_t target_list_length = static_cast<size_t>(list_length(duckdb_scan_state->custom_scan->custom_scan_tlist));
125+
126+
if (prepared_result_types.size() != target_list_length) {
127+
elog(ERROR,
128+
"(PGDuckDB/CreatePlan) Number of columns returned by DuckDB query changed between planning and "
129+
"execution, expected %zu got %zu",
130+
target_list_length, prepared_result_types.size());
131+
}
132+
133+
for (size_t i = 0; i < prepared_result_types.size(); i++) {
134+
Oid postgres_column_oid = pgduckdb::GetPostgresDuckDBType(prepared_result_types[i]);
135+
if (!OidIsValid(postgres_column_oid)) {
136+
elog(ERROR, "(PGDuckDB/CreatePlan) Cache lookup failed for type %u", postgres_column_oid);
137+
}
138+
TargetEntry *target_entry =
139+
list_nth_node(TargetEntry, duckdb_scan_state->custom_scan->custom_scan_tlist, i);
140+
Var *var = castNode(Var, target_entry->expr);
141+
if (var->vartype != postgres_column_oid) {
142+
elog(ERROR, "Types returned by duckdb query changed between planning and execution, expected %d got %d",
143+
var->vartype, postgres_column_oid);
144+
}
145+
}
146+
}
147+
117148
duckdb_scan_state->duckdb_connection = pgduckdb::DuckDBManager::GetConnection();
118149
duckdb_scan_state->prepared_statement = prepared_query.release();
119150
duckdb_scan_state->params = estate->es_param_list_info;

test/pycheck/prepared_test.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,24 @@ def test_prepared_ctas(cur: Cursor):
169169
cur.sql("DROP TABLE t3")
170170
cur.sql(prepared_query)
171171
assert cur.sql("SELECT count(*) FROM t3") == 3
172+
173+
174+
def test_prepared_change_type(cur: Cursor, tmp_path):
175+
tmp_path = tmp_path / "test.csv"
176+
tmp_path.write_text("123\n")
177+
prepared_query = f"SELECT * FROM read_csv('{tmp_path}')"
178+
cur.sql(prepared_query, prepare=True)
179+
180+
tmp_path.write_text("abc\n")
181+
with pytest.raises(
182+
psycopg.errors.InternalError,
183+
match="Types returned by duckdb query changed between planning and execution",
184+
):
185+
cur.sql(prepared_query)
186+
187+
tmp_path.write_text("1,234\n")
188+
with pytest.raises(
189+
psycopg.errors.InternalError,
190+
match="Number of columns returned by DuckDB query changed between planning and execution",
191+
):
192+
cur.sql(prepared_query)

0 commit comments

Comments
 (0)