diff options
| -rw-r--r-- | oblast.go | 83 | ||||
| -rw-r--r-- | plan.go | 103 | ||||
| -rw-r--r-- | plan_test.go | 6 | ||||
| -rw-r--r-- | select_test.go | 1 |
4 files changed, 137 insertions, 56 deletions
@@ -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 { @@ -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) |
