diff options
Diffstat (limited to 'oblast/plan.go')
| -rw-r--r-- | oblast/plan.go | 488 |
1 files changed, 488 insertions, 0 deletions
diff --git a/oblast/plan.go b/oblast/plan.go new file mode 100644 index 0000000..6b5f002 --- /dev/null +++ b/oblast/plan.go @@ -0,0 +1,488 @@ +// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package oblast + +import ( + "cmp" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "sync" +) + +// planOpts holds additional arguments to buildPlan(). +type planOpts struct { + ReadOnly bool + StructTagKey string // defaults to "db" + TableName string + PrimaryKeyColumnNames []string +} + +func collectPlanOptions(popts []PlanOption) planOpts { + opts := planOpts{ + StructTagKey: "db", + } + for _, popt := range popts { + popt(&opts) + } + return opts +} + +type planCacheKey struct { + Type reflect.Type + Dialect string + ReadOnly bool + StructTagKey string + TableName string + PrimaryKeyColumnNames string +} + +var ( + generatedPlans = make(map[planCacheKey]plan) + generatedPlansMutex sync.RWMutex + generatedTuplePlans = make(map[reflect.Type]plan) + generatedTuplePlansMutex sync.Mutex +) + +// getOrBuildPlan is like [buildPlan], but caches generated plans and tries to reuse cached plans. +func getOrBuildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { + key := planCacheKey{ + Type: t, + Dialect: dialect.String(), + ReadOnly: opts.ReadOnly, + StructTagKey: opts.StructTagKey, + TableName: opts.TableName, + PrimaryKeyColumnNames: strings.Join(opts.PrimaryKeyColumnNames, "\000"), + } + + generatedPlansMutex.RLock() + p, ok := generatedPlans[key] + generatedPlansMutex.RUnlock() + if ok { + return p, nil + } + + p, err := buildPlan(t, dialect, opts) + if err != nil { + return plan{}, err + } + generatedPlansMutex.Lock() + generatedPlans[key] = p + generatedPlansMutex.Unlock() + return p, nil +} + +// getOrBuildTuplePlan is like [getOrBuildPlan], but generates the plans used by TupleSelect() et al +func getOrBuildTuplePlan(t reflect.Type) plan { + generatedTuplePlansMutex.Lock() + defer generatedTuplePlansMutex.Unlock() + + p, ok := generatedTuplePlans[t] + if !ok { + indexes := make([][]int, t.NumField()) + for idx := range indexes { + indexes[idx] = []int{idx} + } + p = plan{ + TypeName: t.Name(), + StaticIndexes: indexes, + } + generatedTuplePlans[t] = p + } + return p +} + +// 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 { + TypeName string // for use in error messages + TableName string // from info.TableNameIs marker (if any) + AllColumnNames []string // in order of struct fields (not set for TupleSelect() plans) + PrimaryKeyColumnNames []string // from info.PrimaryKeyIs marker (if any) + AutoColumnNames []string // subset of AllColumnNames where field has `,auto` marker + + // Field index (i.e. argument for reflect.Value.FieldByIndex()) for each column name. Not set for TupleSelect() plans. + IndexByColumnName map[string][]int + // Select indexes for TupleSelect() plans. Always of the form [[0], [1], [2], ..., [N]]. Not set for regular plans. + StaticIndexes [][]int + // Pointer-typed fields that need to be initialized before scanning into this type. + TransparentPointerStructFields []fieldInfo + + // Whether the INSERT query uses QueryRow or Exec. + // - When no auto-generated values are collected, or when a single value can be collected through LastInsertId(), + // this will be false because Exec() is more memory-efficient than QueryRow(); it does not have to allocate an *sql.Rows instance. + // - Otherwise, i.e. when auto-generated values are collected with a RETURNING clause, + // this will be true because Exec() does not support scanning result values. + InsertUsesQueryRow bool + // If InsertUsesQueryRow = false and a primary key is collected from LastInsertId(), + // this decides whether we write it with reflect.Value.SetInt() or reflect.Value.SetUint(). + LastInsertIdIsUnsigned bool + + // Planned queries. + Select plannedQuery // only `SELECT ... FROM ... WHERE `; user supplies the rest during Select{,One}Where() + Insert plannedQuery + Upsert plannedQuery + Update plannedQuery + Delete plannedQuery + + // Whether Insert/Upsert/Update/Delete query planning was inhibited by the ReadOnly option. + // This information is preserved in order to render more useful error messages. + ReadOnly bool +} + +// fieldInfo appears in type plan. +type fieldInfo struct { + Name string + Index []int + ContainsPrimaryKey bool +} + +// plannedQuery appears in type plan. +type plannedQuery struct { + // Empty if the respective query type is not supported by this plan for lack of the required marker types. + Query string + // Arguments for reflect.Value.FieldByIndex() in the correct order for the query arguments of the above query. + ArgumentIndexes [][]int + // Arguments for reflect.Value.FieldByIndex() in the correct order for the Scan() arguments of the above query. + ScanIndexes [][]int +} + +// buildPlan creates a new plan for the given struct type. +func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { + if t.Kind() != reflect.Struct { + return plan{}, fmt.Errorf("expected struct type, but got kind %q", t.Kind().String()) + } + + var p = plan{ + TypeName: t.Name(), + TableName: opts.TableName, + PrimaryKeyColumnNames: opts.PrimaryKeyColumnNames, + IndexByColumnName: make(map[string][]int), + ReadOnly: opts.ReadOnly, + } + + 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) + } + + // discover addressable fields in this type, collect information from markers and tags + for _, field := range assignableFields(t) { + // 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(opts.StructTagKey) == "" { + indexesOfUnusedTransparentStructs = append(indexesOfUnusedTransparentStructs, field.Index) + if field.Type.Kind() == reflect.Pointer { + // remember that, when scanning into a record of type `t`, we need to write a non-nil zeroed struct into this field + // to enable taking an address of its mapped member fields + p.TransparentPointerStructFields = append(p.TransparentPointerStructFields, fieldInfo{ + Name: field.Name, + Index: field.Index, + ContainsPrimaryKey: false, // might be set later + }) + } + continue + } + 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(opts.StructTagKey)), ",") + 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 `%s:%q` on field index %v, but also on field index %v", + opts.StructTagKey, 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 + } + } + + // track which transparent pointer structs contain PK fields + if slices.Contains(p.PrimaryKeyColumnNames, columnName) { + for idx, tpsField := range p.TransparentPointerStructFields { + if isWithin(field.Index, tpsField.Index) { + p.TransparentPointerStructFields[idx].ContainsPrimaryKey = true + } + } + } + + for _, tag := range extraTags { + switch tag { + case "auto": + p.AutoColumnNames = append(p.AutoColumnNames, columnName) + default: + return plan{}, fmt.Errorf("unknown option `%s:%q` on field %q", opts.StructTagKey, ","+tag, field.Name) + } + } + } + + // 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 whole field to a DB column, add an explicit `%s:\"...\"` tag)", + field.Name, field.Type.String(), opts.StructTagKey, + ) + } + + // 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") + } + + // validation: oblast.PrimaryKeyInfo must refer to columns that exist + for _, columnName := range p.PrimaryKeyColumnNames { + _, ok := p.IndexByColumnName[columnName] + if !ok { + return plan{}, fmt.Errorf("no field has tag `%s:%q`, but a field of this name was declared in the primary key", opts.StructTagKey, columnName) + } + } + + // pick strategy for INSERT + if p.TableName != "" { + switch len(p.AutoColumnNames) { + case 0: + p.InsertUsesQueryRow = false + case 1: + if dialect.CanUseLastInsertId() { + columnName := p.AutoColumnNames[0] + field := t.FieldByIndex(p.IndexByColumnName[columnName]) + switch field.Type.Kind() { //nolint:exhaustive // false positive + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + p.InsertUsesQueryRow = false + p.LastInsertIdIsUnsigned = false + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + p.InsertUsesQueryRow = false + p.LastInsertIdIsUnsigned = true + default: + p.InsertUsesQueryRow = true + } + } else { + p.InsertUsesQueryRow = true + } + default: + p.InsertUsesQueryRow = true + } + } + + // prepare query strings + p.Select = p.buildSelectQueryIfPossible(dialect) + if !opts.ReadOnly { + p.Insert = p.buildInsertQueryIfPossible(dialect, false) + p.Upsert = p.buildInsertQueryIfPossible(dialect, true) + p.Update = p.buildUpdateQueryIfPossible(dialect) + p.Delete = p.buildDeleteQueryIfPossible(dialect) + } + + return p, nil +} + +// Like reflect.VisibleFields(), but considers all fields within the type that +// are assignable (i.e. `v.FieldByIndex(...).Set(...)` does not panic). +func assignableFields(t reflect.Type) (result []reflect.StructField) { + for field := range t.Fields() { + // assignment is allowed for exported or embedded fields only + if field.IsExported() || field.Anonymous { + result = append(result, field) + + // recurse into struct fields + ft := field.Type + if ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + for _, subfield := range assignableFields(ft) { + subfield.Index = append(slices.Clone(field.Index), subfield.Index...) + result = append(result, subfield) + } + } + } + } + + return result +} + +func (p plan) getNonAutoColumnNames() []string { + result := make([]string, 0, len(p.AllColumnNames)-len(p.AutoColumnNames)) + for _, columnName := range p.AllColumnNames { + if !slices.Contains(p.AutoColumnNames, columnName) { + result = append(result, columnName) + } + } + return result +} + +func (p plan) getNonPrimaryKeyColumnNames() []string { + result := make([]string, 0, len(p.AllColumnNames)-len(p.PrimaryKeyColumnNames)) + for _, columnName := range p.AllColumnNames { + if !slices.Contains(p.PrimaryKeyColumnNames, columnName) { + result = append(result, columnName) + } + } + return result +} + +func (p plan) buildSelectQueryIfPossible(dialect Dialect) plannedQuery { + if p.TableName == "" { + return plannedQuery{Query: ""} + } + + var ( + scanIndexes = make([][]int, len(p.AllColumnNames)) + quotedColumnNames = make([]string, len(p.AllColumnNames)) + ) + for idx, columnName := range p.AllColumnNames { + scanIndexes[idx] = p.IndexByColumnName[columnName] + quotedColumnNames[idx] = dialect.QuoteIdentifier(columnName) + } + + query := fmt.Sprintf( + `SELECT %s FROM %s WHERE `, + strings.Join(quotedColumnNames, ", "), + dialect.QuoteIdentifier(p.TableName), + ) + return plannedQuery{query, nil, scanIndexes} +} + +func (p plan) buildInsertQueryIfPossible(dialect Dialect, isUpsert bool) plannedQuery { + if p.TableName == "" || len(p.AllColumnNames) == 0 { + return plannedQuery{Query: ""} + } + nonAutoColumnNames := p.getNonAutoColumnNames() + if len(nonAutoColumnNames) == 0 { + return plannedQuery{Query: ""} + } + + // UPSERT queries specifically are only generated if we have non-auto primary keys: + // - cannot hit a key conflict if there are no keys + // - cannot hit a key conflict on insert if all keys are autogenerated (and thus we never supply them during INSERT) + if isUpsert && !slices.ContainsFunc(p.PrimaryKeyColumnNames, func(n string) bool { return !slices.Contains(p.AutoColumnNames, n) }) { + return plannedQuery{Query: ""} + } + + var ( + argumentIndexes = make([][]int, len(nonAutoColumnNames)) + scanIndexes [][]int + quotedColumnNames = make([]string, len(nonAutoColumnNames)) + quotedPlaceholders = make([]string, len(nonAutoColumnNames)) + ) + for idx, columnName := range nonAutoColumnNames { + argumentIndexes[idx] = p.IndexByColumnName[columnName] + quotedColumnNames[idx] = dialect.QuoteIdentifier(columnName) + quotedPlaceholders[idx] = dialect.Placeholder(idx) + } + if len(p.AutoColumnNames) > 0 { + scanIndexes = make([][]int, len(p.AutoColumnNames)) + for idx, columnName := range p.AutoColumnNames { + scanIndexes[idx] = p.IndexByColumnName[columnName] + } + } + + query := fmt.Sprintf( + `INSERT INTO %s (%s) VALUES (%s)`, + dialect.QuoteIdentifier(p.TableName), + strings.Join(quotedColumnNames, ", "), + strings.Join(quotedPlaceholders, ", "), + ) + if isUpsert { + query += dialect.UpsertClause(p.PrimaryKeyColumnNames, p.getNonPrimaryKeyColumnNames()) + } + if len(p.AutoColumnNames) > 0 && p.InsertUsesQueryRow { + quotedAutoColumns := make([]string, len(p.AutoColumnNames)) + for idx, name := range p.AutoColumnNames { + quotedAutoColumns[idx] = dialect.QuoteIdentifier(name) + } + query += ` RETURNING ` + strings.Join(quotedAutoColumns, ", ") + } + return plannedQuery{query, argumentIndexes, scanIndexes} +} + +func (p plan) buildUpdateQueryIfPossible(dialect Dialect) plannedQuery { + if p.TableName == "" || len(p.PrimaryKeyColumnNames) == 0 { + return plannedQuery{Query: ""} + } + nonPrimaryKeyColumnNames := p.getNonPrimaryKeyColumnNames() + if len(nonPrimaryKeyColumnNames) == 0 { + return plannedQuery{Query: ""} + } + + var ( + setArgumentIndexes = make([][]int, len(nonPrimaryKeyColumnNames)) + setClauses = make([]string, len(nonPrimaryKeyColumnNames)) + ) + for idx, columnName := range nonPrimaryKeyColumnNames { + setArgumentIndexes[idx] = p.IndexByColumnName[columnName] + setClauses[idx] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(columnName), dialect.Placeholder(idx)) + } + + var ( + whereArgumentIndexes = make([][]int, len(p.PrimaryKeyColumnNames)) + whereClauses = make([]string, len(p.PrimaryKeyColumnNames)) + ) + for idx, columnName := range p.PrimaryKeyColumnNames { + whereArgumentIndexes[idx] = p.IndexByColumnName[columnName] + whereClauses[idx] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(columnName), dialect.Placeholder(idx+len(setClauses))) + } + + query := fmt.Sprintf( + `UPDATE %s SET %s WHERE %s`, + dialect.QuoteIdentifier(p.TableName), + strings.Join(setClauses, ", "), + strings.Join(whereClauses, " AND "), + ) + return plannedQuery{query, slices.Concat(setArgumentIndexes, whereArgumentIndexes), nil} +} + +func (p plan) buildDeleteQueryIfPossible(dialect Dialect) plannedQuery { + if p.TableName == "" || len(p.PrimaryKeyColumnNames) == 0 { + return plannedQuery{Query: ""} + } + + var ( + argumentIndexes = make([][]int, len(p.PrimaryKeyColumnNames)) + clauses = make([]string, len(p.PrimaryKeyColumnNames)) + ) + for idx, columnName := range p.PrimaryKeyColumnNames { + argumentIndexes[idx] = p.IndexByColumnName[columnName] + clauses[idx] = fmt.Sprintf("%s = %s", dialect.QuoteIdentifier(columnName), dialect.Placeholder(idx)) + } + + query := fmt.Sprintf( + `DELETE FROM %s WHERE %s`, + dialect.QuoteIdentifier(p.TableName), + strings.Join(clauses, " AND "), + ) + return plannedQuery{query, argumentIndexes, nil} +} |
