summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md6
-rw-r--r--Makefile6
-rw-r--r--README.md34
-rw-r--r--benchmark/.gitignore3
-rw-r--r--benchmark/benchmark_test.go641
-rw-r--r--benchmark/go.mod24
-rw-r--r--benchmark/go.sum48
-rw-r--r--benchmark/internal/oblast_pgx/handle.go89
-rw-r--r--benchmark/internal/oblast_pgx/results.go66
-rw-r--r--benchmark/internal/oblast_pgx/statement.go60
-rw-r--r--benchmark/main.go8
-rw-r--r--benchmark/postgres_test.go393
-rw-r--r--dialect.go161
-rw-r--r--errors.go28
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--go.work6
-rw-r--r--go.work.sum22
-rw-r--r--oblast.go218
-rw-r--r--plan.go488
-rw-r--r--plan_test.go657
-rw-r--r--query.go340
-rw-r--r--query_test.go522
-rw-r--r--select.go555
-rw-r--r--select_test.go753
25 files changed, 69 insertions, 5065 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 712b9d4..e27a8cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,12 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
SPDX-License-Identifier: Apache-2.0
-->
+# v0.17.0 (TBD)
+
+API changes:
+
+- Everything except `type RuntimeIndex` becomes a synonym for the identical types in the new package `go.xyrillian.de/gg/oblast` within `gg@v1.16.0`.
+
# v0.16.0 (2026-09-15)
API changes:
diff --git a/Makefile b/Makefile
index ab441e7..e84346b 100644
--- a/Makefile
+++ b/Makefile
@@ -12,12 +12,6 @@ static-check: FORCE
@printf "\e[1;36m>> reuse lint\e[0m\n"
@if ! reuse lint -q; then reuse lint; fi
-benchmark-orm: FORCE
- @cd benchmark && go test -bench BenchmarkORM -benchmem .
-
-benchmark-postgres: FORCE
- @cd benchmark && go test -bench BenchmarkPostgres -benchmem .
-
GO_COVERPKGS := $(shell go list ./... | grep -vw testhelpers | tr '\n' , | sed 's/,$$//')
GO_TESTPKGS := $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)
diff --git a/README.md b/README.md
index d49165d..7ab1fa4 100644
--- a/README.md
+++ b/README.md
@@ -9,36 +9,8 @@ A small ORM library for Go, focused on type safety and performance. Inspired by
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
+## Moved!
-Please refer to the [package documentation](https://pkg.go.dev/go.xyrillian.de/oblast).
+Most of this package has moved into gg. Please refer to the [package documentation](https://pkg.go.dev/go.xyrillian.de/gg/oblast).
-## How to contribute
-
-Before sending a patch, please ensure that `make check` does not report any problems, and run `make benchmark` to check the performance impact of your changes.
-
-To contribute to the primary repository at <https://git.xyrillian.de/go-oblast>, please use `git format-patch` in the usual manner and send patches to the maintainer's mail address (which can be found in the copyright notice headers on each file).
-Alternatively, if you are still using GitHub, you can submit issues and pull requests at the mirror repository <https://github.com/majewsky/go-oblast>.
-
-## 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.
+The only type that remains here for a short while longer is `RuntimeIndex`, because I'm not quite ready to stabilize the method names on that type yet.
diff --git a/benchmark/.gitignore b/benchmark/.gitignore
deleted file mode 100644
index ed6d513..0000000
--- a/benchmark/.gitignore
+++ /dev/null
@@ -1,3 +0,0 @@
-# artifacts from running e.g. `go test -bench . -benchmem -memprofile mem.out`
-/benchmark.test
-/*.out
diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go
deleted file mode 100644
index 487b9c5..0000000
--- a/benchmark/benchmark_test.go
+++ /dev/null
@@ -1,641 +0,0 @@
-// 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/gsql"
- "go.xyrillian.de/oblast"
- "go.xyrillian.de/oblast/internal/testhelpers/must"
- "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/go.mod b/benchmark/go.mod
deleted file mode 100644
index be67a60..0000000
--- a/benchmark/go.mod
+++ /dev/null
@@ -1,24 +0,0 @@
-module go.xyrillian.de/oblast/benchmark
-
-go 1.26.0
-
-require (
- github.com/go-gorp/gorp/v3 v3.1.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.14.0
- go.xyrillian.de/oblast v0.11.0
- 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
-)
diff --git a/benchmark/go.sum b/benchmark/go.sum
deleted file mode 100644
index 2b2755a..0000000
--- a/benchmark/go.sum
+++ /dev/null
@@ -1,48 +0,0 @@
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs=
-github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw=
-github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
-github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
-github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
-github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
-github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.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 v1.14.0 h1:S19Jk3V1dcF9WdXQi7OGWjboVj8/40I4+/G1lZ6i4TI=
-go.xyrillian.de/gg v1.14.0/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k=
-go.xyrillian.de/oblast v0.11.0 h1:ZqHsxoQW/LPgtQ/jXlG1X3DLTYkgxf9zzrDYyykXJG4=
-go.xyrillian.de/oblast v0.11.0/go.mod h1:sYCzxyVFzzL43EglZJn7Vtd8kfHYBwKff/Us4yF8k+Q=
-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/oblast_pgx/handle.go b/benchmark/internal/oblast_pgx/handle.go
deleted file mode 100644
index 4bd72bd..0000000
--- a/benchmark/internal/oblast_pgx/handle.go
+++ /dev/null
@@ -1,89 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package oblast_pgx
-
-import (
- "context"
- "fmt"
- "strconv"
- "sync/atomic"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgconn"
- "github.com/jackc/pgx/v5/pgxpool"
- "go.xyrillian.de/gg/gsql"
-)
-
-type Handle interface {
- Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
- Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
- QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
-}
-
-var (
- _ Handle = &pgx.Conn{}
- _ Handle = &pgxpool.Conn{}
- _ Handle = pgx.Tx(&pgxpool.Tx{})
-)
-
-func Wrap(h Handle) gsql.Handle {
- switch h := h.(type) {
- case *pgx.Conn:
- return wrappedHandle{h}
- case *pgxpool.Conn:
- return wrappedHandle{h}
- case pgx.Tx:
- return wrappedHandle{h}
- default:
- panic(fmt.Sprintf("unexpected type: %#v", h))
- }
-}
-
-var preparedStatementId atomic.Uint64
-
-type wrappedHandle struct {
- inner Handle
-}
-
-// GSQLPrepare implements the [gsql.Handle] interface.
-func (h wrappedHandle) GSQLPrepare(ctx context.Context, query string, repeated bool) (gsql.Statement, error) {
- if !repeated {
- return wrappedUnpreparedStatement{query, h.inner}, nil
- }
-
- name := "oblast_pgx_" + strconv.FormatUint(preparedStatementId.Add(1), 10)
- switch inner := h.inner.(type) {
- case *pgx.Conn:
- stmt, err := inner.Prepare(ctx, name, query)
- return wrappedPreparedStatement{ctx, stmt, h.inner}, err
- case *pgxpool.Conn:
- // pgxpool.Conn does not have Prepare()
- return wrappedUnpreparedStatement{query, h.inner}, nil
- case pgx.Tx:
- stmt, err := inner.Conn().Prepare(ctx, name, query)
- return wrappedPreparedStatement{ctx, stmt, h.inner}, err
- default:
- panic("unreachable") // because of the check in func Wrap()
- }
-}
-
-// Releases a prepared statement.
-func deallocate(ctx context.Context, h Handle, stmt *pgconn.StatementDescription) error {
- switch h := h.(type) {
- case *pgx.Conn:
- return h.Deallocate(ctx, stmt.Name)
- case *pgxpool.Conn:
- panic("unreachable") // because func GSQLPrepare() does not return a wrappedPreparedStatement for this underlying type
- case pgx.Tx:
- return h.Conn().Deallocate(ctx, stmt.Name)
- default:
- panic("unreachable") // because of the check in func Wrap()
- }
-}
-
-// GSQLQuery implements the [gsql.Handle] interface.
-func (h wrappedHandle) GSQLQuery(ctx context.Context, query string, args []any) (gsql.Rows, error) {
- rows, err := h.inner.Query(ctx, query, args...)
- return wrappedRows{rows}, err
-}
diff --git a/benchmark/internal/oblast_pgx/results.go b/benchmark/internal/oblast_pgx/results.go
deleted file mode 100644
index f842d36..0000000
--- a/benchmark/internal/oblast_pgx/results.go
+++ /dev/null
@@ -1,66 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package oblast_pgx
-
-import (
- "database/sql"
- "errors"
-
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgconn"
- "go.xyrillian.de/gg/gsql"
-)
-
-type wrappedRows struct {
- inner pgx.Rows
-}
-
-var _ gsql.Rows = wrappedRows{}
-
-// Columns implements the [gsql.Rows] interface.
-func (r wrappedRows) Columns() ([]string, error) {
- descriptions := r.inner.FieldDescriptions()
- result := make([]string, len(descriptions))
- for idx, desc := range descriptions {
- result[idx] = desc.Name
- }
- return result, nil
-}
-
-// Close implements the [gsql.Rows] interface.
-func (r wrappedRows) Close() error {
- r.inner.Close()
- return nil
-}
-
-// Err implements the [gsql.Rows] interface.
-func (r wrappedRows) Err() error {
- return r.inner.Err()
-}
-
-// Next implements the [gsql.Rows] interface.
-func (r wrappedRows) Next() bool {
- return r.inner.Next()
-}
-
-// Scan implements the [gsql.Rows] interface.
-func (r wrappedRows) Scan(args ...any) error {
- return r.inner.Scan(args...)
-}
-
-type wrappedResult struct {
- inner pgconn.CommandTag
-}
-
-var _ sql.Result = wrappedResult{}
-
-// LastInsertId implements the [sql.Result] interface.
-func (r wrappedResult) LastInsertId() (int64, error) {
- return 0, errors.New("PostgreSQL does not support LastInsertId()")
-}
-
-// LastInsertId implements the [sql.Result] interface.
-func (r wrappedResult) RowsAffected() (int64, error) {
- return r.inner.RowsAffected(), nil
-}
diff --git a/benchmark/internal/oblast_pgx/statement.go b/benchmark/internal/oblast_pgx/statement.go
deleted file mode 100644
index 0a33c73..0000000
--- a/benchmark/internal/oblast_pgx/statement.go
+++ /dev/null
@@ -1,60 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package oblast_pgx
-
-import (
- "context"
- "database/sql"
-
- "github.com/jackc/pgx/v5/pgconn"
- "go.xyrillian.de/gg/gsql"
-)
-
-type wrappedPreparedStatement struct {
- ctx context.Context
- statement *pgconn.StatementDescription
- handle Handle
-}
-
-type wrappedUnpreparedStatement struct {
- query string
- handle Handle
-}
-
-var (
- _ gsql.Statement = wrappedPreparedStatement{}
- _ gsql.Statement = wrappedUnpreparedStatement{}
-)
-
-// Close implements the [gsql.Statement] interface.
-func (s wrappedPreparedStatement) Close() error {
- return deallocate(s.ctx, s.handle, s.statement)
-}
-
-// Close implements the [gsql.Statement] interface.
-func (s wrappedUnpreparedStatement) Close() error {
- return nil
-}
-
-// Exec implements the [gsql.Statement] interface.
-func (s wrappedPreparedStatement) Exec(ctx context.Context, args []any) (sql.Result, error) {
- result, err := s.handle.Exec(ctx, s.statement.Name, args...)
- return wrappedResult{result}, err
-}
-
-// Exec implements the [gsql.Statement] interface.
-func (s wrappedUnpreparedStatement) Exec(ctx context.Context, args []any) (sql.Result, error) {
- result, err := s.handle.Exec(ctx, s.query, args...)
- return wrappedResult{result}, err
-}
-
-// QueryRow implements the [gsql.Statement] interface.
-func (s wrappedPreparedStatement) QueryRow(ctx context.Context, args, slots []any) error {
- return s.handle.QueryRow(ctx, s.statement.Name, args...).Scan(slots...)
-}
-
-// QueryRow implements the [gsql.Statement] interface.
-func (s wrappedUnpreparedStatement) QueryRow(ctx context.Context, args, slots []any) error {
- return s.handle.QueryRow(ctx, s.query, args...).Scan(slots...)
-}
diff --git a/benchmark/main.go b/benchmark/main.go
deleted file mode 100644
index e80c2cd..0000000
--- a/benchmark/main.go
+++ /dev/null
@@ -1,8 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package main
-
-func main() {
- panic("run with `go test -bench`")
-}
diff --git a/benchmark/postgres_test.go b/benchmark/postgres_test.go
deleted file mode 100644
index dba7ca8..0000000
--- a/benchmark/postgres_test.go
+++ /dev/null
@@ -1,393 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package main_test
-
-import (
- "cmp"
- "crypto/sha256"
- "database/sql"
- "fmt"
- "os"
- "strconv"
- "testing"
- "time"
-
- "github.com/jackc/pgx/v5"
- _ "github.com/lib/pq"
- "go.xyrillian.de/gg/assert"
- "go.xyrillian.de/gg/gsql"
- "go.xyrillian.de/oblast"
- "go.xyrillian.de/oblast/benchmark/internal/oblast_pgx"
- "go.xyrillian.de/oblast/internal/testhelpers/must"
-)
-
-// 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 := oblast_pgx.Wrap(pgxConn)
-
- store := oblast.MustNewStore[OblastEntry](
- oblast.PostgresDialect(),
- oblast.TableNameIs("entries"),
- oblast.PrimaryKeyIs("id"),
- )
-
- for _, batchSize := range batchSizesForSelect {
- b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
- partialQuery := `id < ` + strconv.Itoa(batchSize)
- query := `SELECT * FROM entries WHERE ` + partialQuery
-
- b.Run("driver=pq/strategy=oblast", func(b *testing.B) {
- for b.Loop() {
- records := must.Return(store.Select(noctx, 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 := oblast_pgx.Wrap(pgxConn)
-
- // grab a "random" record from the DB, not just the first or the last
- recordID := min(totalRecordCountForSelect*2/3, totalRecordCountForSelect)
-
- store := oblast.MustNewStore[OblastEntry](
- oblast.PostgresDialect(),
- oblast.TableNameIs("entries"),
- oblast.PrimaryKeyIs("id"),
- )
-
- partialQuery := `id = ` + strconv.Itoa(recordID)
- query := `SELECT * FROM entries WHERE ` + partialQuery
- precomputedQuery := store.MustPrepareSelectQueryWhere(partialQuery)
-
- b.Run("driver=pq/strategy=oblast", func(b *testing.B) {
- for b.Loop() {
- r := must.Return(precomputedQuery.SelectOne(noctx, 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 := oblast_pgx.Wrap(pgxConn)
-
- store := oblast.MustNewStore[OblastEntry](
- oblast.PostgresDialect(),
- oblast.TableNameIs("entries"),
- oblast.PrimaryKeyIs("id"),
- )
-
- // test with different amounts of records
- for _, batchSize := range batchSizesForInsertDelete {
- b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
- insertAndDeleteWithOblast := func(b *testing.B, dbh 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 := oblast_pgx.Wrap(pgxConn)
-
- store := oblast.MustNewStore[OblastEntry](
- oblast.PostgresDialect(),
- oblast.TableNameIs("entries"),
- oblast.PrimaryKeyIs("id"),
- )
-
- // test with different amounts of records
- for _, batchSize := range batchSizesForInsertDelete {
- b.Run("N="+strconv.Itoa(batchSize), func(b *testing.B) {
- // prepare a bunch of records that we can update, in a reproducible way
- _ = must.Return(pqDB.Exec(`DELETE FROM entries`))
- _ = must.Return(pgxConn.Exec(noctx, `DELETE FROM entries`))
- pqRecords := make([]OblastEntry, batchSize)
- pqRecordsForInsert := make([]*OblastEntry, batchSize)
- pgxRecords := make([]OblastEntry, batchSize)
- pgxRecordsForInsert := make([]*OblastEntry, batchSize)
- for idx := range batchSize {
- pqRecords[idx] = OblastEntry{Message: "hello"}
- pqRecordsForInsert[idx] = &pqRecords[idx]
- pgxRecords[idx] = OblastEntry{Message: "hello"}
- pgxRecordsForInsert[idx] = &pgxRecords[idx]
- }
- must.Succeed(b, store.Insert(noctx, 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/dialect.go b/dialect.go
deleted file mode 100644
index 11842eb..0000000
--- a/dialect.go
+++ /dev/null
@@ -1,161 +0,0 @@
-// 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/errors.go b/errors.go
deleted file mode 100644
index 0a58340..0000000
--- a/errors.go
+++ /dev/null
@@ -1,28 +0,0 @@
-// 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/go.mod b/go.mod
index ef9e5ba..77b6fde 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,4 @@ module go.xyrillian.de/oblast
go 1.26
-require go.xyrillian.de/gg v1.14.0
+require go.xyrillian.de/gg v1.16.0
diff --git a/go.sum b/go.sum
index ee7f8fa..81a9fd2 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,2 @@
-go.xyrillian.de/gg v1.14.0 h1:S19Jk3V1dcF9WdXQi7OGWjboVj8/40I4+/G1lZ6i4TI=
-go.xyrillian.de/gg v1.14.0/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k=
+go.xyrillian.de/gg v1.16.0 h1:bgwPxhyP2uWaZ5nYfXdBKj/TsAY6r+iHerFilGRQi38=
+go.xyrillian.de/gg v1.16.0/go.mod h1:KU+qaSkaYRywgMOJtSR3O1r9RH2WQc2t8Zc3NAFsnSU=
diff --git a/go.work b/go.work
deleted file mode 100644
index 0f59ee6..0000000
--- a/go.work
+++ /dev/null
@@ -1,6 +0,0 @@
-go 1.26.0
-
-use (
- .
- ./benchmark
-)
diff --git a/go.work.sum b/go.work.sum
deleted file mode 100644
index 5f01129..0000000
--- a/go.work.sum
+++ /dev/null
@@ -1,22 +0,0 @@
-github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/nelsam/hel/v2 v2.3.3/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.xyrillian.de/gg v1.12.0 h1:oW9S91y36lS72D3p9a4axqjkD9zkkjkNZ12+PaZHpos=
-go.xyrillian.de/gg v1.12.0/go.mod h1:DoO4fQSWIrBRlNlCjVyrYM0kAEBt/Jg2GkMH+cGRZ0k=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
-golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
-golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
-golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
-golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
-golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/oblast.go b/oblast.go
index 57d0c63..66b31b7 100644
--- a/oblast.go
+++ b/oblast.go
@@ -1,185 +1,81 @@
// 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 has moved to https://pkg.go.dev/go.xyrillian.de/gg/oblast (except for type [RuntimeIndex]).
package oblast // import "go.xyrillian.de/oblast"
import (
- "database/sql"
- "database/sql/driver"
- "fmt"
- "reflect"
+ "context"
"go.xyrillian.de/gg/gsql"
+ gg_oblast "go.xyrillian.de/gg/oblast"
)
-var (
- // the following types appear in docstring links
- _ sql.Scanner = nil
- _ driver.NamedValueChecker = nil
- _ *gsql.DB = nil
-)
+// Dialect has moved to gg/oblast (follow the link below).
+type Dialect = gg_oblast.Dialect
-// PlanOption is an option that can be given to [NewStore] to influence query planning for a certain type of record.
-type PlanOption func(*planOpts)
+// MariaDBDialect has moved to gg/oblast (follow the link below).
+var MariaDBDialect = gg_oblast.MariaDBDialect
-// 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 }
-}
+// PostgresDialect has moved to gg/oblast (follow the link below).
+var PostgresDialect = gg_oblast.PostgresDialect
+
+// SqliteDialect has moved to gg/oblast (follow the link below).
+var SqliteDialect = gg_oblast.SqliteDialect
+
+// MissingRecordError has moved to gg/oblast (follow the link below).
+type MissingRecordError[R any] = gg_oblast.MissingRecordError[R]
+
+// PlanOption has moved to gg/oblast (follow the link below).
+type PlanOption = gg_oblast.PlanOption
+
+// TableNameIs has moved to gg/oblast (follow the link below).
+var TableNameIs = gg_oblast.TableNameIs
+
+// PrimaryKeyIs has moved to gg/oblast (follow the link below).
+var PrimaryKeyIs = gg_oblast.PrimaryKeyIs
+
+// StructTagKeyIs has moved to gg/oblast (follow the link below).
+var StructTagKeyIs = gg_oblast.StructTagKeyIs
-// 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 }
+// ReadOnly has moved to gg/oblast (follow the link below).
+var ReadOnly = gg_oblast.ReadOnly
+
+// Store has moved to gg/oblast (follow the link below).
+type Store[R any] = gg_oblast.Store[R]
+
+// NewStore has moved to gg/oblast (follow the link below).
+func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) {
+ return gg_oblast.NewStore[R](dialect, opts...)
}
-// 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 }
+// MustNewStore has moved to gg/oblast (follow the link below).
+func MustNewStore[R any](dialect Dialect, opts ...PlanOption) Store[R] {
+ return gg_oblast.MustNewStore[R](dialect, opts...)
}
-// 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 }
+// PreparedSelectQuery has moved to gg/oblast (follow the link below).
+type PreparedSelectQuery[R any] = gg_oblast.PreparedSelectQuery[R]
+
+// Selection has moved to gg/oblast (follow the link below).
+type Selection[R any] = gg_oblast.Selection[R]
+
+// Select has moved to gg/oblast (follow the link below).
+func Select[T any](ctx context.Context, db gsql.Handle, query string, args ...any) Selection[T] {
+ return gg_oblast.Select[T](ctx, db, query, args...)
}
-// 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
+// SelectOne has moved to gg/oblast (follow the link below).
+func SelectOne[T any](ctx context.Context, db gsql.Handle, query string, args ...any) (T, error) {
+ return gg_oblast.SelectOne[T](ctx, db, query, args...)
}
-// 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
+// TupleSelect has moved to gg/oblast (follow the link below).
+func TupleSelect[R any](ctx context.Context, db gsql.Handle, query string, args ...any) Selection[R] {
+ return gg_oblast.TupleSelect[R](ctx, db, query, args...)
}
-// 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
+// TupleSelectOne has moved to gg/oblast (follow the link below).
+func TupleSelectOne[R any](ctx context.Context, db gsql.Handle, query string, args ...any) (R, error) {
+ return gg_oblast.TupleSelectOne[R](ctx, db, query, args...)
}
diff --git a/plan.go b/plan.go
deleted file mode 100644
index 6b5f002..0000000
--- a/plan.go
+++ /dev/null
@@ -1,488 +0,0 @@
-// 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/plan_test.go b/plan_test.go
deleted file mode 100644
index 45666d7..0000000
--- a/plan_test.go
+++ /dev/null
@@ -1,657 +0,0 @@
-// 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/query.go b/query.go
deleted file mode 100644
index 6e375e4..0000000
--- a/query.go
+++ /dev/null
@@ -1,340 +0,0 @@
-// 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/query_test.go b/query_test.go
deleted file mode 100644
index 0a363cf..0000000
--- a/query_test.go
+++ /dev/null
@@ -1,522 +0,0 @@
-// 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/oblast"
- "go.xyrillian.de/oblast/internal/testhelpers/mock"
- "go.xyrillian.de/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/select.go b/select.go
deleted file mode 100644
index 9b2150b..0000000
--- a/select.go
+++ /dev/null
@@ -1,555 +0,0 @@
-// 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"
- . "go.xyrillian.de/gg/option"
-)
-
-// 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()
-}
-
-// SelectOneOrNone is like SelectOne, but returns [None] instead of [sql.ErrNoRows].
-//
-// [None]: https://pkg.go.dev/go.xyrillian.de/gg/option#None
-func (s Store[R]) SelectOneOrNone(ctx context.Context, db gsql.Handle, query string, args ...any) (Option[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 s.Select(ctx, db, query, args...).FirstOrNone()
-}
-
-// 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
-}
-
-// SelectOneOrNoneWhere is like SelectOneWhere, but returns [None] instead of [sql.ErrNoRows].
-//
-// [None]: https://pkg.go.dev/go.xyrillian.de/gg/option#None
-func (s Store[R]) SelectOneOrNoneWhere(ctx context.Context, db gsql.Handle, partialQuery string, args ...any) (Option[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 noRowsToNone(s.SelectOneWhere(ctx, db, partialQuery, args...))
-}
-
-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())
-}
-
-func noRowsToNone[R any](record R, err error) (Option[R], error) {
- switch {
- case err == nil:
- return Some(record), nil
- case errors.Is(err, sql.ErrNoRows):
- return None[R](), nil
- default:
- return None[R](), err
- }
-}
-
-// 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
-}
-
-// SelectOneOrNone is like SelectOne, but returns [None] instead of [sql.ErrNoRows].
-//
-// [None]: https://pkg.go.dev/go.xyrillian.de/gg/option#None
-func (q PreparedSelectQuery[R]) SelectOneOrNone(ctx context.Context, db gsql.Handle, args ...any) (Option[R], error) {
- return noRowsToNone(q.SelectOne(ctx, db, args...))
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// tuple selections
-
-// 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()
-}
-
-// TupleSelectOneOrNone is like [TupleSelectOne], but returns [None] instead of [sql.ErrNoRows].
-//
-// [None]: https://pkg.go.dev/go.xyrillian.de/gg/option#None
-func TupleSelectOneOrNone[R any](ctx context.Context, db gsql.Handle, query string, args ...any) (Option[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...).FirstOrNone()
-}
-
-////////////////////////////////////////////////////////////////////////////////
-// 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
-}
-
-// SelectOneOrNone is like SelectOne, but returns [None] instead of [sql.ErrNoRows].
-//
-// [None]: https://pkg.go.dev/go.xyrillian.de/gg/option#None
-func SelectOneOrNone[T any](ctx context.Context, db gsql.Handle, query string, args ...any) (Option[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.
-
- return noRowsToNone(SelectOne[T](ctx, db, query, args...))
-}
-
-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
-}
-
-// FirstOrNone is like [Selection.First], but signals an empty result set using None instead of [sql.ErrNoRows].
-func (s Selection[R]) FirstOrNone() (Option[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 None[R](), s.Err
- }
- if !s.Rows.Next() {
- return None[R](), nil
- }
- var record R
- err := s.collectRowOrValue(&record)
- if err == nil {
- err = s.Rows.Close()
- }
- return Some(record), err
-}
diff --git a/select_test.go b/select_test.go
deleted file mode 100644
index 6e99463..0000000
--- a/select_test.go
+++ /dev/null
@@ -1,753 +0,0 @@
-// 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/gsql"
- . "go.xyrillian.de/gg/option"
-
- "go.xyrillian.de/gg/assert"
- "go.xyrillian.de/oblast"
- "go.xyrillian.de/oblast/internal/testhelpers/mock"
- "go.xyrillian.de/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.SelectOneOrNone", 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.SelectOneOrNone(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t)
- assert.Equal(t, record, Some(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 Store.SelectOneOrNoneWhere", 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.SelectOneOrNoneWhere(ctx, db, `id < ?`, 3))(t)
- assert.Equal(t, record, Some(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"})
- })
-
- t.Run("using PreparedSelectQuery.SelectOneOrNone", 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.SelectOneOrNone(ctx, db, 3))(t)
- assert.Equal(t, record, Some(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")
- })
-
- t.Run("using oblast.SelectOneOrNone", func(t *testing.T) {
- commonSetupForValueSelect()
- name := must.Return(oblast.SelectOneOrNone[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
- assert.Equal(t, name, Some("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"})
- })
-
- t.Run("using TupleSelectOneOrNone", func(t *testing.T) {
- commonSetupForTupleSelect()
- record := must.Return(oblast.TupleSelectOneOrNone[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3))(t)
- assert.Equal(t, record, Some(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.SelectOneOrNone", func(t *testing.T) {
- md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`).
- ExpectQueryWithArgs(3).
- AndReturnColumns("name", "id")
- record := must.Return(store.SelectOneOrNone(ctx, db, `SELECT * FROM basic_records WHERE id < ?`, 3))(t)
- assert.Equal(t, record, None[basicRecord]())
- })
-
- 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 Store.SelectOneOrNoneWhere", func(t *testing.T) {
- md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
- ExpectQueryWithArgs(3).
- AndReturnColumns("id", "name")
- record := must.Return(store.SelectOneOrNoneWhere(ctx, db, `id < ?`, 3))(t)
- assert.Equal(t, record, None[basicRecord]())
- })
-
- 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())
- })
-
- t.Run("using PreparedSelectQuery.SelectOneOrNone", func(t *testing.T) {
- md.ForQuery(`SELECT "id", "name" FROM "basic_records" WHERE id < ?`).
- ExpectQueryWithArgs(3).
- AndReturnColumns("id", "name")
- query := store.MustPrepareSelectQueryWhere(`id < ?`)
- record := must.Return(query.SelectOneOrNone(ctx, db, 3))(t)
- assert.Equal(t, record, None[basicRecord]())
- })
-
- 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())
- })
-
- t.Run("using oblast.SelectOneOrNone", func(t *testing.T) {
- commonSetupForValueSelect()
- name := must.Return(oblast.SelectOneOrNone[string](ctx, db, `SELECT name FROM basic_records WHERE id < ?`, 3))(t)
- assert.Equal(t, name, None[string]())
- })
-
- 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())
- })
-
- t.Run("using TupleSelectOneOrNone", func(t *testing.T) {
- commonSetupForTupleSelect()
- record := must.Return(oblast.TupleSelectOneOrNone[tupleRecord](ctx, db, `SELECT id, name FROM basic_records WHERE id < ?`, 3))(t)
- assert.Equal(t, record, None[tupleRecord]())
- })
-}
-
-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.SelectOneOrNone", func(t *testing.T) {
- commonSetup(`SELECT * FROM composite_records`)
- record := must.Return(store.SelectOneOrNone(ctx, db, `SELECT * FROM composite_records`))(t)
- assert.Equal(t, record,
- Some(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 Store.SelectOneOrNoneWhere", func(t *testing.T) {
- commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`)
- record := must.Return(store.SelectOneOrNoneWhere(ctx, db, `TRUE`))(t)
- assert.Equal(t, record,
- Some(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))}},
- )
- })
-
- t.Run("using PreparedSelectQuery.SelectOneOrNone", func(t *testing.T) {
- commonSetup(`SELECT "id", "created_at", "updated_at" FROM "composite_records" WHERE TRUE`)
- query := store.MustPrepareSelectQueryWhere(`TRUE`)
- record := must.Return(query.SelectOneOrNone(ctx, db))(t)
- assert.Equal(t, record,
- Some(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")
- })
-}