diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-04-11 20:19:12 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-04-11 20:20:03 +0200 |
| commit | e9d31443f01eda2ecee66dbc25f154a6949a9c97 (patch) | |
| tree | 1824c7dc3290e4d38ab111522938e8a33e2f9618 | |
| parent | 3d28ce0650fc85ca054a608bce32f88f2d90295f (diff) | |
| download | go-oblast-e9d31443f01eda2ecee66dbc25f154a6949a9c97.tar.gz | |
reorganize the API from type DB to type Store
| -rw-r--r-- | benchmark/benchmark_test.go | 9 | ||||
| -rw-r--r-- | db.go | 44 | ||||
| -rw-r--r-- | doc.go | 5 | ||||
| -rw-r--r-- | info/info.go | 55 | ||||
| -rw-r--r-- | internal/plan.go | 42 | ||||
| -rw-r--r-- | internal/plan_test.go | 176 | ||||
| -rw-r--r-- | oblast.go | 58 | ||||
| -rw-r--r-- | query.go | 19 | ||||
| -rw-r--r-- | store.go | 35 |
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) } @@ -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 @@ -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 } +} @@ -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 +} |
