golib/database/csvx/boolish.go
AJ ONeal 914b83a8d6
feat(csvx): BoolishTimestamp retains the original word via Raw()
Unrecognized non-empty values keep the trimmed original in Raw() so
callers can apply column-specific word policy post-parse (e.g. reading
"disabled" as true in a Disabled column) instead of string-matching the
error message.
2026-10-24 00:54:50 -06:00

86 lines
2.7 KiB
Go

package csvx
import (
"fmt"
"strings"
"time"
)
// BoolishTimestamp accepts optional true/false-like values and dates or
// timestamps from human-edited CSV/TSV data. Valid reports that a value was
// supplied; Bool reports its true-like meaning. Error records an unrecognized
// non-empty value without making the CSV decoder reject the row. Raw reports
// the trimmed original value for unrecognized values so callers can apply
// their own column-specific word policy (e.g. reading "disabled" as true in
// a Disabled column).
type BoolishTimestamp struct {
Valid bool
Bool bool
Timestamp time.Time
Error error
raw string
}
// Raw returns the trimmed original value that was parsed, retained for
// unrecognized values so callers can apply their own word policy.
func (v BoolishTimestamp) Raw() string { return v.raw }
// UnmarshalCSV parses a human value. Recognized true-like values and dates
// set Bool; recognized false-like values leave it unset. Unrecognized
// non-empty values are conservatively false with the error retained so the
// collection mapper can apply its own policy.
func (v *BoolishTimestamp) UnmarshalCSV(data []byte) error {
*v = BoolishTimestamp{}
raw := strings.TrimSpace(string(data))
// Null sentinels (isBoolNull): absent means Valid=false. `\N` is the
// exact PG dump sentinel and case-sensitive; the word forms are not.
if isBoolNull(raw) {
return nil
}
v.Valid = true
switch strings.ToLower(raw) {
case "false", "f", "n", "no", "0":
return nil
case "true", "t", "y", "yes", "1":
v.Bool = true
return nil
}
for _, layout := range []string{time.RFC3339, "2006-01-02", "2006-01-02 15:04:05Z07:00", "2006-01-02 15:04:05"} {
parsed, err := time.Parse(layout, raw)
if err == nil {
v.Bool = true
v.Timestamp = parsed
return nil
}
}
// Unrecognized: valid but false, error recorded (Bool must not default
// to true for values this type does not understand). The original value
// is retained for caller-side word policy.
v.Bool = false
v.raw = raw
v.Error = fmt.Errorf("unrecognized boolish timestamp %q", raw)
return nil
}
// MarshalCSV writes a normalized usable record. An unrecognized value is
// conservatively serialized as the current UTC timestamp.
func (v BoolishTimestamp) MarshalCSV() ([]byte, error) {
if !v.Valid {
return nil, nil
}
if v.Error != nil {
return []byte(time.Now().UTC().Format("2006-01-02 15:04:05")), nil
}
if !v.Bool {
return []byte("FALSE"), nil
}
if !v.Timestamp.IsZero() {
return []byte(v.Timestamp.UTC().Format("2006-01-02 15:04:05")), nil
}
return []byte("TRUE"), nil
}
// IsZero lets encoders treat the zero value as an omitted source value.
func (v BoolishTimestamp) IsZero() bool { return !v.Valid }