Skip to content

Commit 810e1c6

Browse files
EDsCODEclaude
andcommitted
Fix missing RowDescription for result-returning queries
This fixes the Fivetran error "Received resultset tuples, but no field structure for them" by ensuring consistent query type detection between handleDescribe and handleExecute. Changes: - Add queryReturnsResults() helper function that: - Strips leading comments before checking query type - Identifies all result-returning query types: SELECT, WITH (CTEs), VALUES, SHOW, TABLE, EXECUTE, EXPLAIN, DESCRIBE - Update handleDescribe (both statement and portal) to use queryReturnsResults() instead of simple SELECT prefix check - Update handleExecute to use queryReturnsResults() for consistency - Add rewritePgCatalogQuery() to extended query protocol (handleParse) The root cause was queries with leading comments like "/*Fivetran*/ SELECT ..." being treated inconsistently: - handleDescribe saw "/*FIVETRAN*/..." and sent NoData - handleExecute stripped comments and returned results 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent df84ae8 commit 810e1c6

2 files changed

Lines changed: 307 additions & 12 deletions

File tree

server/conn.go

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,45 @@ func stripLeadingComments(query string) string {
406406
}
407407
}
408408

409+
// queryReturnsResults checks if a SQL query returns a result set.
410+
// This is used to determine whether to send RowDescription or NoData.
411+
func queryReturnsResults(query string) bool {
412+
upper := strings.ToUpper(stripLeadingComments(query))
413+
// SELECT is the most common
414+
if strings.HasPrefix(upper, "SELECT") {
415+
return true
416+
}
417+
// WITH ... SELECT (CTEs)
418+
if strings.HasPrefix(upper, "WITH") {
419+
return true
420+
}
421+
// VALUES clause returns rows
422+
if strings.HasPrefix(upper, "VALUES") {
423+
return true
424+
}
425+
// SHOW commands return results
426+
if strings.HasPrefix(upper, "SHOW") {
427+
return true
428+
}
429+
// TABLE is shorthand for SELECT * FROM table
430+
if strings.HasPrefix(upper, "TABLE") {
431+
return true
432+
}
433+
// EXECUTE can return results if the prepared statement is a SELECT
434+
if strings.HasPrefix(upper, "EXECUTE") {
435+
return true
436+
}
437+
// EXPLAIN returns results
438+
if strings.HasPrefix(upper, "EXPLAIN") {
439+
return true
440+
}
441+
// DESCRIBE returns results (DuckDB-specific)
442+
if strings.HasPrefix(upper, "DESCRIBE") {
443+
return true
444+
}
445+
return false
446+
}
447+
409448
func (c *clientConn) getCommandType(upperQuery string) string {
410449
// Strip leading comments like /*Fivetran*/ before checking command type
411450
upperQuery = stripLeadingComments(upperQuery)
@@ -958,15 +997,18 @@ func (c *clientConn) handleParse(body []byte) {
958997
}
959998
}
960999

1000+
// Rewrite pg_catalog function calls for compatibility (same as simple query protocol)
1001+
rewrittenQuery := rewritePgCatalogQuery(query)
1002+
9611003
// Convert PostgreSQL $1, $2 placeholders to ? for database/sql
962-
convertedQuery, numParams := convertPlaceholders(query)
1004+
convertedQuery, numParams := convertPlaceholders(rewrittenQuery)
9631005

9641006
// Close existing statement with same name
9651007
delete(c.stmts, stmtName)
9661008

9671009
c.stmts[stmtName] = &preparedStmt{
968-
query: query,
969-
convertedQuery: convertedQuery,
1010+
query: query, // Keep original for logging and Describe
1011+
convertedQuery: convertedQuery, // Rewritten and placeholder-converted for execution
9701012
paramTypes: paramTypes,
9711013
numParams: numParams,
9721014
}
@@ -1106,10 +1148,9 @@ func (c *clientConn) handleDescribe(body []byte) {
11061148
}
11071149
c.sendParameterDescription(paramTypes)
11081150

1109-
// For SELECT queries, we need to send RowDescription
1151+
// For queries that return results, we need to send RowDescription
11101152
// For other queries, send NoData
1111-
upperQuery := strings.ToUpper(strings.TrimSpace(ps.query))
1112-
if !strings.HasPrefix(upperQuery, "SELECT") {
1153+
if !queryReturnsResults(ps.query) {
11131154
writeNoData(c.writer)
11141155
return
11151156
}
@@ -1157,9 +1198,8 @@ func (c *clientConn) handleDescribe(body []byte) {
11571198
return
11581199
}
11591200

1160-
// For non-SELECT, send NoData
1161-
upperQuery := strings.ToUpper(strings.TrimSpace(p.stmt.query))
1162-
if !strings.HasPrefix(upperQuery, "SELECT") {
1201+
// For queries that don't return results, send NoData
1202+
if !queryReturnsResults(p.stmt.query) {
11631203
writeNoData(c.writer)
11641204
return
11651205
}
@@ -1239,10 +1279,11 @@ func (c *clientConn) handleExecute(body []byte) {
12391279

12401280
upperQuery := strings.ToUpper(strings.TrimSpace(p.stmt.query))
12411281
cmdType := c.getCommandType(upperQuery)
1282+
returnsResults := queryReturnsResults(p.stmt.query)
12421283

12431284
log.Printf("[%s] Execute %q with %d params: %s", c.username, portalName, len(args), p.stmt.query)
12441285

1245-
if cmdType != "SELECT" {
1286+
if !returnsResults {
12461287
// Handle nested BEGIN: PostgreSQL issues a warning but continues,
12471288
// while DuckDB throws an error. Match PostgreSQL behavior.
12481289
if cmdType == "BEGIN" && c.txStatus == txStatusTransaction {
@@ -1251,7 +1292,7 @@ func (c *clientConn) handleExecute(body []byte) {
12511292
return
12521293
}
12531294

1254-
// Non-SELECT: use Exec with converted query
1295+
// Non-result-returning query: use Exec with converted query
12551296
result, err := c.db.Exec(p.stmt.convertedQuery, args...)
12561297
if err != nil {
12571298
c.sendError("ERROR", "42000", err.Error())
@@ -1264,7 +1305,7 @@ func (c *clientConn) handleExecute(body []byte) {
12641305
return
12651306
}
12661307

1267-
// SELECT: use Query with converted query
1308+
// Result-returning query: use Query with converted query
12681309
rows, err := c.db.Query(p.stmt.convertedQuery, args...)
12691310
if err != nil {
12701311
c.sendError("ERROR", "42000", err.Error())

server/conn_test.go

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package server
22

33
import (
4+
"strings"
45
"testing"
56
)
67

@@ -358,3 +359,256 @@ func TestNestedBeginDetection(t *testing.T) {
358359
t.Errorf("txStatus should still be %c after nested BEGIN detection, got %c", txStatusTransaction, c.txStatus)
359360
}
360361
}
362+
363+
func TestQueryReturnsResults(t *testing.T) {
364+
tests := []struct {
365+
name string
366+
query string
367+
expected bool
368+
}{
369+
// SELECT queries
370+
{
371+
name: "simple SELECT",
372+
query: "SELECT * FROM users",
373+
expected: true,
374+
},
375+
{
376+
name: "SELECT with comment",
377+
query: "/*Fivetran*/ SELECT * FROM users",
378+
expected: true,
379+
},
380+
{
381+
name: "SELECT with block and line comment",
382+
query: "/* comment */ -- line\nSELECT 1",
383+
expected: true,
384+
},
385+
// WITH/CTE queries
386+
{
387+
name: "WITH clause",
388+
query: "WITH cte AS (SELECT 1) SELECT * FROM cte",
389+
expected: true,
390+
},
391+
{
392+
name: "WITH clause with comment",
393+
query: "/*Fivetran*/ WITH cte AS (SELECT 1) SELECT * FROM cte",
394+
expected: true,
395+
},
396+
// VALUES
397+
{
398+
name: "VALUES",
399+
query: "VALUES (1, 2), (3, 4)",
400+
expected: true,
401+
},
402+
// SHOW
403+
{
404+
name: "SHOW",
405+
query: "SHOW TABLES",
406+
expected: true,
407+
},
408+
// TABLE
409+
{
410+
name: "TABLE command",
411+
query: "TABLE users",
412+
expected: true,
413+
},
414+
// EXPLAIN
415+
{
416+
name: "EXPLAIN",
417+
query: "EXPLAIN SELECT * FROM users",
418+
expected: true,
419+
},
420+
// DESCRIBE
421+
{
422+
name: "DESCRIBE",
423+
query: "DESCRIBE users",
424+
expected: true,
425+
},
426+
// Non-result queries
427+
{
428+
name: "INSERT",
429+
query: "INSERT INTO users VALUES (1)",
430+
expected: false,
431+
},
432+
{
433+
name: "UPDATE",
434+
query: "UPDATE users SET name = 'test'",
435+
expected: false,
436+
},
437+
{
438+
name: "DELETE",
439+
query: "DELETE FROM users",
440+
expected: false,
441+
},
442+
{
443+
name: "CREATE TABLE",
444+
query: "CREATE TABLE test (id INT)",
445+
expected: false,
446+
},
447+
{
448+
name: "CREATE TABLE with comment",
449+
query: "/*Fivetran*/ CREATE TABLE test (id INT)",
450+
expected: false,
451+
},
452+
{
453+
name: "DROP TABLE",
454+
query: "DROP TABLE users",
455+
expected: false,
456+
},
457+
{
458+
name: "BEGIN",
459+
query: "BEGIN",
460+
expected: false,
461+
},
462+
{
463+
name: "COMMIT",
464+
query: "COMMIT",
465+
expected: false,
466+
},
467+
{
468+
name: "ROLLBACK",
469+
query: "ROLLBACK",
470+
expected: false,
471+
},
472+
{
473+
name: "SET",
474+
query: "SET search_path = public",
475+
expected: false,
476+
},
477+
}
478+
479+
for _, tt := range tests {
480+
t.Run(tt.name, func(t *testing.T) {
481+
result := queryReturnsResults(tt.query)
482+
if result != tt.expected {
483+
t.Errorf("queryReturnsResults(%q) = %v, want %v", tt.query, result, tt.expected)
484+
}
485+
})
486+
}
487+
}
488+
489+
// TestDescribeExecuteConsistency verifies that handleDescribe and handleExecute
490+
// use consistent logic to determine if a query returns results.
491+
//
492+
// This test reproduces the original bug where:
493+
// - handleDescribe used: strings.HasPrefix(upperQuery, "SELECT")
494+
// - handleExecute used: getCommandType(upperQuery) which strips comments
495+
//
496+
// For a query like "/* comment */ SELECT * FROM table":
497+
// - Old handleDescribe: "/* COMMENT */..." doesn't start with "SELECT" → sends NoData
498+
// - Old handleExecute: getCommandType strips comments → returns "SELECT" → sends results
499+
//
500+
// This mismatch caused: "Received resultset tuples, but no field structure for them"
501+
func TestDescribeExecuteConsistency(t *testing.T) {
502+
c := &clientConn{}
503+
504+
// Queries that caused the bug: result-returning queries with leading comments
505+
problematicQueries := []struct {
506+
name string
507+
query string
508+
}{
509+
{"block comment before SELECT", "/* comment */ SELECT * FROM users"},
510+
{"block comment before SELECT no space", "/*comment*/SELECT 1"},
511+
{"block comment before WITH", "/* query */ WITH cte AS (SELECT 1) SELECT * FROM cte"},
512+
{"line comment before SELECT", "-- comment\nSELECT * FROM users"},
513+
{"multiple block comments", "/* first */ /* second */ SELECT 1"},
514+
}
515+
516+
for _, tt := range problematicQueries {
517+
t.Run(tt.name, func(t *testing.T) {
518+
// Simulate the OLD handleDescribe logic (the bug)
519+
upperQuery := strings.ToUpper(strings.TrimSpace(tt.query))
520+
oldDescribeWouldReturnResults := strings.HasPrefix(upperQuery, "SELECT")
521+
522+
// Simulate the OLD handleExecute logic
523+
cmdType := c.getCommandType(upperQuery)
524+
oldExecuteWouldReturnResults := cmdType == "SELECT"
525+
526+
// The bug: Describe says no results, Execute says results
527+
if oldDescribeWouldReturnResults != oldExecuteWouldReturnResults {
528+
t.Logf("BUG REPRODUCED: Old logic inconsistent for %q", tt.query)
529+
t.Logf(" Old Describe (HasPrefix SELECT): %v", oldDescribeWouldReturnResults)
530+
t.Logf(" Old Execute (getCommandType): %v (cmdType=%s)", oldExecuteWouldReturnResults, cmdType)
531+
}
532+
533+
// Verify the NEW logic is consistent
534+
newDescribeWouldReturnResults := queryReturnsResults(tt.query)
535+
newExecuteWouldReturnResults := queryReturnsResults(tt.query)
536+
537+
if newDescribeWouldReturnResults != newExecuteWouldReturnResults {
538+
t.Errorf("NEW logic still inconsistent for %q: Describe=%v, Execute=%v",
539+
tt.query, newDescribeWouldReturnResults, newExecuteWouldReturnResults)
540+
}
541+
542+
// Verify the query is correctly identified as returning results
543+
if !newDescribeWouldReturnResults {
544+
t.Errorf("queryReturnsResults(%q) = false, but this query DOES return results", tt.query)
545+
}
546+
547+
// Verify the old logic would have caused the bug
548+
if oldDescribeWouldReturnResults == oldExecuteWouldReturnResults {
549+
t.Logf("Note: Old logic was consistent for %q (no bug for this case)", tt.query)
550+
}
551+
})
552+
}
553+
}
554+
555+
// TestRowDescriptionMismatchScenario simulates the exact sequence that caused
556+
// the "Received resultset tuples, but no field structure for them" error.
557+
func TestRowDescriptionMismatchScenario(t *testing.T) {
558+
// Query with leading comment - common pattern from ETL tools
559+
query := "/* etl query */ SELECT * FROM information_schema.tables"
560+
561+
// Step 1: Client sends Parse (creates prepared statement)
562+
// Step 2: Client sends Describe('S') for statement
563+
// Server checks if query returns results
564+
565+
// OLD BEHAVIOR (the bug):
566+
upperQuery := strings.ToUpper(strings.TrimSpace(query))
567+
oldWouldSendRowDescription := strings.HasPrefix(upperQuery, "SELECT")
568+
// Result: false (query starts with "/* ETL QUERY */", not "SELECT")
569+
// Server would send: NoData
570+
571+
if oldWouldSendRowDescription {
572+
t.Error("Expected old logic to NOT detect this as SELECT (reproducing the bug)")
573+
}
574+
575+
// Step 3: Client sends Bind (creates portal)
576+
// Step 4: Client sends Execute
577+
// Server executes query and checks if it returns results
578+
579+
// OLD BEHAVIOR in Execute used getCommandType which strips comments:
580+
c := &clientConn{}
581+
cmdType := c.getCommandType(upperQuery)
582+
oldExecuteWouldQuery := cmdType == "SELECT"
583+
// Result: true (getCommandType strips comments, sees "SELECT")
584+
// Server would send: RowDescription + DataRows
585+
586+
if !oldExecuteWouldQuery {
587+
t.Error("Expected old Execute logic to detect this as SELECT")
588+
}
589+
590+
// THE BUG: Describe sent NoData, but Execute sent RowDescription + DataRows
591+
// Client received DataRows without expecting them → error
592+
593+
if !oldWouldSendRowDescription && oldExecuteWouldQuery {
594+
t.Log("BUG CONFIRMED: Describe sent NoData, Execute sent results")
595+
t.Log("This causes: 'Received resultset tuples, but no field structure for them'")
596+
}
597+
598+
// NEW BEHAVIOR (the fix):
599+
newWouldSendRowDescription := queryReturnsResults(query)
600+
newExecuteWouldQuery := queryReturnsResults(query)
601+
602+
// Both should now be true and consistent
603+
if !newWouldSendRowDescription {
604+
t.Error("Fixed Describe should detect this as returning results")
605+
}
606+
if !newExecuteWouldQuery {
607+
t.Error("Fixed Execute should detect this as returning results")
608+
}
609+
if newWouldSendRowDescription != newExecuteWouldQuery {
610+
t.Error("Fixed logic should be consistent between Describe and Execute")
611+
}
612+
613+
t.Log("FIX VERIFIED: Both Describe and Execute now correctly identify result-returning queries")
614+
}

0 commit comments

Comments
 (0)