From e4c96bfc9a5db1742c0b3970ea6132af9e26134c Mon Sep 17 00:00:00 2001 From: Bjorn Tipling Date: Mon, 27 Jul 2026 01:38:33 +0000 Subject: [PATCH 1/2] Default postgres DSN to simple_protocol when unset Inject default_query_exec_mode=simple_protocol for postgres scheme URLs in buildConnectionURL so transaction-mode poolers (PgBouncer, Supabase) do not hit SQLSTATE 42P05 prepared-statement conflicts. Explicit DSN/Params values still win; non-postgres schemes unchanged. Co-authored-by: c1-squire-dev[bot] --- README.md | 4 +- examples/redshift-test.yml | 7 ++- pkg/database/database.go | 13 ++++ pkg/database/database_test.go | 110 ++++++++++++++++++++++++++++++++-- 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3b016088..dab932c9 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,14 @@ The connector is configured using a YAML file that defines: -- **Database Connection**: Connection details via DSN (Data Source Name) +- **Database Connection**: Connection details via DSN (Data Source Name) and optional `connect.params` - **Resource Types**: Map database tables/queries to resources (users, roles, etc.) - **Account Provisioning**: Define schemas and credential options for user creation - **Entitlements**: Permissions and roles that can be granted to resources - **Provisioning Actions**: SQL queries for granting/revoking entitlements +For the `postgres` scheme, baton-sql defaults `default_query_exec_mode` to `simple_protocol` when unset so transaction-mode poolers (PgBouncer, Supabase pooler, etc.) work without prepared-statement conflicts. Set the param explicitly (DSN query or `connect.params`) to override. + See examples in the [examples](https://github.com/ConductorOne/baton-sql/tree/main/examples) directory. ### Query placeholders diff --git a/examples/redshift-test.yml b/examples/redshift-test.yml index f8c1a422..4ec1a720 100644 --- a/examples/redshift-test.yml +++ b/examples/redshift-test.yml @@ -36,9 +36,10 @@ # Serverless, native IdP federation (CREATE IDENTITY PROVIDER), CREATE USER / CREATE # ROLE, AD-group -> DbGroups mapping (lives in the IdP). # -# Driver caveat: baton-sql uses jackc/pgx/v5 for the postgres scheme. If prepared- -# statement quirks surface, pin to simple-query protocol via the DSN parameter -# default_query_exec_mode=simple_protocol. +# Driver note: baton-sql uses jackc/pgx/v5 for the postgres scheme and defaults +# default_query_exec_mode to simple_protocol (pooler-safe). Override with +# default_query_exec_mode=cache_statement (DSN or connect.params) if you need +# prepared-statement caching on a direct connection. app_name: Amazon Redshift app_description: Sync Redshift cluster identities, roles, groups, schemas, and tables. diff --git a/pkg/database/database.go b/pkg/database/database.go index 005164ef..8be85c8c 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -589,6 +589,19 @@ func buildConnectionURL(opts ConnectOptions) (*url.URL, error) { parsedUrl.RawQuery = values.Encode() } + // Postgres/pgx defaults to prepared-statement caching (cache_statement), which + // breaks behind transaction-mode poolers (PgBouncer, Supabase pooler, etc.) + // with SQLSTATE 42P05. Inject simple_protocol when the caller has not set an + // explicit default_query_exec_mode so pooler deployments work out of the box. + // Explicit DSN/Params values always win. + if parsedUrl.Scheme == "postgres" { + q := parsedUrl.Query() + if q.Get("default_query_exec_mode") == "" { + q.Set("default_query_exec_mode", "simple_protocol") + parsedUrl.RawQuery = q.Encode() + } + } + if parsedUrl.Scheme == "" && opts.DSN == "" { return nil, fmt.Errorf("database scheme must be specified") } diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index 2e2fab9b..5d7d8645 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -718,7 +718,7 @@ func Test_buildConnectionURL(t *testing.T) { opts: ConnectOptions{ DSN: "postgres://user:pass@localhost:5432/db?sslmode=disable", }, - want: "postgres://user:pass@localhost:5432/db?sslmode=disable", + want: "postgres://user:pass@localhost:5432/db?default_query_exec_mode=simple_protocol&sslmode=disable", }, { name: "Override DSN components", @@ -735,7 +735,7 @@ func Test_buildConnectionURL(t *testing.T) { "application_name": "baton", }, }, - want: "postgres://override_user:override%23pass@override.internal:6543/override_db?application_name=baton&connect_timeout=10&sslmode=require", + want: "postgres://override_user:override%23pass@override.internal:6543/override_db?application_name=baton&connect_timeout=10&default_query_exec_mode=simple_protocol&sslmode=require", }, { name: "Structured config only", @@ -750,7 +750,7 @@ func Test_buildConnectionURL(t *testing.T) { "sslmode": "disable", }, }, - want: "postgres://app_user:s3cr3t%21@db.internal:5432/appdb?sslmode=disable", + want: "postgres://app_user:s3cr3t%21@db.internal:5432/appdb?default_query_exec_mode=simple_protocol&sslmode=disable", }, { name: "Port without host", @@ -770,7 +770,7 @@ func Test_buildConnectionURL(t *testing.T) { User: "testuser", Password: "testpass", }, - want: "postgres://testuser:testpass@[::1]:5432/testdb", + want: "postgres://testuser:testpass@[::1]:5432/testdb?default_query_exec_mode=simple_protocol", }, { name: "IPv6 host without brackets gets brackets added by JoinHostPort", @@ -780,7 +780,7 @@ func Test_buildConnectionURL(t *testing.T) { Port: "5432", Database: "testdb", }, - want: "postgres://[2001:db8::1]:5432/testdb", + want: "postgres://[2001:db8::1]:5432/testdb?default_query_exec_mode=simple_protocol", }, } @@ -804,3 +804,103 @@ func Test_buildConnectionURL(t *testing.T) { }) } } + +func Test_buildConnectionURL_PostgresSimpleProtocolDefault(t *testing.T) { + const modeKey = "default_query_exec_mode" + + tests := []struct { + name string + opts ConnectOptions + wantMode string // empty means key must be absent + wantOther map[string]string + }{ + { + name: "postgres DSN without mode injects simple_protocol", + opts: ConnectOptions{ + DSN: "postgres://user:pass@localhost:5432/db", + }, + wantMode: "simple_protocol", + }, + { + name: "structured postgres with sslmode only also gets simple_protocol", + opts: ConnectOptions{ + Scheme: "postgres", + Host: "db.example", + Port: "5432", + Database: "app", + User: "u", + Password: "p", + Params: map[string]string{ + "sslmode": "require", + }, + }, + wantMode: "simple_protocol", + wantOther: map[string]string{ + "sslmode": "require", + }, + }, + { + name: "DSN already sets cache_statement is preserved", + opts: ConnectOptions{ + DSN: "postgres://user:pass@localhost:5432/db?default_query_exec_mode=cache_statement", + }, + wantMode: "cache_statement", + }, + { + name: "Params set exec is preserved", + opts: ConnectOptions{ + Scheme: "postgres", + Host: "db.example", + Database: "app", + Params: map[string]string{ + "default_query_exec_mode": "exec", + }, + }, + wantMode: "exec", + }, + { + name: "mysql scheme does not inject mode", + opts: ConnectOptions{ + Scheme: "mysql", + Host: "db.example", + Port: "3306", + Database: "app", + User: "u", + Password: "p", + }, + wantMode: "", + }, + { + name: "mysql DSN does not inject mode", + opts: ConnectOptions{ + DSN: "mysql://user:pass@localhost:3306/db", + }, + wantMode: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildConnectionURL(tt.opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + q := got.Query() + gotMode := q.Get(modeKey) + if gotMode != tt.wantMode { + t.Fatalf("%s = %q, want %q (url=%s)", modeKey, gotMode, tt.wantMode, got.String()) + } + if tt.wantMode == "" { + if _, ok := q[modeKey]; ok { + t.Fatalf("%s present on non-postgres url: %s", modeKey, got.String()) + } + } + for k, want := range tt.wantOther { + if got := q.Get(k); got != want { + t.Fatalf("query %s = %q, want %q", k, got, want) + } + } + }) + } +} From f19600e657ea9d9963814d699bff53025a022a9d Mon Sep 17 00:00:00 2001 From: Bjorn Tipling Date: Mon, 27 Jul 2026 03:56:02 +0000 Subject: [PATCH 2/2] Do not inject simple_protocol when default_query_exec_mode is unset Forced injection broke existing postgres connectors that rely on pgx default cache_statement. Leave the URL unchanged when unset; honor explicit DSN or connect.params values (including simple_protocol for poolers). Update tests, README pooler opt-in guidance, and redshift example caveat. Co-authored-by: c1-squire-dev[bot] --- README.md | 9 ++++++++- examples/redshift-test.yml | 7 +++---- pkg/database/database.go | 13 ------------- pkg/database/database_test.go | 34 +++++++++++++++++++++++----------- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index dab932c9..92a32f40 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,14 @@ The connector is configured using a YAML file that defines: - **Entitlements**: Permissions and roles that can be granted to resources - **Provisioning Actions**: SQL queries for granting/revoking entitlements -For the `postgres` scheme, baton-sql defaults `default_query_exec_mode` to `simple_protocol` when unset so transaction-mode poolers (PgBouncer, Supabase pooler, etc.) work without prepared-statement conflicts. Set the param explicitly (DSN query or `connect.params`) to override. +For Postgres behind a transaction-mode pooler (PgBouncer, Supabase pooler on port 6543, etc.), set `default_query_exec_mode` to `simple_protocol` via the DSN query string or `connect.params` to avoid prepared-statement conflicts (SQLSTATE 42P05). When unset, baton-sql leaves the URL unchanged and pgx uses its default (`cache_statement`). + +```yaml +connect: + dsn: "postgres://${HOST}:6543/${DB}" + params: + default_query_exec_mode: simple_protocol +``` See examples in the [examples](https://github.com/ConductorOne/baton-sql/tree/main/examples) directory. diff --git a/examples/redshift-test.yml b/examples/redshift-test.yml index 4ec1a720..7a7b9718 100644 --- a/examples/redshift-test.yml +++ b/examples/redshift-test.yml @@ -36,10 +36,9 @@ # Serverless, native IdP federation (CREATE IDENTITY PROVIDER), CREATE USER / CREATE # ROLE, AD-group -> DbGroups mapping (lives in the IdP). # -# Driver note: baton-sql uses jackc/pgx/v5 for the postgres scheme and defaults -# default_query_exec_mode to simple_protocol (pooler-safe). Override with -# default_query_exec_mode=cache_statement (DSN or connect.params) if you need -# prepared-statement caching on a direct connection. +# Driver caveat: baton-sql uses jackc/pgx/v5 for the postgres scheme. If prepared- +# statement quirks surface, pin to simple-query protocol via the DSN parameter +# default_query_exec_mode=simple_protocol (or connect.params). app_name: Amazon Redshift app_description: Sync Redshift cluster identities, roles, groups, schemas, and tables. diff --git a/pkg/database/database.go b/pkg/database/database.go index 8be85c8c..005164ef 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -589,19 +589,6 @@ func buildConnectionURL(opts ConnectOptions) (*url.URL, error) { parsedUrl.RawQuery = values.Encode() } - // Postgres/pgx defaults to prepared-statement caching (cache_statement), which - // breaks behind transaction-mode poolers (PgBouncer, Supabase pooler, etc.) - // with SQLSTATE 42P05. Inject simple_protocol when the caller has not set an - // explicit default_query_exec_mode so pooler deployments work out of the box. - // Explicit DSN/Params values always win. - if parsedUrl.Scheme == "postgres" { - q := parsedUrl.Query() - if q.Get("default_query_exec_mode") == "" { - q.Set("default_query_exec_mode", "simple_protocol") - parsedUrl.RawQuery = q.Encode() - } - } - if parsedUrl.Scheme == "" && opts.DSN == "" { return nil, fmt.Errorf("database scheme must be specified") } diff --git a/pkg/database/database_test.go b/pkg/database/database_test.go index 5d7d8645..6ee8b3ee 100644 --- a/pkg/database/database_test.go +++ b/pkg/database/database_test.go @@ -718,7 +718,7 @@ func Test_buildConnectionURL(t *testing.T) { opts: ConnectOptions{ DSN: "postgres://user:pass@localhost:5432/db?sslmode=disable", }, - want: "postgres://user:pass@localhost:5432/db?default_query_exec_mode=simple_protocol&sslmode=disable", + want: "postgres://user:pass@localhost:5432/db?sslmode=disable", }, { name: "Override DSN components", @@ -735,7 +735,7 @@ func Test_buildConnectionURL(t *testing.T) { "application_name": "baton", }, }, - want: "postgres://override_user:override%23pass@override.internal:6543/override_db?application_name=baton&connect_timeout=10&default_query_exec_mode=simple_protocol&sslmode=require", + want: "postgres://override_user:override%23pass@override.internal:6543/override_db?application_name=baton&connect_timeout=10&sslmode=require", }, { name: "Structured config only", @@ -750,7 +750,7 @@ func Test_buildConnectionURL(t *testing.T) { "sslmode": "disable", }, }, - want: "postgres://app_user:s3cr3t%21@db.internal:5432/appdb?default_query_exec_mode=simple_protocol&sslmode=disable", + want: "postgres://app_user:s3cr3t%21@db.internal:5432/appdb?sslmode=disable", }, { name: "Port without host", @@ -770,7 +770,7 @@ func Test_buildConnectionURL(t *testing.T) { User: "testuser", Password: "testpass", }, - want: "postgres://testuser:testpass@[::1]:5432/testdb?default_query_exec_mode=simple_protocol", + want: "postgres://testuser:testpass@[::1]:5432/testdb", }, { name: "IPv6 host without brackets gets brackets added by JoinHostPort", @@ -780,7 +780,7 @@ func Test_buildConnectionURL(t *testing.T) { Port: "5432", Database: "testdb", }, - want: "postgres://[2001:db8::1]:5432/testdb?default_query_exec_mode=simple_protocol", + want: "postgres://[2001:db8::1]:5432/testdb", }, } @@ -805,7 +805,7 @@ func Test_buildConnectionURL(t *testing.T) { } } -func Test_buildConnectionURL_PostgresSimpleProtocolDefault(t *testing.T) { +func Test_buildConnectionURL_PostgresQueryExecMode(t *testing.T) { const modeKey = "default_query_exec_mode" tests := []struct { @@ -815,14 +815,14 @@ func Test_buildConnectionURL_PostgresSimpleProtocolDefault(t *testing.T) { wantOther map[string]string }{ { - name: "postgres DSN without mode injects simple_protocol", + name: "unset postgres DSN does not inject default_query_exec_mode", opts: ConnectOptions{ DSN: "postgres://user:pass@localhost:5432/db", }, - wantMode: "simple_protocol", + wantMode: "", }, { - name: "structured postgres with sslmode only also gets simple_protocol", + name: "structured postgres with sslmode only has no mode key", opts: ConnectOptions{ Scheme: "postgres", Host: "db.example", @@ -834,7 +834,7 @@ func Test_buildConnectionURL_PostgresSimpleProtocolDefault(t *testing.T) { "sslmode": "require", }, }, - wantMode: "simple_protocol", + wantMode: "", wantOther: map[string]string{ "sslmode": "require", }, @@ -858,6 +858,18 @@ func Test_buildConnectionURL_PostgresSimpleProtocolDefault(t *testing.T) { }, wantMode: "exec", }, + { + name: "Params set simple_protocol is preserved", + opts: ConnectOptions{ + Scheme: "postgres", + Host: "db.example", + Database: "app", + Params: map[string]string{ + "default_query_exec_mode": "simple_protocol", + }, + }, + wantMode: "simple_protocol", + }, { name: "mysql scheme does not inject mode", opts: ConnectOptions{ @@ -893,7 +905,7 @@ func Test_buildConnectionURL_PostgresSimpleProtocolDefault(t *testing.T) { } if tt.wantMode == "" { if _, ok := q[modeKey]; ok { - t.Fatalf("%s present on non-postgres url: %s", modeKey, got.String()) + t.Fatalf("%s present when unset: %s", modeKey, got.String()) } } for k, want := range tt.wantOther {