aboutsummaryrefslogtreecommitdiff
path: root/internal
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 /internal
parent3d28ce0650fc85ca054a608bce32f88f2d90295f (diff)
downloadgo-oblast-e9d31443f01eda2ecee66dbc25f154a6949a9c97.tar.gz
reorganize the API from type DB to type Store
Diffstat (limited to 'internal')
-rw-r--r--internal/plan.go42
-rw-r--r--internal/plan_test.go176
2 files changed, 97 insertions, 121 deletions
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`)
})
}