Skip to content

Commit 770ea26

Browse files
committed
feat: use internal plpgsql_scanner instead of pg_query_go
Statement splitting, classification, body extraction, and language detection all handled by the new `plpgsql_scanner.go`. Pure Go build now, no C compiler needed! The scanner doesn't validate SQL syntax but `pgcov` doesn't need to because PostgreSQL itself validates when executing tests themselves
1 parent 5b0ad60 commit 770ea26

18 files changed

Lines changed: 407 additions & 591 deletions

File tree

go.mod

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ go 1.25.5
44

55
require (
66
github.com/jackc/pgx/v5 v5.8.0
7-
github.com/pganalyze/pg_query_go/v6 v6.1.0
87
github.com/testcontainers/testcontainers-go v0.40.0
98
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
109
github.com/urfave/cli/v3 v3.6.1

go.sum

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
4242
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
4343
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
4444
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
45-
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
46-
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
4745
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
4846
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
4947
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -95,8 +93,6 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
9593
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
9694
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
9795
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
98-
github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls=
99-
github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50=
10096
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
10197
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
10298
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -173,8 +169,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:
173169
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
174170
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
175171
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
176-
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
177-
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
178172
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
179173
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
180174
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/cli/run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ func Run(ctx context.Context, config *Config, searchPath string) (int, error) {
9999
// Step 7: Collect coverage
100100
collector := coverage.NewCollector()
101101

102+
// Seed all instrumented positions with 0 hits so that unexecuted branches
103+
// (e.g. ELSIF/ELSE arms) appear as "not covered" in reports.
104+
collector.InitializeFromInstrumented(instrumentedSources)
105+
102106
if err := collector.CollectFromRuns(testRuns); err != nil {
103107
return 1, fmt.Errorf("coverage collection failed: %w", err)
104108
}

internal/coverage/collector.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,28 @@ func (c *Collector) GetFileList() []string {
138138
return files
139139
}
140140

141+
// InitializeFromInstrumented seeds the coverage data with 0-hit entries for
142+
// every non-implicit CoveragePoint that has not yet been recorded. This
143+
// ensures that unexecuted branches (e.g. ELSIF/ELSE arms that were never
144+
// taken) appear as "not covered" in reports instead of being absent.
145+
func (c *Collector) InitializeFromInstrumented(instrumented []*instrument.InstrumentedSQL) {
146+
c.mu.Lock()
147+
defer c.mu.Unlock()
148+
149+
for _, inst := range instrumented {
150+
for _, cp := range inst.Locations {
151+
if cp.ImplicitCoverage {
152+
continue // DDL/DML are tracked separately
153+
}
154+
// Only seed if not already present (do not overwrite real hit counts).
155+
posKey := fmt.Sprintf("%d:%d", cp.StartPos, cp.Length)
156+
if _, exists := c.coverage.Positions[cp.File][posKey]; !exists {
157+
c.coverage.AddPosition(cp.File, cp.StartPos, cp.Length, 0)
158+
}
159+
}
160+
}
161+
}
162+
141163
// TotalCoveragePercent returns the overall coverage percentage
142164
func (c *Collector) TotalCoveragePercent() float64 {
143165
c.mu.Lock()

internal/database/listener.go

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,22 @@ import (
88
"github.com/cybertec-postgresql/pgcov/pkg/types"
99
"github.com/jackc/pgx/v5"
1010
"github.com/jackc/pgx/v5/pgconn"
11+
"github.com/jackc/pgx/v5/pgxpool"
1112
)
1213

1314
// Listener handles PostgreSQL LISTEN/NOTIFY for coverage signals
1415
type Listener struct {
15-
conn *pgx.Conn
16-
channel string
17-
signals chan types.CoverageSignal
18-
errors chan error
19-
done chan struct{}
20-
connString string
16+
conn *pgx.Conn
17+
channel string
18+
signals chan types.CoverageSignal
19+
errors chan error
20+
done chan struct{}
2121
}
2222

23-
// NewListener creates a new LISTEN/NOTIFY listener
24-
func NewListener(ctx context.Context, connString string, channel string) (*Listener, error) {
25-
// Parse connection string
26-
config, err := pgx.ParseConfig(connString)
27-
if err != nil {
28-
return nil, fmt.Errorf("failed to parse connection string: %w", err)
29-
}
30-
31-
// Connect to database
32-
conn, err := pgx.ConnectConfig(ctx, config)
23+
// NewListener creates a new LISTEN/NOTIFY listener using the config from a pool.
24+
func NewListener(ctx context.Context, pool *pgxpool.Pool, channel string) (*Listener, error) {
25+
// Connect using the pool's connection config
26+
conn, err := pgx.ConnectConfig(ctx, pool.Config().ConnConfig.Copy())
3327
if err != nil {
3428
return nil, fmt.Errorf("failed to connect for LISTEN: %w", err)
3529
}
@@ -42,12 +36,11 @@ func NewListener(ctx context.Context, connString string, channel string) (*Liste
4236
}
4337

4438
listener := &Listener{
45-
conn: conn,
46-
channel: channel,
47-
signals: make(chan types.CoverageSignal, 1000), // Buffered to avoid blocking
48-
errors: make(chan error, 10),
49-
done: make(chan struct{}),
50-
connString: connString,
39+
conn: conn,
40+
channel: channel,
41+
signals: make(chan types.CoverageSignal, 1000), // Buffered to avoid blocking
42+
errors: make(chan error, 10),
43+
done: make(chan struct{}),
5144
}
5245

5346
// Start background goroutine to receive notifications

internal/database/tempdb.go

Lines changed: 18 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -7,166 +7,44 @@ import (
77
"fmt"
88
"time"
99

10-
"github.com/cybertec-postgresql/pgcov/pkg/types"
1110
"github.com/jackc/pgx/v5/pgxpool"
1211
)
1312

14-
// CreateTempDatabase creates a temporary database with a unique name
15-
func CreateTempDatabase(ctx context.Context, pool *Pool) (*types.TempDatabase, error) {
16-
// Generate unique database name
13+
// CreateTempDatabase creates a temporary database and returns a pool connected to it.
14+
// The database name is accessible via pool.Config().ConnConfig.Database.
15+
func CreateTempDatabase(ctx context.Context, adminPool *Pool) (*pgxpool.Pool, error) {
1716
timestamp := time.Now().Format("20060102_150405")
1817
randomBytes := make([]byte, 4)
1918
if _, err := rand.Read(randomBytes); err != nil {
2019
return nil, fmt.Errorf("failed to generate random suffix: %w", err)
2120
}
2221
randomSuffix := hex.EncodeToString(randomBytes)
23-
2422
dbName := fmt.Sprintf("pgcov_test_%s_%s", timestamp, randomSuffix)
2523

26-
// Create database (must use a connection to template database)
27-
conn, err := pool.Acquire(ctx)
28-
if err != nil {
29-
return nil, fmt.Errorf("failed to acquire connection: %w", err)
30-
}
31-
defer conn.Release()
32-
33-
// CREATE DATABASE cannot run in a transaction
34-
createSQL := fmt.Sprintf("CREATE DATABASE %s", dbName)
35-
_, err = conn.Exec(ctx, createSQL)
24+
_, err := adminPool.Exec(ctx, fmt.Sprintf("CREATE DATABASE %s", dbName))
3625
if err != nil {
3726
return nil, fmt.Errorf("failed to create temporary database: %w", err)
3827
}
3928

40-
// Build connection string for the new database by replacing the dbname in the original connection string
41-
// Parse the original connection config and change the database
42-
baseConfig, err := pgxpool.ParseConfig(pool.config.ConnectionString)
43-
if err != nil {
44-
// Fallback: if parsing fails, drop the database and return error
45-
_, _ = conn.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName))
46-
return nil, fmt.Errorf("failed to parse base connection string: %w", err)
47-
}
29+
// Build connection string for the new database, preserving all original options (sslmode, etc.)
30+
config := adminPool.Pool.Config()
31+
config.ConnConfig.Database = dbName
4832

49-
// Build new connection string with the new database name
50-
connString := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
51-
baseConfig.ConnConfig.Host,
52-
baseConfig.ConnConfig.Port,
53-
baseConfig.ConnConfig.User,
54-
baseConfig.ConnConfig.Password,
55-
dbName,
56-
"prefer") // Default to prefer for sslmode
57-
58-
return &types.TempDatabase{
59-
Name: dbName,
60-
CreatedAt: time.Now(),
61-
ConnectionString: connString,
62-
}, nil
63-
}
64-
65-
// DestroyTempDatabase drops a temporary database
66-
func DestroyTempDatabase(ctx context.Context, pool *Pool, tempDB *types.TempDatabase) error {
67-
if tempDB == nil {
68-
return nil
69-
}
70-
71-
conn, err := pool.Acquire(ctx)
72-
if err != nil {
73-
return fmt.Errorf("failed to acquire connection: %w", err)
74-
}
75-
defer conn.Release()
76-
77-
// Terminate all connections to the database first (PostgreSQL 13+)
78-
terminateSQL := `SELECT pg_terminate_backend(pid)
79-
FROM pg_stat_activity
80-
WHERE datname = $1 AND pid <> pg_backend_pid()`
81-
82-
_, _ = conn.Exec(ctx, terminateSQL, tempDB.Name)
83-
84-
// Drop database with FORCE option (PostgreSQL 13+)
85-
dropSQL := fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", tempDB.Name)
86-
_, err = conn.Exec(ctx, dropSQL)
33+
tempPool, err := pgxpool.NewWithConfig(ctx, config)
8734
if err != nil {
88-
return fmt.Errorf("failed to drop temporary database %s: %w", tempDB.Name, err)
89-
}
90-
91-
// Verify database was actually dropped
92-
if err := verifyDatabaseDropped(ctx, conn, tempDB.Name); err != nil {
93-
return fmt.Errorf("cleanup verification failed for database %s: %w", tempDB.Name, err)
35+
_, _ = adminPool.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName))
36+
return nil, fmt.Errorf("failed to connect to temp database: %w", err)
9437
}
9538

96-
return nil
39+
return tempPool, nil
9740
}
9841

99-
// verifyDatabaseDropped checks that a database no longer exists in PostgreSQL catalog
100-
func verifyDatabaseDropped(ctx context.Context, conn *pgxpool.Conn, dbName string) error {
101-
verifySQL := `
102-
SELECT EXISTS(
103-
SELECT 1
104-
FROM pg_database
105-
WHERE datname = $1
106-
)
107-
`
108-
109-
var exists bool
110-
err := conn.QueryRow(ctx, verifySQL, dbName).Scan(&exists)
111-
if err != nil {
112-
return fmt.Errorf("failed to verify database deletion: %w", err)
113-
}
114-
115-
if exists {
116-
return fmt.Errorf("database %s still exists after DROP command", dbName)
117-
}
118-
119-
return nil
120-
}
121-
122-
// CleanupStaleTempDatabases removes old pgcov temporary databases
123-
// This is useful for cleanup after crashes or interrupted test runs
124-
// Returns list of cleaned databases and any errors encountered
125-
func CleanupStaleTempDatabases(ctx context.Context, pool *Pool, _ time.Duration) ([]string, error) {
126-
conn, err := pool.Acquire(ctx)
127-
if err != nil {
128-
return nil, fmt.Errorf("failed to acquire connection: %w", err)
129-
}
130-
defer conn.Release()
131-
132-
// Find pgcov temp databases
133-
query := `
134-
SELECT datname
135-
FROM pg_database
136-
WHERE datname LIKE 'pgcov_test_%'
137-
`
138-
139-
rows, err := conn.Query(ctx, query)
140-
if err != nil {
141-
return nil, fmt.Errorf("failed to query temp databases: %w", err)
142-
}
143-
defer rows.Close()
144-
145-
var cleaned []string
146-
var failedCleanup []string
147-
148-
for rows.Next() {
149-
var dbName string
150-
if err := rows.Scan(&dbName); err != nil {
151-
continue
152-
}
153-
154-
// Extract timestamp from database name
155-
// Format: pgcov_test_YYYYMMDD_HHMMSS_randomhex
156-
tempDB := &types.TempDatabase{Name: dbName}
157-
158-
// Attempt to drop (will fail if database is in use)
159-
if err := DestroyTempDatabase(ctx, pool, tempDB); err == nil {
160-
cleaned = append(cleaned, dbName)
161-
} else {
162-
failedCleanup = append(failedCleanup, dbName)
163-
}
164-
}
165-
166-
// Report cleanup failures as non-fatal warning
167-
if len(failedCleanup) > 0 {
168-
return cleaned, fmt.Errorf("failed to cleanup %d databases: %v (may be in use)", len(failedCleanup), failedCleanup)
42+
// DestroyTempDatabase closes the temp pool and drops its underlying database.
43+
func DestroyTempDatabase(ctx context.Context, adminPool *Pool, tempPool *pgxpool.Pool) error {
44+
if tempPool == nil {
45+
return nil
16946
}
170-
171-
return cleaned, nil
47+
tempPool.Close()
48+
_, err := adminPool.Exec(ctx, fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", tempPool.Config().ConnConfig.Database))
49+
return err
17250
}

0 commit comments

Comments
 (0)