diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | .golangci.yaml | 1 | ||||
| -rw-r--r-- | Makefile | 1 | ||||
| -rw-r--r-- | REUSE.toml | 4 | ||||
| -rw-r--r-- | db.go | 22 | ||||
| -rw-r--r-- | dialect.go | 30 | ||||
| -rw-r--r-- | info/info.go | 55 | ||||
| -rw-r--r-- | internal/assert/assert.go | 25 | ||||
| -rw-r--r-- | internal/dialect.go | 41 | ||||
| -rw-r--r-- | internal/plan.go (renamed from plan.go) | 66 | ||||
| -rw-r--r-- | internal/plan_test.go | 72 | ||||
| -rw-r--r-- | markers.go | 44 | ||||
| -rw-r--r-- | plan_test.go | 38 |
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: @@ -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 @@ -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" @@ -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 @@ -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) - } -} |
