diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-04-10 14:55:26 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-04-10 14:56:39 +0200 |
| commit | bce3df549ff4ccc8895697a3222269bd14fc22a4 (patch) | |
| tree | 1e1338ec9e9a15b0096b31e692e646d081d71677 | |
| parent | 03b874fdef2003ef9bfff7f4ce3021d594211a30 (diff) | |
| download | go-oblast-bce3df549ff4ccc8895697a3222269bd14fc22a4.tar.gz | |
start reflecting on types to build a query plan
| -rw-r--r-- | db.go | 34 | ||||
| -rw-r--r-- | dialect.go | 70 | ||||
| -rw-r--r-- | doc.go | 2 | ||||
| -rw-r--r-- | markers.go | 44 | ||||
| -rw-r--r-- | plan.go | 150 | ||||
| -rw-r--r-- | plan_test.go | 38 |
6 files changed, 337 insertions, 1 deletions
@@ -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 "" } @@ -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{} @@ -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) + } +} |
