fix(10.1): CR-01 answer a value that does not fit its column with a 422
lagoon.Fill now returns a *lagoon.FillTypeError naming the key when a requested value cannot be stored in its column (a fraction, exponent or overflow for an integer field, or a value of the wrong type). The admin save path maps it to a validation_failed 422 on that field instead of a 500 CapabilityError; genuine capability failures keep the 500.
This commit is contained in:
@@ -15,7 +15,7 @@ Postgres data layer: the shared GORM connection, per-plugin migrations, model he
|
||||
- One shared pool: `lagoon.Open`, `lagoon.Use` and `lagoon.OpenFromApp` return a `*sql.DB` and a `*gorm.DB` built on that same pool; `lagoon.Publish` makes both available on the `backpack.App`.
|
||||
- Database check at connect time: `lagoon.CheckLocale` refuses a database whose default collation is not the ICU `pl-PL` locale, so ordering matches the database default without per-query `COLLATE`.
|
||||
- Per-plugin migrations: `lagoon.Migrate` runs the framework's `system_files` set (`attach.Migrations`) and backend admin identity set (`lagoon.BackendAdminMigrations`), then every `pact.HasMigrations` set in plugin activation order, each in its own `summer_migrations_<plugin_id>` history table (`lagoon.HistoryTableName`). `lagoon.RollbackLast` and `lagoon.Status` cover rollback and history.
|
||||
- Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields, and a fraction or an overflow is an error. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`.
|
||||
- Mass assignment: `lagoon.Fill` copies only allow-listed keys onto a model by GORM column name and silently drops the rest, logging each dropped key once outside production. A `json.Number` (from a decoder using `UseNumber`) fills integer, unsigned and float fields. A value that does not fit its column (a fraction, an exponent or an overflow for an integer field, or a value of the wrong type) is a `lagoon.FillTypeError` naming the key, so a caller can answer it as a validation failure on that field. `lagoon.HasFillable` and `lagoon.HasHidden` are the Go forms of `$fillable` and `$hidden`.
|
||||
- Validation: `lagoon.Validate` accepts Laravel-style rule strings (`required`, `nullable`, `integer`, `numeric`, `between`, `min`, `max`, `in`, `unique`, `boolean`, `email`, `confirmed`, `different`, `mimes`) and returns a field-to-messages map, translated through phrasebook when a translator is given. Unknown rule tokens are an error.
|
||||
- Safe ordering: `lagoon.OrderBy` appends an ORDER BY only for an allow-listed column and an `asc` or `desc` direction.
|
||||
- Pagination: `lagoon.Paginate` builds a `lagoon.Page` with `data` and `meta` (`current_page`, `last_page`, `per_page`, `total`).
|
||||
@@ -102,6 +102,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
|
||||
| `lagoon.Encrypted` | Encrypted text column; `lagoon.Encrypted.Reveal` is the only plaintext accessor. |
|
||||
| `lagoon.Jsonable` | Generic JSON-as-TEXT column with NULL tracking. |
|
||||
| `lagoon.Fill` | Allow-listed mass assignment by column name. |
|
||||
| `lagoon.FillTypeError` | Returned by `lagoon.Fill` when a requested value does not fit its column; `Key` names the column. |
|
||||
| `lagoon.Validate` | Laravel-style rule validation with a `unique` database check. |
|
||||
| `lagoon.OrderBy` | Allow-listed ORDER BY. |
|
||||
| `lagoon.Paginate` | Builds a `lagoon.Page` with `lagoon.PageMeta`. |
|
||||
|
||||
@@ -25,9 +25,25 @@ type HasHidden interface {
|
||||
|
||||
var droppedKeys sync.Map
|
||||
|
||||
// FillTypeError reports a requested value that cannot be stored in its
|
||||
// column: a fraction, an exponent or an overflow for an integer field, or a
|
||||
// value of the wrong type. Key is the column the value was requested for, so
|
||||
// a caller can answer it as a validation failure on that field. Fill returns
|
||||
// any other failure, such as a model that is not a struct pointer, as a
|
||||
// plain error.
|
||||
type FillTypeError struct {
|
||||
Key string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *FillTypeError) Error() string { return "lagoon: fill " + e.Key + ": " + e.Err.Error() }
|
||||
|
||||
func (e *FillTypeError) Unwrap() error { return e.Err }
|
||||
|
||||
// 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.
|
||||
// non-production, each type+key pair is logged once. A value that does not
|
||||
// fit its column is a *FillTypeError.
|
||||
func Fill(model any, allowed []string, requested map[string]any, production bool) error {
|
||||
if model == nil {
|
||||
return fmt.Errorf("lagoon: fill model is nil")
|
||||
@@ -53,7 +69,7 @@ func Fill(model any, allowed []string, requested map[string]any, production bool
|
||||
continue
|
||||
}
|
||||
if err := setField(field, val); err != nil {
|
||||
return fmt.Errorf("lagoon: fill %s: %w", key, err)
|
||||
return &FillTypeError{Key: key, Err: err}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -3,6 +3,7 @@ package lagoon
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -193,3 +194,38 @@ func TestFillJSONNumber(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillTypeErrorNamesTheKey proves a value that does not fit its column
|
||||
// comes back as a *FillTypeError carrying the requested key, so a caller can
|
||||
// answer it as a validation failure on that field, while a bad model stays a
|
||||
// plain error.
|
||||
func TestFillTypeErrorNamesTheKey(t *testing.T) {
|
||||
type numbers struct {
|
||||
Year int `gorm:"column:year"`
|
||||
Count uint8 `gorm:"column:count"`
|
||||
Title string `gorm:"column:title"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
allowed := []string{"year", "count", "title", "price"}
|
||||
cases := map[string]any{
|
||||
"year": json.Number("1977.5"),
|
||||
"count": json.Number("1e21"),
|
||||
"title": true,
|
||||
"price": "cheap",
|
||||
}
|
||||
for key, value := range cases {
|
||||
var row numbers
|
||||
err := Fill(&row, allowed, map[string]any{key: value}, true)
|
||||
var typed *FillTypeError
|
||||
if !errors.As(err, &typed) || typed.Key != key || typed.Err == nil {
|
||||
t.Fatalf("%s = %v: err = %#v, want *FillTypeError for %s", key, value, err, key)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "lagoon: fill "+key+": ") {
|
||||
t.Fatalf("%s: message = %q", key, err.Error())
|
||||
}
|
||||
}
|
||||
var typed *FillTypeError
|
||||
if err := Fill(numbers{}, allowed, map[string]any{"year": 1}, true); err == nil || errors.As(err, &typed) {
|
||||
t.Fatalf("non-pointer model: err = %v, want a plain error", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user