aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--oblast.go83
-rw-r--r--plan.go103
-rw-r--r--plan_test.go6
-rw-r--r--select_test.go1
4 files changed, 137 insertions, 56 deletions
diff --git a/oblast.go b/oblast.go
index 7b40146..52c0cfd 100644
--- a/oblast.go
+++ b/oblast.go
@@ -6,6 +6,9 @@
//
// # Usage pattern
//
+// Oblast can load or store any struct type by matching individual fields to column names (on load) or query arguments (on store).
+// Struct types that are suitable for this kind of mapping are called "record types" throughout this package documentation.
+//
// To use this library, first declare a record type, and create a [Store] for it once to analyze the type and prepare the respective OLTP queries:
//
// type LogEntry struct {
@@ -38,14 +41,73 @@
// }
// fmt.Printf("there are %d log entries so far", len(allEntries))
// }
+//
+// # Mapping rules for record types
+//
+// If the database column has a different name (or casing, e.g. "id" vs. "ID") than the field name, provide it in the field tag "db".
+// The field tag may also contain additional options, separated from the column name by commas.
+// To have Oblast ignore a field, either make it private or declare its column name as "-".
+// For example:
+//
+// type Example struct {
+// FirstValue string `db:"first_value"` // maps to DB column "first_value"
+// SecondValue string // maps to DB column "SecondValue"
+// ThirdValue string `db:"third_value,auto"` // maps to DB column "third_value" with "auto" option
+// FourthValue string `db:",auto"` // maps to DB column "FourthValue" with "auto" option
+// Cache map[string]any `db:"-"` // ignored by Oblast because of column name "-"
+// action func() // ignored by Oblast because field is private
+// }
+//
+// The following field options are understood:
+// - "auto": During [Store.Insert], do not store this field's value. Instead, the database will auto-generate a value, which will be read back into the record. In SQL dialects that use [sql.Result.LastInsertId] for this (as opposed to a RETURNING clause), only at most one field per record type may have this option, and it must be of an integer type.
+//
+// It is possible to place mapped fields within sub-structs, including within embedded types.
+// This is useful e.g. to avoid code duplication for database columns that are repeated across multiple types:
+//
+// type Timestamps struct {
+// CreatedAt time.Time `db:"created_at"`
+// UpdatedAt *time.Time `db:"updated_at"`
+// DeletedAt *time.Time `db:"deleted_at"`
+// }
+//
+// type FooRecord struct {
+// ID int64 `db:"id,auto"`
+// Name string `db:"name"`
+// Timestamps Timestamps
+// }
+// // ... and other struct types that use type Timestamps ...
+//
+// This behavior may be undesirable on custom struct types that implement [sql.Scanner] and/or [driver.Valuer], or are understood by a [driver.NamedValueChecker] set up by your SQL driver.
+// To keep Oblast from recursing into struct types and mapping their fields, provide an explicit `db:"..."` tag on them:
+//
+// type GeoPoint struct {
+// Longitude, Latitude int
+// }
+// func (p *GeoPoint) Scan(src any) error {...}
+// func (p GeoPoint) Value() (driver.Value, error) {...}
+//
+// type Event struct {
+// ID int64 `db:",auto"`
+// Description string
+// Time time.Time
+// // explicit tag ensures that Location.Longitude and Location.Latitude are not mapped individually
+// Location GeoPoint `db:"Location"`
+// }
package oblast // import "go.xyrillian.de/oblast"
import (
"database/sql"
+ "database/sql/driver"
"fmt"
"reflect"
)
+var (
+ // the following types appear in docstring links
+ _ sql.Scanner = nil
+ _ driver.NamedValueChecker = nil
+)
+
// PlanOption is an option that can be given to NewStore() to influence query planning for a certain type of record.
type PlanOption func(*planOpts)
@@ -87,27 +149,6 @@ type Store[R any] struct {
// NewStore initializes a store for record type R.
// Returns an error if R is not a struct type.
-//
-// For the purpose of loading and storing records (i.e. instances of type R) into the database,
-// this function establishes a mapping between fields of type R and database columns by inspecting the "db" tag.
-// For example:
-//
-// type MyRecord struct {
-// ID int64 `db:"record_id,auto"`
-// Foo string `db:"foo"`
-// Bar string
-// Cache map[string]any `db:"-"`
-// action func()
-// }
-//
-// In this type:
-// - The fields "ID" and "Foo" correspond to the database columns "record_id" and "foo" because of the declaration in the "db" tag.
-// - The field "Bar" corresponds to the database column "Bar" because, when no "db" tag is given, the column name is set equal to the field name.
-// - The field "Cache" is not mapped to any database column because it is declared with a "db" tag of "-". Loads and stores will ignore it.
-// - The field "action" is private, so loads and stores will ignore it, too.
-//
-// Besides the declaration of a column name, the following extra tags are understood (as a comma-separated list following the column name):
-// - "auto": During [Store.Insert], do not store this field's value. Instead, the database will auto-generate a value, which will be read back into the record.
func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) {
var popts planOpts
for _, opt := range opts {
diff --git a/plan.go b/plan.go
index da9f9b5..d00b5c2 100644
--- a/plan.go
+++ b/plan.go
@@ -4,6 +4,7 @@
package oblast
import (
+ "cmp"
"errors"
"fmt"
"reflect"
@@ -63,46 +64,86 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) {
IndexByColumnName: make(map[string][]int),
}
- // discover addressable fields in this type,
- // collect information from markers and tags
- for _, field := range reflect.VisibleFields(t) {
- tags := strings.Split(strings.TrimSpace(field.Tag.Get("db")), ",")
+ var (
+ indexesOfOpaqueStructs [][]int
+ indexesOfUnusedTransparentStructs [][]int
+ )
+ isWithin := func(fieldIndex, structIndex []int) bool {
+ // returns whether `structIndex` is a prefix of `fieldIndex` (i.e. whether the field is contained within the struct)
+ return len(fieldIndex) > len(structIndex) && slices.Equal(fieldIndex[0:len(structIndex)], structIndex)
+ }
- switch {
- case field.PkgPath != "":
- // ignore unexported fields (otherwise reflect.Value.Interface() on the field would panic)
- continue
- case field.Anonymous && field.Type.Kind() == reflect.Struct:
- // for embedded struct fields, only consider their members, not the type itself, as a potential column
+ // discover addressable fields in this type, collect information from markers and tags
+ for _, field := range reflect.VisibleFields(t) {
+ // ignore unexported fields (otherwise reflect.Value.Interface() on the field would panic)
+ if field.PkgPath != "" {
continue
- default:
- columnName, extraTags := tags[0], tags[1:]
- if columnName == "-" {
+ }
+
+ // recurse into struct fields (i.e. ignore the struct itself and consider its members instead)
+ // unless the field itself has a `db:"..."` tag
+ if field.Type.Kind() == reflect.Struct || (field.Type.Kind() == reflect.Pointer && field.Type.Elem().Kind() == reflect.Struct) {
+ if field.Tag.Get("db") == "" {
+ indexesOfUnusedTransparentStructs = append(indexesOfUnusedTransparentStructs, field.Index)
continue
}
- if columnName == "" {
- columnName = field.Name
- }
- 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, field.Index,
- )
+ indexesOfOpaqueStructs = append(indexesOfOpaqueStructs, field.Index)
+ }
+
+ // ignore fields that are within a struct type that is mapped as a whole
+ if slices.ContainsFunc(indexesOfOpaqueStructs, func(index []int) bool {
+ return isWithin(field.Index, index)
+ }) {
+ continue
+ }
+
+ // check `db:"..."` tag, ignore fields that are declared with column name "-"
+ tags := strings.Split(strings.TrimSpace(field.Tag.Get("db")), ",")
+ columnName, extraTags := cmp.Or(tags[0], field.Name), tags[1:]
+ if columnName == "-" {
+ continue
+ }
+
+ 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, field.Index,
+ )
+ }
+ p.IndexByColumnName[columnName] = field.Index
+ p.AllColumnNames = append(p.AllColumnNames, columnName)
+
+ // track whether transparent structs contain fields that are mapped
+ restartIteration:
+ for idx, index := range indexesOfUnusedTransparentStructs {
+ if isWithin(field.Index, index) {
+ indexesOfUnusedTransparentStructs = slices.Delete(indexesOfUnusedTransparentStructs, idx, idx+1)
+ goto restartIteration
}
- p.IndexByColumnName[columnName] = field.Index
- p.AllColumnNames = append(p.AllColumnNames, columnName)
-
- for _, tag := range extraTags {
- switch tag {
- case "auto":
- p.AutoColumnNames = append(p.AutoColumnNames, columnName)
- default:
- return plan{}, fmt.Errorf("unknown tag `db:%q` on field index %v", ","+tag, field.Index)
- }
+ }
+
+ for _, tag := range extraTags {
+ switch tag {
+ case "auto":
+ p.AutoColumnNames = append(p.AutoColumnNames, columnName)
+ default:
+ return plan{}, fmt.Errorf("unknown tag `db:%q` on field index %v", ","+tag, field.Index)
}
}
}
+ // validation: transparent structs need to have at least one of their members mapped
+ // (this property is most often violated when a user of a library-defined type is not aware that this type is a struct under the hood,
+ // e.g. a field like "CreatedAt time.Time" needs to have a tag like `db:"created_at"`,
+ // otherwise nothing will be mapped because time.Time does not have any exported fields)
+ for _, index := range indexesOfUnusedTransparentStructs {
+ field := t.FieldByIndex(index)
+ return plan{}, fmt.Errorf(
+ "field %q of type %s does not contain any mapped fields (to map this entire field to a DB column, add an explicit `db:\"...\"` tag)",
+ field.Name, field.Type.String(),
+ )
+ }
+
// 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")
diff --git a/plan_test.go b/plan_test.go
index 3568447..e9d8dea 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -58,13 +58,11 @@ func TestPlanFieldTraversal(t *testing.T) {
})
}
-// TODO: test that, during Select(), assignment into embedded fields with pointer-to-struct type works (docs say that this might panic if we do not allocate into the pointer first)
-
func TestQueryConstructionBasic(t *testing.T) {
type record struct {
ID int64 `db:",auto"`
Description string
- CreatedAt time.Time
+ CreatedAt time.Time `db:"CreatedAt"`
}
opts := planOpts{
TableName: "basic_records",
@@ -199,7 +197,7 @@ func TestQueryConstructionWithoutPrimaryKey(t *testing.T) {
func TestQueryConstructionImpossble(t *testing.T) {
type unstructuredData struct {
Foo int
- Bar string
+ Bar *string
}
opts := planOpts{}
diff --git a/select_test.go b/select_test.go
index d678aa2..c3285be 100644
--- a/select_test.go
+++ b/select_test.go
@@ -220,3 +220,4 @@ func TestSelectWithScanError(t *testing.T) {
// TODO: test error capture during Rows.Close()
// TODO: check for maximum test coverage in select.go
+// TODO: test that, during Select(), assignment into embedded fields with pointer-to-struct type works (docs say that this might panic if we do not allocate into the pointer first)