From 9459243c23c0f37bfb6d6dde0a54fc07960da209 Mon Sep 17 00:00:00 2001 From: Amund Eggen Svandal Date: Thu, 14 May 2026 17:00:00 +0200 Subject: [PATCH 1/2] chore(db): Swap lib/pq for jackc/pgx/v5 as the sql driver lib/pq is in maintenance mode and the community has consolidated on pgx. Switch the database/sql driver name from "postgres" to "pgx" via the pgx/v5/stdlib side-effect import and replace every pq.QuoteIdentifier call site with pgx.Identifier{...}.Sanitize(). Also rename the CloudSQL DSN keyword from "database" to "dbname" since pgx's libpq-style parser does not accept the pq-only alias. The golang-migrate database/postgres driver still pulls lib/pq in as an indirect dep; a follow-up will move migrations onto database/pgx/v5 to drop it entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 7 ++++++- go.sum | 12 ++++++++++++ internal/adapters/accountrepository/postgres.go | 10 +++++----- .../adapters/accountrepository/postgres_test.go | 8 ++++---- internal/adapters/database/cloudsql.go | 2 +- internal/adapters/database/migrator.go | 6 +++--- internal/adapters/database/migrator_test.go | 6 +++--- internal/adapters/database/postgres.go | 7 ++++--- internal/adapters/playerrepository/repository.go | 10 +++++----- .../adapters/playerrepository/repository_test.go | 14 +++++++------- internal/adapters/userrepository/postgres.go | 4 ++-- internal/adapters/userrepository/postgres_test.go | 6 +++--- 12 files changed, 55 insertions(+), 37 deletions(-) diff --git a/go.mod b/go.mod index 203119d4..8dd5bec6 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.26.3 require ( github.com/getsentry/sentry-go v0.46.1 github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.5.4 github.com/jellydator/ttlcache/v3 v3.4.0 - github.com/lib/pq v1.12.3 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 go.opentelemetry.io/otel v1.43.0 @@ -121,6 +121,9 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jgautheron/goconst v1.8.2 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect github.com/jjti/go-spancheck v0.6.5 // indirect @@ -138,6 +141,7 @@ require ( github.com/ldez/tagliatelle v0.7.2 // indirect github.com/ldez/usetesting v0.5.0 // indirect github.com/leonklingele/grouper v1.1.2 // indirect + github.com/lib/pq v1.12.3 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/macabu/inamedparam v0.2.0 // indirect github.com/magiconair/properties v1.8.6 // indirect @@ -219,6 +223,7 @@ require ( go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect diff --git a/go.sum b/go.sum index e05ba383..0b1f997b 100644 --- a/go.sum +++ b/go.sum @@ -388,6 +388,14 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= @@ -632,6 +640,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= @@ -731,6 +740,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -1079,6 +1090,7 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/adapters/accountrepository/postgres.go b/internal/adapters/accountrepository/postgres.go index c69068dc..56318467 100644 --- a/internal/adapters/accountrepository/postgres.go +++ b/internal/adapters/accountrepository/postgres.go @@ -7,8 +7,8 @@ import ( "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" @@ -67,7 +67,7 @@ func (p *Postgres) StoreAccount(ctx context.Context, account domain.Account) err } defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) if err != nil { err := fmt.Errorf("failed to set search path: %w", err) reporting.Report(ctx, err, map[string]string{ @@ -168,7 +168,7 @@ func (p *Postgres) RemoveUsername(ctx context.Context, username string) error { _, err := p.db.ExecContext(ctx, fmt.Sprintf(` DELETE FROM %s.usernames WHERE lower(username) = lower($1)`, - pq.QuoteIdentifier(p.schema), + pgx.Identifier{p.schema}.Sanitize(), ), username, ) @@ -200,7 +200,7 @@ func (p *Postgres) GetAccountByUUID(ctx context.Context, uuid string) (domain.Ac player_uuid, username, queried_at FROM %s.usernames WHERE player_uuid = $1`, - pq.QuoteIdentifier(p.schema), + pgx.Identifier{p.schema}.Sanitize(), ), uuid, ) @@ -232,7 +232,7 @@ func (p *Postgres) GetAccountByUsername(ctx context.Context, username string) (d player_uuid, username, queried_at FROM %s.usernames WHERE lower(username) = lower($1)`, - pq.QuoteIdentifier(p.schema), + pgx.Identifier{p.schema}.Sanitize(), ), username, ) diff --git a/internal/adapters/accountrepository/postgres_test.go b/internal/adapters/accountrepository/postgres_test.go index 9d9e0e58..cb7c3138 100644 --- a/internal/adapters/accountrepository/postgres_test.go +++ b/internal/adapters/accountrepository/postgres_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" + "github.com/jackc/pgx/v5" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "github.com/stretchr/testify/require" "github.com/Amund211/flashlight/internal/adapters/database" @@ -22,7 +22,7 @@ func newPostgres(t *testing.T, db *sqlx.DB, schemaSuffix string) *Postgres { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pq.QuoteIdentifier(schema))) + db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schema}.Sanitize())) migrator := database.NewDatabaseMigrator(db, logger) @@ -61,7 +61,7 @@ func TestPostgres(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) require.NoError(t, err) var entries []dbUsernamesEntry @@ -102,7 +102,7 @@ func TestPostgres(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) require.NoError(t, err) var entries []dbUsernameQueriesEntry diff --git a/internal/adapters/database/cloudsql.go b/internal/adapters/database/cloudsql.go index 8b3fb5f3..6353ace7 100644 --- a/internal/adapters/database/cloudsql.go +++ b/internal/adapters/database/cloudsql.go @@ -5,7 +5,7 @@ import "fmt" // https://cloud.google.com/sql/docs/mysql/connect-functions func GetCloudSQLConnectionString(dbUsername, dbPassword, unixSocketPath string) string { return fmt.Sprintf( - "user=%s password=%s database=%s host=%s", + "user=%s password=%s dbname=%s host=%s", dbUsername, dbPassword, DBName, diff --git a/internal/adapters/database/migrator.go b/internal/adapters/database/migrator.go index 343becc2..a68fd941 100644 --- a/internal/adapters/database/migrator.go +++ b/internal/adapters/database/migrator.go @@ -10,8 +10,8 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5" "github.com/jmoiron/sqlx" - "github.com/lib/pq" ) //go:embed migrations/*.sql @@ -41,12 +41,12 @@ func (m *migrator) migrate(ctx context.Context, schemaName string) error { } defer conn.Close() - _, err = conn.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", pq.QuoteIdentifier(schemaName))) + _, err = conn.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", pgx.Identifier{schemaName}.Sanitize())) if err != nil { return fmt.Errorf("migrate: failed to create schema: %w", err) } - _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schemaName))) + _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schemaName}.Sanitize())) if err != nil { return fmt.Errorf("migrate: failed to set search path: %w", err) } diff --git a/internal/adapters/database/migrator_test.go b/internal/adapters/database/migrator_test.go index 01372197..3dca94a8 100644 --- a/internal/adapters/database/migrator_test.go +++ b/internal/adapters/database/migrator_test.go @@ -9,7 +9,7 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" - "github.com/lib/pq" + "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) @@ -28,7 +28,7 @@ func TestMigrator(t *testing.T) { db, err := NewPostgresDatabase(LocalConnectionString) require.NoError(t, err) - db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pq.QuoteIdentifier(schemaName))) + db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schemaName}.Sanitize())) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) migrator := NewDatabaseMigrator(db, logger) @@ -40,7 +40,7 @@ func TestMigrator(t *testing.T) { conn, err := db.Conn(ctx) require.NoError(t, err) defer conn.Close() - _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schemaName))) + _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schemaName}.Sanitize())) require.NoError(t, err) migrationSource, err := iofs.New(embeddedMigrations, "migrations") diff --git a/internal/adapters/database/postgres.go b/internal/adapters/database/postgres.go index c897638d..49873e28 100644 --- a/internal/adapters/database/postgres.go +++ b/internal/adapters/database/postgres.go @@ -3,8 +3,9 @@ package database import ( "fmt" + "github.com/jackc/pgx/v5" + _ "github.com/jackc/pgx/v5/stdlib" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "github.com/Amund211/flashlight/internal/config" ) @@ -24,7 +25,7 @@ func GetSchemaName(isTesting bool) string { } func NewPostgresDatabase(connectionString string) (*sqlx.DB, error) { - db, err := sqlx.Connect("postgres", connectionString) + db, err := sqlx.Connect("pgx", connectionString) if err != nil { return nil, fmt.Errorf("failed to connect to db: %w", err) } @@ -68,7 +69,7 @@ func createDatabaseIfNotExists(db *sqlx.DB, dbName string) error { return nil } - _, err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", pq.QuoteIdentifier(dbName))) + _, err := db.Exec(fmt.Sprintf("CREATE DATABASE %s", pgx.Identifier{dbName}.Sanitize())) if err != nil { return fmt.Errorf("createDB: failed to create database: %w", err) } diff --git a/internal/adapters/playerrepository/repository.go b/internal/adapters/playerrepository/repository.go index 621bfb6f..ddfd7436 100644 --- a/internal/adapters/playerrepository/repository.go +++ b/internal/adapters/playerrepository/repository.go @@ -11,8 +11,8 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" @@ -208,7 +208,7 @@ func (p *PostgresPlayerRepository) StorePlayer(ctx context.Context, player *doma } defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) if err != nil { err := fmt.Errorf("failed to set search path: %w", err) reporting.Report(ctx, err, map[string]string{ @@ -328,7 +328,7 @@ func (p *PostgresPlayerRepository) GetHistory(ctx context.Context, playerUUID st } defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) if err != nil { err := fmt.Errorf("failed to set search path: %w", err) reporting.Report(ctx, err, map[string]string{ @@ -480,7 +480,7 @@ func (p *PostgresPlayerRepository) GetPlayerPITs(ctx context.Context, playerUUID } defer conn.Close() - _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) if err != nil { err := fmt.Errorf("failed to set search path: %w", err) reporting.Report(ctx, err, map[string]string{ @@ -600,7 +600,7 @@ func (p *PostgresPlayerRepository) FindMilestoneAchievements(ctx context.Context } defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) if err != nil { err := fmt.Errorf("failed to set search path: %w", err) reporting.Report(ctx, err, map[string]string{ diff --git a/internal/adapters/playerrepository/repository_test.go b/internal/adapters/playerrepository/repository_test.go index 79898290..f1e714f4 100644 --- a/internal/adapters/playerrepository/repository_test.go +++ b/internal/adapters/playerrepository/repository_test.go @@ -10,8 +10,8 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "github.com/stretchr/testify/require" "github.com/Amund211/flashlight/internal/adapters/database" @@ -23,7 +23,7 @@ import ( func newPostgresPlayerRepository(t *testing.T, db *sqlx.DB, schema string) *PostgresPlayerRepository { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pq.QuoteIdentifier(schema))) + db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schema}.Sanitize())) migrator := database.NewDatabaseMigrator(db, logger) @@ -73,7 +73,7 @@ func TestPostgresPlayerRepository(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schemaName))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schemaName}.Sanitize())) require.NoError(t, err) row := txx.QueryRowx("SELECT COUNT(*) FROM stats WHERE player_uuid = $1 AND player_data = $2 AND queried_at = $3", normalizedUUID, playerData, player.QueriedAt) @@ -249,7 +249,7 @@ func TestPostgresPlayerRepository(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schemaName))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schemaName}.Sanitize())) require.NoError(t, err) _, err = txx.ExecContext( @@ -353,7 +353,7 @@ func TestPostgresPlayerRepository(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schemaName))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schemaName}.Sanitize())) require.NoError(t, err) // Only one row for this player should exist @@ -577,7 +577,7 @@ func TestPostgresPlayerRepository(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) require.NoError(t, err) _, err = txx.ExecContext(ctx, "DELETE FROM stats") @@ -591,7 +591,7 @@ func TestPostgresPlayerRepository(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(p.schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{p.schema}.Sanitize())) require.NoError(t, err) var count int diff --git a/internal/adapters/userrepository/postgres.go b/internal/adapters/userrepository/postgres.go index a3fdcf83..1c2149e7 100644 --- a/internal/adapters/userrepository/postgres.go +++ b/internal/adapters/userrepository/postgres.go @@ -5,8 +5,8 @@ import ( "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" @@ -65,7 +65,7 @@ func (p *Postgres) RegisterVisit(ctx context.Context, userID string, ipHash stri last_user_agent = EXCLUDED.last_user_agent, seen_count = users.seen_count + 1 RETURNING user_id, first_seen_at, last_seen_at, last_ip_hash, last_user_agent, seen_count`, - pq.QuoteIdentifier(p.schema)), + pgx.Identifier{p.schema}.Sanitize()), userID, now, ipHash, diff --git a/internal/adapters/userrepository/postgres_test.go b/internal/adapters/userrepository/postgres_test.go index eb26404a..7c3e2ef7 100644 --- a/internal/adapters/userrepository/postgres_test.go +++ b/internal/adapters/userrepository/postgres_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" + "github.com/jackc/pgx/v5" "github.com/jmoiron/sqlx" - "github.com/lib/pq" "github.com/stretchr/testify/require" "github.com/Amund211/flashlight/internal/adapters/database" @@ -23,7 +23,7 @@ func newPostgres(t *testing.T, db *sqlx.DB, schemaSuffix string, nowFunc func() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pq.QuoteIdentifier(schema))) + db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schema}.Sanitize())) migrator := database.NewDatabaseMigrator(db, logger) @@ -48,7 +48,7 @@ func TestPostgresRegisterVisit(t *testing.T) { require.NoError(t, err) defer txx.Rollback() - _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pq.QuoteIdentifier(schema))) + _, err = txx.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schema}.Sanitize())) require.NoError(t, err) var user dbUser From 37abad532b1a9018b9aed1bd5589ae205ca6f2d6 Mon Sep 17 00:00:00 2001 From: Amund Eggen Svandal Date: Thu, 14 May 2026 17:15:54 +0200 Subject: [PATCH 2/2] chore(db): Run migrations via database/pgx/v5 with per-schema DSN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the migrate driver from database/postgres to database/pgx/v5 and inject search_path via a one-off *sql.DB built with stdlib.OpenDB(*connConfig) where RuntimeParams["search_path"] is set to the target schema. Every connection in that pool gets search_path as a startup parameter, so migrate's WithInstance driver finds the right schema regardless of which conn it borrows. The schema name is run through pgx.Identifier.Sanitize() before being stuffed into the runtime param: Postgres parses each entry of search_path as a SQL identifier, so unquoted names get lower-cased and reject hyphens — without the quoting, mixed-case test schemas like find_milestone_Single_milestone_reached silently resolved to a non-existent schema and unqualified DDL failed with SQLSTATE 3F000. NewDatabaseMigrator now takes a connection string instead of an *sqlx.DB, mirroring how it builds its own short-lived pool. NewCloudsqlPostgresDatabase had only one caller and was redundant once ConnectionString was extracted; removed. lib/pq is no longer reachable from flashlight's binary or tests — it remains in go.sum only because jmoiron/sqlx's own test suite imports it, which the Go module graph keeps even though we never compile it. Co-Authored-By: Claude Opus 4.7 (1M context) --- go.mod | 1 + go.sum | 2 ++ .../accountrepository/postgres_test.go | 2 +- internal/adapters/database/migrator.go | 30 +++++++---------- internal/adapters/database/migrator_test.go | 14 ++++---- internal/adapters/database/postgres.go | 33 ++++++++++++++----- internal/adapters/database/postgres_test.go | 2 +- .../playerrepository/repository_test.go | 2 +- .../adapters/userrepository/postgres_test.go | 2 +- main.go | 5 +-- 10 files changed, 52 insertions(+), 41 deletions(-) diff --git a/go.mod b/go.mod index 8dd5bec6..20343203 100644 --- a/go.mod +++ b/go.mod @@ -121,6 +121,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index 0b1f997b..be7ed962 100644 --- a/go.sum +++ b/go.sum @@ -388,6 +388,8 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= diff --git a/internal/adapters/accountrepository/postgres_test.go b/internal/adapters/accountrepository/postgres_test.go index cb7c3138..715b0793 100644 --- a/internal/adapters/accountrepository/postgres_test.go +++ b/internal/adapters/accountrepository/postgres_test.go @@ -24,7 +24,7 @@ func newPostgres(t *testing.T, db *sqlx.DB, schemaSuffix string) *Postgres { db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schema}.Sanitize())) - migrator := database.NewDatabaseMigrator(db, logger) + migrator := database.NewDatabaseMigrator(database.LocalConnectionString, logger) err := migrator.Migrate(t.Context(), schema) require.NoError(t, err) diff --git a/internal/adapters/database/migrator.go b/internal/adapters/database/migrator.go index a68fd941..2a01e2e4 100644 --- a/internal/adapters/database/migrator.go +++ b/internal/adapters/database/migrator.go @@ -8,25 +8,24 @@ import ( "log/slog" "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/postgres" + pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jackc/pgx/v5" - "github.com/jmoiron/sqlx" ) //go:embed migrations/*.sql var embeddedMigrations embed.FS type migrator struct { - db *sqlx.DB + connectionString string logger *slog.Logger } -func NewDatabaseMigrator(db *sqlx.DB, logger *slog.Logger) *migrator { +func NewDatabaseMigrator(connectionString string, logger *slog.Logger) *migrator { return &migrator{ - db: db, - logger: logger, + connectionString: connectionString, + logger: logger, } } @@ -35,37 +34,32 @@ func (m *migrator) Migrate(ctx context.Context, schemaName string) error { } func (m *migrator) migrate(ctx context.Context, schemaName string) error { - conn, err := m.db.Conn(ctx) + db, err := openSchemaScopedDB(m.connectionString, schemaName) if err != nil { - return fmt.Errorf("migrate: failed to connect to db: %w", err) + return fmt.Errorf("migrate: %w", err) } - defer conn.Close() + defer db.Close() - _, err = conn.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", pgx.Identifier{schemaName}.Sanitize())) + _, err = db.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", pgx.Identifier{schemaName}.Sanitize())) if err != nil { return fmt.Errorf("migrate: failed to create schema: %w", err) } - _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schemaName}.Sanitize())) - if err != nil { - return fmt.Errorf("migrate: failed to set search path: %w", err) - } - migrationSource, err := iofs.New(embeddedMigrations, "migrations") if err != nil { return fmt.Errorf("migrate: failed to create driver from embedded migrations: %w", err) } defer migrationSource.Close() - dbDriver, err := postgres.WithConnection(ctx, conn, &postgres.Config{ + dbDriver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{ DatabaseName: DBName, SchemaName: schemaName, }) if err != nil { - return fmt.Errorf("migrate: failed to create postgres driver: %w", err) + return fmt.Errorf("migrate: failed to create pgx driver: %w", err) } - migratorInstance, err := migrate.NewWithInstance("iofs", migrationSource, "postgres", dbDriver) + migratorInstance, err := migrate.NewWithInstance("iofs", migrationSource, "pgx5", dbDriver) if err != nil { return fmt.Errorf("migrate: failed to create migration instance: %w", err) } diff --git a/internal/adapters/database/migrator_test.go b/internal/adapters/database/migrator_test.go index 3dca94a8..a056ab8b 100644 --- a/internal/adapters/database/migrator_test.go +++ b/internal/adapters/database/migrator_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/golang-migrate/migrate/v4" - "github.com/golang-migrate/migrate/v4/database/postgres" + pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" @@ -31,29 +31,27 @@ func TestMigrator(t *testing.T) { db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schemaName}.Sanitize())) logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - migrator := NewDatabaseMigrator(db, logger) + migrator := NewDatabaseMigrator(LocalConnectionString, logger) err = migrator.migrate(ctx, schemaName) require.NoError(t, err, "error migrating up") // Migrate down manually - conn, err := db.Conn(ctx) - require.NoError(t, err) - defer conn.Close() - _, err = conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s", pgx.Identifier{schemaName}.Sanitize())) + schemaDB, err := openSchemaScopedDB(LocalConnectionString, schemaName) require.NoError(t, err) + defer schemaDB.Close() migrationSource, err := iofs.New(embeddedMigrations, "migrations") require.NoError(t, err) defer migrationSource.Close() - dbDriver, err := postgres.WithConnection(ctx, conn, &postgres.Config{ + dbDriver, err := pgxmigrate.WithInstance(schemaDB, &pgxmigrate.Config{ DatabaseName: DBName, SchemaName: schemaName, }) require.NoError(t, err) - migratorInstance, err := migrate.NewWithInstance("iofs", migrationSource, "postgres", dbDriver) + migratorInstance, err := migrate.NewWithInstance("iofs", migrationSource, "pgx5", dbDriver) require.NoError(t, err) defer migratorInstance.Close() diff --git a/internal/adapters/database/postgres.go b/internal/adapters/database/postgres.go index 49873e28..f4115feb 100644 --- a/internal/adapters/database/postgres.go +++ b/internal/adapters/database/postgres.go @@ -1,10 +1,11 @@ package database import ( + "database/sql" "fmt" "github.com/jackc/pgx/v5" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5/stdlib" "github.com/jmoiron/sqlx" "github.com/Amund211/flashlight/internal/config" @@ -38,20 +39,34 @@ func NewPostgresDatabase(connectionString string) (*sqlx.DB, error) { return db, nil } -func NewCloudsqlPostgresDatabase(conf config.Config) (*sqlx.DB, error) { - var connectionString string +func ConnectionString(conf config.Config) string { if conf.IsDevelopment() { - connectionString = LocalConnectionString - } else { - connectionString = GetCloudSQLConnectionString(conf.DBUsername(), conf.DBPassword(), conf.CloudSQLUnixSocketPath()) + return LocalConnectionString } + return GetCloudSQLConnectionString(conf.DBUsername(), conf.DBPassword(), conf.CloudSQLUnixSocketPath()) +} - db, err := NewPostgresDatabase(connectionString) +// openSchemaScopedDB returns a one-off *sql.DB whose every connection has its +// search_path set to schemaName. Used by the migrator (and its tests) so +// migrate's WithInstance driver runs unqualified SQL in the right schema +// regardless of which conn from the pool it borrows. +// +// The schema name is double-quoted before being sent as a startup parameter: +// Postgres parses each comma-separated entry of search_path as a SQL +// identifier, so unquoted entries get lower-cased and reject hyphens. Without +// the quoting, mixed-case or hyphenated schema names silently resolve to a +// non-existent schema and unqualified DDL fails with SQLSTATE 3F000. +func openSchemaScopedDB(connectionString, schemaName string) (*sql.DB, error) { + connConfig, err := pgx.ParseConfig(connectionString) if err != nil { - return nil, fmt.Errorf("failed to create postgres database: %w", err) + return nil, fmt.Errorf("failed to parse connection config: %w", err) + } + if connConfig.RuntimeParams == nil { + connConfig.RuntimeParams = map[string]string{} } + connConfig.RuntimeParams["search_path"] = pgx.Identifier{schemaName}.Sanitize() - return db, nil + return stdlib.OpenDB(*connConfig), nil } func createDatabaseIfNotExists(db *sqlx.DB, dbName string) error { diff --git a/internal/adapters/database/postgres_test.go b/internal/adapters/database/postgres_test.go index 592cb554..36cc6584 100644 --- a/internal/adapters/database/postgres_test.go +++ b/internal/adapters/database/postgres_test.go @@ -33,7 +33,7 @@ func TestDB(t *testing.T) { t.Run("createDatabaseIfNotExists", func(t *testing.T) { t.Parallel() - db, err := sqlx.Connect("postgres", LocalConnectionString) + db, err := sqlx.Connect("pgx", LocalConnectionString) require.NoError(t, err) t.Run("already existing", func(t *testing.T) { t.Parallel() diff --git a/internal/adapters/playerrepository/repository_test.go b/internal/adapters/playerrepository/repository_test.go index f1e714f4..1418ca46 100644 --- a/internal/adapters/playerrepository/repository_test.go +++ b/internal/adapters/playerrepository/repository_test.go @@ -25,7 +25,7 @@ func newPostgresPlayerRepository(t *testing.T, db *sqlx.DB, schema string) *Post db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schema}.Sanitize())) - migrator := database.NewDatabaseMigrator(db, logger) + migrator := database.NewDatabaseMigrator(database.LocalConnectionString, logger) err := migrator.Migrate(t.Context(), schema) require.NoError(t, err) diff --git a/internal/adapters/userrepository/postgres_test.go b/internal/adapters/userrepository/postgres_test.go index 7c3e2ef7..75f17606 100644 --- a/internal/adapters/userrepository/postgres_test.go +++ b/internal/adapters/userrepository/postgres_test.go @@ -25,7 +25,7 @@ func newPostgres(t *testing.T, db *sqlx.DB, schemaSuffix string, nowFunc func() db.MustExec(fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", pgx.Identifier{schema}.Sanitize())) - migrator := database.NewDatabaseMigrator(db, logger) + migrator := database.NewDatabaseMigrator(database.LocalConnectionString, logger) err := migrator.Migrate(t.Context(), schema) require.NoError(t, err) diff --git a/main.go b/main.go index 988ecb02..9232eb62 100644 --- a/main.go +++ b/main.go @@ -130,7 +130,8 @@ func main() { logger.InfoContext(ctx, "Initialized Sentry middleware") logger.InfoContext(ctx, "Initializing database connection") - db, err := database.NewCloudsqlPostgresDatabase(config) + connectionString := database.ConnectionString(config) + db, err := database.NewPostgresDatabase(connectionString) if err != nil { fail("Failed to initialize PostgresPlayerRepository", "error", err.Error()) } @@ -145,7 +146,7 @@ func main() { repositorySchemaName := database.GetSchemaName(!config.IsProduction()) - err = database.NewDatabaseMigrator(db, logger.With("component", "migrator")).Migrate(ctx, repositorySchemaName) + err = database.NewDatabaseMigrator(connectionString, logger.With("component", "migrator")).Migrate(ctx, repositorySchemaName) if err != nil { fail("Failed to migrate database", "error", err.Error()) }