aboutsummaryrefslogtreecommitdiff
path: root/benchmark
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-09-17 16:21:40 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-09-17 16:21:40 +0200
commit63df99f2e1c742069464d219a9f340c1f8f5bd90 (patch)
treeb64e6742c78e0d7e4bac1d6f3ed8a2c03f2f9c9b /benchmark
parentfe37a82d0495a8b59b80677dd42d0d94ffafdfb4 (diff)
downloadgo-gg-63df99f2e1c742069464d219a9f340c1f8f5bd90.tar.gz
oblast: import from go.xyrillian.de/oblast
- type RuntimeIndex is left behind for now because I don't want to commit to the method names yet - the "OrNone" methods are removed in favor of gsql.NoneIfNoRows()
Diffstat (limited to 'benchmark')
-rw-r--r--benchmark/go.mod21
-rw-r--r--benchmark/go.sum48
-rw-r--r--benchmark/internal/testhelpers/must/must.go26
-rw-r--r--benchmark/oblast/benchmark_test.go641
-rw-r--r--benchmark/oblast/postgres_test.go393
-rw-r--r--benchmark/pathrouter/pathrouter_test.go (renamed from benchmark/pathrouter_test.go)0
6 files changed, 1126 insertions, 3 deletions
diff --git a/benchmark/go.mod b/benchmark/go.mod
index bc404df..d712500 100644
--- a/benchmark/go.mod
+++ b/benchmark/go.mod
@@ -3,6 +3,25 @@ module go.xyrillian.de/gg/benchmark
go 1.26
require (
+ github.com/go-gorp/gorp/v3 v3.1.0
github.com/gorilla/mux v1.8.1
- go.xyrillian.de/gg v1.13.0
+ github.com/jackc/pgx/v5 v5.10.0
+ github.com/lib/pq v1.12.3
+ github.com/mattn/go-sqlite3 v1.14.48
+ go.xyrillian.de/gg v1.15.0
+ go.xyrillian.de/gg-pgx v0.1.1
+ gorm.io/driver/sqlite v1.6.0
+ gorm.io/gorm v1.31.2
)
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+)
+
+replace go.xyrillian.de/gg => ..
diff --git a/benchmark/go.sum b/benchmark/go.sum
index e5138fd..e3adf8e 100644
--- a/benchmark/go.sum
+++ b/benchmark/go.sum
@@ -1,4 +1,48 @@
+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/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
-go.xyrillian.de/gg v1.10.1 h1:V6oSU+tl25vaRQaMy6Y3jl/0kNoY/a25x4WIk5zQFAw=
-go.xyrillian.de/gg v1.10.1/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k=
+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.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
+github.com/jackc/pgx/v5 v5.10.0/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=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
+github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
+github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
+github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+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/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-pgx v0.1.1 h1:Y5mpj0kHEUy7yimo3njjuIUV7ISei55g+RCuo9fbD6A=
+go.xyrillian.de/gg-pgx v0.1.1/go.mod h1:rrprin7EhwAm4zluxlWv1e5SE0uUzmN4JuS1aY0dQcI=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+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=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
diff --git a/benchmark/internal/testhelpers/must/must.go b/benchmark/internal/testhelpers/must/must.go
new file mode 100644
index 0000000..7a137c6
--- /dev/null
+++ b/benchmark/internal/testhelpers/must/must.go
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package must
+
+import "testing"
+
+// Succeed fails the test if err is not nil.
+func Succeed(t testing.TB, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err.Error())
+ }
+}
+
+// Return wraps a function returning two output values,
+// and either forwards the result value on success, or fails the test on error.
+func Return[V any](value V, err error) func(testing.TB) V {
+ return func(t testing.TB) V {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err.Error())
+ }
+ return value
+ }
+}
diff --git a/benchmark/oblast/benchmark_test.go b/benchmark/oblast/benchmark_test.go
new file mode 100644
index 0000000..0ea86a1
--- /dev/null
+++ b/benchmark/oblast/benchmark_test.go
@@ -0,0 +1,641 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package main_test
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "fmt"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/go-gorp/gorp/v3"
+ _ "github.com/mattn/go-sqlite3"
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/benchmark/internal/testhelpers/must"
+ "go.xyrillian.de/gg/gsql"
+ "go.xyrillian.de/gg/oblast"
+ "gorm.io/driver/sqlite"
+ "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 BenchmarkORMHeadingHeadingHeadingHeadingHeadingHeadingHeadingHeading(b *testing.B) {
+ for b.Loop() {
+ time.Sleep(time.Microsecond)
+ }
+}
+
+var (
+ totalRecordCountForSelect = 10000
+ batchSizesForSelect = []int{1, 10, 100, 1000}
+ batchSizesForInsertDelete = []int{1, 2, 4, 8, 16, 100}
+ batchSizesForUpdate = []int{1, 2, 4, 8, 16, 100}
+)
+
+func makeSqliteTestDB(t testing.TB, recordCount int) (db *gsql.DB, dsn string) {
+ dsn = fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
+ db = gsql.NewDB(must.Return(sql.Open("sqlite3", dsn))(t))
+ _ = must.Return(db.Exec(`CREATE TABLE entries (id INTEGER, message TEXT, PRIMARY KEY (id AUTOINCREMENT))`))(t)
+
+ if recordCount > 0 {
+ // fill in some random-looking, but deterministic data
+ stmt := must.Return(db.Prepare(`INSERT INTO entries (id, message) VALUES (?, ?)`))(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, dsn
+}
+
+type OblastEntry struct {
+ ID int `db:"id,auto"`
+ Message string `db:"message"`
+}
+
+type GorpEntry struct {
+ ID int `db:"id"`
+ Message string `db:"message"`
+}
+
+type GormEntry struct {
+ ID int `gorm:"primaryKey"`
+ Message string
+}
+
+func (GormEntry) TableName() string { return "entries" }
+
+func BenchmarkORMSelectMany(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, totalRecordCountForSelect)
+
+ // test with different sizes of resultsets (N=1 is an OLTP-like workload,
+ // then the larger N lean more towards the OLAP side of things)
+ for _, batchSize := range batchSizesForSelect {
+ b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
+ // prepare the functions that will be benched
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+ gorpDB := gorp.DbMap{Db: db.DB, Dialect: gorp.SqliteDialect{}}
+ gormDB := must.Return(gorm.Open(sqlite.Open(dsn), &gorm.Config{}))(b)
+ partialQuery := `id < ` + strconv.Itoa(batchSize)
+ query := `SELECT * FROM entries WHERE ` + partialQuery
+ precomputedQuery := store.MustPrepareSelectQueryWhere(partialQuery)
+
+ selectWithOblast := func(b *testing.B) {
+ records := must.Return(store.Select(noctx, db, query).Collect())(b)
+ assert.Equal(b, len(records), batchSize)
+ }
+
+ selectWithOblastWhere := func(b *testing.B) {
+ records := must.Return(precomputedQuery.Select(noctx, db).Collect())(b)
+ assert.Equal(b, len(records), batchSize)
+ }
+
+ selectWithGorp := func(b *testing.B) {
+ var records []GorpEntry
+ _ = must.Return(gorpDB.Select(&records, query))(b)
+ assert.Equal(b, len(records), batchSize)
+ }
+
+ selectWithGorm := func(b *testing.B) {
+ records := must.Return(gorm.G[GormEntry](gormDB).Where(partialQuery).Find(b.Context()))(b)
+ assert.Equal(b, len(records), batchSize)
+ }
+
+ selectWithSqlite := func(b *testing.B) {
+ var count int
+ rows := must.Return(db.Query(query))(b) //nolint:rowserrcheck // false positive
+ var (
+ id int64
+ message string
+ )
+ for rows.Next() {
+ must.Succeed(b, rows.Scan(&id, &message))
+ if id != 20000 && message != "" { // always true; ensures that values are not optimized away
+ count++
+ }
+ }
+ must.Succeed(b, rows.Close())
+ assert.Equal(b, count, batchSize)
+ }
+
+ // run once to prewarm caches (if any)
+ selectWithOblast(b)
+ selectWithGorp(b)
+ selectWithGorm(b)
+ if b.Failed() {
+ b.FailNow()
+ }
+
+ // run actual benchmark
+ b.Run("via Gorm using Find", func(b *testing.B) {
+ for b.Loop() {
+ selectWithGorm(b)
+ }
+ })
+ b.Run("via Gorp using Select", func(b *testing.B) {
+ for b.Loop() {
+ selectWithGorp(b)
+ }
+ })
+ b.Run("via Oblast using Select", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblast(b)
+ }
+ })
+ b.Run("via Oblast using SelectWhere", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblastWhere(b)
+ }
+ })
+ b.Run("just SQLite", func(b *testing.B) {
+ for b.Loop() {
+ selectWithSqlite(b)
+ }
+ })
+ })
+ }
+}
+
+func BenchmarkORMSelectManyValues(b *testing.B) {
+ db, _ := makeSqliteTestDB(b, totalRecordCountForSelect)
+
+ // test with different sizes of resultsets (N=1 is an OLTP-like workload,
+ // then the larger N lean more towards the OLAP side of things)
+ for _, batchSize := range batchSizesForSelect {
+ b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
+ // prepare the functions that will be benched
+ query := `SELECT message FROM entries WHERE id < ` + strconv.Itoa(batchSize)
+ selectWithOblast := func(b *testing.B) {
+ messages := must.Return(oblast.Select[string](noctx, db, query).Collect())(b)
+ assert.Equal(b, len(messages), batchSize)
+ }
+ selectWithSqlite := func(b *testing.B) {
+ var count int
+ rows := must.Return(db.Query(query))(b) //nolint:rowserrcheck // false positive
+ var message string
+ for rows.Next() {
+ must.Succeed(b, rows.Scan(&message))
+ count++
+ }
+ must.Succeed(b, rows.Close())
+ assert.Equal(b, count, batchSize)
+ }
+
+ // run actual benchmark
+ b.Run("via Oblast", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblast(b)
+ }
+ })
+ b.Run("just SQLite", func(b *testing.B) {
+ for b.Loop() {
+ selectWithSqlite(b)
+ }
+ })
+ })
+ }
+}
+
+func BenchmarkORMSelectOne(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, totalRecordCountForSelect)
+
+ // grab a "random" record from the DB, not just the first or the last
+ recordID := min(totalRecordCountForSelect*2/3, totalRecordCountForSelect)
+
+ // prepare the functions that will be benched
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+ gorpDB := gorp.DbMap{Db: db.DB, Dialect: gorp.SqliteDialect{}}
+ gormDB := must.Return(gorm.Open(sqlite.Open(dsn), &gorm.Config{}))(b)
+ partialQuery := `id = ` + strconv.Itoa(recordID)
+ query := `SELECT * FROM entries WHERE ` + partialQuery
+ precomputedQuery := store.MustPrepareSelectQueryWhere(partialQuery)
+
+ selectWithOblast := func(b *testing.B) {
+ r := must.Return(store.SelectOne(noctx, db, query))(b)
+ assert.Equal(b, r.ID, recordID)
+ }
+
+ selectWithOblastWhere := func(b *testing.B) {
+ r := must.Return(precomputedQuery.SelectOne(noctx, db))(b)
+ assert.Equal(b, r.ID, recordID)
+ }
+
+ selectWithGorp := func(b *testing.B) {
+ var r GorpEntry
+ must.Succeed(b, gorpDB.SelectOne(&r, query))
+ assert.Equal(b, r.ID, recordID)
+ }
+
+ selectWithGorm := func(b *testing.B) {
+ r := must.Return(gorm.G[GormEntry](gormDB).Where(partialQuery).First(b.Context()))(b)
+ assert.Equal(b, r.ID, recordID)
+ }
+
+ selectWithSqlite := func(b *testing.B) {
+ var (
+ id int64
+ message string
+ )
+ must.Succeed(b, db.QueryRow(query).Scan(&id, &message))
+ assert.Equal(b, id, int64(recordID))
+ }
+
+ // run once to prewarm caches (if any)
+ selectWithOblast(b)
+ selectWithGorp(b)
+ selectWithGorm(b)
+ if b.Failed() {
+ b.FailNow()
+ }
+
+ // run actual benchmark
+ b.Run("via Gorm using First", func(b *testing.B) {
+ for b.Loop() {
+ selectWithGorm(b)
+ }
+ })
+ b.Run("via Gorp using SelectOne", func(b *testing.B) {
+ for b.Loop() {
+ selectWithGorp(b)
+ }
+ })
+ b.Run("via Oblast using SelectOne", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblast(b)
+ }
+ })
+ b.Run("via Oblast using SelectOneWhere", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblastWhere(b)
+ }
+ })
+ b.Run("just SQLite", func(b *testing.B) {
+ for b.Loop() {
+ selectWithSqlite(b)
+ }
+ })
+}
+
+func BenchmarkORMSelectOneValue(b *testing.B) {
+ db, _ := makeSqliteTestDB(b, totalRecordCountForSelect)
+
+ // grab a "random" record from the DB, not just the first or the last
+ recordID := min(totalRecordCountForSelect*2/3, totalRecordCountForSelect)
+
+ // prepare the functions that will be benched
+ query := `SELECT message FROM entries WHERE id = ` + strconv.Itoa(recordID)
+ selectWithOblast := func(b *testing.B) {
+ message := must.Return(oblast.SelectOne[string](noctx, db, query))(b)
+ assert.Equal(b, len(message), 71)
+ }
+ selectWithSqlite := func(b *testing.B) {
+ var message string
+ must.Succeed(b, db.QueryRow(query).Scan(&message))
+ assert.Equal(b, len(message), 71)
+ }
+
+ // run actual benchmark
+ b.Run("via Oblast", func(b *testing.B) {
+ for b.Loop() {
+ selectWithOblast(b)
+ }
+ })
+ b.Run("just SQLite", func(b *testing.B) {
+ for b.Loop() {
+ selectWithSqlite(b)
+ }
+ })
+}
+
+func BenchmarkORMInsertAndDelete(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, 0)
+
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+ gorpDB := gorp.DbMap{Db: db.DB, Dialect: gorp.SqliteDialect{}}
+ gorpDB.AddTableWithName(GorpEntry{}, "entries").SetKeys(true, "id")
+ gormDB := must.Return(gorm.Open(sqlite.Open(dsn), &gorm.Config{}))(b)
+
+ // test with different amounts of records
+ for _, batchSize := range batchSizesForInsertDelete {
+ b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
+ // prepare the functions that will be benched
+ insertAndDeleteWithOblast := func(b *testing.B) {
+ 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, db, recordsForInsert...))
+ for _, r := range records {
+ if r.ID == 0 {
+ b.Errorf("ID was not filled!")
+ }
+ }
+ must.Succeed(b, store.Delete(noctx, db, records...))
+ }
+ if batchSize == 1 {
+ insertAndDeleteWithOblast = func(b *testing.B) {
+ record := OblastEntry{Message: "hello"}
+ must.Succeed(b, store.Insert(noctx, db, &record))
+ if record.ID == 0 {
+ b.Errorf("ID was not filled!")
+ }
+ must.Succeed(b, store.Delete(noctx, db, record))
+ }
+ }
+
+ insertAndDeleteWithGorp := func(b *testing.B) {
+ records := make([]any, batchSize)
+ for idx := range records {
+ records[idx] = &GorpEntry{Message: "hello"}
+ }
+ must.Succeed(b, gorpDB.Insert(records...))
+ for _, r := range records {
+ if r.(*GorpEntry).ID == 0 {
+ b.Errorf("ID was not filled!")
+ }
+ }
+ _ = must.Return(gorpDB.Delete(records...))(b)
+ }
+ if batchSize == 1 {
+ insertAndDeleteWithGorp = func(b *testing.B) {
+ record := GorpEntry{Message: "hello"}
+ must.Succeed(b, gorpDB.Insert(&record))
+ if record.ID == 0 {
+ b.Errorf("ID was not filled!")
+ }
+ _ = must.Return(gorpDB.Delete(&record))(b)
+ }
+ }
+
+ insertAndDeleteWithGorm := func(b *testing.B) {
+ records := make([]GormEntry, batchSize)
+ for idx := range records {
+ records[idx] = GormEntry{Message: "hello"}
+ }
+ must.Succeed(b, gorm.G[GormEntry](gormDB).CreateInBatches(b.Context(), &records, batchSize))
+ for _, r := range records {
+ if r.ID == 0 {
+ b.Errorf("ID was not filled!")
+ }
+ }
+ result := gormDB.Delete(&records)
+ assert.ErrEqual(b, result.Error, nil)
+ assert.Equal(b, result.RowsAffected, int64(batchSize))
+ }
+ if batchSize == 1 {
+ insertAndDeleteWithGorm = func(b *testing.B) {
+ record := GormEntry{Message: "hello"}
+ must.Succeed(b, gorm.G[GormEntry](gormDB).Create(b.Context(), &record))
+ result := gormDB.Delete(&record)
+ assert.ErrEqual(b, result.Error, nil)
+ assert.Equal(b, result.RowsAffected, 1)
+ }
+ }
+
+ insertAndDeleteWithStraightExec := func(b *testing.B) {
+ ids := make([]int64, batchSize)
+ for idx := range ids {
+ result := must.Return(db.Exec(`INSERT INTO entries (message) VALUES (?)`, "hello"))(b)
+ ids[idx] = must.Return(result.LastInsertId())(b)
+ }
+ for _, id := range ids {
+ _ = must.Return(db.Exec(`DELETE FROM entries WHERE id = ?`, id))(b)
+ }
+ }
+
+ insertAndDeleteWithPreparedExec := func(b *testing.B) {
+ ids := make([]int64, batchSize)
+ stmtInsert := must.Return(db.Prepare(`INSERT INTO entries (message) VALUES (?)`))(b)
+ defer stmtInsert.Close()
+ for idx := range ids {
+ result := must.Return(stmtInsert.Exec("hello"))(b)
+ ids[idx] = must.Return(result.LastInsertId())(b)
+ }
+ stmtDelete := must.Return(db.Prepare(`DELETE FROM entries WHERE id = ?`))(b)
+ defer stmtDelete.Close()
+ for _, id := range ids {
+ _ = must.Return(stmtDelete.Exec(id))(b)
+ }
+ }
+
+ insertAndDeleteWithStraightQueryRow := func(b *testing.B) {
+ ids := make([]int64, batchSize)
+ for idx := range ids {
+ must.Succeed(b, db.QueryRow(`INSERT INTO entries (message) VALUES (?) RETURNING id`, "hello").Scan(&ids[idx]))
+ }
+ for _, id := range ids {
+ _ = must.Return(db.Exec(`DELETE FROM entries WHERE id = ?`, id))(b)
+ }
+ }
+
+ insertAndDeleteWithPreparedQueryRow := func(b *testing.B) {
+ ids := make([]int64, batchSize)
+ stmtInsert := must.Return(db.Prepare(`INSERT INTO entries (message) VALUES (?) RETURNING id`))(b)
+ defer stmtInsert.Close()
+ for idx := range ids {
+ must.Succeed(b, stmtInsert.QueryRow("hello").Scan(&ids[idx]))
+ }
+ stmtDelete := must.Return(db.Prepare(`DELETE FROM entries WHERE id = ?`))(b)
+ defer stmtDelete.Close()
+ for _, id := range ids {
+ _ = must.Return(stmtDelete.Exec(id))(b)
+ }
+ }
+
+ // run once to prewarm caches (if any)
+ insertAndDeleteWithOblast(b)
+ insertAndDeleteWithGorp(b)
+ insertAndDeleteWithGorm(b)
+
+ b.Run("via Gorm", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithGorm(b)
+ }
+ })
+ b.Run("via Gorp", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithGorp(b)
+ }
+ })
+ b.Run("via Oblast", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithOblast(b)
+ }
+ })
+ b.Run("just SQLite (straight Exec)", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithStraightExec(b)
+ }
+ })
+ b.Run("just SQLite (prepared Exec)", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithPreparedExec(b)
+ }
+ })
+ b.Run("just SQLite (straight QueryRow)", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithStraightQueryRow(b)
+ }
+ })
+ b.Run("just SQLite (prepared QueryRow)", func(b *testing.B) {
+ for b.Loop() {
+ insertAndDeleteWithPreparedQueryRow(b)
+ }
+ })
+ })
+ }
+}
+
+func BenchmarkORMUpdate(b *testing.B) {
+ db, dsn := makeSqliteTestDB(b, 0)
+
+ store := oblast.MustNewStore[OblastEntry](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("entries"),
+ oblast.PrimaryKeyIs("id"),
+ )
+ gorpDB := gorp.DbMap{Db: db.DB, Dialect: gorp.SqliteDialect{}}
+ gorpDB.AddTableWithName(GorpEntry{}, "entries").SetKeys(true, "id")
+ gormDB := must.Return(gorm.Open(sqlite.Open(dsn), &gorm.Config{}))(b)
+
+ // test with different amounts of records
+ for _, batchSize := range batchSizesForUpdate {
+ 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(db.Exec(`DELETE FROM entries`))
+ recordsForOblast := make([]OblastEntry, batchSize)
+ recordsForOblastForInsert := make([]*OblastEntry, batchSize)
+ for idx := range recordsForOblast {
+ recordsForOblast[idx] = OblastEntry{Message: "hello"}
+ recordsForOblastForInsert[idx] = &recordsForOblast[idx]
+ }
+ must.Succeed(b, store.Insert(noctx, db, recordsForOblastForInsert...))
+ recordsForGorp := make([]any, batchSize)
+ for idx, r := range recordsForOblast {
+ recordsForGorp[idx] = new(GorpEntry(r))
+ }
+ recordsForGorm := make([]GormEntry, batchSize)
+ for idx, r := range recordsForOblast {
+ recordsForGorm[idx] = GormEntry(r)
+ }
+
+ // prepare the functions that will be benched
+ updateWithOblast := func(b *testing.B, message string) {
+ for idx := range recordsForOblast {
+ recordsForOblast[idx].Message = message
+ }
+ must.Succeed(b, store.Update(noctx, db, recordsForOblast...))
+ }
+ updateWithGorp := func(b *testing.B, message string) {
+ for _, r := range recordsForGorp {
+ r.(*GorpEntry).Message = message
+ }
+ _ = must.Return(gorpDB.Update(recordsForGorp...))(b)
+ }
+ updateWithGorm := func(b *testing.B, message string) {
+ for idx := range recordsForGorm {
+ recordsForGorm[idx].Message = message
+ }
+ result := gormDB.Save(&recordsForGorm)
+ assert.ErrEqual(b, result.Error, nil)
+ assert.Equal(b, result.RowsAffected, int64(batchSize))
+ }
+ updateWithStraightSqlite := func(b *testing.B, message string) {
+ for _, r := range recordsForOblast {
+ _ = must.Return(db.Exec(`UPDATE entries SET message = ? WHERE id = ?`, message, r.ID))(b)
+ }
+ }
+ updateWithPreparedSqlite := func(b *testing.B, message string) {
+ stmt := must.Return(db.Prepare(`UPDATE entries SET message = ? WHERE id = ?`))(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
+ must.Succeed(b, db.QueryRow(`SELECT COUNT(*) FROM entries WHERE message = ?`, message).Scan(&count))
+ assert.Equal(b, count, int64(batchSize))
+ }
+
+ // run once to prewarm caches (if any)
+ updateWithGorm(b, "warming up")
+ updateWithGorp(b, "warming up")
+ updateWithOblast(b, "warming up")
+
+ b.Run("via Gorm", func(b *testing.B) {
+ idx := 0
+ for b.Loop() {
+ idx++
+ message := fmt.Sprintf("round %d", idx)
+ updateWithGorm(b, message)
+ checkRecordsUpdated(b, message)
+ }
+ })
+ b.Run("via Gorp", func(b *testing.B) {
+ idx := 0
+ for b.Loop() {
+ idx++
+ message := fmt.Sprintf("round %d", idx)
+ updateWithGorp(b, message)
+ checkRecordsUpdated(b, message)
+ }
+ })
+ b.Run("via Oblast", func(b *testing.B) {
+ idx := 0
+ for b.Loop() {
+ idx++
+ message := fmt.Sprintf("round %d", idx)
+ updateWithOblast(b, message)
+ checkRecordsUpdated(b, message)
+ }
+ })
+ b.Run("just SQLite (straight)", func(b *testing.B) {
+ idx := 0
+ for b.Loop() {
+ idx++
+ message := fmt.Sprintf("round %d", idx)
+ updateWithStraightSqlite(b, message)
+ checkRecordsUpdated(b, message)
+ }
+ })
+ b.Run("just SQLite (prepared)", func(b *testing.B) {
+ idx := 0
+ for b.Loop() {
+ idx++
+ message := fmt.Sprintf("round %d", idx)
+ updateWithPreparedSqlite(b, message)
+ checkRecordsUpdated(b, message)
+ }
+ })
+ })
+ }
+}
diff --git a/benchmark/oblast/postgres_test.go b/benchmark/oblast/postgres_test.go
new file mode 100644
index 0000000..5772619
--- /dev/null
+++ b/benchmark/oblast/postgres_test.go
@@ -0,0 +1,393 @@
+// 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"
+ gg_pgx "go.xyrillian.de/gg-pgx"
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/benchmark/internal/testhelpers/must"
+ "go.xyrillian.de/gg/gsql"
+ "go.xyrillian.de/gg/oblast"
+)
+
+// NOTE: In this file, we benchmark different PostgreSQL database drivers against each other with or without Oblast in between.
+// 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) *gsql.DB {
+ dsn := cmp.Or(os.Getenv("BENCHMARK_POSTGRES_DSN"), defaultPostgresDSN)
+ db := gsql.NewDB(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
+ query := `INSERT INTO entries (id, message) VALUES ($1, $2)`
+ stmt := must.Return(conn.Prepare(ctx, query, query))(t)
+ for idx := range recordCount {
+ buf := sha256.Sum256([]byte(strconv.Itoa(idx)))
+ _ = must.Return(conn.Exec(ctx, query, 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)
+ pgxConn := connectToPgxTestDB(b, totalRecordCountForSelect)
+ pgxConnH := gg_pgx.NewConn(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, pqDB, query).Collect())(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).Collect())(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)
+ 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)
+ pgxConn := connectToPgxTestDB(b, totalRecordCountForSelect)
+ pgxConnH := gg_pgx.NewConn(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, pqDB))(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)
+ pgxConn := connectToPgxTestDB(b, 0)
+ pgxConnH := gg_pgx.NewConn(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 gsql.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, pqDB)
+ }
+ })
+
+ 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)
+ pgxConn := connectToPgxTestDB(b, 0)
+ pgxConnH := gg_pgx.NewConn(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, pqDB, 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 gsql.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, pqDB, 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/benchmark/pathrouter_test.go b/benchmark/pathrouter/pathrouter_test.go
index 361cc2f..361cc2f 100644
--- a/benchmark/pathrouter_test.go
+++ b/benchmark/pathrouter/pathrouter_test.go