aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-04-11 20:19:12 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-04-11 20:20:03 +0200
commite9d31443f01eda2ecee66dbc25f154a6949a9c97 (patch)
tree1824c7dc3290e4d38ab111522938e8a33e2f9618
parent3d28ce0650fc85ca054a608bce32f88f2d90295f (diff)
downloadgo-oblast-e9d31443f01eda2ecee66dbc25f154a6949a9c97.tar.gz
reorganize the API from type DB to type Store
-rw-r--r--benchmark/benchmark_test.go9
-rw-r--r--db.go44
-rw-r--r--doc.go5
-rw-r--r--info/info.go55
-rw-r--r--internal/plan.go42
-rw-r--r--internal/plan_test.go176
-rw-r--r--oblast.go58
-rw-r--r--query.go19
-rw-r--r--store.go35
9 files changed, 204 insertions, 239 deletions
diff --git a/benchmark/benchmark_test.go b/benchmark/benchmark_test.go
index 356d188..e0822f2 100644
--- a/benchmark/benchmark_test.go
+++ b/benchmark/benchmark_test.go
@@ -49,16 +49,19 @@ func BenchmarkSelect(b *testing.B) {
for _, selectedRecordCount := range []int{1, 10, 100, 1000} {
b.Run("N="+strconv.Itoa(selectedRecordCount), func(b *testing.B) {
// prepare the functions that will be benched
- odb := oblast.NewDB(db, oblast.SqliteDialect())
- gdb := gorp.DbMap{Db: db, Dialect: gorp.SqliteDialect{}}
type record struct {
ID int `db:"id"`
Message string `db:"message"`
}
+ store, err := oblast.NewStore[record](oblast.SqliteDialect())
+ if err != nil {
+ b.Fatal(err)
+ }
+ gdb := gorp.DbMap{Db: db, Dialect: gorp.SqliteDialect{}}
query := `SELECT * FROM entries WHERE id < ` + strconv.Itoa(selectedRecordCount) //nolint:gosec
selectWithOblast := func(b *testing.B) {
- records, err := oblast.Select[record](odb, query)
+ records, err := store.Select(db, query)
if err != nil {
b.Error(err)
}
diff --git a/db.go b/db.go
deleted file mode 100644
index cdf9e1a..0000000
--- a/db.go
+++ /dev/null
@@ -1,44 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package oblast
-
-import (
- "database/sql"
- "reflect"
- "sync"
-
- "go.xyrillian.de/oblast/internal"
-)
-
-// DB wraps an [sql.DB] instance for use with Oblast's query interface.
-type DB struct {
- *sql.DB
- dialect Dialect
- plans map[reflect.Type]internal.Plan
- planMutex sync.Mutex
-}
-
-func NewDB(db *sql.DB, dialect Dialect) *DB {
- return &DB{
- DB: db,
- dialect: dialect,
- plans: make(map[reflect.Type]internal.Plan),
- }
-}
-
-func (d *DB) getPlan(t reflect.Type) (internal.Plan, error) {
- d.planMutex.Lock()
- defer d.planMutex.Unlock()
- p, ok := d.plans[t]
- if ok {
- return p, nil
- }
- p, err := internal.BuildPlan(t, d.dialect)
- if err == nil {
- d.plans[t] = p
- }
- return p, err
-}
-
-// TODO: Begin() -> custom Tx type; add interface to allow Select() et all to take either *DB or *Tx
diff --git a/doc.go b/doc.go
deleted file mode 100644
index c6f8d72..0000000
--- a/doc.go
+++ /dev/null
@@ -1,5 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-// Package oblast is an ORM library for Go.
-package oblast // import "go.xyrillian.de/oblast"
diff --git a/info/info.go b/info/info.go
deleted file mode 100644
index 7ca35ba..0000000
--- a/info/info.go
+++ /dev/null
@@ -1,55 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-// Package info contains marker types that can be placed in user-defined struct types.
-// Doing so enables certain operations within Oblast that rely on the metadata attached to these markers.
-package info // import "go.xyrillian.de/oblast/info"
-
-// seal is a private type that appears in interface definitions below,
-// to ensure that only types from this package can implement these interfaces.
-type seal struct{}
-
-// TableNameIs is a zero-sized marker for struct types that correspond to tables.
-// Put this as an embedded field on your struct type and put the table name in the field's `db` tag.
-//
-// // For example, this struct type...
-// type LogEntry struct {
-// info.TableNameIs `db:"log_entries"`
-// info.PrimaryKeyIs `db:"id"`
-// ID int64 `db:"id,auto"`
-// CreatedAt time.Time `db:"created_at"`
-// Message string `db:"message"`
-// }
-// // ...corresponds to this table schema (shown here in PostgreSQL syntax):
-// CREATE TABLE log_entries (
-// id BIGSERIAL NOT NULL PRIMARY KEY,
-// created_at TIMESTAMPTZ NOT NULL,
-// message TEXT NOT NULL
-// );
-//
-// This marker is required for all operations that use autogenerated SQL statements.
-type TableNameIs struct{}
-
-// hasTableMarker implements the IsTable interface.
-func (TableNameIs) hasTableMarker(seal) {}
-
-// IsTable is implemented by all types that have an embedded field of type [TableNameIs].
-type IsTable interface {
- hasTableMarker(seal)
-}
-
-// PrimaryKeyIs is a zero-sized marker for struct types that correspond to tables with a primary key.
-// Put this as an embedded field on your struct type and list the columns of the primary key in the field's `db` tag,
-// as shown on the example for type [TableNameIs].
-//
-// This marker is required for all UPDATE and DELETE operations that use autogenerated SQL statements.
-type PrimaryKeyIs struct{}
-
-// hasPrimaryKeyMarker implements the IsTableWithPrimaryKey interface.
-func (PrimaryKeyIs) hasPrimaryKeyMarker(seal) {} //nolint:unused // TODO
-
-// IsTableWithPrimaryKey is implemented by all types that have embedded fields of type [TableNameIs] and [PrimaryKeyIs].
-type IsTableWithPrimaryKey interface {
- hasTableMarker(seal)
- hasPrimaryKeyMarker(seal)
-}
diff --git a/internal/plan.go b/internal/plan.go
index 2ed4136..ac199bf 100644
--- a/internal/plan.go
+++ b/internal/plan.go
@@ -4,12 +4,11 @@
package internal
import (
+ "errors"
"fmt"
"reflect"
"slices"
"strings"
-
- "go.xyrillian.de/oblast/info"
)
// Plan holds all information that we can derive from reflecting on a given type.
@@ -39,27 +38,30 @@ type PlannedQuery struct {
ArgumentIndexes [][]int
}
-var (
- tableNameMarkerType = reflect.TypeFor[info.TableNameIs]()
- primaryKeyMarkerType = reflect.TypeFor[info.PrimaryKeyIs]()
-)
+// PlanOpts holds additional arguments to BuildPlan().
+type PlanOpts struct {
+ TableName string
+ PrimaryKeyColumnNames []string
+}
// BuildPlan creates a new plan for the given struct type.
-func BuildPlan(t reflect.Type, dialect Dialect) (Plan, error) {
- p, err := buildPlan(t, dialect)
+func BuildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) {
+ p, err := buildPlan(t, dialect, opts)
if err != nil {
return Plan{}, fmt.Errorf("cannot use type %s.%s for queries: %w", t.PkgPath(), t.Name(), err)
}
return p, nil
}
-func buildPlan(t reflect.Type, dialect Dialect) (Plan, error) {
+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 %s", t.Kind().String())
}
var p = Plan{
- IndexByColumnName: make(map[string][]int),
+ TableName: opts.TableName,
+ PrimaryKeyColumnNames: opts.PrimaryKeyColumnNames,
+ IndexByColumnName: make(map[string][]int),
}
// discover addressable fields in this type,
@@ -71,19 +73,6 @@ func buildPlan(t reflect.Type, dialect Dialect) (Plan, error) {
case field.PkgPath != "":
// ignore unexported fields (otherwise reflect.Value.Interface() on the field would panic)
continue
- case field.Type == tableNameMarkerType:
- // only consider this marker when directly on `t` itself, not within embedded fields
- if len(field.Index) == 1 {
- if len(tags) > 1 {
- return Plan{}, fmt.Errorf("invalid table name %q (may not contain commas)", field.Tag.Get("db"))
- }
- p.TableName = tags[0]
- }
- case field.Type == primaryKeyMarkerType:
- // only consider this marker when directly on `t` itself, not within embedded fields
- if len(field.Index) == 1 {
- p.PrimaryKeyColumnNames = tags
- }
case field.Anonymous && field.Type.Kind() == reflect.Struct:
// for embedded struct fields, only consider their members, not the type itself, as a potential column
continue
@@ -115,11 +104,16 @@ func buildPlan(t reflect.Type, dialect Dialect) (Plan, error) {
}
}
+ // 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("PrimaryKeyInfo refers to column %[1]q, but no field has tag `db:%[1]q`", columnName)
+ return Plan{}, fmt.Errorf("no field has tag `db:%q`, but a field of this name was declared in the primary key", columnName)
}
}
diff --git a/internal/plan_test.go b/internal/plan_test.go
index c504ace..88afedc 100644
--- a/internal/plan_test.go
+++ b/internal/plan_test.go
@@ -8,79 +8,52 @@ import (
"testing"
"time"
- "go.xyrillian.de/oblast/info"
"go.xyrillian.de/oblast/internal"
"go.xyrillian.de/oblast/internal/assert"
)
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 {
- info.TableNameIs `db:"log_entries"`
- info.PrimaryKeyIs `db:"id"`
- ID int64 `db:"id,auto"`
- CreatedAt time.Time `db:"created_at"`
- Message string
- private1 bool `db:"private1"` //nolint:unused
- Ignored any `db:"-"`
+ ID int64 `db:"id,auto"`
+ Message string
+ private1 bool `db:"private1"` //nolint:unused
+ Ignored any `db:"-"`
+ Timestamps
+ yetMoreTimestamps
}
- // assert on interface implementations
- var (
- _ info.IsTable = Log{}
- _ info.IsTableWithPrimaryKey = Log{}
- )
-
// check that the plan for Log:
// 1. has no IndexByColumnName entries for marker types
// 2. uses the field name as a column name for "Message"
// 3. ignores "private1" because it cannot be written through reflection
// 4. ignores "Ignored" because its column name is "-"
- // 5. recognizes "id" as an autofilled column
- plan, err := internal.BuildPlan(reflect.TypeFor[Log](), internal.PostgresDialect{})
+ // 5. traverses into "Timestamps" and includes its fields as well
+ // 6. traverses into "yetMoreTimestamps" as well (despite the extra pointer and the type being private)
+ // 7. recognizes "id" as an autofilled column
+ plan, err := internal.BuildPlan(reflect.TypeFor[Log](), internal.PostgresDialect{}, internal.PlanOpts{
+ TableName: "log_entries",
+ PrimaryKeyColumnNames: []string{"id"},
+ })
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.TableName, "log_entries")
- assert.DeepEqual(t, plan.AllColumnNames, []string{"id", "created_at", "Message"})
+ assert.DeepEqual(t, plan.AllColumnNames, []string{"id", "Message", "created_at", "updated_at", "deleted_at"})
assert.DeepEqual(t, plan.PrimaryKeyColumnNames, []string{"id"})
assert.DeepEqual(t, plan.AutoColumnNames, []string{"id"})
assert.DeepEqual(t, plan.IndexByColumnName, map[string][]int{
- "id": {2},
- "created_at": {3},
- "Message": {4},
- })
-
- type extraTimestampFields struct {
- UpdatedAt *time.Time `db:"updated_at"`
- DeletedAt *time.Time `db:"deleted_at"`
- }
-
- type record struct {
- Log
- *extraTimestampFields
- Foo bool `db:"foo"`
- }
-
- // check that the plan for record:
- // 1. works at all, even though it as a whole is an unexported type
- // 2. traverses into Log and includes all of its fields as well
- // 3. traverses into *extraTimestampFields (despite the extra pointer and the type being private), too
- // 3. completely ignores the marker types in type Log
- plan, err = internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{})
- if err != nil {
- t.Error(err)
- }
- assert.Equal(t, plan.TableName, "")
- assert.DeepEqual(t, plan.AllColumnNames, []string{"id", "created_at", "Message", "updated_at", "deleted_at", "foo"})
- assert.DeepEqual(t, plan.PrimaryKeyColumnNames, nil)
- assert.DeepEqual(t, plan.AutoColumnNames, []string{"id"}) // this is okay, it does not bear significance in practice since no queries are generated
- assert.DeepEqual(t, plan.IndexByColumnName, map[string][]int{
- "id": {0, 2},
- "created_at": {0, 3},
- "Message": {0, 4},
- "updated_at": {1, 0},
- "deleted_at": {1, 1},
- "foo": {2},
+ "id": {0},
+ "Message": {1},
+ "created_at": {4, 0},
+ "updated_at": {4, 1},
+ "deleted_at": {5, 0},
})
}
@@ -88,54 +61,58 @@ func TestPlanFieldTraversal(t *testing.T) {
func TestQueryConstructionBasic(t *testing.T) {
type record struct {
- info.TableNameIs `db:"basic_records"`
- info.PrimaryKeyIs `db:"ID"`
- ID int64 `db:",auto"`
- Description string
- CreatedAt time.Time
+ ID int64 `db:",auto"`
+ Description string
+ CreatedAt time.Time
+ }
+ opts := internal.PlanOpts{
+ TableName: "basic_records",
+ PrimaryKeyColumnNames: []string{"ID"},
}
t.Run("PostgresDialect", func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{})
+ plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts)
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.Insert.Query, `INSERT INTO "basic_records" ("Description", "CreatedAt") VALUES ($1, $2) RETURNING "ID"`)
- assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{3}, {4}})
+ assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}})
assert.Equal(t, plan.Update.Query, `UPDATE "basic_records" SET "Description" = $1, "CreatedAt" = $2 WHERE "ID" = $3`)
- assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{3}, {4}, {2}})
+ assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{1}, {2}, {0}})
assert.Equal(t, plan.Delete.Query, `DELETE FROM "basic_records" WHERE "ID" = $1`)
- assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}})
+ assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}})
})
t.Run("SqliteDialect", func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{})
+ plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts)
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.Insert.Query, `INSERT INTO "basic_records" ("Description", "CreatedAt") VALUES (?, ?)`)
- assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{3}, {4}})
+ assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}})
assert.Equal(t, plan.Update.Query, `UPDATE "basic_records" SET "Description" = ?, "CreatedAt" = ? WHERE "ID" = ?`)
- assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{3}, {4}, {2}})
+ assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{1}, {2}, {0}})
assert.Equal(t, plan.Delete.Query, `DELETE FROM "basic_records" WHERE "ID" = ?`)
- assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}})
+ assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}})
})
}
func TestQueryConstructionWithoutPrimaryKey(t *testing.T) {
type relation struct {
- info.TableNameIs `db:"foo_bar_relations"`
- FooID int64 `db:"foo_id"`
- BarID int64 `db:"bar_id"`
+ FooID int64 `db:"foo_id"`
+ BarID int64 `db:"bar_id"`
+ }
+ opts := internal.PlanOpts{
+ TableName: "foo_bar_relations",
}
t.Run("PostgresDialect", func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.PostgresDialect{})
+ plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.PostgresDialect{}, opts)
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.Insert.Query, `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES ($1, $2)`)
- assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}})
+ assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}})
assert.Equal(t, plan.Update.Query, "")
assert.DeepEqual(t, plan.Update.ArgumentIndexes, nil)
assert.Equal(t, plan.Delete.Query, "")
@@ -143,12 +120,12 @@ func TestQueryConstructionWithoutPrimaryKey(t *testing.T) {
})
t.Run("SqliteDialect", func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.SqliteDialect{})
+ plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.SqliteDialect{}, opts)
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.Insert.Query, `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?)`)
- assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}})
+ assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}})
assert.Equal(t, plan.Update.Query, "")
assert.DeepEqual(t, plan.Update.ArgumentIndexes, nil)
assert.Equal(t, plan.Delete.Query, "")
@@ -161,10 +138,11 @@ func TestQueryConstructionImpossble(t *testing.T) {
Foo int
Bar string
}
+ opts := internal.PlanOpts{}
testWith := func(dialect internal.Dialect) func(*testing.T) {
return func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[unstructuredData](), dialect)
+ plan, err := internal.BuildPlan(reflect.TypeFor[unstructuredData](), dialect, opts)
if err != nil {
t.Error(err)
}
@@ -184,64 +162,68 @@ func TestQueryConstructionImpossble(t *testing.T) {
func TestQueryConstructionWithMultiplePrimaryKeyColumns(t *testing.T) {
type record struct {
- info.TableNameIs `db:"complex_records"`
- info.PrimaryKeyIs `db:"group_id,name"`
- GroupID int64 `db:"group_id"`
- Name string `db:"name"`
- CreatedAt time.Time `db:"created_at"`
+ GroupID int64 `db:"group_id"`
+ Name string `db:"name"`
+ CreatedAt time.Time `db:"created_at"`
+ }
+ opts := internal.PlanOpts{
+ TableName: "complex_records",
+ PrimaryKeyColumnNames: []string{"group_id", "name"},
}
t.Run("PostgresDialect", func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{})
+ plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts)
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.Insert.Query, `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES ($1, $2, $3)`)
- assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{2}, {3}, {4}})
+ assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}, {2}})
assert.Equal(t, plan.Update.Query, `UPDATE "complex_records" SET "created_at" = $1 WHERE "group_id" = $2 AND "name" = $3`)
- assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{4}, {2}, {3}})
+ assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{2}, {0}, {1}})
assert.Equal(t, plan.Delete.Query, `DELETE FROM "complex_records" WHERE "group_id" = $1 AND "name" = $2`)
- assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}, {3}})
+ assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}, {1}})
})
t.Run("SqliteDialect", func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{})
+ plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts)
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.Insert.Query, `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES (?, ?, ?)`)
- assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{2}, {3}, {4}})
+ assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}, {2}})
assert.Equal(t, plan.Update.Query, `UPDATE "complex_records" SET "created_at" = ? WHERE "group_id" = ? AND "name" = ?`)
- assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{4}, {2}, {3}})
+ assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{2}, {0}, {1}})
assert.Equal(t, plan.Delete.Query, `DELETE FROM "complex_records" WHERE "group_id" = ? AND "name" = ?`)
- assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}, {3}})
+ assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}, {1}})
})
}
func TestQueryConstructionWithMultipleAutoColumns(t *testing.T) {
type record struct {
- info.TableNameIs `db:"autogenerated_records"`
- info.PrimaryKeyIs `db:"id"`
- ID int64 `db:"id,auto"`
- Name string `db:"name"`
- CreatedAt time.Time `db:"created_at,auto"`
+ ID int64 `db:"id,auto"`
+ Name string `db:"name"`
+ CreatedAt time.Time `db:"created_at,auto"`
+ }
+ opts := internal.PlanOpts{
+ TableName: "autogenerated_records",
+ PrimaryKeyColumnNames: []string{"id"},
}
t.Run("PostgresDialect", func(t *testing.T) {
- plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{})
+ plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts)
if err != nil {
t.Error(err)
}
assert.Equal(t, plan.Insert.Query, `INSERT INTO "autogenerated_records" ("name") VALUES ($1) RETURNING "id", "created_at"`)
- assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{3}})
+ assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}})
assert.Equal(t, plan.Update.Query, `UPDATE "autogenerated_records" SET "name" = $1, "created_at" = $2 WHERE "id" = $3`)
- assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{3}, {4}, {2}})
+ assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{1}, {2}, {0}})
assert.Equal(t, plan.Delete.Query, `DELETE FROM "autogenerated_records" WHERE "id" = $1`)
- assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}})
+ assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}})
})
t.Run("SqliteDialect", func(t *testing.T) {
- _, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{})
+ _, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts)
assert.Equal(t, err.Error(), `cannot use type go.xyrillian.de/oblast/internal_test.record for queries: multiple columns are marked as auto-filled (id, created_at), but this SQL dialect only supports at most one per table`)
})
}
diff --git a/oblast.go b/oblast.go
new file mode 100644
index 0000000..9ed60a4
--- /dev/null
+++ b/oblast.go
@@ -0,0 +1,58 @@
+// 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
+//
+// 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 *sql.DB) error {
+// newEntry := LogEntry{
+// CreatedAt: time.Now(),
+// Message: "Hello World.",
+// }
+// err := logEntryStore.Insert(db, &newEntry)
+// if err != nil {
+// return err
+// }
+// fmt.Printf("created log entry %d", newEntry.ID)
+//
+// allEntries, err := logEntryStore.SelectWhere(db, `created_at < NOW()`)
+// if err != nil {
+// return err
+// }
+// fmt.Printf("there are %d log entries so far", len(allEntries))
+// }
+package oblast // import "go.xyrillian.de/oblast"
+
+import "go.xyrillian.de/oblast/internal"
+
+// PlanOption is an option that can be given to NewStore() to influence query planning for a certain type of record.
+type PlanOption func(*internal.PlanOpts)
+
+// TableNameIs is a PlanOption for record types that correspond to exactly one database table (as opposed to a join of multiple tables).
+// This option is required to enable any of the methods of [Store] that use partially or fully auto-generated query strings.
+func TableNameIs(name string) PlanOption {
+ return func(opts *internal.PlanOpts) { opts.TableName = name }
+}
+
+// PrimaryKeyIs is a PlanOption for record types that correspond to a database table with a primary key.
+// This option is required to enable use of the [Store.Update] and [Store.Delete] methods.
+func PrimaryKeyIs(columnNames ...string) PlanOption {
+ return func(opts *internal.PlanOpts) { opts.PrimaryKeyColumnNames = columnNames }
+}
diff --git a/query.go b/query.go
index a464c9e..e47feb6 100644
--- a/query.go
+++ b/query.go
@@ -11,15 +11,12 @@ import (
"go.xyrillian.de/oblast/internal"
)
-func Select[T any](db *DB, query string, args ...any) (result []T, returnedError error) {
+// TODO: allow taking *sql.Tx in addition to *sql.DB
+func (s Store[R]) Select(db *sql.DB, query string, args ...any) (result []R, returnedError 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 T should be factored out into a reusable function.
+ // Any expression that does not depend on type R should be factored out into a reusable function.
- plan, err := db.getPlan(reflect.TypeFor[T]())
- if err != nil {
- return nil, err
- }
- rows, indexes, err := db.startQuery(plan, query, args...)
+ rows, indexes, err := startQuery(db, s.plan, query, args...)
if err != nil {
return nil, err
}
@@ -29,8 +26,8 @@ func Select[T any](db *DB, query string, args ...any) (result []T, returnedError
slots := make([]any, len(indexes))
for rows.Next() {
- var target T
- err = db.collectRow(rows, reflect.ValueOf(&target).Elem(), slots, indexes)
+ var target R
+ err = collectRow(rows, reflect.ValueOf(&target).Elem(), slots, indexes)
if err != nil {
return nil, err
}
@@ -40,7 +37,7 @@ func Select[T any](db *DB, query string, args ...any) (result []T, returnedError
return result, nil
}
-func (db *DB) startQuery(plan internal.Plan, query string, args ...any) (rows *sql.Rows, indexes [][]int, err error) {
+func startQuery(db *sql.DB, plan internal.Plan, query string, args ...any) (rows *sql.Rows, indexes [][]int, err error) {
rows, err = db.Query(query, args...)
if err != nil {
return nil, nil, fmt.Errorf("during Query(): %w", err)
@@ -73,7 +70,7 @@ func (db *DB) startQuery(plan internal.Plan, query string, args ...any) (rows *s
return rows, indexes, nil
}
-func (db *DB) collectRow(rows *sql.Rows, v reflect.Value, slots []any, indexes [][]int) error {
+func collectRow(rows *sql.Rows, v reflect.Value, slots []any, indexes [][]int) error {
for idx, index := range indexes {
slots[idx] = v.FieldByIndex(index).Addr().Interface()
}
diff --git a/store.go b/store.go
new file mode 100644
index 0000000..4ab0f4b
--- /dev/null
+++ b/store.go
@@ -0,0 +1,35 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "reflect"
+
+ "go.xyrillian.de/oblast/internal"
+)
+
+// Store is the main interface of this library.
+// It 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]s were provided during [NewStore].
+type Store[R any] struct {
+ plan internal.Plan
+}
+
+// NewStore initializes a store for record type R.
+func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) {
+ var popts internal.PlanOpts
+ for _, opt := range opts {
+ opt(&popts)
+ }
+ plan, err := internal.BuildPlan(reflect.TypeFor[R](), dialect, popts)
+ return Store[R]{plan}, err
+}
+
+// MustNewStore is like [NewStore], but panics on error.
+func MustNewStore[R any](dialect Dialect, opts ...PlanOption) Store[R] {
+ store, err := NewStore[R](dialect, opts...)
+ if err != nil {
+ panic(err.Error())
+ }
+ return store
+}