aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile7
-rw-r--r--benchmark/benchmark_test.go24
-rw-r--r--benchmark/go.mod9
-rw-r--r--benchmark/go.sum30
-rw-r--r--benchmark/internal/oblast_pgx/handle.go81
-rw-r--r--benchmark/internal/oblast_pgx/results.go66
-rw-r--r--benchmark/internal/oblast_pgx/statement.go60
-rw-r--r--benchmark/postgres_test.go396
-rw-r--r--go.work.sum17
9 files changed, 670 insertions, 20 deletions
diff --git a/Makefile b/Makefile
index d364193..ab441e7 100644
--- a/Makefile
+++ b/Makefile
@@ -12,8 +12,11 @@ static-check: FORCE
@printf "\e[1;36m>> reuse lint\e[0m\n"
@if ! reuse lint -q; then reuse lint; fi
-benchmark: FORCE
- @cd benchmark && go test -bench . -benchmem .
+benchmark-orm: FORCE
+ @cd benchmark && go test -bench BenchmarkORM -benchmem .
+
+benchmark-postgres: FORCE
+ @cd benchmark && go test -bench BenchmarkPostgres -benchmem .
GO_COVERPKGS := $(shell go list ./... | grep -vw testhelpers | tr '\n' , | sed 's/,$$//')
GO_TESTPKGS := $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)
diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go
index f0967b9..da764b2 100644
--- a/benchmark/benchmark_test.go
+++ b/benchmark/benchmark_test.go
@@ -21,13 +21,16 @@ import (
"gorm.io/gorm"
)
+// NOTE: In this file, we benchmark different ORMs against each other and against hand-written operations using plain database/sql.
+// All benchmarks are called "BenchmarkORM...".
+
// Do not use b.Context() within benchmarks, or you will merely demonstrate that using a deep stack of Context objects is expensive.
var noctx = context.Background()
// This is not a real benchmark (obviously).
// Its purpose is to be the first line that is printed, while having one of the longest names,
// so that all other results are aligned with it and the table looks nice.
-func BenchmarkHeadingHeadingHeadingHeadingHeadingHeadingHeadingHeading(b *testing.B) {
+func BenchmarkORMHeadingHeadingHeadingHeadingHeadingHeadingHeadingHeading(b *testing.B) {
for b.Loop() {
time.Sleep(time.Microsecond)
}
@@ -40,7 +43,7 @@ var (
batchSizesForUpdate = []int{1, 2, 4, 8, 16, 100}
)
-func makeTestDB(t testing.TB, recordCount int) (db *sql.DB, dsn string) {
+func makeSqliteTestDB(t testing.TB, recordCount int) (db *sql.DB, dsn string) {
dsn = fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
db = must.Return(sql.Open("sqlite3", dsn))(t)
_ = must.Return(db.Exec(`CREATE TABLE entries (id INTEGER, message TEXT, PRIMARY KEY (id AUTOINCREMENT))`))(t)
@@ -75,8 +78,8 @@ type GormEntry struct {
func (GormEntry) TableName() string { return "entries" }
-func BenchmarkSelectMany(b *testing.B) {
- db, dsn := makeTestDB(b, totalRecordCountForSelect)
+func BenchmarkORMSelectMany(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, totalRecordCountForSelect)
dbh := oblast.Wrap(db)
// test with different sizes of resultsets (N=1 is an OLTP-like workload,
@@ -171,8 +174,8 @@ func BenchmarkSelectMany(b *testing.B) {
}
}
-func BenchmarkSelectOne(b *testing.B) {
- db, dsn := makeTestDB(b, totalRecordCountForSelect)
+func BenchmarkORMSelectOne(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, totalRecordCountForSelect)
dbh := oblast.Wrap(db)
// grab a "random" record from the DB, not just the first or the last
@@ -256,8 +259,8 @@ func BenchmarkSelectOne(b *testing.B) {
})
}
-func BenchmarkInsertAndDelete(b *testing.B) {
- db, dsn := makeTestDB(b, 0)
+func BenchmarkORMInsertAndDelete(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, 0)
dbh := oblast.Wrap(db)
store := oblast.MustNewStore[OblastEntry](
@@ -442,8 +445,8 @@ func BenchmarkInsertAndDelete(b *testing.B) {
}
}
-func BenchmarkUpdate(b *testing.B) {
- db, dsn := makeTestDB(b, 0)
+func BenchmarkORMUpdate(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, 0)
dbh := oblast.Wrap(db)
store := oblast.MustNewStore[OblastEntry](
@@ -507,6 +510,7 @@ func BenchmarkUpdate(b *testing.B) {
for _, r := range recordsForOblast {
_ = must.Return(stmt.Exec(message, r.ID))(b)
}
+ must.Succeed(b, stmt.Close())
}
checkRecordsUpdated := func(b *testing.B, message string) {
var count int64
diff --git a/benchmark/go.mod b/benchmark/go.mod
index 75f27db..5f981fa 100644
--- a/benchmark/go.mod
+++ b/benchmark/go.mod
@@ -4,14 +4,19 @@ go 1.26.0
require (
github.com/go-gorp/gorp/v3 v3.1.0
+ github.com/jackc/pgx/v5 v5.9.2
+ github.com/lib/pq v1.10.7
github.com/mattn/go-sqlite3 v1.14.42
- go.xyrillian.de/oblast v0.0.0-20260417200949-ba4f55e75e6b
+ go.xyrillian.de/oblast v0.7.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- golang.org/x/text v0.20.0 // indirect
+ go.xyrillian.de/gg v1.7.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
)
diff --git a/benchmark/go.sum b/benchmark/go.sum
index 5701940..0455a12 100644
--- a/benchmark/go.sum
+++ b/benchmark/go.sum
@@ -1,9 +1,18 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
+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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -16,12 +25,21 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
-github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-go.xyrillian.de/oblast v0.0.0-20260417200949-ba4f55e75e6b h1:TalkSqODn/2HbLiwd/Tgb5KFXmzJa6O1BbassZyLnVU=
-go.xyrillian.de/oblast v0.0.0-20260417200949-ba4f55e75e6b/go.mod h1:lo6ekGOHTID0KeSWhNQV1gjYJ2BfhXgenUEBNBnZkBM=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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=
+go.xyrillian.de/gg v1.7.0 h1:IA0BJaX9TtBD7crH+CSoK4lYmBk5zi7nUQd0YRzPNf0=
+go.xyrillian.de/gg v1.7.0/go.mod h1:dj+ZhCwC6JKWyFvImhVNXQAErrRcYMUkXu6vwWYNrzQ=
+go.xyrillian.de/oblast v0.7.0 h1:0+TR2De6/OvOm7MpLYukgkkzgZ3HUW/oTtOe3EslsH4=
+go.xyrillian.de/oblast v0.7.0/go.mod h1:fR/UKmOOv44L27Xe2hlkrHKs9pl+D/9wWXFH6WiDeYI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+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=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
diff --git a/benchmark/internal/oblast_pgx/handle.go b/benchmark/internal/oblast_pgx/handle.go
new file mode 100644
index 0000000..6a88e2b
--- /dev/null
+++ b/benchmark/internal/oblast_pgx/handle.go
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast_pgx
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "sync/atomic"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "go.xyrillian.de/oblast/handle"
+)
+
+type Handle interface {
+ Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
+ Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
+ QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
+}
+
+var (
+ _ Handle = &pgx.Conn{}
+ _ Handle = pgx.Tx(nil)
+)
+
+// TODO: offer wrapping for pgxpool.Pool and pgxpool.Conn?
+func Wrap(h Handle) handle.Handle {
+ switch h := h.(type) {
+ case *pgx.Conn:
+ return wrappedHandle{h}
+ case pgx.Tx:
+ return wrappedHandle{h}
+ default:
+ panic(fmt.Sprintf("unexpected type: %#v", h))
+ }
+}
+
+var preparedStatementId atomic.Uint64
+
+type wrappedHandle struct {
+ inner Handle
+}
+
+// Prepare implements the [handle.Handle] interface.
+func (h wrappedHandle) Prepare(ctx context.Context, query string, repeated bool) (handle.Statement, error) {
+ if !repeated {
+ return wrappedUnpreparedStatement{query, h.inner}, nil
+ }
+
+ name := "oblast_pgx_" + strconv.FormatUint(preparedStatementId.Add(1), 10)
+ switch inner := h.inner.(type) {
+ case *pgx.Conn:
+ stmt, err := inner.Prepare(ctx, name, query)
+ return wrappedPreparedStatement{ctx, stmt, h.inner}, err
+ case pgx.Tx:
+ stmt, err := inner.Conn().Prepare(ctx, name, query)
+ return wrappedPreparedStatement{ctx, stmt, h.inner}, err
+ default:
+ panic("unreachable") // because of the check in func Wrap()
+ }
+}
+
+// Releases a prepared statement.
+func deallocate(ctx context.Context, h Handle, stmt *pgconn.StatementDescription) error {
+ switch h := h.(type) {
+ case *pgx.Conn:
+ return h.Deallocate(ctx, stmt.Name)
+ case pgx.Tx:
+ return h.Conn().Deallocate(ctx, stmt.Name)
+ default:
+ panic("unreachable") // because of the check in func Wrap()
+ }
+}
+
+// Query implements the [handle.Handle] interface.
+func (h wrappedHandle) Query(ctx context.Context, query string, args []any) (handle.Rows, error) {
+ rows, err := h.inner.Query(ctx, query, args...)
+ return wrappedRows{rows}, err
+}
diff --git a/benchmark/internal/oblast_pgx/results.go b/benchmark/internal/oblast_pgx/results.go
new file mode 100644
index 0000000..3ccb5ce
--- /dev/null
+++ b/benchmark/internal/oblast_pgx/results.go
@@ -0,0 +1,66 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast_pgx
+
+import (
+ "database/sql"
+ "errors"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "go.xyrillian.de/oblast/handle"
+)
+
+type wrappedRows struct {
+ inner pgx.Rows
+}
+
+var _ handle.Rows = wrappedRows{}
+
+// Columns implements the [handle.Rows] interface.
+func (r wrappedRows) Columns() ([]string, error) {
+ descriptions := r.inner.FieldDescriptions()
+ result := make([]string, len(descriptions))
+ for idx, desc := range descriptions {
+ result[idx] = desc.Name
+ }
+ return result, nil
+}
+
+// Close implements the [handle.Rows] interface.
+func (r wrappedRows) Close() error {
+ r.inner.Close()
+ return nil
+}
+
+// Err implements the [handle.Rows] interface.
+func (r wrappedRows) Err() error {
+ return r.inner.Err()
+}
+
+// Next implements the [handle.Rows] interface.
+func (r wrappedRows) Next() bool {
+ return r.inner.Next()
+}
+
+// Scan implements the [handle.Rows] interface.
+func (r wrappedRows) Scan(args ...any) error {
+ return r.inner.Scan(args...)
+}
+
+type wrappedResult struct {
+ inner pgconn.CommandTag
+}
+
+var _ sql.Result = wrappedResult{}
+
+// LastInsertId implements the [sql.Result] interface.
+func (r wrappedResult) LastInsertId() (int64, error) {
+ return 0, errors.New("PostgreSQL does not support LastInsertId()")
+}
+
+// LastInsertId implements the [sql.Result] interface.
+func (r wrappedResult) RowsAffected() (int64, error) {
+ return r.inner.RowsAffected(), nil
+}
diff --git a/benchmark/internal/oblast_pgx/statement.go b/benchmark/internal/oblast_pgx/statement.go
new file mode 100644
index 0000000..d81c579
--- /dev/null
+++ b/benchmark/internal/oblast_pgx/statement.go
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast_pgx
+
+import (
+ "context"
+ "database/sql"
+
+ "github.com/jackc/pgx/v5/pgconn"
+ "go.xyrillian.de/oblast/handle"
+)
+
+type wrappedPreparedStatement struct {
+ ctx context.Context
+ statement *pgconn.StatementDescription
+ handle Handle
+}
+
+type wrappedUnpreparedStatement struct {
+ query string
+ handle Handle
+}
+
+var (
+ _ handle.Statement = wrappedPreparedStatement{}
+ _ handle.Statement = wrappedUnpreparedStatement{}
+)
+
+// Close implements the [handle.Statement] interface.
+func (s wrappedPreparedStatement) Close() error {
+ return deallocate(s.ctx, s.handle, s.statement)
+}
+
+// Close implements the [handle.Statement] interface.
+func (s wrappedUnpreparedStatement) Close() error {
+ return nil
+}
+
+// Exec implements the [handle.Statement] interface.
+func (s wrappedPreparedStatement) Exec(ctx context.Context, args []any) (sql.Result, error) {
+ result, err := s.handle.Exec(ctx, s.statement.Name, args...)
+ return wrappedResult{result}, err
+}
+
+// Exec implements the [handle.Statement] interface.
+func (s wrappedUnpreparedStatement) Exec(ctx context.Context, args []any) (sql.Result, error) {
+ result, err := s.handle.Exec(ctx, s.query, args...)
+ return wrappedResult{result}, err
+}
+
+// QueryRow implements the [handle.Statement] interface.
+func (s wrappedPreparedStatement) QueryRow(ctx context.Context, args, slots []any) error {
+ return s.handle.QueryRow(ctx, s.statement.Name, args...).Scan(slots...)
+}
+
+// QueryRow implements the [handle.Statement] interface.
+func (s wrappedUnpreparedStatement) QueryRow(ctx context.Context, args, slots []any) error {
+ return s.handle.QueryRow(ctx, s.query, args...).Scan(slots...)
+}
diff --git a/benchmark/postgres_test.go b/benchmark/postgres_test.go
new file mode 100644
index 0000000..320ea2a
--- /dev/null
+++ b/benchmark/postgres_test.go
@@ -0,0 +1,396 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package main_test
+
+import (
+ "cmp"
+ "crypto/sha256"
+ "database/sql"
+ "fmt"
+ "os"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ _ "github.com/lib/pq"
+ "go.xyrillian.de/oblast"
+ "go.xyrillian.de/oblast/benchmark/internal/oblast_pgx"
+ "go.xyrillian.de/oblast/internal/testhelpers/assert"
+ "go.xyrillian.de/oblast/internal/testhelpers/must"
+)
+
+// NOTE: In this file, we benchmark different PostgreSQL database drivers against each other with or without Oblast inbetween.
+// All benchmarks are called "BenchmarkPostgres...".
+// To run these benchmarks, you need to have provide a DSN to a PostgreSQL database in $BENCHMARK_POSTGRES_DSN.
+
+// This is not a real benchmark (obviously).
+// Its purpose is to be the first line that is printed, while having one of the longest names,
+// so that all other results are aligned with it and the table looks nice.
+func BenchmarkPostgresHeadingHeadingHeadingHeadingHeadingHeadingHeadingHeading(b *testing.B) {
+ for b.Loop() {
+ time.Sleep(time.Microsecond)
+ }
+}
+
+const defaultPostgresDSN = "host=localhost user=postgres dbname=oblast_benchmark sslmode=disable"
+
+func connectToPostgresTestDB(t testing.TB, recordCount int) *sql.DB {
+ dsn := cmp.Or(os.Getenv("BENCHMARK_POSTGRES_DSN"), defaultPostgresDSN)
+ db := must.Return(sql.Open("postgres", dsn))(t)
+ _ = must.Return(db.Exec(`CREATE TEMPORARY TABLE entries (id BIGSERIAL, message TEXT)`))(t)
+
+ if recordCount > 0 {
+ // fill in some random-looking, but deterministic data
+ stmt := must.Return(db.Prepare(`INSERT INTO entries (id, message) VALUES ($1, $2)`))(t)
+ for idx := range recordCount {
+ buf := sha256.Sum256([]byte(strconv.Itoa(idx)))
+ _ = must.Return(stmt.Exec(idx, fmt.Sprintf("sha256:%x", buf[:])))(t)
+ }
+ must.Succeed(t, stmt.Close())
+ }
+
+ return db
+}
+
+func connectToPgxTestDB(t testing.TB, recordCount int) *pgx.Conn {
+ ctx := t.Context()
+ dsn := cmp.Or(os.Getenv("BENCHMARK_POSTGRES_DSN"), defaultPostgresDSN)
+ conn := must.Return(pgx.Connect(ctx, dsn))(t)
+ _ = must.Return(conn.Exec(ctx, `CREATE TEMPORARY TABLE entries (id BIGSERIAL, message TEXT)`))(t)
+
+ if recordCount > 0 {
+ // fill in some random-looking, but deterministic data
+ sql := `INSERT INTO entries (id, message) VALUES ($1, $2)`
+ stmt := must.Return(conn.Prepare(ctx, sql, sql))(t)
+ for idx := range recordCount {
+ buf := sha256.Sum256([]byte(strconv.Itoa(idx)))
+ _ = must.Return(conn.Exec(ctx, sql, idx, fmt.Sprintf("sha256:%x", buf[:])))(t)
+ }
+ must.Succeed(t, conn.Deallocate(ctx, stmt.Name))
+ }
+
+ return conn
+}
+
+func BenchmarkPostgresSelect(b *testing.B) {
+ pqDB := connectToPostgresTestDB(b, totalRecordCountForSelect)
+ pqDBH := oblast.Wrap(pqDB)
+ pgxConn := connectToPgxTestDB(b, totalRecordCountForSelect)
+ pgxConnH := oblast_pgx.Wrap(pgxConn)
+
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.PostgresDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ for _, batchSize := range batchSizesForSelect {
+ b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
+ partialQuery := `id < ` + strconv.Itoa(batchSize)
+ query := `SELECT * FROM entries WHERE ` + partialQuery
+
+ b.Run("driver=pq/strategy=oblast", func(b *testing.B) {
+ for b.Loop() {
+ records := must.Return(store.Select(noctx, pqDBH, query))(b)
+ assert.Equal(b, len(records), batchSize)
+ }
+ })
+
+ b.Run("driver=pgx/strategy=oblast", func(b *testing.B) {
+ for b.Loop() {
+ records := must.Return(store.Select(noctx, pgxConnH, query))(b)
+ assert.Equal(b, len(records), batchSize)
+ }
+ })
+
+ b.Run("driver=pq/strategy=straight", func(b *testing.B) {
+ for b.Loop() {
+ var records []OblastEntry
+ rows := must.Return(pqDB.Query(query))(b) //nolint:rowserrcheck // false positive
+ for rows.Next() {
+ var e OblastEntry
+ must.Succeed(b, rows.Scan(&e.ID, &e.Message))
+ records = append(records, e)
+ }
+ must.Succeed(b, rows.Close())
+ assert.Equal(b, len(records), batchSize)
+ }
+ })
+
+ b.Run("driver=pgx/strategy=straight", func(b *testing.B) {
+ for b.Loop() {
+ var records []OblastEntry
+ rows := must.Return(pgxConn.Query(noctx, query))(b) //nolint:rowserrcheck // false positive
+ for rows.Next() {
+ var e OblastEntry
+ must.Succeed(b, rows.Scan(&e.ID, &e.Message))
+ records = append(records, e)
+ }
+ rows.Close()
+ assert.Equal(b, len(records), batchSize)
+ }
+ })
+ })
+ }
+}
+
+func BenchmarkPostgresSelectOne(b *testing.B) {
+ pqDB := connectToPostgresTestDB(b, totalRecordCountForSelect)
+ pqDBH := oblast.Wrap(pqDB)
+ pgxConn := connectToPgxTestDB(b, totalRecordCountForSelect)
+ pgxConnH := oblast_pgx.Wrap(pgxConn)
+
+ // grab a "random" record from the DB, not just the first or the last
+ recordID := min(totalRecordCountForSelect*2/3, totalRecordCountForSelect)
+
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.PostgresDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ partialQuery := `id = ` + strconv.Itoa(recordID)
+ query := `SELECT * FROM entries WHERE ` + partialQuery
+ precomputedQuery := store.MustPrepareSelectQueryWhere(partialQuery)
+
+ b.Run("driver=pq/strategy=oblast", func(b *testing.B) {
+ for b.Loop() {
+ r := must.Return(precomputedQuery.SelectOne(noctx, pqDBH))(b)
+ assert.Equal(b, r.ID, recordID)
+ }
+ })
+
+ b.Run("driver=pgx/strategy=oblast", func(b *testing.B) {
+ for b.Loop() {
+ r := must.Return(precomputedQuery.SelectOne(noctx, pgxConnH))(b)
+ assert.Equal(b, r.ID, recordID)
+ }
+ })
+
+ b.Run("driver=pq/strategy=straight", func(b *testing.B) {
+ for b.Loop() {
+ var (
+ id int64
+ message string
+ )
+ must.Succeed(b, pqDB.QueryRow(query).Scan(&id, &message))
+ assert.Equal(b, id, int64(recordID))
+ }
+ })
+
+ b.Run("driver=pgx/strategy=straight", func(b *testing.B) {
+ for b.Loop() {
+ var (
+ id int64
+ message string
+ )
+ must.Succeed(b, pgxConn.QueryRow(noctx, query).Scan(&id, &message))
+ assert.Equal(b, id, int64(recordID))
+ }
+ })
+}
+
+func BenchmarkPostgresInsertAndDelete(b *testing.B) {
+ pqDB := connectToPostgresTestDB(b, 0)
+ pqDBH := oblast.Wrap(pqDB)
+ pgxConn := connectToPgxTestDB(b, 0)
+ pgxConnH := oblast_pgx.Wrap(pgxConn)
+
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.PostgresDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ // test with different amounts of records
+ for _, batchSize := range batchSizesForInsertDelete {
+ b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
+ insertAndDeleteWithOblast := func(b *testing.B, dbh oblast.Handle) {
+ records := make([]OblastEntry, batchSize)
+ recordsForInsert := make([]*OblastEntry, batchSize)
+ for idx := range records {
+ records[idx] = OblastEntry{Message: "hello"}
+ recordsForInsert[idx] = &records[idx]
+ }
+ must.Succeed(b, store.Insert(noctx, dbh, recordsForInsert...))
+ for _, r := range records {
+ if r.ID == 0 {
+ b.Errorf("ID was not filled!")
+ }
+ }
+ must.Succeed(b, store.Delete(noctx, dbh, records...))
+ }
+
+ b.Run("driver=pq/strategy=oblast", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithOblast(b, pqDBH)
+ }
+ })
+
+ b.Run("driver=pgx/strategy=oblast", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithOblast(b, pgxConnH)
+ }
+ })
+
+ insertQuery := `INSERT INTO entries (message) VALUES ($1) RETURNING id`
+ deleteQuery := `DELETE FROM entries WHERE id = $1`
+
+ b.Run("driver=pq/strategy=straight", func(b *testing.B) {
+ for b.Loop() {
+ ids := make([]int64, batchSize)
+ for idx := range ids {
+ must.Succeed(b, pqDB.QueryRow(insertQuery, "hello").Scan(&ids[idx]))
+ }
+ for _, id := range ids {
+ _ = must.Return(pqDB.Exec(deleteQuery, id))(b)
+ }
+ }
+ })
+
+ b.Run("driver=pgx/strategy=straight", func(b *testing.B) {
+ for b.Loop() {
+ ids := make([]int64, batchSize)
+ for idx := range ids {
+ must.Succeed(b, pgxConn.QueryRow(noctx, insertQuery, "hello").Scan(&ids[idx]))
+ }
+ for _, id := range ids {
+ _ = must.Return(pgxConn.Exec(noctx, deleteQuery, id))(b)
+ }
+ }
+ })
+
+ b.Run("driver=pq/strategy=prepared", func(b *testing.B) {
+ for b.Loop() {
+ ids := make([]int64, batchSize)
+ stmtInsert := must.Return(pqDB.Prepare(insertQuery))(b)
+ defer stmtInsert.Close()
+ for idx := range ids {
+ must.Succeed(b, stmtInsert.QueryRow("hello").Scan(&ids[idx]))
+ }
+ stmtDelete := must.Return(pqDB.Prepare(deleteQuery))(b)
+ defer stmtDelete.Close()
+ for _, id := range ids {
+ _ = must.Return(stmtDelete.Exec(id))(b)
+ }
+ }
+ })
+
+ b.Run("driver=pgx/strategy=prepared", func(b *testing.B) {
+ for b.Loop() {
+ stmtInsert := must.Return(pgxConn.Prepare(noctx, "my-insert", insertQuery))(b)
+ ids := make([]int64, batchSize)
+ for idx := range ids {
+ must.Succeed(b, pgxConn.QueryRow(noctx, stmtInsert.Name, "hello").Scan(&ids[idx]))
+ }
+ must.Succeed(b, pgxConn.Deallocate(noctx, stmtInsert.Name))
+ stmtDelete := must.Return(pgxConn.Prepare(noctx, "my-delete", deleteQuery))(b)
+ for _, id := range ids {
+ _ = must.Return(pgxConn.Exec(noctx, stmtDelete.Name, id))(b)
+ }
+ must.Succeed(b, pgxConn.Deallocate(noctx, stmtDelete.Name))
+ }
+ })
+ })
+ }
+}
+
+func BenchmarkPostgresUpdate(b *testing.B) {
+ pqDB := connectToPostgresTestDB(b, 0)
+ pqDBH := oblast.Wrap(pqDB)
+ pgxConn := connectToPgxTestDB(b, 0)
+ pgxConnH := oblast_pgx.Wrap(pgxConn)
+
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.PostgresDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ // test with different amounts of records
+ for _, batchSize := range batchSizesForInsertDelete {
+ b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
+ // prepare a bunch of records that we can update, in a reproducible way
+ _ = must.Return(pqDB.Exec(`DELETE FROM entries`))
+ _ = must.Return(pgxConn.Exec(noctx, `DELETE FROM entries`))
+ pqRecords := make([]OblastEntry, batchSize)
+ pqRecordsForInsert := make([]*OblastEntry, batchSize)
+ pgxRecords := make([]OblastEntry, batchSize)
+ pgxRecordsForInsert := make([]*OblastEntry, batchSize)
+ for idx := range batchSize {
+ pqRecords[idx] = OblastEntry{Message: "hello"}
+ pqRecordsForInsert[idx] = &pqRecords[idx]
+ pgxRecords[idx] = OblastEntry{Message: "hello"}
+ pgxRecordsForInsert[idx] = &pgxRecords[idx]
+ }
+ must.Succeed(b, store.Insert(noctx, pqDBH, pqRecordsForInsert...))
+ must.Succeed(b, store.Insert(noctx, pgxConnH, pgxRecordsForInsert...))
+
+ // each benchmark will, while looping, write changing values each time in the same way
+ loop := func(b *testing.B, action func(string)) {
+ idx := 0
+ for b.Loop() {
+ idx++
+ message := fmt.Sprintf("round %d", idx)
+ action(message)
+ }
+ }
+
+ updateWithOblast := func(b *testing.B, dbh oblast.Handle, records []OblastEntry) func(string) {
+ return func(message string) {
+ for idx := range records {
+ records[idx].Message = message
+ }
+ must.Succeed(b, store.Update(noctx, dbh, records...))
+ }
+ }
+
+ b.Run("driver=pq/strategy=oblast", func(b *testing.B) {
+ loop(b, updateWithOblast(b, pqDBH, pqRecords))
+ })
+
+ b.Run("driver=pgx/strategy=oblast", func(b *testing.B) {
+ loop(b, updateWithOblast(b, pgxConnH, pgxRecords))
+ })
+
+ updateQuery := `UPDATE entries SET message = $1 WHERE id = $2`
+
+ b.Run("driver=pq/strategy=straight", func(b *testing.B) {
+ loop(b, func(message string) {
+ for _, r := range pqRecords {
+ _ = must.Return(pqDB.Exec(updateQuery, message, r.ID))(b)
+ }
+ })
+ })
+
+ b.Run("driver=pgx/strategy=straight", func(b *testing.B) {
+ loop(b, func(message string) {
+ for _, r := range pgxRecords {
+ _ = must.Return(pgxConn.Exec(noctx, updateQuery, message, r.ID))(b)
+ }
+ })
+ })
+
+ b.Run("driver=pq/strategy=prepared", func(b *testing.B) {
+ loop(b, func(message string) {
+ stmt := must.Return(pqDB.Prepare(updateQuery))(b)
+ for _, r := range pqRecords {
+ _ = must.Return(stmt.Exec(message, r.ID))(b)
+ }
+ })
+ })
+
+ b.Run("driver=pgx/strategy=prepared", func(b *testing.B) {
+ loop(b, func(message string) {
+ stmt := must.Return(pgxConn.Prepare(noctx, "my-update", updateQuery))(b)
+ for _, r := range pgxRecords {
+ _ = must.Return(pgxConn.Exec(noctx, stmt.Name, message, r.ID))(b)
+ }
+ must.Succeed(b, pgxConn.Deallocate(noctx, stmt.Name))
+ })
+ })
+ })
+ }
+}
diff --git a/go.work.sum b/go.work.sum
index 6216e4f..7d52308 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -1,10 +1,27 @@
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
+github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/nelsam/hel/v2 v2.3.3/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=