Compare commits

...
2 Commits
Author SHA1 Message Date
Hein a220338eea feat(spectypes): add CIString, LCString, and UCString types with tests
Tests / Unit Tests (push) Failing after 28s
Tests / Integration Tests (push) Failing after 30s
Build , Vet Test, and Lint / Build (push) Successful in 1m8s
Build , Vet Test, and Lint / Lint Code (push) Successful in 1m18s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 1m35s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 1m36s
2026-09-21 14:06:33 +02:00
Hein 20c67166d0 fix(handler): support implicit updates from request body 2026-09-21 11:18:10 +02:00
3 changed files with 611 additions and 2 deletions
+55 -2
View File
@@ -233,8 +233,18 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
return
}
validId, _ := strconv.ParseInt(id, 10, 64)
if validId > 0 {
h.handleUpdate(ctx, w, id, nil, data, options)
updateID := id
isUpdate := validId > 0
if !isUpdate {
// No valid /:id in the URL - check whether the body itself carries
// a valid primary key value and treat this as an update if so.
if pkID, ok := h.extractPrimaryKeyFromBody(model, data); ok && pkID != "0" {
updateID = pkID
isUpdate = true
}
}
if isUpdate {
h.handleUpdate(ctx, w, updateID, nil, data, options)
} else {
h.handleCreate(ctx, w, data, options)
}
@@ -271,6 +281,49 @@ func (h *Handler) Handle(w common.ResponseWriter, r common.Request, params map[s
}
}
// extractPrimaryKeyFromBody looks for a valid primary key value inside a
// decoded (single-record) POST body, keyed by the model's primary key column
// or its JSON equivalent. It returns the string form of that value and true
// if one was found and is non-empty/non-zero; otherwise ("", false).
func (h *Handler) extractPrimaryKeyFromBody(model interface{}, data interface{}) (string, bool) {
dataMap, ok := data.(map[string]interface{})
if !ok {
// Batch payloads (slices) aren't eligible for this implicit-update detection.
return "", false
}
pkCol := reflection.GetPrimaryKeyName(model)
if pkCol == "" {
return "", false
}
val, exists := dataMap[pkCol]
if !exists {
modelType := reflection.GetPointerElement(reflect.TypeOf(model))
for jsonKey, col := range reflection.BuildJSONToDBColumnMap(modelType) {
if col == pkCol {
val, exists = dataMap[jsonKey]
break
}
}
}
if !exists || val == nil || reflection.IsEmptyValue(val) {
return "", false
}
switch v := val.(type) {
case float64:
if v <= 0 {
return "", false
}
return strconv.FormatInt(int64(v), 10), true
case string:
return v, true
default:
return fmt.Sprintf("%v", v), true
}
}
// HandleGet processes GET requests for metadata
func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params map[string]string) {
// Capture panics and return error response
+177
View File
@@ -0,0 +1,177 @@
package spectypes
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
)
// CIString is a string that stores, scans, and returns its value exactly as
// given (no case normalization), but compares case-insensitively via Equal
// and EqualString. Use it as a bun model field type for columns (e.g.
// citext, or codes matched case-insensitively) where you want Go-side
// case-insensitive comparisons without forcing the stored/returned value to
// a particular case.
type CIString string
// Value implements driver.Valuer. The value is passed through unchanged.
func (s CIString) Value() (driver.Value, error) {
return string(s), nil
}
// Scan implements sql.Scanner. The value is stored unchanged.
func (s *CIString) Scan(value any) error {
switch v := value.(type) {
case string:
*s = CIString(v)
case []byte:
*s = CIString(v)
case nil:
*s = ""
default:
return fmt.Errorf("cannot scan %T into CIString", value)
}
return nil
}
// String implements fmt.Stringer.
func (s CIString) String() string { return string(s) }
// Equal reports whether s and other are equal, ignoring case.
func (s CIString) Equal(other CIString) bool {
return strings.EqualFold(string(s), string(other))
}
// EqualString reports whether s equals other, ignoring case.
func (s CIString) EqualString(other string) bool {
return strings.EqualFold(string(s), other)
}
// Compare returns -1, 0, or +1 if s is less than, equal to, or greater than
// other, ignoring case. Useful with slices.SortFunc or similar.
func (s CIString) Compare(other CIString) int {
return strings.Compare(strings.ToLower(string(s)), strings.ToLower(string(other)))
}
// Less reports whether s sorts before other, ignoring case. Suitable for
// sort.Slice or slices.SortFunc comparisons.
func (s CIString) Less(other CIString) bool {
return s.Compare(other) < 0
}
// LCString is a string that always stores, scans, and returns as lowercase.
// Use it as a bun model field type for columns that must be normalized to
// lowercase (e.g. codes, slugs, emails) rather than merely compared
// case-insensitively; see CIString if the original case must be preserved.
type LCString string
// Value implements driver.Valuer, always lowercase.
func (s LCString) Value() (driver.Value, error) {
return strings.ToLower(string(s)), nil
}
// Scan implements sql.Scanner, always lowercase.
func (s *LCString) Scan(value any) error {
switch v := value.(type) {
case string:
*s = LCString(strings.ToLower(v))
case []byte:
*s = LCString(strings.ToLower(string(v)))
case nil:
*s = ""
default:
return fmt.Errorf("cannot scan %T into LCString", value)
}
return nil
}
// String implements fmt.Stringer, always lowercase.
func (s LCString) String() string { return strings.ToLower(string(s)) }
// Equal reports whether s and other are equal (case-insensitively, since
// both normalize to lowercase).
func (s LCString) Equal(other LCString) bool {
return s.String() == other.String()
}
// EqualString reports whether s equals other, ignoring case.
func (s LCString) EqualString(other string) bool {
return s.String() == strings.ToLower(other)
}
// MarshalJSON implements json.Marshaler, always lowercase. Needed because
// encoding/json marshals a bare string-kind type as-is and does not call
// Value/String, so a value constructed directly (not scanned from the DB)
// would otherwise serialize with its original case.
func (s LCString) MarshalJSON() ([]byte, error) {
return json.Marshal(strings.ToLower(string(s)))
}
// UnmarshalJSON implements json.Unmarshaler, always lowercase.
func (s *LCString) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = LCString(strings.ToLower(str))
return nil
}
// UCString is a string that always stores, scans, and returns as uppercase.
// Use it as a bun model field type for columns that must be normalized to
// uppercase (e.g. table prefix codes) rather than merely compared
// case-insensitively; see CIString if the original case must be preserved.
type UCString string
// Value implements driver.Valuer, always uppercase.
func (s UCString) Value() (driver.Value, error) {
return strings.ToUpper(string(s)), nil
}
// Scan implements sql.Scanner, always uppercase.
func (s *UCString) Scan(value any) error {
switch v := value.(type) {
case string:
*s = UCString(strings.ToUpper(v))
case []byte:
*s = UCString(strings.ToUpper(string(v)))
case nil:
*s = ""
default:
return fmt.Errorf("cannot scan %T into UCString", value)
}
return nil
}
// String implements fmt.Stringer, always uppercase.
func (s UCString) String() string { return strings.ToUpper(string(s)) }
// Equal reports whether s and other are equal (case-insensitively, since
// both normalize to uppercase).
func (s UCString) Equal(other UCString) bool {
return s.String() == other.String()
}
// EqualString reports whether s equals other, ignoring case.
func (s UCString) EqualString(other string) bool {
return s.String() == strings.ToUpper(other)
}
// MarshalJSON implements json.Marshaler, always uppercase. Needed because
// encoding/json marshals a bare string-kind type as-is and does not call
// Value/String, so a value constructed directly (not scanned from the DB)
// would otherwise serialize with its original case.
func (s UCString) MarshalJSON() ([]byte, error) {
return json.Marshal(strings.ToUpper(string(s)))
}
// UnmarshalJSON implements json.Unmarshaler, always uppercase.
func (s *UCString) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
*s = UCString(strings.ToUpper(str))
return nil
}
+379
View File
@@ -0,0 +1,379 @@
package spectypes
import (
"encoding/json"
"sort"
"testing"
)
func TestCIString_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected CIString
}{
{name: "plain string", input: "MixedCase", expected: "MixedCase"},
{name: "bytes as string", input: []byte("FromBytes"), expected: "FromBytes"},
{name: "nil value", input: nil, expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s CIString
if err := s.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if s != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, s)
}
})
}
}
func TestCIString_Scan_InvalidType(t *testing.T) {
var s CIString
if err := s.Scan(123); err == nil {
t.Fatal("expected error scanning int into CIString, got nil")
}
}
func TestCIString_Value(t *testing.T) {
s := CIString("MixedCase")
v, err := s.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if v != "MixedCase" {
t.Errorf("expected %q, got %q (case must be preserved)", "MixedCase", v)
}
}
func TestCIString_String(t *testing.T) {
s := CIString("MixedCase")
if s.String() != "MixedCase" {
t.Errorf("expected %q, got %q", "MixedCase", s.String())
}
}
func TestCIString_Equal(t *testing.T) {
tests := []struct {
name string
a, b CIString
expected bool
}{
{name: "same case", a: "ABC", b: "ABC", expected: true},
{name: "different case", a: "ABC", b: "abc", expected: true},
{name: "mixed case", a: "AbC", b: "aBc", expected: true},
{name: "not equal", a: "ABC", b: "XYZ", expected: false},
{name: "both empty", a: "", b: "", expected: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.a.Equal(tt.b); got != tt.expected {
t.Errorf("Equal(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.expected)
}
})
}
}
func TestCIString_EqualString(t *testing.T) {
s := CIString("ABC")
if !s.EqualString("abc") {
t.Error("expected EqualString to match case-insensitively")
}
if s.EqualString("xyz") {
t.Error("expected EqualString to not match different strings")
}
}
func TestCIString_Compare(t *testing.T) {
tests := []struct {
name string
a, b CIString
expected int
}{
{name: "equal same case", a: "abc", b: "abc", expected: 0},
{name: "equal different case", a: "ABC", b: "abc", expected: 0},
{name: "less", a: "abc", b: "xyz", expected: -1},
{name: "less different case", a: "ABC", b: "xyz", expected: -1},
{name: "greater", a: "xyz", b: "abc", expected: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.a.Compare(tt.b); got != tt.expected {
t.Errorf("Compare(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.expected)
}
})
}
}
func TestCIString_Less(t *testing.T) {
if !CIString("abc").Less("xyz") {
t.Error("expected abc < xyz")
}
if CIString("xyz").Less("abc") {
t.Error("expected xyz not < abc")
}
if CIString("ABC").Less("abc") {
t.Error("expected ABC not < abc (equal ignoring case)")
}
}
func TestCIString_Sort(t *testing.T) {
vals := []CIString{"banana", "Apple", "cherry", "apple"}
sort.Slice(vals, func(i, j int) bool { return vals[i].Less(vals[j]) })
// After a case-insensitive sort, "Apple"/"apple" must be adjacent and first,
// followed by banana then cherry.
if !vals[0].EqualString("apple") || !vals[1].EqualString("apple") {
t.Errorf("expected the two apple variants first, got %v", vals)
}
if !vals[2].EqualString("banana") {
t.Errorf("expected banana third, got %v", vals)
}
if !vals[3].EqualString("cherry") {
t.Errorf("expected cherry fourth, got %v", vals)
}
}
func TestLCString_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected LCString
}{
{name: "mixed case string", input: "MixedCase", expected: "mixedcase"},
{name: "bytes mixed case", input: []byte("FromBytes"), expected: "frombytes"},
{name: "nil value", input: nil, expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s LCString
if err := s.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if s != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, s)
}
})
}
}
func TestLCString_Scan_InvalidType(t *testing.T) {
var s LCString
if err := s.Scan(123); err == nil {
t.Fatal("expected error scanning int into LCString, got nil")
}
}
func TestLCString_Value(t *testing.T) {
s := LCString("MixedCase")
v, err := s.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if v != "mixedcase" {
t.Errorf("expected %q, got %q", "mixedcase", v)
}
}
func TestLCString_String(t *testing.T) {
s := LCString("MixedCase")
if s.String() != "mixedcase" {
t.Errorf("expected %q, got %q", "mixedcase", s.String())
}
}
func TestLCString_Equal(t *testing.T) {
if !LCString("ABC").Equal(LCString("abc")) {
t.Error("expected ABC and abc to be equal")
}
if LCString("ABC").Equal(LCString("xyz")) {
t.Error("expected ABC and xyz to not be equal")
}
}
func TestLCString_EqualString(t *testing.T) {
if !LCString("ABC").EqualString("abc") {
t.Error("expected EqualString to match case-insensitively")
}
}
func TestUCString_Scan(t *testing.T) {
tests := []struct {
name string
input interface{}
expected UCString
}{
{name: "mixed case string", input: "MixedCase", expected: "MIXEDCASE"},
{name: "bytes mixed case", input: []byte("FromBytes"), expected: "FROMBYTES"},
{name: "nil value", input: nil, expected: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s UCString
if err := s.Scan(tt.input); err != nil {
t.Fatalf("Scan failed: %v", err)
}
if s != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, s)
}
})
}
}
func TestUCString_Scan_InvalidType(t *testing.T) {
var s UCString
if err := s.Scan(123); err == nil {
t.Fatal("expected error scanning int into UCString, got nil")
}
}
func TestUCString_Value(t *testing.T) {
s := UCString("MixedCase")
v, err := s.Value()
if err != nil {
t.Fatalf("Value failed: %v", err)
}
if v != "MIXEDCASE" {
t.Errorf("expected %q, got %q", "MIXEDCASE", v)
}
}
func TestUCString_String(t *testing.T) {
s := UCString("MixedCase")
if s.String() != "MIXEDCASE" {
t.Errorf("expected %q, got %q", "MIXEDCASE", s.String())
}
}
func TestUCString_Equal(t *testing.T) {
if !UCString("ABC").Equal(UCString("abc")) {
t.Error("expected ABC and abc to be equal")
}
if UCString("ABC").Equal(UCString("xyz")) {
t.Error("expected ABC and xyz to not be equal")
}
}
func TestUCString_EqualString(t *testing.T) {
if !UCString("ABC").EqualString("abc") {
t.Error("expected EqualString to match case-insensitively")
}
}
// TestLCString_MarshalJSON_NotFromDB verifies a value constructed directly
// in Go (never passed through Scan) still normalizes on JSON marshal.
func TestLCString_MarshalJSON_NotFromDB(t *testing.T) {
s := LCString("MixedCase")
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `"mixedcase"` {
t.Errorf("expected %s, got %s", `"mixedcase"`, b)
}
}
func TestLCString_UnmarshalJSON(t *testing.T) {
var s LCString
if err := json.Unmarshal([]byte(`"MixedCase"`), &s); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if s != "mixedcase" {
t.Errorf("expected %q, got %q", "mixedcase", s)
}
}
func TestLCString_JSON_StructField(t *testing.T) {
type wrapper struct {
Code LCString `json:"code"`
}
in := wrapper{Code: "MixedCase"}
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `{"code":"mixedcase"}` {
t.Errorf("expected %s, got %s", `{"code":"mixedcase"}`, b)
}
var out wrapper
if err := json.Unmarshal([]byte(`{"code":"AnotherMixedCase"}`), &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out.Code != "anothermixedcase" {
t.Errorf("expected %q, got %q", "anothermixedcase", out.Code)
}
}
// TestUCString_MarshalJSON_NotFromDB verifies a value constructed directly
// in Go (never passed through Scan) still normalizes on JSON marshal.
func TestUCString_MarshalJSON_NotFromDB(t *testing.T) {
s := UCString("MixedCase")
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `"MIXEDCASE"` {
t.Errorf("expected %s, got %s", `"MIXEDCASE"`, b)
}
}
func TestUCString_UnmarshalJSON(t *testing.T) {
var s UCString
if err := json.Unmarshal([]byte(`"MixedCase"`), &s); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if s != "MIXEDCASE" {
t.Errorf("expected %q, got %q", "MIXEDCASE", s)
}
}
func TestUCString_JSON_StructField(t *testing.T) {
type wrapper struct {
Code UCString `json:"code"`
}
in := wrapper{Code: "MixedCase"}
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `{"code":"MIXEDCASE"}` {
t.Errorf("expected %s, got %s", `{"code":"MIXEDCASE"}`, b)
}
var out wrapper
if err := json.Unmarshal([]byte(`{"code":"AnotherMixedCase"}`), &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out.Code != "ANOTHERMIXEDCASE" {
t.Errorf("expected %q, got %q", "ANOTHERMIXEDCASE", out.Code)
}
}
// TestCIString_JSON_PreservesCase confirms CIString needs no custom JSON
// methods: it should never normalize case, only its DB Value/Scan and the
// Equal/EqualString comparisons apply case-insensitivity.
func TestCIString_JSON_PreservesCase(t *testing.T) {
s := CIString("MixedCase")
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
if string(b) != `"MixedCase"` {
t.Errorf("expected %s, got %s", `"MixedCase"`, b)
}
var out CIString
if err := json.Unmarshal([]byte(`"AnotherMixedCase"`), &out); err != nil {
t.Fatalf("Unmarshal failed: %v", err)
}
if out != "AnotherMixedCase" {
t.Errorf("expected case to be preserved, got %q", out)
}
}