aboutsummaryrefslogtreecommitdiff
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
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()
-rw-r--r--CHANGELOG.md5
-rw-r--r--Makefile9
-rw-r--r--README.md1
-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
-rw-r--r--oblast/README.md41
-rw-r--r--oblast/dialect.go161
-rw-r--r--oblast/errors.go28
-rw-r--r--oblast/internal/testhelpers/mock/mock.go320
-rw-r--r--oblast/internal/testhelpers/must/must.go26
-rw-r--r--oblast/oblast.go185
-rw-r--r--oblast/plan.go488
-rw-r--r--oblast/plan_test.go657
-rw-r--r--oblast/query.go340
-rw-r--r--oblast/query_test.go522
-rw-r--r--oblast/select.go474
-rw-r--r--oblast/select_test.go646
21 files changed, 5029 insertions, 3 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a37cb78..ffb7131 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,11 @@ SPDX-License-Identifier: Apache-2.0
Changes:
+- Add package oblast.
+ This used to live at <https://git.xyrillian.de/go-oblast/>, but for the 1.x series,
+ I'm moving nearly everything from `go.xyrillian.de/oblast` to `go.xyrillian.de/gg/oblast` because removing the module boundary
+ between gsql and oblast opens up some code reuse opportunities and may help future optimizations.
+ The only API surface that remains in the old location for now is `type RuntimeIndex`, because I want to contemplate method naming once again before stabilizing it.
- gsql: Add NoneIfNoRows.
This replaces "OrNone" variants of methods in Oblast, e.g. SelectOne() and then also SelectOneOrNone().
diff --git a/Makefile b/Makefile
index a141d5f..63ad0e3 100644
--- a/Makefile
+++ b/Makefile
@@ -22,6 +22,15 @@ build/cover.html: build/cover.out
@printf "\e[1;36m>> go tool cover\e[0m\n"
go tool cover -html $< -o $@
+benchmark-oblast-orm: FORCE
+ @cd benchmark && go test -bench BenchmarkORM -benchmem ./oblast
+
+benchmark-oblast-postgres: FORCE
+ @cd benchmark && go test -bench BenchmarkPostgres -benchmem ./oblast
+
+benchamrk-pathrouter: FORCE
+ @cd benchmark && go test -bench . -benchmem ./pathrouter
+
help: FORCE
@printf "\n"
@printf "\e[1mUsage:\e[0m\n"
diff --git a/README.md b/README.md
index c3826e1..94ac08f 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ My personal extension of the standard library.
### Addons for database/sql
- [gsql](./gsql/): abstraction layer for database libraries, supporting both database/sql drivers and non-standard drivers like [pgx](https://github.com/jackc/pgx)
+- [oblast](./oblast/): a small ORM library, focused on type safety and performance; cf. [its own README](https://pkg.go.dev/go.xyrillian.de/gg/oblast) for details
- [pgruntime](./pgruntime/): connection handling for PostgreSQL databases, including optional support for database migrations and self-contained test DB instances
### Addons for errors
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
diff --git a/oblast/README.md b/oblast/README.md
new file mode 100644
index 0000000..d4e3b73
--- /dev/null
+++ b/oblast/README.md
@@ -0,0 +1,41 @@
+<!--
+SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+SPDX-License-Identifier: Apache-2.0
+-->
+
+# Oblast
+
+A small ORM library for Go, focused on type safety and performance. Inspired by [Gorp](https://pkg.go.dev/gopkg.in/gorp.v3), but without the bits that make Gorp slow.
+
+You may think that the name refers to the type of administrative division that exists in several Slavic countries, but it's actually just an acronym for what this library does: **Ob**ject **L**oading **A**nd **St**oring.
+
+## How to use
+
+Please refer to the [package documentation](https://pkg.go.dev/go.xyrillian.de/gg/oblast).
+
+## How to contribute
+
+Please refer to the [README on module level](https://pkg.go.dev/go.xyrillian.de/gg).
+
+## Design goals and priorities
+
+The design goals, ordered by priority (most important comes first), are:
+
+- An intuitive API that encodes type safety through the use of generics.
+- A minimal amount of memory allocations in hot paths.
+- A minimal amount of CPU usage.
+- As few library dependencies as possible.
+
+Explicit non-goals include:
+
+- A fully featured API for query construction:
+ Oblast does not offer methods like `table.Where("created_at < ?", time.Now()).Order("name").Join("products")`; it only deals with mapping between database columns and fields of struct types, nothing else.
+ This is not just a question of performance.
+ The author of this library does not believe that it is worthwhile to have an API like this.
+ Writing SQL queries by hand is significantly simpler, and does not take away any convenience, except for rare edge cases.
+- Support for schema generation or manipulation:
+ Another thing that the author of this library does not believe to be worthwhile in an ORM library.
+ In real-world applications, you will need to manage the schema using versioned schema migrations.
+ Schemas generated by ORM libraries from type declarations cannot really offer this, especially once you get into stored functions, triggers and so on.
+
+The author realizes that this means that Oblast is technically only an OM library, not an ORM library. Sometimes, optimization means getting rid of on of th lttrs.
diff --git a/oblast/dialect.go b/oblast/dialect.go
new file mode 100644
index 0000000..11842eb
--- /dev/null
+++ b/oblast/dialect.go
@@ -0,0 +1,161 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "database/sql"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+var (
+ // force imports to make docstring links work
+ _ = sql.Result(nil)
+)
+
+// Dialect accounts for differences between different SQL dialects
+// that are relevant to query generation within Oblast.
+//
+// # Compatibility notice
+//
+// This interface may be extended, even within minor versions, when doing so is
+// required to add support for new DB dialects that differ from previously
+// supported dialects in unexpected ways.
+type Dialect interface {
+ // Placeholder returns the placeholder for the i-th query argument.
+ // Most dialects use "?", but e.g. PostgreSQL uses "$1", "$2" and so on.
+ // The argument numbers from 0 like a slice index.
+ Placeholder(i int) string
+
+ // QuoteIdentifier wraps the name of a column or table in quotes,
+ // in order to avoid the name from being interpreted as a keyword.
+ QuoteIdentifier(name string) string
+
+ // CanUseLastInsertId returns true if this type of database system can report
+ // a single auto-generated int primary key using [sql.Result.LastInsertId].
+ // If true, the RETURNING clause will be omitted for matching INSERT queries.
+ CanUseLastInsertId() bool
+
+ // UpsertClause generates an "ON CONFLICT" or similar clause
+ // that can be appended to an INSERT query to make it fall back to
+ // behave like UPDATE if a record with the same primary key already exists.
+ // This is only used for record types that have a primary key.
+ UpsertClause(pkColumns, otherColumns []string) string
+
+ // String returns a unique identifier for this Dialect instance.
+ // Different instances shall return the same string only if all their methods behave identically.
+ // This information is used to cache generated query plans.
+ String() string
+}
+
+// MariaDBDialect is the dialect of MariaDB 10.5+ databases.
+//
+// This dialect does NOT support MySQL, as well as ancient MariaDB versions (10.5 was released 2020-06-24),
+// because those do not understand the "INSERT ... RETURNING" syntax.
+func MariaDBDialect() Dialect {
+ return mariadbDialect{}
+}
+
+type mariadbDialect struct{}
+
+func (mariadbDialect) Placeholder(_ int) string {
+ return "?"
+}
+
+func (mariadbDialect) QuoteIdentifier(name string) string {
+ return "`" + strings.ReplaceAll(name, "`", "``") + "`"
+}
+
+func (mariadbDialect) CanUseLastInsertId() bool {
+ return true
+}
+
+func (d mariadbDialect) UpsertClause(pkColumns, otherColumns []string) string {
+ clauses := make([]string, max(1, len(otherColumns)))
+ if len(otherColumns) == 0 {
+ // we need at least one UPDATE clause; if there are no non-PK columns,
+ // we can just use one of the PK columns, updating those is a safe no-op
+ clauses[0] = fmt.Sprintf(`%[1]s = VALUES(%[1]s)`, d.QuoteIdentifier(pkColumns[0]))
+ } else {
+ for idx, name := range otherColumns {
+ clauses[idx] = fmt.Sprintf(`%[1]s = VALUES(%[1]s)`, d.QuoteIdentifier(name))
+ }
+ }
+ return ` ON DUPLICATE KEY UPDATE ` + strings.Join(clauses, ", ")
+}
+
+func (mariadbDialect) String() string {
+ return "mariadb"
+}
+
+// PostgresDialect is the dialect of PostgreSQL databases.
+func PostgresDialect() Dialect {
+ return postgresDialect{}
+}
+
+type postgresDialect struct{}
+
+func (postgresDialect) Placeholder(i int) string {
+ return "$" + strconv.Itoa(i+1)
+}
+
+func (postgresDialect) QuoteIdentifier(name string) string {
+ return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
+}
+
+func (postgresDialect) CanUseLastInsertId() bool {
+ return false
+}
+
+func (d postgresDialect) UpsertClause(pkColumns, otherColumns []string) string {
+ quotedPkColumns := make([]string, len(pkColumns))
+ for idx, name := range pkColumns {
+ quotedPkColumns[idx] = d.QuoteIdentifier(name)
+ }
+ clauses := make([]string, len(otherColumns))
+ for idx, name := range otherColumns {
+ clauses[idx] = fmt.Sprintf(`%[1]s = EXCLUDED.%[1]s`, d.QuoteIdentifier(name))
+ }
+ if len(otherColumns) == 0 {
+ return fmt.Sprintf(` ON CONFLICT (%s) DO NOTHING`, strings.Join(quotedPkColumns, ", "))
+ } else {
+ return fmt.Sprintf(` ON CONFLICT (%s) DO UPDATE SET %s`,
+ strings.Join(quotedPkColumns, ", "), strings.Join(clauses, ", "))
+ }
+}
+
+func (postgresDialect) String() string {
+ return "postgres"
+}
+
+// SqliteDialect is the dialect of SQLite 3.35.0+ databases.
+//
+// This dialect does NOT support ancient SQLite versions (3.35.0 was released 2021-03-12)
+// that do not understand the "INSERT ... RETURNING" syntax.
+func SqliteDialect() Dialect {
+ return sqliteDialect{}
+}
+
+type sqliteDialect struct{}
+
+func (sqliteDialect) Placeholder(_ int) string {
+ return "?"
+}
+
+func (sqliteDialect) QuoteIdentifier(name string) string {
+ return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
+}
+
+func (sqliteDialect) CanUseLastInsertId() bool {
+ return true
+}
+
+func (sqliteDialect) UpsertClause(pkColumns, otherColumns []string) string {
+ return postgresDialect{}.UpsertClause(pkColumns, otherColumns)
+}
+
+func (sqliteDialect) String() string {
+ return "sqliteDialect"
+}
diff --git a/oblast/errors.go b/oblast/errors.go
new file mode 100644
index 0000000..0a58340
--- /dev/null
+++ b/oblast/errors.go
@@ -0,0 +1,28 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+)
+
+// MissingRecordError is returned by [Store.Update] if one of the rows to be updated does not exist in the DB.
+type MissingRecordError[R any] struct {
+ // The record that was provided to [Store.Update],
+ // but for which no row with the same primary key values could be located.
+ Record R
+ plan plan
+}
+
+// Error implements the builtin/error interface.
+func (e MissingRecordError[R]) Error() string {
+ keyDescs := make([]string, len(e.plan.PrimaryKeyColumnNames))
+ v := reflect.ValueOf(e.Record)
+ for idx, columnName := range e.plan.PrimaryKeyColumnNames {
+ keyDescs[idx] = fmt.Sprintf("%s = %#v", columnName, v.FieldByIndex(e.plan.IndexByColumnName[columnName]))
+ }
+ return "could not UPDATE record that does not exist in the database: " + strings.Join(keyDescs, ", ")
+}
diff --git a/oblast/internal/testhelpers/mock/mock.go b/oblast/internal/testhelpers/mock/mock.go
new file mode 100644
index 0000000..626366e
--- /dev/null
+++ b/oblast/internal/testhelpers/mock/mock.go
@@ -0,0 +1,320 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package mock
+
+import (
+ "context"
+ "database/sql/driver"
+ "errors"
+ "fmt"
+ "io"
+ "reflect"
+ "slices"
+ "strings"
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// type Driver
+
+// Driver is a mock SQL driver that only accepts queries that were preannounced.
+type Driver struct {
+ responseSetsByQuery map[string]*ResponseSet
+}
+
+// assert that interface is implemented
+var _ driver.Connector = &Driver{}
+
+// NewDriver instantiates a new driver.
+// The result returns [driver.Connector] and can be given to [sql.OpenDB].
+func NewDriver() *Driver {
+ return &Driver{
+ responseSetsByQuery: make(map[string]*ResponseSet),
+ }
+}
+
+// Connect implements the [driver.Connector] interface.
+func (d *Driver) Connect(ctx context.Context) (driver.Conn, error) {
+ return &connection{d: d}, nil
+}
+
+// Driver implements the [driver.Connector] interface.
+func (d *Driver) Driver() driver.Driver {
+ // Not needed. Implementing the Driver interface would only be necessary if
+ // we wanted to use sql.Open() instead of sql.OpenDB(), or if we wanted to
+ // use sql.DB.Driver().
+ panic("unimplemented")
+}
+
+// ForQuery tells the driver to expect the given query string to be sent soon.
+// The return value can be used to plan what to return when the query is actually executed.
+func (d *Driver) ForQuery(query string) *ResponseSet {
+ if d.responseSetsByQuery[query] == nil {
+ d.responseSetsByQuery[query] = &ResponseSet{}
+ }
+ return d.responseSetsByQuery[query]
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// type ResponseSet
+
+// ResponseSet is a set of mock responses for a query sent to type [Driver].
+type ResponseSet struct {
+ expectedExecs []expectation[Result]
+ expectedQueries []expectation[Rows]
+}
+
+type expectation[T any] struct {
+ args []driver.Value
+ output *T
+}
+
+func newExpectation[T any](args []any) expectation[T] {
+ e := expectation[T]{
+ args: make([]driver.Value, len(args)),
+ output: new(T),
+ }
+ for idx, arg := range args {
+ var err error
+ e.args[idx], err = driver.DefaultParameterConverter.ConvertValue(arg)
+ if err != nil {
+ panic(fmt.Sprintf("could not convert value %#v into driver.Value: %s", arg, err.Error()))
+ }
+ }
+ return e
+}
+
+// ExpectExecWithArgs plans a response to an Exec() call.
+func (rs *ResponseSet) ExpectExecWithArgs(args ...any) *Result {
+ e := newExpectation[Result](args)
+ rs.expectedExecs = append(rs.expectedExecs, e)
+ return e.output
+}
+
+// ExpectQueryWithArgs plans a response to a Query() or QueryRows() call.
+func (rs *ResponseSet) ExpectQueryWithArgs(args ...any) *Rows {
+ e := newExpectation[Rows](args)
+ rs.expectedQueries = append(rs.expectedQueries, e)
+ return e.output
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// type connection
+
+type connection struct {
+ d *Driver
+ closed bool
+}
+
+// Prepare implements the [driver.Conn] interface.
+func (c *connection) Prepare(query string) (driver.Stmt, error) {
+ rs := c.d.responseSetsByQuery[query]
+ if rs == nil {
+ return nil, fmt.Errorf("unexpected query: %s", query)
+ }
+ return &statement{c: c, query: query, rs: rs}, nil
+}
+
+// Close implements the [driver.Conn] interface.
+func (c *connection) Close() error {
+ c.closed = true
+ return nil
+}
+
+// Begin implements the [driver.Conn] interface.
+func (c *connection) Begin() (driver.Tx, error) {
+ return transaction{}, nil
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// type transaction
+
+type transaction struct{}
+
+// Commit implements the [driver.Tx] interface.
+func (t transaction) Commit() error {
+ return nil // unused
+}
+
+// Rollback implements the [driver.Tx] interface.
+func (t transaction) Rollback() error {
+ return nil // unused
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// type statement
+
+type statement struct {
+ c *connection
+ query string
+ rs *ResponseSet
+ closed bool
+}
+
+// Close implements the [driver.Stmt] interface.
+func (s *statement) Close() error {
+ return nil
+}
+
+// NumInput implements the [driver.Stmt] interface.
+func (s *statement) NumInput() int {
+ // option 1: when using SQLite dialect, count `?`
+ count := strings.Count(s.query, "?")
+ if count > 0 {
+ return count
+ }
+
+ // option 2: when using PostgreSQL dialect, find `$1`, `$2`, etc.
+ for strings.Contains(s.query, fmt.Sprintf("$%d", count+1)) {
+ count++
+ }
+ return count
+}
+
+// Exec implements the [driver.Stmt] interface.
+func (s *statement) Exec(args []driver.Value) (driver.Result, error) {
+ if s.closed {
+ return nil, errors.New("statement was closed")
+ }
+ if s.c.closed {
+ return nil, errors.New("connection was closed")
+ }
+ for idx, e := range s.rs.expectedExecs {
+ if reflect.DeepEqual(e.args, args) {
+ s.rs.expectedExecs = slices.Delete(s.rs.expectedExecs, idx, idx+1)
+ return result{r: *e.output}, nil
+ }
+ }
+ return nil, fmt.Errorf("unexpected arguments for query %q: %#v", s.query, args)
+}
+
+// Query implements the [driver.Stmt] interface.
+func (s *statement) Query(args []driver.Value) (driver.Rows, error) {
+ if s.closed {
+ return nil, errors.New("statement was closed")
+ }
+ if s.c.closed {
+ return nil, errors.New("connection was closed")
+ }
+ for idx, e := range s.rs.expectedQueries {
+ if reflect.DeepEqual(e.args, args) {
+ s.rs.expectedQueries = slices.Delete(s.rs.expectedQueries, idx, idx+1)
+ return &rows{r: *e.output}, nil
+ }
+ }
+ return nil, fmt.Errorf("unexpected arguments for query %q: %#v", s.query, args)
+}
+
+///////////////////////////////////////////////////////////////////////////////////////////
+// type Result
+
+// Result is a mock response for an Exec() call.
+// It is constructed by [ResponseSet.ExpectExec].
+type Result struct {
+ lastInsertId *int64
+ rowsAffected *int64
+}
+
+// AndReturnLastInsertId configures a mock LastInsertId() value for this Result.
+// Returns the same Result instance to allow chaining additional method calls.
+func (r *Result) AndReturnLastInsertId(id int64) *Result {
+ r.lastInsertId = &id
+ return r
+}
+
+// AndReturnRowsAffected configures a mock RowsAffected() value for this Result.
+// Returns the same Result instance to allow chaining additional method calls.
+func (r *Result) AndReturnRowsAffected(count int64) *Result {
+ r.rowsAffected = &count
+ return r
+}
+
+type result struct {
+ r Result
+}
+
+// LastInsertId implements the [driver.Result] interface.
+func (r result) LastInsertId() (int64, error) {
+ if r.r.lastInsertId == nil {
+ return 0, errors.New("AndReturnLastInsertId() was not called for this Result")
+ }
+ return *r.r.lastInsertId, nil
+}
+
+// RowsAffected implements the [driver.Result] interface.
+func (r result) RowsAffected() (int64, error) {
+ if r.r.rowsAffected == nil {
+ return 0, errors.New("AndReturnRowsAffected() was not called for this Result")
+ }
+ return *r.r.rowsAffected, nil
+}
+
+// /////////////////////////////////////////////////////////////////////////////////////////
+// type Rows
+
+// Rows is a mock response for a Query() or QueryRow() call.
+// It is constructed by [ResponseSet.ExpectQuery].
+type Rows struct {
+ columns []string
+ results [][]any
+ closeError error
+}
+
+// AndReturnColumns configures the set of column names that will be returned by this query.
+// Returns the same Result instance to allow chaining additional method calls.
+func (r *Rows) AndReturnColumns(columns ...string) *Rows {
+ if len(r.columns) > 0 {
+ panic("AndReturnColumns() called multiple times for the same Rows object")
+ }
+ r.columns = columns
+ return r
+}
+
+// WithRow adds a row to the result set that will be returned by this query.
+// This may only be called after AndReturnColumns().
+func (r *Rows) WithRow(values ...any) *Rows {
+ if len(r.columns) == 0 {
+ panic("AndReturnColumns() has not been called for this Rows object yet")
+ }
+ if len(r.columns) != len(values) {
+ panic("WithRow() must be called with the same number of args as the preceding AndReturnColumns() call")
+ }
+ r.results = append(r.results, values)
+ return r
+}
+
+// AndCloseFailsWith sets up Close() for this Rows to fail with the provided error message.
+func (r *Rows) AndCloseFailsWith(err error) {
+ r.closeError = err
+}
+
+type rows struct {
+ r Rows
+ closed bool
+}
+
+// Columns implements the [driver.Rows] interface.
+func (r *rows) Columns() []string {
+ return r.r.columns
+}
+
+// Close implements the [driver.Rows] interface.
+func (r *rows) Close() error {
+ r.closed = true
+ return r.r.closeError
+}
+
+// Next implements the [driver.Rows] interface.
+func (r *rows) Next(dest []driver.Value) error {
+ if r.closed {
+ return errors.New("rows object was closed")
+ }
+ if len(r.r.results) == 0 {
+ return io.EOF
+ }
+ for idx, value := range r.r.results[0] {
+ dest[idx] = value
+ }
+ r.r.results = r.r.results[1:]
+ return nil
+}
diff --git a/oblast/internal/testhelpers/must/must.go b/oblast/internal/testhelpers/must/must.go
new file mode 100644
index 0000000..7a137c6
--- /dev/null
+++ b/oblast/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/oblast/oblast.go b/oblast/oblast.go
new file mode 100644
index 0000000..57d0c63
--- /dev/null
+++ b/oblast/oblast.go
@@ -0,0 +1,185 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+// Package oblast is an ORM library for Go, focusing specifically on just the loading and storing of records in the most efficient manner possible.
+// No utilities are provided for generating DDL or managing schema migrations, or for building complex OLAP queries.
+//
+// # Usage pattern
+//
+// Oblast can load or store any struct type by matching individual fields to column names (on load) or query arguments (on store).
+// Struct types that are suitable for this kind of mapping are called "record types" throughout this package documentation.
+//
+// To use this library, first declare a record type, and create a [Store] for it once to analyze the type and prepare the respective OLTP queries:
+//
+// type LogEntry struct {
+// ID int64 `db:"id,auto"`
+// CreatedAt time.Time `db:"created_at"`
+// Message string `db:"message"`
+// }
+// var logEntryStore = oblast.NewStore[LogEntry](
+// oblast.PostgresDialect(),
+// oblast.TableNameIs("log_entries"),
+// oblast.PrimaryKeyIs("id"),
+// )
+//
+// Then use it many times to perform load and store operations:
+//
+// func doStuff(db *gsql.DB) error {
+// newEntry := LogEntry{
+// CreatedAt: time.Now(),
+// Message: "Hello World.",
+// }
+// err := logEntryStore.Insert(dbh, &newEntry)
+// if err != nil {
+// return err
+// }
+// fmt.Printf("created log entry %d", newEntry.ID)
+//
+// allEntries, err := logEntryStore.SelectWhere(dbh, `created_at < NOW()`)
+// if err != nil {
+// return err
+// }
+// fmt.Printf("there are %d log entries so far", len(allEntries))
+// }
+//
+// In this example, [*gsql.DB] is a thin wrapper around [*sql.DB], which can be obtained with the [gsql.NewDB] function.
+// A [*gsql.DB] can be used in the same way as an [*sql.DB], but if Oblast is only to be used for specific functions,
+// then individual [*sql.Conn] or [*sql.Tx] instances can also be wrapped with the [gsql.NewConn] and [gsql.NewTx] functions.
+//
+// The gsql package serves as an abstraction around different database driver libraries,
+// allowing Oblast to also be used with different database drivers such as pgx (see documentation in package gsql for details).
+//
+// # Mapping rules for record types
+//
+// If the database column has a different name (or casing, e.g. "id" vs. "ID") than the field name, provide it in the field tag "db".
+// The field tag may also contain additional options, separated from the column name by commas.
+// To have Oblast ignore a field, either make it private or declare its column name as "-".
+// For example:
+//
+// type Example struct {
+// FirstValue string `db:"first_value"` // maps to DB column "first_value"
+// SecondValue string // maps to DB column "SecondValue"
+// ThirdValue string `db:"third_value,auto"` // maps to DB column "third_value" with "auto" option
+// FourthValue string `db:",auto"` // maps to DB column "FourthValue" with "auto" option
+// Cache map[string]any `db:"-"` // ignored by Oblast because of column name "-"
+// action func() // ignored by Oblast because field is private
+// }
+//
+// The following field options are understood:
+// - "auto": During [Store.Insert], do not store this field's value. Instead, the database will auto-generate a value, which will be read back into the record. In SQL dialects that use [sql.Result.LastInsertId] for this (as opposed to a RETURNING clause), only at most one field per record type may have this option, and it must be of an integer type.
+//
+// It is possible to place mapped fields within sub-structs, including within embedded types.
+// This is useful e.g. to avoid code duplication for database columns that are repeated across multiple types:
+//
+// type Timestamps struct {
+// CreatedAt time.Time `db:"created_at"`
+// UpdatedAt *time.Time `db:"updated_at"`
+// DeletedAt *time.Time `db:"deleted_at"`
+// }
+//
+// type FooRecord struct {
+// ID int64 `db:"id,auto"`
+// Name string `db:"name"`
+// Timestamps Timestamps
+// }
+// // ... and other struct types that use type Timestamps ...
+//
+// This behavior may be undesirable on custom struct types that implement [sql.Scanner] and/or [driver.Valuer], or are understood by a [driver.NamedValueChecker] set up by your SQL driver.
+// To keep Oblast from recursing into struct types and mapping their fields, provide an explicit `db:"..."` tag on them:
+//
+// type GeoPoint struct {
+// Longitude, Latitude int
+// }
+// func (p *GeoPoint) Scan(src any) error {...}
+// func (p GeoPoint) Value() (driver.Value, error) {...}
+//
+// type Event struct {
+// ID int64 `db:",auto"`
+// Description string
+// Time time.Time
+// // explicit tag ensures that Location.Longitude and Location.Latitude are not mapped individually
+// Location GeoPoint `db:"Location"`
+// }
+package oblast // import "go.xyrillian.de/oblast"
+
+import (
+ "database/sql"
+ "database/sql/driver"
+ "fmt"
+ "reflect"
+
+ "go.xyrillian.de/gg/gsql"
+)
+
+var (
+ // the following types appear in docstring links
+ _ sql.Scanner = nil
+ _ driver.NamedValueChecker = nil
+ _ *gsql.DB = nil
+)
+
+// PlanOption is an option that can be given to [NewStore] to influence query planning for a certain type of record.
+type PlanOption func(*planOpts)
+
+// TableNameIs is a PlanOption for record types that correspond to exactly one database table (as opposed to a join of multiple tables).
+// This option is required to enable any of the methods of [Store] that use partially or fully auto-generated query strings.
+func TableNameIs(name string) PlanOption {
+ return func(opts *planOpts) { opts.TableName = name }
+}
+
+// PrimaryKeyIs is a PlanOption for record types that correspond to a database table with a primary key.
+// This option is required to enable use of the [Store.Update] and [Store.Delete] methods.
+func PrimaryKeyIs(columnNames ...string) PlanOption {
+ return func(opts *planOpts) { opts.PrimaryKeyColumnNames = columnNames }
+}
+
+// StructTagKeyIs is a PlanOption for record types that allows renaming the struct tag key that Oblast inspects from its default value of "db".
+// For example, providing StructTagKeyIs("oblast") means that a struct tag like `db:",auto"` must be written as `oblast:",auto"` instead.
+//
+// This is useful when migrating from or to another ORM library that uses the same `db:"..."` tag as Oblast, but with conflicting semantics.
+func StructTagKeyIs(key string) PlanOption {
+ return func(opts *planOpts) { opts.StructTagKey = key }
+}
+
+// ReadOnly is a PlanOption that disables all write operations for the resulting [Store] type
+// (i.e., [Store.Insert], [Store.Update], [Store.Upsert] and [Store.Delete]).
+// Besides read-only tables (i.e. tables where the current user lacks write permissions),
+// this is useful for record types that only model a few columns of a table and which,
+// when used in write operations, might result in incomplete records.
+func ReadOnly() PlanOption {
+ return func(opts *planOpts) { opts.ReadOnly = true }
+}
+
+// Store holds information on how to read and write data into record type R,
+// and can also be used to execute autogenerated queries if the respective [PlanOption] values were provided during [NewStore].
+type Store[R any] struct {
+ plan plan
+}
+
+// NewStore initializes a store for record type R.
+// Returns an error if R is not a struct type.
+//
+// In most situations, the intended usage pattern is to call NewStore (or [MustNewStore]) once per record type,
+// and hold the result in a global variable.
+//
+// When dealing with private one-off record types that are declared within the function or method using them,
+// NewStore (or [MustNewStore]) may also be called once per function call.
+// NewStore will internally cache its results and return a cheap copy on subsequent calls with the same arguments,
+// only incurring the cost of a read lock on a mutex.
+func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) {
+ plan, err := getOrBuildPlan(reflect.TypeFor[R](), dialect, collectPlanOptions(opts))
+ if err != nil {
+ var zero R
+ return Store[R]{}, fmt.Errorf("cannot use type %T for queries: %w", zero, err)
+ }
+ return Store[R]{plan}, err
+}
+
+// MustNewStore is like [NewStore], but panics on error.
+func MustNewStore[R any](dialect Dialect, opts ...PlanOption) Store[R] {
+ store, err := NewStore[R](dialect, opts...)
+ if err != nil {
+ panic(err.Error())
+ }
+ return store
+}
diff --git a/oblast/plan.go b/oblast/plan.go
new file mode 100644
index 0000000..6b5f002
--- /dev/null
+++ b/oblast/plan.go
@@ -0,0 +1,488 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "cmp"
+ "errors"
+ "fmt"
+ "reflect"
+ "slices"
+ "strings"
+ "sync"
+)
+
+// planOpts holds additional arguments to buildPlan().
+type planOpts struct {
+ ReadOnly bool
+ StructTagKey string // defaults to "db"
+ TableName string
+ PrimaryKeyColumnNames []string
+}
+
+func collectPlanOptions(popts []PlanOption) planOpts {
+ opts := planOpts{
+ StructTagKey: "db",
+ }
+ for _, popt := range popts {
+ popt(&opts)
+ }
+ return opts
+}
+
+type planCacheKey struct {
+ Type reflect.Type
+ Dialect string
+ ReadOnly bool
+ StructTagKey string
+ TableName string
+ PrimaryKeyColumnNames string
+}
+
+var (
+ generatedPlans = make(map[planCacheKey]plan)
+ generatedPlansMutex sync.RWMutex
+ generatedTuplePlans = make(map[reflect.Type]plan)
+ generatedTuplePlansMutex sync.Mutex
+)
+
+// getOrBuildPlan is like [buildPlan], but caches generated plans and tries to reuse cached plans.
+func getOrBuildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) {
+ key := planCacheKey{
+ Type: t,
+ Dialect: dialect.String(),
+ ReadOnly: opts.ReadOnly,
+ StructTagKey: opts.StructTagKey,
+ TableName: opts.TableName,
+ PrimaryKeyColumnNames: strings.Join(opts.PrimaryKeyColumnNames, "\000"),
+ }
+
+ generatedPlansMutex.RLock()
+ p, ok := generatedPlans[key]
+ generatedPlansMutex.RUnlock()
+ if ok {
+ return p, nil
+ }
+
+ p, err := buildPlan(t, dialect, opts)
+ if err != nil {
+ return plan{}, err
+ }
+ generatedPlansMutex.Lock()
+ generatedPlans[key] = p
+ generatedPlansMutex.Unlock()
+ return p, nil
+}
+
+// getOrBuildTuplePlan is like [getOrBuildPlan], but generates the plans used by TupleSelect() et al
+func getOrBuildTuplePlan(t reflect.Type) plan {
+ generatedTuplePlansMutex.Lock()
+ defer generatedTuplePlansMutex.Unlock()
+
+ p, ok := generatedTuplePlans[t]
+ if !ok {
+ indexes := make([][]int, t.NumField())
+ for idx := range indexes {
+ indexes[idx] = []int{idx}
+ }
+ p = plan{
+ TypeName: t.Name(),
+ StaticIndexes: indexes,
+ }
+ generatedTuplePlans[t] = p
+ }
+ return p
+}
+
+// plan holds all information that we can derive from reflecting on a given type.
+// The queries held within are only valid within the context of a given SQL dialect.
+type plan struct {
+ TypeName string // for use in error messages
+ TableName string // from info.TableNameIs marker (if any)
+ AllColumnNames []string // in order of struct fields (not set for TupleSelect() plans)
+ PrimaryKeyColumnNames []string // from info.PrimaryKeyIs marker (if any)
+ AutoColumnNames []string // subset of AllColumnNames where field has `,auto` marker
+
+ // Field index (i.e. argument for reflect.Value.FieldByIndex()) for each column name. Not set for TupleSelect() plans.
+ IndexByColumnName map[string][]int
+ // Select indexes for TupleSelect() plans. Always of the form [[0], [1], [2], ..., [N]]. Not set for regular plans.
+ StaticIndexes [][]int
+ // Pointer-typed fields that need to be initialized before scanning into this type.
+ TransparentPointerStructFields []fieldInfo
+
+ // Whether the INSERT query uses QueryRow or Exec.
+ // - When no auto-generated values are collected, or when a single value can be collected through LastInsertId(),
+ // this will be false because Exec() is more memory-efficient than QueryRow(); it does not have to allocate an *sql.Rows instance.
+ // - Otherwise, i.e. when auto-generated values are collected with a RETURNING clause,
+ // this will be true because Exec() does not support scanning result values.
+ InsertUsesQueryRow bool
+ // If InsertUsesQueryRow = false and a primary key is collected from LastInsertId(),
+ // this decides whether we write it with reflect.Value.SetInt() or reflect.Value.SetUint().
+ LastInsertIdIsUnsigned bool
+
+ // Planned queries.
+ Select plannedQuery // only `SELECT ... FROM ... WHERE `; user supplies the rest during Select{,One}Where()
+ Insert plannedQuery
+ Upsert plannedQuery
+ Update plannedQuery
+ Delete plannedQuery
+
+ // Whether Insert/Upsert/Update/Delete query planning was inhibited by the ReadOnly option.
+ // This information is preserved in order to render more useful error messages.
+ ReadOnly bool
+}
+
+// fieldInfo appears in type plan.
+type fieldInfo struct {
+ Name string
+ Index []int
+ ContainsPrimaryKey bool
+}
+
+// plannedQuery appears in type plan.
+type plannedQuery struct {
+ // Empty if the respective query type is not supported by this plan for lack of the required marker types.
+ Query string
+ // Arguments for reflect.Value.FieldByIndex() in the correct order for the query arguments of the above query.
+ ArgumentIndexes [][]int
+ // Arguments for reflect.Value.FieldByIndex() in the correct order for the Scan() arguments of the above query.
+ ScanIndexes [][]int
+}
+
+// buildPlan creates a new plan for the given struct type.
+func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) {
+ if t.Kind() != reflect.Struct {
+ return plan{}, fmt.Errorf("expected struct type, but got kind %q", t.Kind().String())
+ }
+
+ var p = plan{
+ TypeName: t.Name(),
+ TableName: opts.TableName,
+ PrimaryKeyColumnNames: opts.PrimaryKeyColumnNames,
+ IndexByColumnName: make(map[string][]int),
+ ReadOnly: opts.ReadOnly,
+ }
+
+ var (
+ indexesOfOpaqueStructs [][]int
+ indexesOfUnusedTransparentStructs [][]int
+ )
+ isWithin := func(fieldIndex, structIndex []int) bool {
+ // returns whether `structIndex` is a prefix of `fieldIndex` (i.e. whether the field is contained within the struct)
+ return len(fieldIndex) > len(structIndex) && slices.Equal(fieldIndex[0:len(structIndex)], structIndex)
+ }
+
+ // discover addressable fields in this type, collect information from markers and tags
+ for _, field := range assignableFields(t) {
+ // recurse into struct fields (i.e. ignore the struct itself and consider its members instead)
+ // unless the field itself has a `db:"..."` tag
+ if field.Type.Kind() == reflect.Struct || (field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct) {
+ if field.Tag.Get(opts.StructTagKey) == "" {
+ indexesOfUnusedTransparentStructs = append(indexesOfUnusedTransparentStructs, field.Index)
+ if field.Type.Kind() == reflect.Pointer {
+ // remember that, when scanning into a record of type `t`, we need to write a non-nil zeroed struct into this field
+ // to enable taking an address of its mapped member fields
+ p.TransparentPointerStructFields = append(p.TransparentPointerStructFields, fieldInfo{
+ Name: field.Name,
+ Index: field.Index,
+ ContainsPrimaryKey: false, // might be set later
+ })
+ }
+ continue
+ }
+ indexesOfOpaqueStructs = append(indexesOfOpaqueStructs, field.Index)
+ }
+
+ // ignore fields that are within a struct type that is mapped as a whole
+ if slices.ContainsFunc(indexesOfOpaqueStructs, func(index []int) bool {
+ return isWithin(field.Index, index)
+ }) {
+ continue
+ }
+
+ // check `db:"..."` tag, ignore fields that are declared with column name "-"
+ tags := strings.Split(strings.TrimSpace(field.Tag.Get(opts.StructTagKey)), ",")
+ columnName, extraTags := cmp.Or(tags[0], field.Name), tags[1:]
+ if columnName == "-" {
+ continue
+ }
+
+ if otherIndex := p.IndexByColumnName[columnName]; otherIndex != nil {
+ return plan{}, fmt.Errorf(
+ "duplicate tag `%s:%q` on field index %v, but also on field index %v",
+ opts.StructTagKey, columnName, otherIndex, field.Index,
+ )
+ }
+ p.IndexByColumnName[columnName] = field.Index
+ p.AllColumnNames = append(p.AllColumnNames, columnName)
+
+ // track whether transparent structs contain fields that are mapped
+ restartIteration:
+ for idx, index := range indexesOfUnusedTransparentStructs {
+ if isWithin(field.Index, index) {
+ indexesOfUnusedTransparentStructs = slices.Delete(indexesOfUnusedTransparentStructs, idx, idx+1)
+ goto restartIteration
+ }
+ }
+
+ // track which transparent pointer structs contain PK fields
+ if slices.Contains(p.PrimaryKeyColumnNames, columnName) {
+ for idx, tpsField := range p.TransparentPointerStructFields {
+ if isWithin(field.Index, tpsField.Index) {
+ p.TransparentPointerStructFields[idx].ContainsPrimaryKey = true
+ }
+ }
+ }
+
+ for _, tag := range extraTags {
+ switch tag {
+ case "auto":
+ p.AutoColumnNames = append(p.AutoColumnNames, columnName)
+ default:
+ return plan{}, fmt.Errorf("unknown option `%s:%q` on field %q", opts.StructTagKey, ","+tag, field.Name)
+ }
+ }
+ }
+
+ // validation: transparent structs need to have at least one of their members mapped
+ // (this property is most often violated when a user of a library-defined type is not aware that this type is a struct under the hood,
+ // e.g. a field like "CreatedAt time.Time" needs to have a tag like `db:"created_at"`,
+ // otherwise nothing will be mapped because time.Time does not have any exported fields)
+ for _, index := range indexesOfUnusedTransparentStructs {
+ field := t.FieldByIndex(index)
+ return plan{}, fmt.Errorf(
+ "field %q of type %s does not contain any mapped fields (to map this whole field to a DB column, add an explicit `%s:\"...\"` tag)",
+ field.Name, field.Type.String(), opts.StructTagKey,
+ )
+ }
+
+ // validation: defining a primary key only makes sense for records that map onto a single table
+ if len(p.PrimaryKeyColumnNames) > 0 && p.TableName == "" {
+ return plan{}, errors.New("cannot declare a primary key without also providing the TableNameIs option")
+ }
+
+ // validation: oblast.PrimaryKeyInfo must refer to columns that exist
+ for _, columnName := range p.PrimaryKeyColumnNames {
+ _, ok := p.IndexByColumnName[columnName]
+ if !ok {
+ return plan{}, fmt.Errorf("no field has tag `%s:%q`, but a field of this name was declared in the primary key", opts.StructTagKey, columnName)
+ }
+ }
+
+ // pick strategy for INSERT
+ if p.TableName != "" {
+ switch len(p.AutoColumnNames) {
+ case 0:
+ p.InsertUsesQueryRow = false
+ case 1:
+ if dialect.CanUseLastInsertId() {
+ columnName := p.AutoColumnNames[0]
+ field := t.FieldByIndex(p.IndexByColumnName[columnName])
+ switch field.Type.Kind() { //nolint:exhaustive // false positive
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
+ p.InsertUsesQueryRow = false
+ p.LastInsertIdIsUnsigned = false
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ p.InsertUsesQueryRow = false
+ p.LastInsertIdIsUnsigned = true
+ default:
+ p.InsertUsesQueryRow = true
+ }
+ } else {
+ p.InsertUsesQueryRow = true
+ }
+ default:
+ p.InsertUsesQueryRow = true
+ }
+ }
+
+ // prepare query strings
+ p.Select = p.buildSelectQueryIfPossible(dialect)
+ if !opts.ReadOnly {
+ p.Insert = p.buildInsertQueryIfPossible(dialect, false)
+ p.Upsert = p.buildInsertQueryIfPossible(dialect, true)
+ p.Update = p.buildUpdateQueryIfPossible(dialect)
+ p.Delete = p.buildDeleteQueryIfPossible(dialect)
+ }
+
+ return p, nil
+}
+
+// Like reflect.VisibleFields(), but considers all fields within the type that
+// are assignable (i.e. `v.FieldByIndex(...).Set(...)` does not panic).
+func assignableFields(t reflect.Type) (result []reflect.StructField) {
+ for field := range t.Fields() {
+ // assignment is allowed for exported or embedded fields only
+ if field.IsExported() || field.Anonymous {
+ result = append(result, field)
+
+ // recurse into struct fields
+ ft := field.Type
+ if ft.Kind() == reflect.Pointer {
+ ft = ft.Elem()
+ }
+ if ft.Kind() == reflect.Struct {
+ for _, subfield := range assignableFields(ft) {
+ subfield.Index = append(slices.Clone(field.Index), subfield.Index...)
+ result = append(result, subfield)
+ }
+ }
+ }
+ }
+
+ return result
+}
+
+func (p plan) getNonAutoColumnNames() []string {
+ result := make([]string, 0, len(p.AllColumnNames)-len(p.AutoColumnNames))
+ for _, columnName := range p.AllColumnNames {
+ if !slices.Contains(p.AutoColumnNames, columnName) {
+ result = append(result, columnName)
+ }
+ }
+ return result
+}
+
+func (p plan) getNonPrimaryKeyColumnNames() []string {
+ result := make([]string, 0, len(p.AllColumnNames)-len(p.PrimaryKeyColumnNames))
+ for _, columnName := range p.AllColumnNames {
+ if !slices.Contains(p.PrimaryKeyColumnNames, columnName) {
+ result = append(result, columnName)
+ }
+ }
+ return result
+}
+
+func (p plan) buildSelectQueryIfPossible(dialect Dialect) plannedQuery {
+ if p.TableName == "" {
+ return plannedQuery{Query: ""}
+ }
+
+ var (
+ scanIndexes = make([][]int, len(p.AllColumnNames))
+ quotedColumnNames = make([]string, len(p.AllColumnNames))
+ )
+ for idx, columnName := range p.AllColumnNames {
+ scanIndexes[idx] = p.IndexByColumnName[columnName]
+ quotedColumnNames[idx] = dialect.QuoteIdentifier(columnName)
+ }
+
+ query := fmt.Sprintf(
+ `SELECT %s FROM %s WHERE `,
+ strings.Join(quotedColumnNames, ", "),
+ dialect.QuoteIdentifier(p.TableName),
+ )
+ return plannedQuery{query, nil, scanIndexes}
+}
+
+func (p plan) buildInsertQueryIfPossible(dialect Dialect, isUpsert bool) plannedQuery {
+ if p.TableName == "" || len(p.AllColumnNames) == 0 {
+ return plannedQuery{Query: ""}
+ }
+ nonAutoColumnNames := p.getNonAutoColumnNames()
+ if len(nonAutoColumnNames) == 0 {
+ return plannedQuery{Query: ""}
+ }
+
+ // UPSERT queries specifically are only generated if we have non-auto primary keys:
+ // - cannot hit a key conflict if there are no keys
+ // - cannot hit a key conflict on insert if all keys are autogenerated (and thus we never supply them during INSERT)
+ if isUpsert && !slices.ContainsFunc(p.PrimaryKeyColumnNames, func(n string) bool { return !slices.Contains(p.AutoColumnNames, n) }) {
+ return plannedQuery{Query: ""}
+ }
+
+ var (
+ argumentIndexes = make([][]int, len(nonAutoColumnNames))
+ scanIndexes [][]int
+ quotedColumnNames = make([]string, len(nonAutoColumnNames))
+ quotedPlaceholders = make([]string, len(nonAutoColumnNames))
+ )
+ for idx, columnName := range nonAutoColumnNames {
+ argumentIndexes[idx] = p.IndexByColumnName[columnName]
+ quotedColumnNames[idx] = dialect.QuoteIdentifier(columnName)
+ quotedPlaceholders[idx] = dialect.Placeholder(idx)
+ }
+ if len(p.AutoColumnNames) > 0 {
+ scanIndexes = make([][]int, len(p.AutoColumnNames))
+ for idx, columnName := range p.AutoColumnNames {
+ scanIndexes[idx] = p.IndexByColumnName[columnName]
+ }
+ }
+
+ query := fmt.Sprintf(
+ `INSERT INTO %s (%s) VALUES (%s)`,
+ dialect.QuoteIdentifier(p.TableName),
+ strings.Join(quotedColumnNames, ", "),
+ strings.Join(quotedPlaceholders, ", "),
+ )
+ if isUpsert {
+ query += dialect.UpsertClause(p.PrimaryKeyColumnNames, p.getNonPrimaryKeyColumnNames())
+ }
+ if len(p.AutoColumnNames) > 0 && p.InsertUsesQueryRow {
+ quotedAutoColumns := make([]string, len(p.AutoColumnNames))
+ for idx, name := range p.AutoColumnNames {
+ quotedAutoColumns[idx] = dialect.QuoteIdentifier(name)
+ }
+ query += ` RETURNING ` + strings.Join(quotedAutoColumns, ", ")
+ }
+ return plannedQuery{query, argumentIndexes, scanIndexes}
+}
+
+func (p plan) buildUpdateQueryIfPossible(dialect Dialect) plannedQuery {
+ if p.TableName == "" || len(p.PrimaryKeyColumnNames) == 0 {
+ return plannedQuery{Query: ""}
+ }
+ nonPrimaryKeyColumnNames := p.getNonPrimaryKeyColumnNames()
+ if len(nonPrimaryKeyColumnNames) == 0 {
+ return plannedQuery{Query: ""}
+ }
+
+ var (
+ setArgumentIndexes = make([][]int, len(nonPrimaryKeyColumnNames))
+ setClauses = make([]string, len(nonPrimaryKeyColumnNames))
+ )
+ for idx, columnName := range nonPrimaryKeyColumnNames {
+ setArgumentIndexes[idx] = p.IndexByColumnName[columnName]
+ setClauses[idx] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(columnName), dialect.Placeholder(idx))
+ }
+
+ var (
+ whereArgumentIndexes = make([][]int, len(p.PrimaryKeyColumnNames))
+ whereClauses = make([]string, len(p.PrimaryKeyColumnNames))
+ )
+ for idx, columnName := range p.PrimaryKeyColumnNames {
+ whereArgumentIndexes[idx] = p.IndexByColumnName[columnName]
+ whereClauses[idx] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(columnName), dialect.Placeholder(idx+len(setClauses)))
+ }
+
+ query := fmt.Sprintf(
+ `UPDATE %s SET %s WHERE %s`,
+ dialect.QuoteIdentifier(p.TableName),
+ strings.Join(setClauses, ", "),
+ strings.Join(whereClauses, " AND "),
+ )
+ return plannedQuery{query, slices.Concat(setArgumentIndexes, whereArgumentIndexes), nil}
+}
+
+func (p plan) buildDeleteQueryIfPossible(dialect Dialect) plannedQuery {
+ if p.TableName == "" || len(p.PrimaryKeyColumnNames) == 0 {
+ return plannedQuery{Query: ""}
+ }
+
+ var (
+ argumentIndexes = make([][]int, len(p.PrimaryKeyColumnNames))
+ clauses = make([]string, len(p.PrimaryKeyColumnNames))
+ )
+ for idx, columnName := range p.PrimaryKeyColumnNames {
+ argumentIndexes[idx] = p.IndexByColumnName[columnName]
+ clauses[idx] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(columnName), dialect.Placeholder(idx))
+ }
+
+ query := fmt.Sprintf(
+ `DELETE FROM %s WHERE %s`,
+ dialect.QuoteIdentifier(p.TableName),
+ strings.Join(clauses, " AND "),
+ )
+ return plannedQuery{query, argumentIndexes, nil}
+}
diff --git a/oblast/plan_test.go b/oblast/plan_test.go
new file mode 100644
index 0000000..45666d7
--- /dev/null
+++ b/oblast/plan_test.go
@@ -0,0 +1,657 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+// ^ NOTE: This is testing internal types and thus must reside in the same package.
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "go.xyrillian.de/gg/assert"
+)
+
+// Clears all fields of `p` that are not used when actually running queries,
+// i.e. intermediate fields holding analysis results that went into query planning.
+// This is used to shorten the assertions in TestQueryConstruction...() below.
+func onlyQueryPlans(p plan) plan {
+ p.AllColumnNames = nil
+ p.AutoColumnNames = nil
+ p.IndexByColumnName = nil
+ p.PrimaryKeyColumnNames = nil
+ p.TableName = ""
+ p.TypeName = ""
+ return p
+}
+
+// Basically the opposite of onlyQueryPlans.
+// TestPlanFieldTraversal() only cares about the analysis part, not the query construction phase.
+func onlyAnalysisResult(p plan) plan {
+ p.Select = plannedQuery{}
+ p.Insert = plannedQuery{}
+ p.Upsert = plannedQuery{}
+ p.Update = plannedQuery{}
+ p.Delete = plannedQuery{}
+ return p
+}
+
+func TestPlanFieldTraversal(t *testing.T) {
+ type Timestamps struct {
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt *time.Time `db:"updated_at"`
+ }
+ type yetMoreTimestamps struct {
+ DeletedAt *time.Time `db:"deleted_at"`
+ }
+ type Log struct {
+ ID int64 `db:"id,auto"`
+ Message string
+ private1 bool `db:"private1"` //nolint:unused
+ Ignored any `db:"-"`
+ Timestamps
+ *yetMoreTimestamps
+ MoreText struct {
+ Description string
+ }
+ YetMoreText struct {
+ Payload string
+ } `db:"-"`
+ OpaqueText struct {
+ ShortMessage string
+ LongMessage string
+ } `db:"OpaqueText"`
+ }
+
+ // check that the plan for Log:
+ // 1. uses the field name as a column name for "Message"
+ // 2. ignores "private1" because it cannot be written through reflection
+ // 3. ignores "Ignored" because its column name is "-"
+ // 4. traverses into "Timestamps" and includes its fields as well
+ // 5. traverses into "yetMoreTimestamps" as well (despite the extra pointer and the type being private)
+ // 6. traverses into "MoreText" and includes its fields as well
+ // 7. does not traverse into "YetMoreText" and does not include its fields because of `db:"-"`
+ // 8. does not traverse into "OpaqueText" because the struct is mapped as a whole
+ // 9. recognizes "id" as an autofilled column
+ p, err := buildPlan(reflect.TypeFor[Log](), PostgresDialect(), planOpts{
+ StructTagKey: "db",
+ TableName: "log_entries",
+ PrimaryKeyColumnNames: []string{"id"},
+ })
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyAnalysisResult(p), plan{
+ TypeName: "Log",
+ TableName: "log_entries",
+ AllColumnNames: []string{"id", "Message", "created_at", "updated_at", "deleted_at", "Description", "OpaqueText"},
+ PrimaryKeyColumnNames: []string{"id"},
+ AutoColumnNames: []string{"id"},
+ IndexByColumnName: map[string][]int{
+ "id": {0},
+ "Message": {1},
+ "created_at": {4, 0},
+ "updated_at": {4, 1},
+ "deleted_at": {5, 0},
+ "Description": {6, 0},
+ "OpaqueText": {8},
+ },
+ InsertUsesQueryRow: true,
+ TransparentPointerStructFields: []fieldInfo{{
+ Name: "yetMoreTimestamps",
+ Index: []int{5},
+ }},
+ })
+}
+
+func TestQueryConstructionBasic(t *testing.T) {
+ type record struct {
+ ID int64 `db:",auto"`
+ Description string
+ CreatedAt time.Time `db:"CreatedAt"`
+ }
+ opts := planOpts{
+ StructTagKey: "db",
+ TableName: "basic_records",
+ PrimaryKeyColumnNames: []string{"ID"},
+ }
+
+ t.Run("MariaDBDialect", func(t *testing.T) {
+ opts.ReadOnly = false
+ p, err := buildPlan(reflect.TypeFor[record](), MariaDBDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: "SELECT `ID`, `Description`, `CreatedAt` FROM `basic_records` WHERE ",
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: "INSERT INTO `basic_records` (`Description`, `CreatedAt`) VALUES (?, ?)",
+ ArgumentIndexes: [][]int{{1}, {2}},
+ ScanIndexes: [][]int{{0}},
+ },
+ Update: plannedQuery{
+ Query: "UPDATE `basic_records` SET `Description` = ?, `CreatedAt` = ? WHERE `ID` = ?",
+ ArgumentIndexes: [][]int{{1}, {2}, {0}},
+ },
+ Delete: plannedQuery{
+ Query: "DELETE FROM `basic_records` WHERE `ID` = ?",
+ ArgumentIndexes: [][]int{{0}},
+ },
+ })
+
+ opts.ReadOnly = true
+ p2, err := buildPlan(reflect.TypeFor[record](), MariaDBDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p2), plan{
+ ReadOnly: true,
+ Select: p.Select,
+ })
+ })
+
+ t.Run("PostgresDialect", func(t *testing.T) {
+ opts.ReadOnly = false
+ p, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ InsertUsesQueryRow: true,
+ Select: plannedQuery{
+ Query: `SELECT "ID", "Description", "CreatedAt" FROM "basic_records" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "basic_records" ("Description", "CreatedAt") VALUES ($1, $2) RETURNING "ID"`,
+ ArgumentIndexes: [][]int{{1}, {2}},
+ ScanIndexes: [][]int{{0}},
+ },
+ Update: plannedQuery{
+ Query: `UPDATE "basic_records" SET "Description" = $1, "CreatedAt" = $2 WHERE "ID" = $3`,
+ ArgumentIndexes: [][]int{{1}, {2}, {0}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "basic_records" WHERE "ID" = $1`,
+ ArgumentIndexes: [][]int{{0}},
+ },
+ })
+
+ opts.ReadOnly = true
+ p2, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p2), plan{
+ InsertUsesQueryRow: true,
+ ReadOnly: true,
+ Select: p.Select,
+ })
+ })
+
+ t.Run("SqliteDialect", func(t *testing.T) {
+ opts.ReadOnly = false
+ p, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: `SELECT "ID", "Description", "CreatedAt" FROM "basic_records" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "basic_records" ("Description", "CreatedAt") VALUES (?, ?)`,
+ ArgumentIndexes: [][]int{{1}, {2}},
+ ScanIndexes: [][]int{{0}},
+ },
+ Update: plannedQuery{
+ Query: `UPDATE "basic_records" SET "Description" = ?, "CreatedAt" = ? WHERE "ID" = ?`,
+ ArgumentIndexes: [][]int{{1}, {2}, {0}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "basic_records" WHERE "ID" = ?`,
+ ArgumentIndexes: [][]int{{0}},
+ },
+ })
+
+ opts.ReadOnly = true
+ p2, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p2), plan{
+ ReadOnly: true,
+ Select: p.Select,
+ })
+ })
+}
+
+func TestQueryConstructionWithOnlyPrimaryKey(t *testing.T) {
+ type relation struct {
+ FooID int64 `db:"foo_id"`
+ BarID int64 `db:"bar_id"`
+ }
+ opts := planOpts{
+ StructTagKey: "db",
+ TableName: "foo_bar_relations",
+ PrimaryKeyColumnNames: []string{"foo_id", "bar_id"},
+ }
+
+ t.Run("MariaDBDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[relation](), MariaDBDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: "SELECT `foo_id`, `bar_id` FROM `foo_bar_relations` WHERE ",
+ ScanIndexes: [][]int{{0}, {1}},
+ },
+ Insert: plannedQuery{
+ Query: "INSERT INTO `foo_bar_relations` (`foo_id`, `bar_id`) VALUES (?, ?)",
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ Upsert: plannedQuery{
+ Query: "INSERT INTO `foo_bar_relations` (`foo_id`, `bar_id`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `foo_id` = VALUES(`foo_id`)",
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ Delete: plannedQuery{
+ Query: "DELETE FROM `foo_bar_relations` WHERE `foo_id` = ? AND `bar_id` = ?",
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+
+ t.Run("PostgresDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[relation](), PostgresDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: `SELECT "foo_id", "bar_id" FROM "foo_bar_relations" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES ($1, $2)`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ Upsert: plannedQuery{
+ Query: `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES ($1, $2) ON CONFLICT ("foo_id", "bar_id") DO NOTHING`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "foo_bar_relations" WHERE "foo_id" = $1 AND "bar_id" = $2`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+
+ t.Run("SqliteDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[relation](), SqliteDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: `SELECT "foo_id", "bar_id" FROM "foo_bar_relations" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?)`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ Upsert: plannedQuery{
+ Query: `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?) ON CONFLICT ("foo_id", "bar_id") DO NOTHING`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "foo_bar_relations" WHERE "foo_id" = ? AND "bar_id" = ?`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+}
+
+func TestQueryConstructionWithoutPrimaryKey(t *testing.T) {
+ type relation struct {
+ FooID int64 `db:"foo_id"`
+ BarID int64 `db:"bar_id"`
+ }
+ opts := planOpts{
+ StructTagKey: "db",
+ TableName: "foo_bar_relations",
+ }
+
+ t.Run("MariaDBDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[relation](), MariaDBDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: "SELECT `foo_id`, `bar_id` FROM `foo_bar_relations` WHERE ",
+ ScanIndexes: [][]int{{0}, {1}},
+ },
+ Insert: plannedQuery{
+ Query: "INSERT INTO `foo_bar_relations` (`foo_id`, `bar_id`) VALUES (?, ?)",
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+
+ t.Run("PostgresDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[relation](), PostgresDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: `SELECT "foo_id", "bar_id" FROM "foo_bar_relations" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES ($1, $2)`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+
+ t.Run("SqliteDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[relation](), SqliteDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: `SELECT "foo_id", "bar_id" FROM "foo_bar_relations" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?)`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+}
+
+func TestQueryConstructionImpossible(t *testing.T) {
+ type unstructuredData struct {
+ Foo int
+ Bar *string
+ }
+ opts := planOpts{
+ StructTagKey: "db",
+ }
+
+ testWith := func(dialect Dialect) func(*testing.T) {
+ return func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[unstructuredData](), dialect, opts)
+ if err != nil {
+ t.Error(err)
+ }
+
+ assert.Equal(t, onlyQueryPlans(p), plan{})
+ }
+ }
+
+ t.Run("MariaDBDialect", testWith(MariaDBDialect()))
+ t.Run("PostgresDialect", testWith(PostgresDialect()))
+ t.Run("SqliteDialect", testWith(SqliteDialect()))
+}
+
+func TestQueryConstructionWithMultiplePrimaryKeyColumns(t *testing.T) {
+ type record struct {
+ GroupID int64 `db:"group_id"`
+ Name string `db:"name"`
+ CreatedAt time.Time `db:"created_at"`
+ }
+ opts := planOpts{
+ StructTagKey: "db",
+ TableName: "complex_records",
+ PrimaryKeyColumnNames: []string{"group_id", "name"},
+ }
+
+ t.Run("MariaDBDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[record](), MariaDBDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: "SELECT `group_id`, `name`, `created_at` FROM `complex_records` WHERE ",
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: "INSERT INTO `complex_records` (`group_id`, `name`, `created_at`) VALUES (?, ?, ?)",
+ ArgumentIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Upsert: plannedQuery{
+ Query: "INSERT INTO `complex_records` (`group_id`, `name`, `created_at`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `created_at` = VALUES(`created_at`)",
+ ArgumentIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Update: plannedQuery{
+ Query: "UPDATE `complex_records` SET `created_at` = ? WHERE `group_id` = ? AND `name` = ?",
+ ArgumentIndexes: [][]int{{2}, {0}, {1}},
+ },
+ Delete: plannedQuery{
+ Query: "DELETE FROM `complex_records` WHERE `group_id` = ? AND `name` = ?",
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+
+ t.Run("PostgresDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: `SELECT "group_id", "name", "created_at" FROM "complex_records" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES ($1, $2, $3)`,
+ ArgumentIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Upsert: plannedQuery{
+ Query: `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES ($1, $2, $3) ON CONFLICT ("group_id", "name") DO UPDATE SET "created_at" = EXCLUDED."created_at"`,
+ ArgumentIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Update: plannedQuery{
+ Query: `UPDATE "complex_records" SET "created_at" = $1 WHERE "group_id" = $2 AND "name" = $3`,
+ ArgumentIndexes: [][]int{{2}, {0}, {1}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "complex_records" WHERE "group_id" = $1 AND "name" = $2`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+
+ t.Run("SqliteDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ Select: plannedQuery{
+ Query: `SELECT "group_id", "name", "created_at" FROM "complex_records" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES (?, ?, ?)`,
+ ArgumentIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Upsert: plannedQuery{
+ Query: `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES (?, ?, ?) ON CONFLICT ("group_id", "name") DO UPDATE SET "created_at" = EXCLUDED."created_at"`,
+ ArgumentIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Update: plannedQuery{
+ Query: `UPDATE "complex_records" SET "created_at" = ? WHERE "group_id" = ? AND "name" = ?`,
+ ArgumentIndexes: [][]int{{2}, {0}, {1}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "complex_records" WHERE "group_id" = ? AND "name" = ?`,
+ ArgumentIndexes: [][]int{{0}, {1}},
+ },
+ })
+ })
+}
+
+func TestQueryConstructionWithMultipleAutoColumns(t *testing.T) {
+ type record struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ CreatedAt time.Time `db:"created_at,auto"`
+ }
+ opts := planOpts{
+ StructTagKey: "db",
+ TableName: "autogenerated_records",
+ PrimaryKeyColumnNames: []string{"id"},
+ }
+
+ t.Run("MariaDBDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[record](), MariaDBDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ InsertUsesQueryRow: true,
+ Select: plannedQuery{
+ Query: "SELECT `id`, `name`, `created_at` FROM `autogenerated_records` WHERE ",
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: "INSERT INTO `autogenerated_records` (`name`) VALUES (?) RETURNING `id`, `created_at`",
+ ArgumentIndexes: [][]int{{1}},
+ ScanIndexes: [][]int{{0}, {2}},
+ },
+ Update: plannedQuery{
+ Query: "UPDATE `autogenerated_records` SET `name` = ?, `created_at` = ? WHERE `id` = ?",
+ ArgumentIndexes: [][]int{{1}, {2}, {0}},
+ },
+ Delete: plannedQuery{
+ Query: "DELETE FROM `autogenerated_records` WHERE `id` = ?",
+ ArgumentIndexes: [][]int{{0}},
+ },
+ })
+ })
+
+ t.Run("PostgresDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ InsertUsesQueryRow: true,
+ Select: plannedQuery{
+ Query: `SELECT "id", "name", "created_at" FROM "autogenerated_records" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "autogenerated_records" ("name") VALUES ($1) RETURNING "id", "created_at"`,
+ ArgumentIndexes: [][]int{{1}},
+ ScanIndexes: [][]int{{0}, {2}},
+ },
+ Update: plannedQuery{
+ Query: `UPDATE "autogenerated_records" SET "name" = $1, "created_at" = $2 WHERE "id" = $3`,
+ ArgumentIndexes: [][]int{{1}, {2}, {0}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "autogenerated_records" WHERE "id" = $1`,
+ ArgumentIndexes: [][]int{{0}},
+ },
+ })
+ })
+
+ t.Run("SqliteDialect", func(t *testing.T) {
+ p, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p), plan{
+ InsertUsesQueryRow: true,
+ Select: plannedQuery{
+ Query: `SELECT "id", "name", "created_at" FROM "autogenerated_records" WHERE `,
+ ScanIndexes: [][]int{{0}, {1}, {2}},
+ },
+ Insert: plannedQuery{
+ Query: `INSERT INTO "autogenerated_records" ("name") VALUES (?) RETURNING "id", "created_at"`,
+ ArgumentIndexes: [][]int{{1}},
+ ScanIndexes: [][]int{{0}, {2}},
+ },
+ Update: plannedQuery{
+ Query: `UPDATE "autogenerated_records" SET "name" = ?, "created_at" = ? WHERE "id" = ?`,
+ ArgumentIndexes: [][]int{{1}, {2}, {0}},
+ },
+ Delete: plannedQuery{
+ Query: `DELETE FROM "autogenerated_records" WHERE "id" = ?`,
+ ArgumentIndexes: [][]int{{0}},
+ },
+ })
+ })
+}
+
+func TestPlanErrorCases(t *testing.T) {
+ type recordUsedViaPointer struct {
+ ID int64 `db:"id"`
+ }
+
+ _, err := NewStore[*recordUsedViaPointer](SqliteDialect())
+ assert.Equal(t, err.Error(), `cannot use type *oblast.recordUsedViaPointer for queries: `+
+ `expected struct type, but got kind "ptr"`)
+
+ type recordWithDuplicateTags struct {
+ Foo int64 `db:"Bar"`
+ Qux float64
+ Bar string
+ }
+ _, err = NewStore[recordWithDuplicateTags](SqliteDialect())
+ assert.Equal(t, err.Error(), `cannot use type oblast.recordWithDuplicateTags for queries: `+
+ "duplicate tag `db:\"Bar\"` on field index [0], but also on field index [2]")
+
+ type recordWithUnusedTransparentStruct struct {
+ ID int64
+ CreatedAt time.Time // has no exported fields!
+ }
+ _, err = NewStore[recordWithUnusedTransparentStruct](SqliteDialect())
+ assert.Equal(t, err.Error(), `cannot use type oblast.recordWithUnusedTransparentStruct for queries: `+
+ "field \"CreatedAt\" of type time.Time does not contain any mapped fields (to map this whole field to a DB column, add an explicit `db:\"...\"` tag)")
+
+ type recordWithPKButNoTableName struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+ }
+ _, err = NewStore[recordWithPKButNoTableName](SqliteDialect(),
+ PrimaryKeyIs("id"),
+ )
+ assert.Equal(t, err.Error(), `cannot use type oblast.recordWithPKButNoTableName for queries: `+
+ `cannot declare a primary key without also providing the TableNameIs option`)
+
+ type recordWithUnknownPK struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+ }
+ _, err = NewStore[recordWithUnknownPK](SqliteDialect(),
+ TableNameIs("records"),
+ PrimaryKeyIs("record_id"),
+ )
+ assert.Equal(t, err.Error(), `cannot use type oblast.recordWithUnknownPK for queries: `+
+ "no field has tag `db:\"record_id\"`, but a field of this name was declared in the primary key")
+
+ type recordWithWeirdTagOption struct {
+ ID int64 `db:",auto"`
+ Name string `db:",unique"`
+ Description string
+ }
+ _, err = NewStore[recordWithWeirdTagOption](SqliteDialect())
+ assert.Equal(t, err.Error(), `cannot use type oblast.recordWithWeirdTagOption for queries: `+
+ "unknown option `db:\",unique\"` on field \"Name\"")
+}
diff --git a/oblast/query.go b/oblast/query.go
new file mode 100644
index 0000000..6e375e4
--- /dev/null
+++ b/oblast/query.go
@@ -0,0 +1,340 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "reflect"
+
+ "go.xyrillian.de/gg/errext"
+ "go.xyrillian.de/gg/gsql"
+)
+
+// PrepareThreshold is a tuning parameter for the strategy used by all methods of [Store] operating on batches of records provided by the caller
+// (specifically, [Store.Insert], [Store.Update] and [Store.Delete]).
+//
+// For large amounts of records, it is obviously advantageous to build a prepared statement for the one query that will be used repeatedly on all of them.
+// However, building a prepared statement is associated with some amount of bookkeeping on the level of the database/sql library.
+// When operating on individual records or small amounts of records at a time (that is, in OLTP rather than OLAP workloads), this overhead becomes a measurable performance burden.
+//
+// This tuning parameter defines the minimum number of records that will justify maintaining a prepared statement.
+// Our benchmarking with the mattn/go-sqlite3 driver (and last checked with Go 1.26.2 on x86_64) indicates that this becomes a worthwhile investment at 8 or more records, so this is our default.
+// If your benchmarking indicates a different tradeoff depending on your choice of Go version or SQL driver, you may adjust this variable accordingly.
+//
+// The actual effect of this setting is to control the value of the "repeated" argument in [Handle.Prepare].
+var PrepareThreshold int = 8
+
+// prepare behaves like [Handle.Prepare].
+func prepare(ctx context.Context, db gsql.Handle, readOnly bool, query, operation string, inputSize int) (gsql.Statement, error) {
+ if query == "" {
+ if readOnly {
+ return nil, fmt.Errorf("cannot execute %s() because query planning used the ReadOnly() option", operation)
+ }
+ return nil, fmt.Errorf("cannot execute %s() because query could not be autogenerated", operation)
+ }
+
+ return db.GSQLPrepare(ctx, query, inputSize >= PrepareThreshold)
+}
+
+// Insert executes an SQL INSERT statement for each of the provided records.
+//
+// Fields that are declared with the "auto" tag will not be written into the DB,
+// and instead their value (as auto-generated by the DB on insert) will be placed in the record.
+// (This is why this method, as well as [Store.Upsert], need to take their arguments by-pointer instead of by-value).
+//
+// Returns an error if [NewStore] was called without the [TableNameIs] option, which is required to generate a query for this method.
+//
+// Returns an error if any of the `records` has a non-zero value in any column marked as `db:",auto"`.
+// Records that already exist in the database should be handled with [Store.Update] instead.
+// To automatically decide between INSERT and UPDATE on a per-record basis, use [Store.Upsert] instead.
+func (s Store[R]) Insert(ctx context.Context, db gsql.Handle, records ...*R) error {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Insert.Query, "Insert", len(records))
+ if err != nil {
+ return err
+ }
+ return s.insertUsing(ctx, stmt, db, records)
+}
+
+func (s Store[R]) insertUsing(ctx context.Context, stmt gsql.Statement, db gsql.Handle, records []*R) error {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ var (
+ argumentIndexes = s.plan.Insert.ArgumentIndexes
+ argumentSlots = make([]any, len(argumentIndexes))
+ scanIndexes = s.plan.Insert.ScanIndexes
+ scanSlots = make([]any, len(scanIndexes))
+ )
+
+ for idx, r := range records {
+ v := reflect.ValueOf(r).Elem()
+ err := checkTransparentPointerStructFieldsInitialized("INSERT", idx, v, s.plan, false)
+ if err != nil {
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+ }
+ err = insertRecord(ctx, s.plan, v, idx, stmt, argumentIndexes, argumentSlots, scanIndexes, scanSlots)
+ if err != nil {
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+ }
+ }
+
+ return errext.WithCleanup(nil, "Stmt.Close", stmt.Close())
+}
+
+func insertRecord(ctx context.Context, plan plan, v reflect.Value, recordIndex int, stmt gsql.Statement, argumentIndexes [][]int, argumentSlots []any, scanIndexes [][]int, scanSlots []any) error {
+ for idx, index := range argumentIndexes {
+ argumentSlots[idx] = v.FieldByIndex(index).Interface()
+ }
+ for idx, index := range scanIndexes {
+ f := v.FieldByIndex(index)
+ if !f.IsZero() {
+ return fmt.Errorf(`refusing to INSERT record with idx = %d that already has non-zero values in its "auto" columns`, recordIndex)
+ }
+ scanSlots[idx] = f.Addr().Interface()
+ }
+
+ var (
+ result sql.Result
+ err error
+ )
+ switch {
+ case len(scanSlots) == 0:
+ _, err = stmt.Exec(ctx, argumentSlots)
+ case plan.InsertUsesQueryRow:
+ err = stmt.QueryRow(ctx, argumentSlots, scanSlots)
+ default:
+ result, err = stmt.Exec(ctx, argumentSlots)
+ }
+ if err != nil {
+ return fmt.Errorf("while inserting record with idx = %d: %w", recordIndex, err)
+ }
+
+ if result != nil {
+ id, err := result.LastInsertId()
+ if err != nil {
+ return fmt.Errorf("while getting LastInsertId for record with idx = %d: %w", recordIndex, err)
+ }
+ if plan.LastInsertIdIsUnsigned {
+ if id < 0 {
+ return fmt.Errorf("LastInsertId() = %d for record with idx = %d cannot be converted to uint", id, recordIndex)
+ }
+ v.FieldByIndex(scanIndexes[0]).SetUint(uint64(id))
+ } else {
+ v.FieldByIndex(scanIndexes[0]).SetInt(id)
+ }
+ }
+
+ return nil
+}
+
+// This check must be performed within all query functions that access existing values using FieldByIndex(),
+// to ensure that FieldByIndex() does not panic on indirection through a nil pointer.
+func checkTransparentPointerStructFieldsInitialized(operation string, recordIndex int, v reflect.Value, plan plan, onlyPK bool) error {
+ for _, field := range plan.TransparentPointerStructFields {
+ f := v.FieldByIndex(field.Index)
+ if !f.IsZero() {
+ continue
+ }
+ if onlyPK {
+ if field.ContainsPrimaryKey {
+ return fmt.Errorf(`refusing to %s record with idx = %d: cannot access all primary key fields because field %q holds a nil pointer`,
+ operation, recordIndex, field.Name)
+ }
+ } else {
+ return fmt.Errorf(`refusing to %s record with idx = %d: cannot access all mapped fields because field %q holds a nil pointer`,
+ operation, recordIndex, field.Name)
+ }
+ }
+ return nil
+}
+
+// Update executes an SQL UPDATE statement for each of the provided records, updating all non-primary-key columns with the values in the records.
+// Returns [MissingRecordError] if any of the records does not exist in the database, that is, if for any of the records, the database contains no row with the same primary key values.
+//
+// Returns an error if [NewStore] was called without the [TableNameIs] or [PrimaryKeyIs] options, which are both required to generate a query for this method.
+func (s Store[R]) Update(ctx context.Context, db gsql.Handle, records ...R) error {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Update.Query, "Update", len(records))
+ if err != nil {
+ return err
+ }
+
+ var (
+ argumentIndexes = s.plan.Update.ArgumentIndexes
+ argumentSlots = make([]any, len(argumentIndexes))
+ )
+
+ for idx := range records {
+ v := reflect.ValueOf(&records[idx]).Elem()
+ err := checkTransparentPointerStructFieldsInitialized("UPDATE", idx, v, s.plan, false)
+ if err != nil {
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+ }
+ rowsAffected, err := updateRecord(ctx, v, idx, stmt, argumentIndexes, argumentSlots)
+ if err == nil && rowsAffected == 0 {
+ err = MissingRecordError[R]{records[idx], s.plan}
+ }
+ if err != nil {
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+ }
+ }
+ return errext.WithCleanup(nil, "Stmt.Close", stmt.Close())
+}
+
+func updateRecord(ctx context.Context, v reflect.Value, recordIndex int, stmt gsql.Statement, argumentIndexes [][]int, argumentSlots []any) (int64, error) {
+ for idx, index := range argumentIndexes {
+ argumentSlots[idx] = v.FieldByIndex(index).Interface()
+ }
+ result, err := stmt.Exec(ctx, argumentSlots)
+ if err != nil {
+ return 0, fmt.Errorf("while updating record with idx = %d: %w", recordIndex, err)
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return 0, fmt.Errorf("during RowsAffected() for record with idx = %d: %w", recordIndex, err)
+ }
+ return rowsAffected, nil
+}
+
+// Delete executes an SQL DELETE statement for each of the provided records, using their primary keys to locate the respective table rows.
+//
+// Returns an error if [NewStore] was called without the [TableNameIs] or [PrimaryKeyIs] options, which are both required to generate a query for this method.
+func (s Store[R]) Delete(ctx context.Context, db gsql.Handle, records ...R) error {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Delete.Query, "Delete", len(records))
+ if err != nil {
+ return err
+ }
+
+ var (
+ argumentIndexes = s.plan.Delete.ArgumentIndexes
+ argumentSlots = make([]any, len(argumentIndexes))
+ )
+
+ for idx := range records {
+ v := reflect.ValueOf(&records[idx]).Elem()
+ err := deleteRecord(ctx, s.plan, v, idx, stmt, argumentIndexes, argumentSlots)
+ if err != nil {
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+ }
+ }
+
+ return errext.WithCleanup(nil, "Stmt.Close", stmt.Close())
+}
+
+func deleteRecord(ctx context.Context, plan plan, v reflect.Value, recordIndex int, stmt gsql.Statement, argumentIndexes [][]int, argumentSlots []any) error {
+ err := checkTransparentPointerStructFieldsInitialized("DELETE", recordIndex, v, plan, true)
+ if err != nil {
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+ }
+ for idx, index := range argumentIndexes {
+ argumentSlots[idx] = v.FieldByIndex(index).Interface()
+ }
+ _, err = stmt.Exec(ctx, argumentSlots)
+ if err != nil {
+ return fmt.Errorf("while deleting record with idx = %d: %w", recordIndex, err)
+ }
+ return nil
+}
+
+// Upsert executes either an SQL INSERT or UPDATE statement for each of the provided records,
+// based on whether the record already exists in the DB or not.
+//
+// - For record types that have fields declared with the "auto" tag, INSERT is chosen if and only if those fields hold zero values.
+// Returns an error if only some of the respective fields hold zero values while others don't.
+// Returns an error if [NewStore] was called without the [TableNameIs] or [PrimaryKeyIs] options, which are both required to generate the respective queries for this method.
+// - For record types that do not have fields declared with the "auto" tag, an INSERT ... ON CONFLICT statement is used.
+// Returns an error if [NewStore] was called without the [TableNameIs] option, which is required to generate a query for this method.
+func (s Store[R]) Upsert(ctx context.Context, db gsql.Handle, records ...*R) error {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ if len(s.plan.AutoColumnNames) == 0 {
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Upsert.Query, "Upsert", len(records))
+ if err != nil {
+ return err
+ }
+ return s.insertUsing(ctx, stmt, db, records)
+ }
+
+ // TODO: respect PrepareThreshold (or not? may be too much bookkeeping overhead for not a whole lot of benefit)
+ insertStmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Insert.Query, "Insert", 0)
+ if err != nil {
+ return err
+ }
+ updateStmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Update.Query, "Update", 0)
+ if err != nil {
+ return errext.WithCleanup(err, "InsertStmt.Close", insertStmt.Close())
+ }
+
+ err = s.doUpsert(ctx, db, insertStmt, updateStmt, records)
+ err = errext.WithCleanup(err, "InsertStmt.Close", insertStmt.Close())
+ err = errext.WithCleanup(err, "UpdateStmt.Close", updateStmt.Close())
+ return err
+}
+
+func (s Store[R]) doUpsert(ctx context.Context, db gsql.Handle, insertStmt, updateStmt gsql.Statement, records []*R) error {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ var (
+ insertArgumentIndexes = s.plan.Insert.ArgumentIndexes
+ insertArgumentSlots = make([]any, len(insertArgumentIndexes))
+ insertScanIndexes = s.plan.Insert.ScanIndexes
+ insertScanSlots = make([]any, len(insertScanIndexes))
+ updateArgumentIndexes = s.plan.Update.ArgumentIndexes
+ updateArgumentSlots = make([]any, len(updateArgumentIndexes))
+ )
+
+ for idx, r := range records {
+ v := reflect.ValueOf(r).Elem()
+ err := checkTransparentPointerStructFieldsInitialized("INSERT or UPDATE", idx, v, s.plan, false)
+ if err != nil {
+ return err
+ }
+ isInsert, err := upsertDecideStrategy(v, idx, insertScanIndexes)
+ if err != nil {
+ return err
+ }
+
+ if isInsert {
+ err = insertRecord(ctx, s.plan, v, idx, insertStmt, insertArgumentIndexes, insertArgumentSlots, insertScanIndexes, insertScanSlots)
+ } else {
+ var rowsAffected int64
+ rowsAffected, err = updateRecord(ctx, v, idx, updateStmt, updateArgumentIndexes, updateArgumentSlots)
+ if err == nil && rowsAffected == 0 {
+ err = MissingRecordError[R]{*r, s.plan}
+ }
+ }
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func upsertDecideStrategy(v reflect.Value, recordIndex int, scanIndexes [][]int) (isInsert bool, err error) {
+ var isUpdate bool
+ for _, index := range scanIndexes {
+ if v.FieldByIndex(index).IsZero() {
+ isInsert = true
+ } else {
+ isUpdate = true
+ }
+ }
+ if isInsert && isUpdate {
+ return false, fmt.Errorf(`cannot decide whether to INSERT or UPDATE record with idx = %d: some "auto" columns are zero, others are not`, recordIndex)
+ }
+ return isInsert, nil
+}
diff --git a/oblast/query_test.go b/oblast/query_test.go
new file mode 100644
index 0000000..94c819b
--- /dev/null
+++ b/oblast/query_test.go
@@ -0,0 +1,522 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast_test
+
+import (
+ "database/sql"
+ "strconv"
+ "testing"
+ "time"
+
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/gsql"
+ "go.xyrillian.de/gg/oblast"
+ "go.xyrillian.de/gg/oblast/internal/testhelpers/mock"
+ "go.xyrillian.de/gg/oblast/internal/testhelpers/must"
+)
+
+func TestInsertBasic(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+
+ // testing with the SQLite dialect exercises the Exec()-based codepath
+ t.Run("driver=sqlite", func(t *testing.T) {
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} {
+ t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) {
+ records := make([]*basicRecord, batchSize)
+ for idx := range batchSize {
+ records[idx] = &basicRecord{Name: "new"}
+ md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES (?)`).
+ ExpectExecWithArgs("new").
+ AndReturnLastInsertId(int64(42 + idx))
+ }
+ must.Succeed(t, store.Insert(ctx, db, records...))
+ for idx, r := range records {
+ assert.Equal(t, r.ID, int64(42+idx))
+ }
+ })
+ }
+ })
+
+ // testing with the Postgres dialect exercises the QueryRow()-based codepath
+ t.Run("driver=postgres", func(t *testing.T) {
+ store := oblast.MustNewStore[basicRecord](
+ oblast.PostgresDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} {
+ t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) {
+ records := make([]*basicRecord, batchSize)
+ for idx := range batchSize {
+ records[idx] = &basicRecord{Name: "new"}
+ md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES ($1) RETURNING "id"`).
+ ExpectQueryWithArgs("new").
+ AndReturnColumns("id").
+ WithRow(int64(42 + idx))
+ }
+ must.Succeed(t, store.Insert(ctx, db, records...))
+ for idx, r := range records {
+ assert.Equal(t, r.ID, int64(42+idx))
+ }
+ })
+ }
+ })
+}
+
+func TestInsertWithUintPrimaryKey(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type exoticRecord struct {
+ ID uint64 `oblast:"id,auto"`
+ Name string `oblast:"name"`
+ }
+ store := oblast.MustNewStore[exoticRecord](
+ oblast.SqliteDialect(),
+ oblast.StructTagKeyIs("oblast"), // this test also randomly provides coverage for this option
+ oblast.TableNameIs("exotic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ // success case: positive ID fits into uint64
+ md.ForQuery(`INSERT INTO "exotic_records" ("name") VALUES (?)`).
+ ExpectExecWithArgs("new").
+ AndReturnLastInsertId(42)
+ record := exoticRecord{Name: "new"}
+ must.Succeed(t, store.Insert(ctx, db, &record))
+ assert.Equal(t, record.ID, 42)
+
+ // error case: negative ID cannot be converted to uint64
+ md.ForQuery(`INSERT INTO "exotic_records" ("name") VALUES (?)`).
+ ExpectExecWithArgs("another").
+ AndReturnLastInsertId(-42)
+ record = exoticRecord{Name: "another"}
+ err := store.Insert(ctx, db, &record)
+ assert.ErrEqual(t, err, "LastInsertId() = -42 for record with idx = 0 cannot be converted to uint")
+}
+
+func TestUpdateBasic(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} {
+ t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) {
+ records := make([]basicRecord, batchSize)
+ for idx := range batchSize {
+ r := basicRecord{ID: int64(42 + idx), Name: "updated"}
+ records[idx] = r
+ md.ForQuery(`UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`).
+ ExpectExecWithArgs(r.Name, r.ID).
+ AndReturnRowsAffected(1)
+ }
+ must.Succeed(t, store.Update(ctx, db, records...))
+ })
+ }
+}
+
+func TestDeleteBasic(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} {
+ t.Run("N="+strconv.Itoa(batchSize), func(t *testing.T) {
+ records := make([]basicRecord, batchSize)
+ for idx := range batchSize {
+ r := basicRecord{ID: int64(42 + idx), Name: "removed"}
+ records[idx] = r
+ md.ForQuery(`DELETE FROM "basic_records" WHERE "id" = ?`).
+ ExpectExecWithArgs(r.ID).
+ AndReturnRowsAffected(1)
+ }
+ must.Succeed(t, store.Delete(ctx, db, records...))
+ })
+ }
+}
+
+func TestUpsertBasicWithAutoColumn(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES (?)`).
+ ExpectExecWithArgs("first needs insert").
+ AndReturnLastInsertId(1)
+ md.ForQuery(`UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`).
+ ExpectExecWithArgs("second needs update", 2).
+ AndReturnRowsAffected(1)
+ md.ForQuery(`INSERT INTO "basic_records" ("name") VALUES (?)`).
+ ExpectExecWithArgs("third needs insert").
+ AndReturnLastInsertId(3)
+ md.ForQuery(`UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`).
+ ExpectExecWithArgs("fourth needs update", 4).
+ AndReturnRowsAffected(1)
+
+ records := []*basicRecord{
+ {Name: "first needs insert"},
+ {ID: 2, Name: "second needs update"},
+ {Name: "third needs insert"},
+ {ID: 4, Name: "fourth needs update"},
+ }
+ must.Succeed(t, store.Upsert(ctx, db, records...))
+
+ assert.Equal(t, records, []*basicRecord{
+ {ID: 1, Name: "first needs insert"},
+ {ID: 2, Name: "second needs update"},
+ {ID: 3, Name: "third needs insert"},
+ {ID: 4, Name: "fourth needs update"},
+ })
+}
+
+func TestWriteQueriesNotPossible(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ // no TableNameIs() or PrimaryKeyIs() given
+ )
+
+ r := basicRecord{Name: "foo"}
+ err := store.Insert(ctx, db, &r)
+ assert.ErrEqual(t, err, "cannot execute Insert() because query could not be autogenerated")
+
+ err = store.Upsert(ctx, db, &r)
+ assert.ErrEqual(t, err, "cannot execute Insert() because query could not be autogenerated")
+
+ r.ID = 42
+ err = store.Update(ctx, db, r)
+ assert.ErrEqual(t, err, "cannot execute Update() because query could not be autogenerated")
+
+ err = store.Delete(ctx, db, r)
+ assert.ErrEqual(t, err, "cannot execute Delete() because query could not be autogenerated")
+
+ store = oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("records"),
+ oblast.PrimaryKeyIs("id"),
+ oblast.ReadOnly(),
+ )
+
+ r = basicRecord{Name: "foo"}
+ err = store.Insert(ctx, db, &r)
+ assert.ErrEqual(t, err, "cannot execute Insert() because query planning used the ReadOnly() option")
+
+ err = store.Upsert(ctx, db, &r)
+ assert.ErrEqual(t, err, "cannot execute Insert() because query planning used the ReadOnly() option")
+
+ r.ID = 42
+ err = store.Update(ctx, db, r)
+ assert.ErrEqual(t, err, "cannot execute Update() because query planning used the ReadOnly() option")
+
+ err = store.Delete(ctx, db, r)
+ assert.ErrEqual(t, err, "cannot execute Delete() because query planning used the ReadOnly() option")
+}
+
+func TestWriteQueriesFailDuringPrepare(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ for _, batchSize := range []int{1, oblast.PrepareThreshold - 1, oblast.PrepareThreshold + 1} {
+ records := make([]basicRecord, batchSize)
+ recordsForInsert := make([]*basicRecord, batchSize)
+ for idx := range batchSize {
+ records[idx] = basicRecord{ID: int64(42 + idx), Name: "foo"}
+ recordsForInsert[idx] = &basicRecord{Name: "foo"}
+ }
+
+ err := store.Insert(ctx, db, recordsForInsert...)
+ baseError := `unexpected query: INSERT INTO "basic_records" ("name") VALUES (?)`
+ if batchSize < oblast.PrepareThreshold {
+ assert.ErrEqual(t, err, "while inserting record with idx = 0: "+baseError)
+ } else {
+ assert.ErrEqual(t, err, "during Prepare(): "+baseError)
+ }
+
+ err = store.Update(ctx, db, records...)
+ baseError = `unexpected query: UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`
+ if batchSize < oblast.PrepareThreshold {
+ assert.ErrEqual(t, err, "while updating record with idx = 0: "+baseError)
+ } else {
+ assert.ErrEqual(t, err, "during Prepare(): "+baseError)
+ }
+
+ err = store.Delete(ctx, db, records...)
+ baseError = `unexpected query: DELETE FROM "basic_records" WHERE "id" = ?`
+ if batchSize < oblast.PrepareThreshold {
+ assert.ErrEqual(t, err, "while deleting record with idx = 0: "+baseError)
+ } else {
+ assert.ErrEqual(t, err, "during Prepare(): "+baseError)
+ }
+ }
+}
+
+func TestUpdateOrUpsertFailsOnMissingRecord(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ // test Update()
+ md.ForQuery(`UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`).
+ ExpectExecWithArgs("changed", 42).
+ AndReturnRowsAffected(0)
+ err := store.Update(ctx, db, basicRecord{ID: 42, Name: "changed"})
+ assert.ErrEqual(t, err, "could not UPDATE record that does not exist in the database: id = 42")
+ _, hasCorrectType := err.(oblast.MissingRecordError[basicRecord]) //nolint:errorlint // we explicitly do not want a wrapped error
+ assert.Equal(t, hasCorrectType, true)
+
+ // test Upsert() -> this will not try inserting because the strategy
+ // is chosen based on the fill state of the "auto" field
+ md.ForQuery(`UPDATE "basic_records" SET "name" = ? WHERE "id" = ?`).
+ ExpectExecWithArgs("changed", 42).
+ AndReturnRowsAffected(0)
+ err = store.Upsert(ctx, db, &basicRecord{ID: 42, Name: "changed"})
+ assert.ErrEqual(t, err, "could not UPDATE record that does not exist in the database: id = 42")
+ _, hasCorrectType = err.(oblast.MissingRecordError[basicRecord]) //nolint:errorlint // we explicitly do not want a wrapped error
+ assert.Equal(t, hasCorrectType, true)
+}
+
+func TestInsertFailsOnFilledAutoField(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ err := store.Insert(ctx, db, &basicRecord{ID: 23, Name: "third"})
+ assert.ErrEqual(t, err, `refusing to INSERT record with idx = 0 that already has non-zero values in its "auto" columns`)
+}
+
+func TestInsertAndUpsertWithNoAutoColumns(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type relation struct {
+ FooID int64 `db:"foo_id"`
+ BarID int64 `db:"bar_id"`
+ }
+ store := oblast.MustNewStore[relation](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("foo_bar_relations"),
+ oblast.PrimaryKeyIs("foo_id", "bar_id"),
+ )
+
+ // test Insert()
+ md.ForQuery(`INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?)`).
+ ExpectExecWithArgs(23, 42).
+ AndReturnRowsAffected(1)
+ must.Succeed(t, store.Insert(ctx, db, &relation{23, 42}))
+
+ // test Upsert()
+ md.ForQuery(`INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?) ON CONFLICT ("foo_id", "bar_id") DO NOTHING`).
+ ExpectExecWithArgs(1, 2).
+ AndReturnRowsAffected(1)
+ md.ForQuery(`INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?) ON CONFLICT ("foo_id", "bar_id") DO NOTHING`).
+ ExpectExecWithArgs(3, 4).
+ AndReturnRowsAffected(1)
+ must.Succeed(t, store.Upsert(ctx, db, &relation{1, 2}, &relation{3, 4}))
+}
+
+func TestUpsertFailsOnMixedAutoFieldState(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type complexRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ CreatedAt time.Time `db:"created_at,auto"`
+ }
+ store := oblast.MustNewStore[complexRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("complex_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ brokenRecord := complexRecord{
+ ID: 42, // this looks like we need to UPDATE
+ Name: "foo",
+ CreatedAt: time.Time{}, // this looks like we need to INSERT
+ }
+ err := store.Upsert(ctx, db, &brokenRecord)
+ assert.ErrEqual(t, err, `cannot decide whether to INSERT or UPDATE record with idx = 0: some "auto" columns are zero, others are not`)
+}
+
+func TestUninitializedTransparentPointerStructs(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ // declare a record type that has a transparent pointer struct containing non-primary-key fields
+ type timestamps struct {
+ CreatedAt time.Time `db:"created_at"`
+ DeletedAt *time.Time `db:"deleted_at"`
+ }
+ type nestedRecord struct {
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ *timestamps
+ }
+ nestedRecordStore := oblast.MustNewStore[nestedRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("nested_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ // declare another record type that has a primary key field within a transparent pointer struct
+ type commonFields struct {
+ ID int64 `db:"id,auto"`
+ CreatedAt time.Time `db:"created_at"`
+ DeletedAt *time.Time `db:"deleted_at"`
+ }
+ type weirdRecord struct {
+ *commonFields
+ Name string `db:"name"`
+ }
+ weirdRecordStore := oblast.MustNewStore[weirdRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("weird_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ // check detection on INSERT
+ freshBrokenRecord := nestedRecord{
+ Name: "foo",
+ timestamps: nil, // problem: cannot access `freshBrokenRecord.CreatedAt` or `freshBrokenRecord.DeletedAt`
+ }
+ err := nestedRecordStore.Insert(ctx, db, &freshBrokenRecord)
+ assert.ErrEqual(t, err, `refusing to INSERT record with idx = 0: cannot access all mapped fields because field "timestamps" holds a nil pointer`)
+ err = nestedRecordStore.Upsert(ctx, db, &freshBrokenRecord)
+ assert.ErrEqual(t, err, `refusing to INSERT or UPDATE record with idx = 0: cannot access all mapped fields because field "timestamps" holds a nil pointer`)
+
+ // check success case on INSERT
+ now := time.Now()
+ freshIntactRecord := nestedRecord{
+ Name: "foo",
+ timestamps: &timestamps{CreatedAt: now, DeletedAt: nil},
+ }
+ md.ForQuery(`INSERT INTO "nested_records" ("name", "created_at", "deleted_at") VALUES (?, ?, ?)`).
+ ExpectExecWithArgs("foo", now, (*time.Time)(nil)).
+ AndReturnLastInsertId(1)
+ must.Succeed(t, nestedRecordStore.Insert(ctx, db, &freshIntactRecord))
+ assert.Equal(t, freshIntactRecord.ID, 1)
+
+ // check detection on UPDATE
+ existingBrokenRecord := nestedRecord{
+ ID: 42,
+ Name: "bar",
+ timestamps: nil, // same problem as above
+ }
+ err = nestedRecordStore.Update(ctx, db, existingBrokenRecord)
+ assert.ErrEqual(t, err, `refusing to UPDATE record with idx = 0: cannot access all mapped fields because field "timestamps" holds a nil pointer`)
+ err = nestedRecordStore.Upsert(ctx, db, &freshBrokenRecord)
+ assert.ErrEqual(t, err, `refusing to INSERT or UPDATE record with idx = 0: cannot access all mapped fields because field "timestamps" holds a nil pointer`)
+
+ // check success case on UPDATE
+ now = time.Now()
+ existingIntactRecord := nestedRecord{
+ ID: 42,
+ Name: "bar",
+ timestamps: &timestamps{CreatedAt: now, DeletedAt: nil},
+ }
+ md.ForQuery(`UPDATE "nested_records" SET "name" = ?, "created_at" = ?, "deleted_at" = ? WHERE "id" = ?`).
+ ExpectExecWithArgs("bar", now, (*time.Time)(nil), 42).
+ AndReturnRowsAffected(1)
+ must.Succeed(t, nestedRecordStore.Update(ctx, db, existingIntactRecord))
+
+ // check that detection on DELETE does not care about transparent pointer structs as long as they do not contain PK fields
+ md.ForQuery(`DELETE FROM "nested_records" WHERE "id" = ?`).
+ ExpectExecWithArgs(42).
+ AndReturnRowsAffected(1)
+ must.Succeed(t, nestedRecordStore.Delete(ctx, db, existingBrokenRecord))
+
+ // check detection on DELETE where it matters
+ existingWeirdRecord := weirdRecord{
+ commonFields: nil, // problem: cannot access `existingWeirdRecord.ID`
+ Name: "qux",
+ }
+ err = weirdRecordStore.Delete(ctx, db, existingWeirdRecord)
+ assert.ErrEqual(t, err, `refusing to DELETE record with idx = 0: cannot access all primary key fields because field "commonFields" holds a nil pointer`)
+}
diff --git a/oblast/select.go b/oblast/select.go
new file mode 100644
index 0000000..66ba9e8
--- /dev/null
+++ b/oblast/select.go
@@ -0,0 +1,474 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "reflect"
+
+ "go.xyrillian.de/gg/errext"
+ "go.xyrillian.de/gg/gsql"
+)
+
+// Select executes the provided SQL query and fills an instance of the record type R for each row in the result set,
+// according to the column names reported by the database as part of the result set.
+//
+// An error is returned if any column name in the result set does not correspond to an addressable field in R.
+// Errors can be retrieved through the methods on type [Selection].
+func (s Store[R]) Select(ctx context.Context, db gsql.Handle, query string, args ...any) Selection[R] {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ return Selection[R]{startSelectQuery(ctx, db, s.plan, query, args...)}
+}
+
+// SelectWhere is like [Store.Select], but you only provide the part of the SELECT query that comes after the WHERE.
+// The initial part ("SELECT ... FROM ... WHERE") is autogenerated and prepended to partialQuery.
+// This has two benefits:
+// - It is more efficient because the strategy for loading result rows into the record type R has already been precomputed during [NewStore],
+// whereas a regular [Store.Select] must inspect the column names in the result set for each [Store.Select] call.
+// - For record types that contain only some of the columns of the corresponding database table,
+// the autogenerated SELECT query will only load exactly the necessary fields and nothing else.
+//
+// partialQuery is implied to start right after the WHERE keyword, which is added automatically.
+// To select all records unconditionally, provide a partialQuery of "TRUE", leading to a full query of "SELECT ... FROM ... WHERE TRUE".
+// Besides a condition for the WHERE clause, it may contain additional clauses, such as ORDER BY or LIMIT.
+//
+// Returns an error if [NewStore] was called without the [TableNameIs] option, which is required to generate a query for this method.
+// Errors can be retrieved through the methods on type [Selection].
+func (s Store[R]) SelectWhere(ctx context.Context, db gsql.Handle, partialQuery string, args ...any) Selection[R] {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ return Selection[R]{startSelectWhereQuery(ctx, db, s.plan, partialQuery, args...)}
+}
+
+func startSelectQuery(ctx context.Context, db gsql.Handle, plan plan, query string, args ...any) selection {
+ rows, err := db.GSQLQuery(ctx, query, args)
+ if err != nil {
+ return selection{Err: fmt.Errorf("during Query(): %w", err)}
+ }
+
+ // fast exit for TupleSelect()
+ if len(plan.IndexByColumnName) == 0 {
+ return selection{
+ Rows: rows,
+ Slots: make([]any, len(plan.StaticIndexes)),
+ Indexes: plan.StaticIndexes,
+ }
+ }
+
+ columnNames, err := rows.Columns()
+ if err != nil {
+ err = fmt.Errorf("during rows.Columns(): %w", err)
+ return selection{Err: errext.WithCleanup(err, "Rows.Close", rows.Close())}
+ }
+ indexes := make([][]int, len(columnNames))
+ for idx, columnName := range columnNames {
+ var ok bool
+ indexes[idx], ok = plan.IndexByColumnName[columnName]
+ if !ok {
+ err := fmt.Errorf(
+ "result has column %q in position %d, but no field in type %s has `db:%[1]q`",
+ columnName, idx, plan.TypeName,
+ )
+ return selection{Err: errext.WithCleanup(err, "Rows.Close", rows.Close())}
+ }
+ }
+
+ return selection{
+ Rows: rows,
+ Slots: make([]any, len(indexes)),
+ Err: nil,
+ Indexes: indexes,
+ TransparentPointerStructFields: plan.TransparentPointerStructFields,
+ }
+}
+
+func startSelectWhereQuery(ctx context.Context, db gsql.Handle, plan plan, partialQuery string, args ...any) selection {
+ if plan.Select.Query == "" {
+ return selection{Err: errors.New("cannot execute SelectWhere() because query could not be autogenerated")}
+ }
+ query := plan.Select.Query + partialQuery
+ rows, err := db.GSQLQuery(ctx, query, args)
+ if err != nil {
+ return selection{Err: fmt.Errorf("during Query(): %w", err)}
+ }
+ return selection{
+ Rows: rows,
+ Slots: make([]any, len(plan.Select.ScanIndexes)),
+ Err: nil,
+ Indexes: plan.Select.ScanIndexes,
+ TransparentPointerStructFields: plan.TransparentPointerStructFields,
+ }
+}
+
+// SelectOne executes the provided SQL query and fills an instance of the record type R if there is exactly one row in the result set,
+// according to the column names reported by the database as part of the result set.
+//
+// If there are no rows in the result set, [sql.ErrNoRows] is returned.
+//
+// Warning: Because of limitations in the interface of database/sql, this function is built on [Store.Select] and cannot be any faster than it.
+// For maximum performance, use [Store.SelectOneWhere] which avoids the overhead of potentially having to read multiple rows.
+func (s Store[R]) SelectOne(ctx context.Context, db gsql.Handle, query string, args ...any) (R, error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+ //
+ // NOTE: The "limitation in the interface of database/sql" is that type sql.Row does not have the Columns() method,
+ // which we need when mapping result columns to struct fields for user-provided queries.
+
+ return s.Select(ctx, db, query, args...).First()
+}
+
+// SelectOneWhere is like [Store.SelectOne], but you only provide the part of the SELECT query that comes after the WHERE.
+// See [Store.SelectWhere] for an explanation of how the full query is constructed from this partial query.
+//
+// This method is more efficient than [Store.SelectOne] on CPU runtime, but has a slight memory allocation overhead per call from query preparation.
+// This can be avoided by using [Store.PrepareSelectQueryWhere] instead.
+func (s Store[R]) SelectOneWhere(ctx context.Context, db gsql.Handle, partialQuery string, args ...any) (R, error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ var result R
+ err := selectOneWhere(ctx, db, s.plan, reflect.ValueOf(&result).Elem(), partialQuery, args)
+ return result, err
+}
+
+func selectOneWhere(ctx context.Context, db gsql.Handle, plan plan, v reflect.Value, partialQuery string, args []any) error {
+ if plan.Select.Query == "" {
+ return errors.New("cannot execute SelectOneWhere() because query could not be autogenerated")
+ }
+ query := plan.Select.Query + partialQuery
+ return selectOne(ctx, db, plan, v, query, args)
+}
+
+func selectOne(ctx context.Context, db gsql.Handle, plan plan, v reflect.Value, query string, args []any) error {
+ for _, field := range plan.TransparentPointerStructFields {
+ f := v.FieldByIndex(field.Index)
+ f.Set(reflect.New(f.Type().Elem()))
+ }
+ slots := make([]any, len(plan.Select.ScanIndexes))
+ for idx, index := range plan.Select.ScanIndexes {
+ slots[idx] = v.FieldByIndex(index).Addr().Interface()
+ }
+ stmt, err := db.GSQLPrepare(ctx, query, false)
+ if err != nil {
+ return err
+ }
+ err = stmt.QueryRow(ctx, args, slots)
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+}
+
+// PrepareSelectQueryWhere performs the same query string preparation as [Store.SelectWhere] or [Store.SelectOneWhere].
+// The resulting query can then be executed multiple times without incurring repeated memory allocation overhead from this preparation step.
+func (s Store[R]) PrepareSelectQueryWhere(partialQuery string) (PreparedSelectQuery[R], error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ query, err := prepareSelectQueryWhere(s.plan, partialQuery)
+ return PreparedSelectQuery[R]{s, query}, err
+}
+
+// MustPrepareSelectQueryWhere is like [Store.PrepareSelectQueryWhere], but panics on error.
+func (s Store[R]) MustPrepareSelectQueryWhere(partialQuery string) PreparedSelectQuery[R] {
+ q, err := s.PrepareSelectQueryWhere(partialQuery)
+ if err != nil {
+ panic(err.Error())
+ }
+ return q
+}
+
+func prepareSelectQueryWhere(plan plan, partialQuery string) (string, error) {
+ if plan.Select.Query == "" {
+ return "", errors.New("cannot execute PrepareSelectQueryWhere() because query could not be autogenerated")
+ }
+ return plan.Select.Query + partialQuery, nil
+}
+
+// PreparedSelectQuery holds a pre-computed SELECT query that was customized by the user.
+// This type is an optimization to avoid performing the same query string manipulations over and over again in hot paths.
+//
+// It is returned by [Store.PrepareSelectQueryWhere].
+type PreparedSelectQuery[R any] struct {
+ store Store[R]
+ query string
+}
+
+// Select behaves the same as [Store.SelectWhere], but uses the query that was precomputed when q was constructed.
+func (q PreparedSelectQuery[R]) Select(ctx context.Context, db gsql.Handle, args ...any) Selection[R] {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+ return Selection[R]{startSelectQuery(ctx, db, q.store.plan, q.query, args...)}
+}
+
+// SelectOne behaves the same as [Store.SelectOneWhere], but uses the query that was precomputed when q was constructed.
+func (q PreparedSelectQuery[R]) SelectOne(ctx context.Context, db gsql.Handle, args ...any) (R, error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ var result R
+ err := selectOne(ctx, db, q.store.plan, reflect.ValueOf(&result).Elem(), q.query, args)
+ return result, err
+}
+
+// TupleSelect executes the provided SQL query and fills an instance of the record type R for each row in the result set.
+// Unlike [Store.Select], struct fields are matched to the result columns not based on names or struct tags, but purely based on order:
+// Values from the first column are stored in the first result field, and so on.
+//
+// This is usually more convenient when defining an ad-hoc record type for a single query. Compare:
+//
+// const query = `SELECT given_name, COUNT(*) AS user_count FROM users WHERE family_name = $1 GROUP BY first_name`
+// type record struct {
+// GivenName string `db:"given_name"`
+// UserCount uint64 `db:"user_count"`
+// }
+// err = oblast.MustNewStore[record](config.DB.Dialect).Select(ctx, db, query, lastName).Foreach(func(r record) error {
+// return doSomethingWith(r.GivenName, r.UserCount)
+// })
+//
+// With:
+//
+// const query = `SELECT given_name, COUNT(*) FROM users WHERE family_name = $1 GROUP BY first_name`
+// type record struct {
+// GivenName string
+// UserCount uint64
+// }
+// err = oblast.TupleSelect[record](ctx, db, query, lastName).Foreach(func(r record) error {
+// return doSomethingWith(r.GivenName, r.UserCount)
+// })
+//
+// Do not use this function with queries of the form `SELECT * FROM ...`,
+// where the order of columns is not well-defined and may vary between otherwise compatible DB schemas.
+func TupleSelect[R any](ctx context.Context, db gsql.Handle, query string, args ...any) Selection[R] {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ plan := getOrBuildTuplePlan(reflect.TypeFor[R]())
+ return Selection[R]{startSelectQuery(ctx, db, plan, query, args...)}
+}
+
+// TupleSelectOne executes the provided SQL query and fills an instance of the record type R if there is exactly one row in the result set,
+// following the same behavior as [TupleSelect] for mapping a row into a record.
+//
+// If there are no rows in the result set, [sql.ErrNoRows] is returned.
+func TupleSelectOne[R any](ctx context.Context, db gsql.Handle, query string, args ...any) (R, error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ return TupleSelect[R](ctx, db, query, args...).First()
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// non-record selections
+
+// Select executes the provided SQL query that returns rows that each contain exactly one value.
+func Select[T any](ctx context.Context, db gsql.Handle, query string, args ...any) Selection[T] {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ return Selection[T]{startSelectValueQuery(ctx, db, query, args)}
+}
+
+func startSelectValueQuery(ctx context.Context, db gsql.Handle, query string, args []any) selection {
+ rows, err := db.GSQLQuery(ctx, query, args)
+ if err != nil {
+ return selection{Err: fmt.Errorf("during Query(): %w", err)}
+ }
+ return selection{Rows: rows} // all other members are nil because this is a non-record selection
+}
+
+// SelectOne executes the provided SQL query that returns exactly one row containing exactly one value.
+//
+// This is the same as declaring a value of type T and then saying db.QueryRow(query, args...).Scan(&value)
+// or whatever the equivalent for the DB handle in question is.
+//
+// If there are no rows in the result set, [sql.ErrNoRows] is returned.
+func SelectOne[T any](ctx context.Context, db gsql.Handle, query string, args ...any) (T, error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ var result T
+ err := selectOneValue(ctx, db, &result, query, args)
+ return result, err
+}
+
+func selectOneValue(ctx context.Context, db gsql.Handle, target any, query string, args []any) error {
+ stmt, err := db.GSQLPrepare(ctx, query, false)
+ if err != nil {
+ return err
+ }
+ err = stmt.QueryRow(ctx, args, []any{target})
+ return errext.WithCleanup(err, "Stmt.Close", stmt.Close())
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// type Selection
+
+// Selection provides access to the result set from a [Select], [Store.Select], [Store.SelectWhere] or [PreparedSelectQuery.Select] call.
+//
+// Instances of this type are not meant to be held in variables.
+// Instead, chain one of its method calls directly after the Select or SelectWhere call to choose how to process the result set.
+type Selection[R any] struct {
+ selection
+}
+
+// selection contains the payload of [Selection].
+// This separate type does not have type arguments and thus is not duplicated by monomorphization.
+type selection struct {
+ // from startSelectQuery()
+ Rows gsql.Rows
+ Slots []any // NOTE: len(s.Slots) == len(s.Indexes); will be empty for non-record selections (created by Select[T])
+ Err error // NOTE: if this field is set, all other fields will be unset
+ // from plan; will all be empty for non-record selections (created by Select[T])
+ Indexes [][]int
+ TransparentPointerStructFields []fieldInfo
+}
+
+func (s selection) collectRowOrValue(pointerToTarget any) error {
+ if len(s.Slots) > 0 {
+ return s.collectRow(reflect.ValueOf(pointerToTarget).Elem(), s.Slots)
+ } else {
+ return s.collectValue(pointerToTarget)
+ }
+}
+
+func (s selection) collectRow(v reflect.Value, slots []any) error {
+ for _, field := range s.TransparentPointerStructFields {
+ f := v.FieldByIndex(field.Index)
+ f.Set(reflect.New(f.Type().Elem()))
+ }
+ for idx, index := range s.Indexes {
+ slots[idx] = v.FieldByIndex(index).Addr().Interface()
+ }
+ err := s.Rows.Scan(slots...)
+ if err != nil {
+ return errext.WithCleanup(err, "Rows.Close", s.Rows.Close())
+ }
+ return nil
+}
+
+func (s selection) collectValue(pointerToTarget any) error {
+ err := s.Rows.Scan(pointerToTarget)
+ if err != nil {
+ return errext.WithCleanup(err, "Rows.Close", s.Rows.Close())
+ }
+ return nil
+}
+
+// Collect returns all of the selected records as a slice.
+// This is the most versatile output format for type [Selection], but may cause a spike in memory usage for big result sets.
+func (s Selection[R]) Collect() ([]R, error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ if s.Err != nil {
+ return nil, s.Err
+ }
+
+ var result []R
+ for s.Rows.Next() {
+ var target *R
+ result, target = growRecordSlice(result)
+ err := s.collectRowOrValue(target)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return result, s.Rows.Err()
+}
+
+// Appends an empty R to the slice and returns a pointer to it, as well as the updated slice.
+// It is more efficient to write:
+//
+// var result []R
+// for rows.Next() {
+// var target *R
+// result, target = growRecordSlice(result)
+// doSomethingWith(rows, reflect.ValueOf(target).Elem())
+// }
+//
+// Instead of the more obvious:
+//
+// var result []R
+// for rows.Next() {
+// var target R
+// doSomethingWith(rows, reflect.ValueOf(&target).Elem())
+// result = append(result, target)
+// }
+//
+// In the second phrasing, `target` escapes to the heap because of `reflect.ValueOf(&target)`,
+// causing an additional allocation for `target` as well as a memcpy of `target` during `append()`.
+func growRecordSlice[R any](records []R) (newRecords []R, target *R) {
+ var zero R
+ newRecords = append(records, zero)
+ return newRecords, &newRecords[len(newRecords)-1]
+}
+
+// Foreach retrieves the selected records one at a time, and calls the provided callback once for each record in order.
+// An error is returned if a database error occurs, of if any of the callback invocations returns an error.
+// In either case, subsequent records from the result set will not be loaded and the callbgck will not be invoked again.
+func (s Selection[R]) Foreach(action func(R) error) error {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ if s.Err != nil {
+ return s.Err
+ }
+
+ // NOTE: `record` will escape to the heap because of the reflect.ValueOf() call.
+ // By reusing the same `record` throughout the loop, this function will only allocate at most one instance of R on the heap.
+ var (
+ record R
+ v reflect.Value
+ isRecord = len(s.Slots) > 0
+ )
+ if isRecord {
+ v = reflect.ValueOf(&record).Elem()
+ }
+ for s.Rows.Next() {
+ var (
+ zero R
+ err error
+ )
+ record = zero
+ if isRecord {
+ err = s.collectRow(v, s.Slots)
+ } else {
+ err = s.collectValue(&record)
+ }
+ if err != nil {
+ return err
+ }
+ err = action(record)
+ if err != nil {
+ return errext.WithCleanup(err, "Rows.Close", s.Rows.Close())
+ }
+ }
+ return nil
+}
+
+// First retrieves just the first record from the result set, and then closes the result set without checking for additional records.
+// If there are no rows in the result set, [sql.ErrNoRows] is returned.
+// Using this method results in similar behavior to [Store.SelectOne].
+func (s Selection[R]) First() (R, error) {
+ // NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
+
+ var record R
+ if s.Err != nil {
+ return record, s.Err
+ }
+ if !s.Rows.Next() {
+ return record, sql.ErrNoRows
+ }
+ err := s.collectRowOrValue(&record)
+ if err == nil {
+ err = s.Rows.Close()
+ }
+ return record, err
+}
diff --git a/oblast/select_test.go b/oblast/select_test.go
new file mode 100644
index 0000000..0318d4e
--- /dev/null
+++ b/oblast/select_test.go
@@ -0,0 +1,646 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast_test
+
+import (
+ "database/sql"
+ "errors"
+ "testing"
+ "time"
+
+ "go.xyrillian.de/gg/assert"
+ "go.xyrillian.de/gg/gsql"
+ "go.xyrillian.de/gg/oblast"
+ "go.xyrillian.de/gg/oblast/internal/testhelpers/mock"
+ "go.xyrillian.de/gg/oblast/internal/testhelpers/must"
+)
+
+func TestSelectReturningSomeRecords(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ t.Run("using Store.Select", func(t *testing.T) {
+ md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name", "id").
+ WithRow("foo", 1).
+ WithRow("bar", 2)
+ records := must.Return(store.Select(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, []basicRecord{
+ {1, "foo"},
+ {2, "bar"},
+ })
+ })
+
+ t.Run("using Store.SelectWhere", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name").
+ WithRow(1, "ffoo").
+ WithRow(2, "bbar")
+ records := must.Return(store.SelectWhere(ctx, db, `id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, []basicRecord{
+ {1, "ffoo"},
+ {2, "bbar"},
+ })
+ })
+
+ t.Run("using PreparedSelectQuery.Select", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name").
+ WithRow(1, "fffoo").
+ WithRow(2, "bbbar")
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ records := must.Return(query.Select(ctx, db, 3).Collect())(t)
+ assert.Equal(t, records, []basicRecord{
+ {1, "fffoo"},
+ {2, "bbbar"},
+ })
+ })
+
+ t.Run("using Store.SelectOne", func(t *testing.T) {
+ md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name", "id").
+ WithRow("ffffoo", 1).
+ WithRow("bbbbar", 2)
+ record := must.Return(store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, record, basicRecord{1, "ffffoo"})
+ })
+
+ t.Run("using Store.SelectOneWhere", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name").
+ WithRow(1, "fffffoo").
+ WithRow(2, "bbbbbar")
+ record := must.Return(store.SelectOneWhere(ctx, db, `id < ?`, 3))(t)
+ assert.Equal(t, record, basicRecord{1, "fffffoo"})
+ })
+
+ t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name").
+ WithRow(1, "ffffffoo").
+ WithRow(2, "bbbbbbar")
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ record := must.Return(query.SelectOne(ctx, db, 3))(t)
+ assert.Equal(t, record, basicRecord{1, "ffffffoo"})
+ })
+
+ commonSetupForValueSelect := func() {
+ md.ForQuery(`SELECT name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name").
+ WithRow("foo").
+ WithRow("bar")
+ }
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ commonSetupForValueSelect()
+ names := must.Return(oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, names, []string{"foo", "bar"})
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ commonSetupForValueSelect()
+ name := must.Return(oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, name, "foo")
+ })
+
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+ commonSetupForTupleSelect := func() {
+ md.ForQuery(`SELECT id, name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name").
+ WithRow(1, "foo").
+ WithRow(2, "bar")
+ }
+
+ t.Run("using TupleSelect", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ records := must.Return(oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, []tupleRecord{
+ {1, "foo"},
+ {2, "bar"},
+ })
+ })
+
+ t.Run("using TupleSelectOne", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ record := must.Return(oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3))(t)
+ assert.Equal(t, record, tupleRecord{1, "foo"})
+ })
+}
+
+func TestSelectReturningNoRecords(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ t.Run("using Store.Select", func(t *testing.T) {
+ md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name", "id")
+ records := must.Return(store.Select(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, nil)
+ })
+
+ t.Run("using Store.SelectWhere", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name")
+ records := must.Return(store.SelectWhere(ctx, db, `id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, nil)
+ })
+
+ t.Run("using PreparedSelectQuery.Select", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name")
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ records := must.Return(query.Select(ctx, db, 3).Collect())(t)
+ assert.Equal(t, records, nil)
+ })
+
+ t.Run("using Store.SelectOne", func(t *testing.T) {
+ md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name", "id")
+ _, err := store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, sql.ErrNoRows.Error())
+ })
+
+ t.Run("using Store.SelectOneWhere", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name")
+ _, err := store.SelectOneWhere(ctx, db, `id < ?`, 3)
+ assert.ErrEqual(t, err, sql.ErrNoRows.Error())
+ })
+
+ t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) {
+ md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name")
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ _, err := query.SelectOne(ctx, db, 3)
+ assert.ErrEqual(t, err, sql.ErrNoRows.Error())
+ })
+
+ commonSetupForValueSelect := func() {
+ md.ForQuery(`SELECT name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name")
+ }
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ commonSetupForValueSelect()
+ names := must.Return(oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, names, nil)
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ commonSetupForValueSelect()
+ _, err := oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, sql.ErrNoRows.Error())
+ })
+
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+ commonSetupForTupleSelect := func() {
+ md.ForQuery(`SELECT id, name FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name")
+ }
+
+ t.Run("using TupleSelect", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ records := must.Return(oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect())(t)
+ assert.Equal(t, records, nil)
+ })
+
+ t.Run("using TupleSelectOne", func(t *testing.T) {
+ commonSetupForTupleSelect()
+ _, err := oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, sql.ErrNoRows.Error())
+ })
+}
+
+func TestSelectIntoUnexpectedField(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id"`
+ Description string `db:"desc"` // but DB knows only the field "name"!
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ expectedError := "result has column \"name\" in position 0, but no field in type basicRecord has `db:\"name\"`"
+ commonSetup := func() {
+ md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name", "id").
+ WithRow("foo", 1).
+ WithRow("bar", 2)
+ }
+
+ // NOTE: This problem cannot occur with SelectWhere() and SelectOneWhere() because of their use of query generation.
+
+ t.Run("using Store.Select", func(t *testing.T) {
+ commonSetup()
+ _, err := store.Select(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, expectedError)
+ })
+
+ t.Run("using Store.SelectOne", func(t *testing.T) {
+ commonSetup()
+ _, err := store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, expectedError)
+ })
+}
+
+func TestSelectWithScanError(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id"`
+ CreatedAt time.Time `db:"created_at"` // but the DB will give us strings that are not timestamps
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ expectedError := `sql: Scan error on column index 1, name "created_at": unsupported Scan, storing driver.Value type string into type *time.Time`
+ commonSetup := func(query string) {
+ md.ForQuery(query).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "created_at").
+ WithRow(1, "foo").
+ WithRow(2, "bar")
+ }
+
+ t.Run("using Store.Select", func(t *testing.T) {
+ commonSetup(`SELECT * FROM basic_records WHERE id < ?`)
+ _, err := store.Select(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, expectedError)
+ })
+
+ t.Run("using Store.SelectWhere", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at" FROM "basic_records" WHERE id < ?`)
+ _, err := store.SelectWhere(ctx, db, `id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, expectedError)
+ })
+
+ t.Run("using PreparedSelectQuery.Select", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at" FROM "basic_records" WHERE id < ?`)
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ _, err := query.Select(ctx, db, 3).Collect()
+ assert.ErrEqual(t, err, expectedError)
+ })
+
+ t.Run("using Store.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT * FROM basic_records WHERE id < ?`)
+ _, err := store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, expectedError)
+ })
+
+ t.Run("using Store.SelectOneWhere", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at" FROM "basic_records" WHERE id < ?`)
+ _, err := store.SelectOneWhere(ctx, db, `id < ?`, 3)
+ assert.ErrEqual(t, err, expectedError)
+ })
+
+ t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at" FROM "basic_records" WHERE id < ?`)
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ _, err := query.SelectOne(ctx, db, 3)
+ assert.ErrEqual(t, err, expectedError)
+ })
+}
+
+func TestSelectIntoEmbeddedTypes(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type HasCreatedAt struct {
+ CreatedAt time.Time `db:"created_at"`
+ }
+ type HasUpdatedAt struct {
+ UpdatedAt *time.Time `db:"updated_at"`
+ }
+ type compositeRecord struct {
+ ID int64 `db:"id"`
+ HasCreatedAt
+ // This test specifically wants to see that this field gets initialized
+ // whenever one of the Store.Select methods creates a compositeRecord instance.
+ *HasUpdatedAt
+ }
+ store := oblast.MustNewStore[compositeRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("composite_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ commonSetup := func(query string) {
+ md.ForQuery(query).
+ ExpectQueryWithArgs(nil...).
+ AndReturnColumns("id", "created_at", "updated_at").
+ WithRow(1, time.Unix(1, 0), time.Unix(3, 0)).
+ WithRow(2, time.Unix(2, 0), nil)
+ }
+
+ t.Run("using Store.Select", func(t *testing.T) {
+ commonSetup(`SELECT * FROM composite_records`)
+ records := must.Return(store.Select(ctx, db, `SELECT * FROM composite_records`).Collect())(t)
+ assert.Equal(t, records, []compositeRecord{
+ {1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}},
+ {2, HasCreatedAt{time.Unix(2, 0)}, &HasUpdatedAt{nil}},
+ })
+ })
+
+ t.Run("using Store.SelectWhere", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`)
+ records := must.Return(store.SelectWhere(ctx, db, `TRUE`).Collect())(t)
+ assert.Equal(t, records, []compositeRecord{
+ {1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}},
+ {2, HasCreatedAt{time.Unix(2, 0)}, &HasUpdatedAt{nil}},
+ })
+ })
+
+ t.Run("using PreparedSelectQuery.Select", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`)
+ query := store.MustPrepareSelectQueryWhere(`TRUE`)
+ records := must.Return(query.Select(ctx, db).Collect())(t)
+ assert.Equal(t, records, []compositeRecord{
+ {1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}},
+ {2, HasCreatedAt{time.Unix(2, 0)}, &HasUpdatedAt{nil}},
+ })
+ })
+
+ t.Run("using Store.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT * FROM composite_records`)
+ record := must.Return(store.SelectOne(ctx, db, `SELECT * FROM composite_records`))(t)
+ assert.Equal(t, record,
+ compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}},
+ )
+ })
+
+ t.Run("using Store.SelectOneWhere", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`)
+ record := must.Return(store.SelectOneWhere(ctx, db, `TRUE`))(t)
+ assert.Equal(t, record,
+ compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}},
+ )
+ })
+
+ t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`)
+ query := store.MustPrepareSelectQueryWhere(`TRUE`)
+ record := must.Return(query.SelectOne(ctx, db))(t)
+ assert.Equal(t, record,
+ compositeRecord{1, HasCreatedAt{time.Unix(1, 0)}, &HasUpdatedAt{new(time.Unix(3, 0))}},
+ )
+ })
+}
+
+func TestSelectCapturingQueryError(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ t.Run("using Store.Select", func(t *testing.T) {
+ _, err := store.Select(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT * FROM basic_records WHERE id < ?")
+ })
+
+ t.Run("using Store.SelectWhere", func(t *testing.T) {
+ _, err := store.SelectWhere(ctx, db, `id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, `during Query(): unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ })
+
+ t.Run("using PreparedSelectQuery.Select", func(t *testing.T) {
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ _, err := query.Select(ctx, db, 3).Collect()
+ assert.ErrEqual(t, err, `during Query(): unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ })
+
+ t.Run("using Store.SelectOne", func(t *testing.T) {
+ _, err := store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT * FROM basic_records WHERE id < ?")
+ })
+
+ t.Run("using Store.SelectOneWhere", func(t *testing.T) {
+ _, err := store.SelectOneWhere(ctx, db, `id < ?`, 3)
+ assert.ErrEqual(t, err, `unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ })
+
+ t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) {
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ _, err := query.SelectOne(ctx, db, 3)
+ assert.ErrEqual(t, err, `unexpected query: SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ })
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ _, err := oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT name FROM basic_records WHERE id < ?")
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ _, err := oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "unexpected query: SELECT name FROM basic_records WHERE id < ?")
+ })
+
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+
+ t.Run("using oblast.TupleSelect", func(t *testing.T) {
+ _, err := oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT id, name FROM basic_records WHERE id < ?")
+ })
+
+ t.Run("using oblast.TupleSelectOne", func(t *testing.T) {
+ _, err := oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "during Query(): unexpected query: SELECT id, name FROM basic_records WHERE id < ?")
+ })
+}
+
+func TestSelectCapturingCloseError(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("basic_records"),
+ oblast.PrimaryKeyIs("id"),
+ )
+
+ commonSetup := func(query string) {
+ md.ForQuery(query).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("id", "name").
+ WithRow(1, "foo").
+ WithRow(2, "bar").
+ AndCloseFailsWith(errors.New("datacenter on fire"))
+ }
+
+ t.Run("using Store.Select", func(t *testing.T) {
+ commonSetup(`SELECT * FROM basic_records WHERE id < ?`)
+ _, err := store.Select(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using Store.SelectWhere", func(t *testing.T) {
+ commonSetup(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ _, err := store.SelectWhere(ctx, db, `id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using PreparedSelectQuery.Select", func(t *testing.T) {
+ commonSetup(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ _, err := query.Select(ctx, db, 3).Collect()
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using Store.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT * FROM basic_records WHERE id < ?`)
+ _, err := store.SelectOne(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using Store.SelectOneWhere", func(t *testing.T) {
+ commonSetup(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ _, err := store.SelectOneWhere(ctx, db, `id < ?`, 3)
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using PreparedSelectQuery.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`)
+ query := store.MustPrepareSelectQueryWhere(`id < ?`)
+ _, err := query.SelectOne(ctx, db, 3)
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ type tupleRecord struct {
+ ID int64
+ Name string
+ }
+
+ t.Run("using oblast.TupleSelect", func(t *testing.T) {
+ commonSetup(`SELECT id, name FROM basic_records WHERE id < ?`)
+ _, err := oblast.TupleSelect[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using oblast.TupleSelectOne", func(t *testing.T) {
+ commonSetup(`SELECT id, name FROM basic_records WHERE id < ?`)
+ _, err := oblast.TupleSelectOne[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ commonSetup = func(query string) {
+ md.ForQuery(query).
+ ExpectQueryWithArgs(3).
+ AndReturnColumns("name").
+ WithRow("foo").
+ WithRow("bar").
+ AndCloseFailsWith(errors.New("datacenter on fire"))
+ }
+
+ t.Run("using oblast.Select", func(t *testing.T) {
+ commonSetup(`SELECT name FROM basic_records WHERE id < ?`)
+ _, err := oblast.Select[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+
+ t.Run("using oblast.SelectOne", func(t *testing.T) {
+ commonSetup(`SELECT name FROM basic_records WHERE id < ?`)
+ _, err := oblast.SelectOne[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3)
+ assert.ErrEqual(t, err, "datacenter on fire")
+ })
+}
+
+func TestSelectNotPossibleWithoutTableName(t *testing.T) {
+ ctx := t.Context()
+ md := mock.NewDriver()
+ db := gsql.NewDB(sql.OpenDB(md))
+
+ type basicRecord struct {
+ ID int64 `db:"id"`
+ Name string `db:"name"`
+ }
+ store := oblast.MustNewStore[basicRecord](oblast.SqliteDialect())
+
+ t.Run("using Store.SelectWhere", func(t *testing.T) {
+ _, err := store.SelectWhere(ctx, db, `id < ?`, 3).Collect()
+ assert.ErrEqual(t, err, "cannot execute SelectWhere() because query could not be autogenerated")
+ })
+
+ t.Run("using Store.SelectOneWhere", func(t *testing.T) {
+ _, err := store.SelectOneWhere(ctx, db, `id < ?`, 3)
+ assert.ErrEqual(t, err, "cannot execute SelectOneWhere() because query could not be autogenerated")
+ })
+
+ t.Run("using PreparedSelectQuery", func(t *testing.T) {
+ _, err := store.PrepareSelectQueryWhere(`id < ?`)
+ assert.ErrEqual(t, err, "cannot execute PrepareSelectQueryWhere() because query could not be autogenerated")
+ })
+}