aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-04-10 20:13:15 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-04-10 20:13:15 +0200
commit293e2a52e0b45065db12ff27f89f1adebe4bf4d2 (patch)
tree2e7efecdf5ee359df9c6b09ece3443ea33432462
parente0fb5aa0acc1983648ab1480f22114aead234eeb (diff)
downloadgo-oblast-293e2a52e0b45065db12ff27f89f1adebe4bf4d2.tar.gz
reorganize code
-rw-r--r--.gitignore1
-rw-r--r--.golangci.yaml1
-rw-r--r--Makefile1
-rw-r--r--REUSE.toml4
-rw-r--r--db.go22
-rw-r--r--dialect.go30
-rw-r--r--info/info.go55
-rw-r--r--internal/assert/assert.go25
-rw-r--r--internal/dialect.go41
-rw-r--r--internal/plan.go (renamed from plan.go)66
-rw-r--r--internal/plan_test.go72
-rw-r--r--markers.go44
-rw-r--r--plan_test.go38
13 files changed, 245 insertions, 155 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..84c048a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/build/
diff --git a/.golangci.yaml b/.golangci.yaml
index 6222cab..995ee5a 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -3,7 +3,6 @@
version: "2"
run:
- modules-download-mode: vendor
timeout: 3m0s # none by default in v2
formatters:
diff --git a/Makefile b/Makefile
index c042ca6..add5af5 100644
--- a/Makefile
+++ b/Makefile
@@ -16,6 +16,7 @@ GO_COVERPKGS := $(shell go list ./... | tr '\n' , | sed 's/,$$//')
GO_TESTPKGS := $(shell go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}' ./...)
build/cover.out: FORCE
+ @mkdir -p build
@printf "\e[1;36m>> go test\e[0m\n"
go test -shuffle=on -coverprofile=build/cover.out -covermode=count -coverpkg=$(GO_COVERPKGS) $(GO_TESTPKGS)
build/cover.html: build/cover.out
diff --git a/REUSE.toml b/REUSE.toml
index d41cb5b..fbb08c7 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -7,8 +7,12 @@ SPDX-PackageDownloadLocation = "https://git.xyrillian.de/oblast"
[[annotations]]
path = [
".gitignore",
+ "benchmark/go.mod",
+ "benchmark/go.sum",
"description",
"go.mod",
+ "go.work",
+ "go.work.sum",
]
SPDX-FileCopyrightText = "Stefan Majewsky <majewsky@gmx.net>"
SPDX-License-Identifier = "Apache-2.0"
diff --git a/db.go b/db.go
index 8f1a050..83d1863 100644
--- a/db.go
+++ b/db.go
@@ -9,13 +9,15 @@ import (
"fmt"
"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]plan
+ plans map[reflect.Type]internal.Plan
planMutex sync.Mutex
}
@@ -23,14 +25,22 @@ func NewDB(db *sql.DB, dialect Dialect) *DB {
return &DB{
DB: db,
dialect: dialect,
- plans: make(map[reflect.Type]plan),
+ plans: make(map[reflect.Type]internal.Plan),
}
}
-// TODO: remove
-func Keks[T IsTable](ctx context.Context, db *DB) error {
- _, err := db.getPlan(reflect.TypeFor[T]())
- return err
+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/dialect.go b/dialect.go
index f29a34a..7a3acff 100644
--- a/dialect.go
+++ b/dialect.go
@@ -3,10 +3,7 @@
package oblast
-import (
- "strconv"
- "strings"
-)
+import "go.xyrillian.de/oblast/internal"
// Dialect accounts for differences between different SQL dialects
// that are relevant to query generation within Oblast.
@@ -40,31 +37,10 @@ type Dialect interface {
// 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) }
-func (postgresDialect) QuoteIdentifier(name string) string { return `"` + name + `"` }
-func (postgresDialect) UsesLastInsertID() bool { return false }
-
-func (p postgresDialect) InsertSuffixForAutoColumns(columns []string) string {
- quotedColumns := make([]string, len(columns))
- for idx, name := range columns {
- quotedColumns[idx] = p.QuoteIdentifier(name)
- }
- return ` RETURNING ` + strings.Join(quotedColumns, ", ")
+ return internal.PostgresDialect{}
}
// SqliteDialect is the dialect of SQLite databases.
func SqliteDialect() Dialect {
- return sqliteDialect{}
+ return internal.SqliteDialect{}
}
-
-type sqliteDialect struct{}
-
-func (sqliteDialect) Placeholder(_ int) string { return "?" }
-func (sqliteDialect) QuoteIdentifier(name string) string { return `"` + name + `"` }
-func (sqliteDialect) UsesLastInsertID() bool { return true }
-func (sqliteDialect) InsertSuffixForAutoColumns(columns []string) string { return "" }
diff --git a/info/info.go b/info/info.go
new file mode 100644
index 0000000..7ca35ba
--- /dev/null
+++ b/info/info.go
@@ -0,0 +1,55 @@
+// 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/assert/assert.go b/internal/assert/assert.go
new file mode 100644
index 0000000..c4e7b50
--- /dev/null
+++ b/internal/assert/assert.go
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package assert
+
+import (
+ "reflect"
+ "testing"
+)
+
+// Equal is a test assertion.
+func Equal[V comparable](t *testing.T, actual, expected V) {
+ t.Helper()
+ if actual != expected {
+ t.Errorf("expected %#v, but got %#v", expected, actual)
+ }
+}
+
+// DeepEqual is a test assertion.
+func DeepEqual[V any](t *testing.T, actual, expected V) {
+ t.Helper()
+ if !reflect.DeepEqual(actual, expected) {
+ t.Errorf("expected %#v, but got %#v", expected, actual)
+ }
+}
diff --git a/internal/dialect.go b/internal/dialect.go
new file mode 100644
index 0000000..0cf90a2
--- /dev/null
+++ b/internal/dialect.go
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package internal
+
+import (
+ "strconv"
+ "strings"
+)
+
+// Dialect is a copy of the interface of the same name in package oblast.
+// We cannot refer to that interface within this package because that would constitute a cyclic dependency.
+type Dialect interface {
+ Placeholder(i int) string
+ QuoteIdentifier(name string) string
+ UsesLastInsertID() bool
+ InsertSuffixForAutoColumns(columns []string) string
+}
+
+// PostgresDialect is the dialect of PostgreSQL databases.
+type PostgresDialect struct{}
+
+func (PostgresDialect) Placeholder(i int) string { return "$" + strconv.Itoa(i) }
+func (PostgresDialect) QuoteIdentifier(name string) string { return `"` + name + `"` }
+func (PostgresDialect) UsesLastInsertID() bool { return false }
+
+func (p PostgresDialect) InsertSuffixForAutoColumns(columns []string) string {
+ quotedColumns := make([]string, len(columns))
+ for idx, name := range columns {
+ quotedColumns[idx] = p.QuoteIdentifier(name)
+ }
+ return ` RETURNING ` + strings.Join(quotedColumns, ", ")
+}
+
+// SqliteDialect is the dialect of SQLite databases.
+type SqliteDialect struct{}
+
+func (SqliteDialect) Placeholder(_ int) string { return "?" }
+func (SqliteDialect) QuoteIdentifier(name string) string { return `"` + name + `"` }
+func (SqliteDialect) UsesLastInsertID() bool { return true }
+func (SqliteDialect) InsertSuffixForAutoColumns(columns []string) string { return "" }
diff --git a/plan.go b/internal/plan.go
index b4cd18b..0defd15 100644
--- a/plan.go
+++ b/internal/plan.go
@@ -1,17 +1,24 @@
// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
// SPDX-License-Identifier: Apache-2.0
-package oblast
+package internal
import (
"fmt"
"reflect"
"slices"
"strings"
+
+ "go.xyrillian.de/oblast/info"
)
-// plan holds all information that we can derive from reflecting on a given type.
-type plan struct {
+// 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 {
+ // Information extracted from applicable marker types (if any).
+ TableName string
+ PrimaryKeyColumns []string
+
// Argument for reflect.Value.FieldByIndex() for each column name.
IndexByColumnName map[string][]int
// Which columns will be filled automatically by the DB during insert.
@@ -29,38 +36,20 @@ type plan struct {
InsertFieldOrder [][]int
}
-func (d *DB) getPlan(t reflect.Type) (plan, error) {
- d.planMutex.Lock()
- defer d.planMutex.Unlock()
- p, ok := d.plans[t]
- if ok {
- return p, nil
- }
- p, err := buildPlan(t, d.dialect)
- if err == nil {
- d.plans[t] = p
- }
- return p, err
-}
-
var (
- tableInfoType = reflect.TypeFor[TableInfo]()
- primaryKeyInfoType = reflect.TypeFor[PrimaryKeyInfo]()
+ tableNameMarkerType = reflect.TypeFor[info.TableNameIs]()
+ primaryKeyMarkerType = reflect.TypeFor[info.PrimaryKeyIs]()
)
-func buildPlan(t reflect.Type, dialect Dialect) (plan, error) {
+func BuildPlan(t reflect.Type, dialect Dialect) (Plan, error) {
if t.Kind() != reflect.Struct {
- return plan{}, fmt.Errorf("expected record type to be a struct, but got kind %s (full type: %s.%s)",
+ return Plan{}, fmt.Errorf("expected record type to be a struct, but got kind %s (full type: %s.%s)",
t.Kind(), t.PkgPath(), t.Name())
}
- var (
- p = plan{
- IndexByColumnName: make(map[string][]int),
- }
- tableName string
- primaryKeyColumns []string
- )
+ var p = Plan{
+ IndexByColumnName: make(map[string][]int),
+ }
// discover addressable fields in this type,
// collect information from markers and tags
@@ -73,23 +62,23 @@ func buildPlan(t reflect.Type, dialect Dialect) (plan, error) {
tags := strings.Split(fullTag, ",")
switch field.Type {
- case tableInfoType:
+ case tableNameMarkerType:
// only consider this marker when directly on `t` itself, not within embedded fields
if len(index) == 1 {
if len(tags) > 1 {
- return plan{}, fmt.Errorf("invalid table name %q (may not contain commas)", fullTag)
+ return Plan{}, fmt.Errorf("invalid table name %q (may not contain commas)", fullTag)
}
- tableName = tags[0]
+ p.TableName = tags[0]
}
- case primaryKeyInfoType:
+ case primaryKeyMarkerType:
// only consider this marker when directly on `t` itself, not within embedded fields
if len(index) == 1 {
- primaryKeyColumns = tags
+ p.PrimaryKeyColumns = tags
}
default:
columnName, extraTags := tags[0], tags[1:]
if otherIndex := p.IndexByColumnName[columnName]; otherIndex != nil {
- return plan{}, fmt.Errorf(
+ return Plan{}, fmt.Errorf(
"duplicate tag `db:%q` on field index %v, but also on field index %v",
columnName, otherIndex, index,
)
@@ -101,23 +90,23 @@ func buildPlan(t reflect.Type, dialect Dialect) (plan, error) {
case "auto":
p.AutoColumns = append(p.AutoColumns, columnName)
default:
- return plan{}, fmt.Errorf("unknown tag `db:%q` on field index %v", ","+tag, index)
+ return Plan{}, fmt.Errorf("unknown tag `db:%q` on field index %v", ","+tag, index)
}
}
}
}
// validation: oblast.PrimaryKeyInfo must refer to columns that exist
- for _, columnName := range primaryKeyColumns {
+ for _, columnName := range p.PrimaryKeyColumns {
_, 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("PrimaryKeyInfo refers to column %[1]q, but no field has tag `db:%[1]q`", columnName)
}
}
// validation: LastInsertID() only works if at most one column is auto-filled
if dialect.UsesLastInsertID() && len(p.AutoColumns) > 1 {
- return plan{}, fmt.Errorf(
+ return Plan{}, fmt.Errorf(
"multiple columns are marked as auto-filled (%s), but this SQL dialect only supports at most one per table",
strings.Join(p.AutoColumns, ", "),
)
@@ -126,7 +115,6 @@ func buildPlan(t reflect.Type, dialect Dialect) (plan, error) {
// TODO: build INSERT query if possible
// TODO: build UPDATE query if possible
// TODO: build DELETE query if possible
- _ = tableName
return p, nil
}
diff --git a/internal/plan_test.go b/internal/plan_test.go
new file mode 100644
index 0000000..827c6e4
--- /dev/null
+++ b/internal/plan_test.go
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package internal_test
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "go.xyrillian.de/oblast/info"
+ "go.xyrillian.de/oblast/internal"
+ "go.xyrillian.de/oblast/internal/assert"
+)
+
+func TestPlanFieldTraversal(t *testing.T) {
+ 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 `db:"message"`
+ private1 bool `db:"private1"` //nolint:unused
+ }
+
+ // 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. ignores "private1" because it cannot be written through reflection
+ // 3. recognizes "id" as an autofilled column
+ plan, err := internal.BuildPlan(reflect.TypeFor[Log](), internal.PostgresDialect{})
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, plan.TableName, "log_entries")
+ assert.DeepEqual(t, plan.PrimaryKeyColumns, []string{"id"})
+ assert.DeepEqual(t, plan.AutoColumns, []string{"id"})
+ assert.DeepEqual(t, plan.IndexByColumnName, map[string][]int{
+ "id": {2},
+ "created_at": {3},
+ "message": {4},
+ })
+
+ type record struct {
+ Log
+ Keks bool `db:"keks"`
+ private2 bool `db:"private2"` //nolint:unused
+ }
+
+ // 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. 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.PrimaryKeyColumns, nil)
+ assert.DeepEqual(t, plan.AutoColumns, []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},
+ "keks": {1},
+ })
+}
diff --git a/markers.go b/markers.go
deleted file mode 100644
index e62fe52..0000000
--- a/markers.go
+++ /dev/null
@@ -1,44 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package oblast
-
-// 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{}
-
-// TableInfo is a 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 {
-// oblast.TableInfo `db:"log_entries"`
-// oblast.PrimaryKeyInfo `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 TableInfo struct{}
-
-// isTable implements the IsTable interface.
-func (TableInfo) isTable(seal) {}
-
-// IsTable is implemented by all types that have an embedded field of type [TableInfo].
-type IsTable interface {
- isTable(seal)
-}
-
-// PrimaryKeyInfo is a 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 [TableInfo].
-//
-// This marker is required for all UPDATE and DELETE operations that use autogenerated SQL statements.
-type PrimaryKeyInfo struct{}
diff --git a/plan_test.go b/plan_test.go
deleted file mode 100644
index 0cf5afa..0000000
--- a/plan_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
-// SPDX-License-Identifier: Apache-2.0
-
-package oblast_test
-
-import (
- "testing"
- "time"
-
- "go.xyrillian.de/oblast"
-)
-
-func TestPlan(t *testing.T) {
- type Log struct {
- oblast.TableInfo `db:"log_entries"`
- oblast.PrimaryKeyInfo `db:"id"`
- ID int64 `db:"id,auto"`
- CreatedAt time.Time `db:"created_at"`
- Message string `db:"message"`
- private1 bool `db:"private1"`
- }
-
- type record struct {
- Log
- Keks bool `db:"keks"`
- private2 bool `db:"private2"`
- }
-
- db := oblast.NewDB(nil, oblast.PostgresDialect())
- err := oblast.Keks[record](t.Context(), db)
- if err != nil {
- t.Error(err)
- }
- err = oblast.Keks[Log](t.Context(), db)
- if err != nil {
- t.Error(err)
- }
-}