feat(12.2-01): add lagoon.Date and lagoon.TimeOfDay with Fill and required support

- Date (DATE) and TimeOfDay (TIME) with Scanner, Valuer, JSON and text forms
- Fill falls back to encoding.TextUnmarshaler for string sources after
  every existing conversion, so time.Time and the new types fill from JSON
- required treats a zero time.Time, Date or TimeOfDay as empty
- lagoon README, models and casts-and-validation docs
This commit is contained in:
Jakub Zych
2026-10-02 17:40:48 +02:00
parent 19f4cf8232
commit f48a886f94
7 changed files with 532 additions and 5 deletions

View File

@@ -2,6 +2,7 @@ package lagoon
import (
"database/sql"
"encoding"
"encoding/json"
"fmt"
"log/slog"
@@ -184,7 +185,52 @@ func convertValue(src reflect.Value, destType reflect.Type) (reflect.Value, erro
if src.Type().ConvertibleTo(destType) {
return src.Convert(destType), nil
}
return reflect.Value{}, fmt.Errorf("cannot assign %s to %s", src.Type(), destType)
assignErr := fmt.Errorf("cannot assign %s to %s", src.Type(), destType)
if out, handled, err := convertText(src, destType); handled {
if err != nil {
return reflect.Value{}, fmt.Errorf("%w: %w", assignErr, err)
}
return out, nil
}
return reflect.Value{}, assignErr
}
var (
textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
scannerType = reflect.TypeOf((*sql.Scanner)(nil)).Elem()
dateType = reflect.TypeOf(Date{})
timeOfDayType = reflect.TypeOf(TimeOfDay{})
)
// convertText fills a destination type whose pointer implements
// encoding.TextUnmarshaler (time.Time, Date, TimeOfDay) from a string or
// []byte source, so a JSON date string fills a date column. It runs only
// after every other conversion failed, and it skips types that also
// implement sql.Scanner (other than Date and TimeOfDay), which setField
// already fills through Scan, so nothing that filled before changes path.
// handled is false when the fallback does not apply.
func convertText(src reflect.Value, destType reflect.Type) (reflect.Value, bool, error) {
var text []byte
switch v := src.Interface().(type) {
case string:
text = []byte(v)
case []byte:
text = v
default:
return reflect.Value{}, false, nil
}
ptrType := reflect.PointerTo(destType)
if !ptrType.Implements(textUnmarshalerType) {
return reflect.Value{}, false, nil
}
if ptrType.Implements(scannerType) && destType != dateType && destType != timeOfDayType {
return reflect.Value{}, false, nil
}
ptr := reflect.New(destType)
if err := ptr.Interface().(encoding.TextUnmarshaler).UnmarshalText(text); err != nil {
return reflect.Value{}, true, err
}
return ptr.Elem(), true, nil
}
// convertNumber parses a json.Number, which a request decoder using