aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--db.go34
-rw-r--r--dialect.go70
-rw-r--r--doc.go2
-rw-r--r--markers.go44
-rw-r--r--plan.go150
-rw-r--r--plan_test.go38
6 files changed, 337 insertions, 1 deletions
diff --git a/db.go b/db.go
new file mode 100644
index 0000000..a511d6f
--- /dev/null
+++ b/db.go
@@ -0,0 +1,34 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "context"
+ "database/sql"
+ "reflect"
+ "sync"
+)
+
+// 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
+ planMutex sync.Mutex
+}
+
+func NewDB(db *sql.DB, dialect Dialect) *DB {
+ return &DB{
+ DB: db,
+ dialect: dialect,
+ plans: make(map[reflect.Type]plan),
+ }
+}
+
+func Keks[T IsTable](ctx context.Context, db *DB) error {
+ _, err := db.getPlan(reflect.TypeFor[T]())
+ return err
+}
+
+// TODO: Begin() -> custom Tx type
diff --git a/dialect.go b/dialect.go
new file mode 100644
index 0000000..f29a34a
--- /dev/null
+++ b/dialect.go
@@ -0,0 +1,70 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "strconv"
+ "strings"
+)
+
+// 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.
+ 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
+
+ // UsesLastInsertID returns whether values for auto-generated columns are
+ // collected from LastInsertID(). If false, the INSERT query must instead
+ // yield a result row containing the values.
+ UsesLastInsertID() bool
+
+ // InsertSuffixForAutoColumns is appended to `INSERT (...) VALUES (...)`
+ // statements to collect values for auto-filled columns.
+ //
+ // If UsesLastInsertID is true, this is usually not needed and the empty
+ // string can be returned.
+ InsertSuffixForAutoColumns(columns []string) string
+}
+
+// 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, ", ")
+}
+
+// SqliteDialect is the dialect of SQLite databases.
+func SqliteDialect() Dialect {
+ return 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/doc.go b/doc.go
index fcb4700..c6f8d72 100644
--- a/doc.go
+++ b/doc.go
@@ -2,4 +2,4 @@
// SPDX-License-Identifier: Apache-2.0
// Package oblast is an ORM library for Go.
-package oblast
+package oblast // import "go.xyrillian.de/oblast"
diff --git a/markers.go b/markers.go
new file mode 100644
index 0000000..e62fe52
--- /dev/null
+++ b/markers.go
@@ -0,0 +1,44 @@
+// 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.go b/plan.go
new file mode 100644
index 0000000..b4cd18b
--- /dev/null
+++ b/plan.go
@@ -0,0 +1,150 @@
+// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package oblast
+
+import (
+ "fmt"
+ "reflect"
+ "slices"
+ "strings"
+)
+
+// plan holds all information that we can derive from reflecting on a given type.
+type plan struct {
+ // Argument for reflect.Value.FieldByIndex() for each column name.
+ IndexByColumnName map[string][]int
+ // Which columns will be filled automatically by the DB during insert.
+ // This corresponds to having a tag like `db:"foo,auto"`.
+ // In DB dialects that use LastInsertID(), this list may have at most one element.
+ AutoColumns []string
+
+ // Prepared queries (or empty strings if the respective query types are not
+ // supported for lack of the respective markers).
+ InsertQuery string
+ UpdateQuery string
+ DeleteQuery string
+
+ // Arguments for reflect.Value.FieldByIndex() in the required order for p.InsertQuery.
+ 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]()
+)
+
+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)",
+ t.Kind(), t.PkgPath(), t.Name())
+ }
+
+ var (
+ p = plan{
+ IndexByColumnName: make(map[string][]int),
+ }
+ tableName string
+ primaryKeyColumns []string
+ )
+
+ // discover addressable fields in this type,
+ // collect information from markers and tags
+ for _, index := range getAllAddressableFieldIndexes(t) {
+ field := t.FieldByIndex(index)
+ fullTag := strings.TrimSpace(field.Tag.Get("db"))
+ if fullTag == "" || fullTag == "-" {
+ continue
+ }
+ tags := strings.Split(fullTag, ",")
+
+ switch field.Type {
+ case tableInfoType:
+ // 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)
+ }
+ tableName = tags[0]
+ }
+ case primaryKeyInfoType:
+ // only consider this marker when directly on `t` itself, not within embedded fields
+ if len(index) == 1 {
+ primaryKeyColumns = tags
+ }
+ default:
+ columnName, extraTags := tags[0], tags[1:]
+ if otherIndex := p.IndexByColumnName[columnName]; otherIndex != nil {
+ return plan{}, fmt.Errorf(
+ "duplicate tag `db:%q` on field index %v, but also on field index %v",
+ columnName, otherIndex, index,
+ )
+ }
+ p.IndexByColumnName[columnName] = index
+
+ for _, tag := range extraTags {
+ switch tag {
+ case "auto":
+ p.AutoColumns = append(p.AutoColumns, columnName)
+ default:
+ 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 {
+ _, 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)
+ }
+ }
+
+ // validation: LastInsertID() only works if at most one column is auto-filled
+ if dialect.UsesLastInsertID() && len(p.AutoColumns) > 1 {
+ 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, ", "),
+ )
+ }
+
+ // TODO: build INSERT query if possible
+ // TODO: build UPDATE query if possible
+ // TODO: build DELETE query if possible
+ _ = tableName
+
+ return p, nil
+}
+
+// WARNING: Panics if t.Kind() != reflect.Struct.
+func getAllAddressableFieldIndexes(t reflect.Type) (result [][]int) {
+ for field := range t.Fields() {
+ // recurse into embedded fields
+ if field.Anonymous && field.Type.Kind() == reflect.Struct {
+ for _, subindex := range getAllAddressableFieldIndexes(field.Type) {
+ result = append(result, append(slices.Clone(field.Index), subindex...))
+ }
+ }
+
+ // only fields are addressable (otherwise reflect.Value.Interface() on the field would panic)
+ if field.PkgPath == "" {
+ result = append(result, field.Index)
+ }
+ }
+ return result
+}
diff --git a/plan_test.go b/plan_test.go
new file mode 100644
index 0000000..0cf5afa
--- /dev/null
+++ b/plan_test.go
@@ -0,0 +1,38 @@
+// 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)
+ }
+}