Files
summercms/modules/lagoon/fill.go
Jakub Zych c3efbc3428 fix(10.1-03): fill numeric model fields from JSON numbers
- lagoon.Fill converts a json.Number (from a UseNumber decoder, as cabana's
  save path uses) into integer, unsigned and float fields; a fraction or an
  overflow is an error
- before this, saving a type: number field into an *int column was a 500
- README documents the conversion
2026-09-29 02:24:25 +02:00

219 lines
5.9 KiB
Go

package lagoon
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"reflect"
"strconv"
"strings"
"sync"
)
// HasFillable is the Go form of Eloquent $fillable: the model's backstop
// allow-list for mass assignment (D-05).
type HasFillable interface {
Fillable() []string
}
// HasHidden is the Go form of Eloquent $hidden: column names that must not
// appear in an accidental JSON marshal (D-08).
type HasHidden interface {
Hidden() []string
}
var droppedKeys sync.Map
// Fill copies requested keys onto model only when they are also in allowed.
// Unknown and non-fillable keys are dropped with no error (D-06). In
// non-production, each type+key pair is logged once.
func Fill(model any, allowed []string, requested map[string]any, production bool) error {
if model == nil {
return fmt.Errorf("lagoon: fill model is nil")
}
rv := reflect.ValueOf(model)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("lagoon: fill model must be a non-nil pointer")
}
rv = rv.Elem()
if rv.Kind() != reflect.Struct {
return fmt.Errorf("lagoon: fill model must point to a struct")
}
rt := rv.Type()
typeName := rt.String()
for key, val := range requested {
if !allowListed(key, allowed) {
logDroppedKeyOnce(production, typeName, key)
continue
}
field, ok := fieldByColumn(rv, rt, key)
if !ok {
logDroppedKeyOnce(production, typeName, key)
continue
}
if err := setField(field, val); err != nil {
return fmt.Errorf("lagoon: fill %s: %w", key, err)
}
}
return nil
}
func logDroppedKeyOnce(production bool, typeName, key string) {
if production {
return
}
k := typeName + "." + key
if _, loaded := droppedKeys.LoadOrStore(k, struct{}{}); loaded {
return
}
slog.Warn("lagoon: dropped non-fillable key", "type", typeName, "key", key)
}
func fieldByColumn(rv reflect.Value, rt reflect.Type, column string) (reflect.Value, bool) {
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if !f.IsExported() {
continue
}
if gormColumn(f.Tag.Get("gorm")) == column {
return rv.Field(i), true
}
}
return reflect.Value{}, false
}
func gormColumn(tag string) string {
for _, part := range strings.Split(tag, ";") {
part = strings.TrimSpace(part)
if after, ok := strings.CutPrefix(part, "column:"); ok {
return after
}
}
return ""
}
func setField(field reflect.Value, val any) error {
if !field.CanSet() {
return fmt.Errorf("field cannot be set")
}
if val == nil {
field.Set(reflect.Zero(field.Type()))
if field.CanAddr() {
if scanner, ok := field.Addr().Interface().(sql.Scanner); ok {
return scanner.Scan(nil)
}
}
return nil
}
src := reflect.ValueOf(val)
if field.Kind() == reflect.Ptr {
elemType := field.Type().Elem()
converted, err := convertValue(src, elemType)
if err != nil {
return err
}
ptr := reflect.New(elemType)
ptr.Elem().Set(converted)
field.Set(ptr)
return nil
}
converted, err := convertValue(src, field.Type())
if err == nil {
field.Set(converted)
return nil
}
if field.Type() == encryptedType {
return err
}
if field.CanAddr() {
if scanner, ok := field.Addr().Interface().(sql.Scanner); ok {
scanSrc, scanErr := fillScanSource(val)
if scanErr != nil {
return scanErr
}
return scanner.Scan(scanSrc)
}
}
return err
}
func fillScanSource(val any) (any, error) {
switch v := val.(type) {
case string, []byte:
return v, nil
default:
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
return b, nil
}
}
var encryptedType = reflect.TypeOf(Encrypted{})
func convertValue(src reflect.Value, destType reflect.Type) (reflect.Value, error) {
if destType == encryptedType {
return encryptedFromRequest(src)
}
if src.Type().AssignableTo(destType) {
return src, nil
}
if n, ok := src.Interface().(json.Number); ok {
if out, handled, err := convertNumber(n, destType); handled {
return out, err
}
}
if src.Type().ConvertibleTo(destType) {
return src.Convert(destType), nil
}
return reflect.Value{}, fmt.Errorf("cannot assign %s to %s", src.Type(), destType)
}
// convertNumber parses a json.Number, which a request decoder using
// UseNumber produces, into an integer, unsigned or float field. A value that
// does not parse as that kind or overflows it is an error. handled is false
// for any other destination kind, so a string field still takes the number's
// text through the ordinary conversion.
func convertNumber(n json.Number, destType reflect.Type) (reflect.Value, bool, error) {
out := reflect.New(destType).Elem()
switch destType.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
i, err := strconv.ParseInt(n.String(), 10, 64)
if err != nil || out.OverflowInt(i) {
return reflect.Value{}, true, fmt.Errorf("cannot assign number %s to %s", n, destType)
}
out.SetInt(i)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
u, err := strconv.ParseUint(n.String(), 10, 64)
if err != nil || out.OverflowUint(u) {
return reflect.Value{}, true, fmt.Errorf("cannot assign number %s to %s", n, destType)
}
out.SetUint(u)
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(n.String(), 64)
if err != nil || out.OverflowFloat(f) {
return reflect.Value{}, true, fmt.Errorf("cannot assign number %s to %s", n, destType)
}
out.SetFloat(f)
default:
return reflect.Value{}, false, nil
}
return out, true, nil
}
// encryptedFromRequest treats request input for an Encrypted column as
// plaintext. It never falls through to Encrypted.Scan: Scan decrypts, so a
// write path that scanned request input would reject real secrets and accept
// another row's ciphertext, copying that row's secret.
func encryptedFromRequest(src reflect.Value) (reflect.Value, error) {
if src.Type() == encryptedType {
return src, nil
}
if src.Kind() != reflect.String {
return reflect.Value{}, fmt.Errorf("encrypted value must be a string")
}
return reflect.ValueOf(NewEncrypted(src.String())), nil
}