feat(09-04): execute allowlisted deterministic list queries

- Search, sort, filters, and pagination use compiled selectors and bound values
- Equal sort keys break ties on the primary key so adjacent pages do not overlap
- Unknown identifiers return validation_failed before SQL
This commit is contained in:
Jakub Zych
2026-09-24 19:03:44 +02:00
parent 7f451c3572
commit 6a57f63e81
3 changed files with 537 additions and 55 deletions

View File

@@ -172,28 +172,21 @@ func (s *service) list(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
rows, total, err := queryList(r.Context(), db, cc)
result, err := ExecuteList(r.Context(), db, cc, listQueryFromRequest(r))
var invalid *ListValidationError
if errors.As(err, &invalid) {
WriteErrorDetails(w, http.StatusUnprocessableEntity, "validation_failed", "Validation failed", invalid.Details)
return
}
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
data := make([]map[string]any, 0, len(rows))
for _, row := range rows {
data = append(data, projectRow(row, cc.List.Columns))
}
per := cc.List.RecordsPerPage
if per < 1 {
per = 20
}
last := int((total + int64(per) - 1) / int64(per))
if last < 1 {
last = 1
}
WriteData(w, http.StatusOK, data, map[string]any{
"page": 1,
"per_page": per,
"total": total,
"last_page": last,
WriteData(w, http.StatusOK, result.Data, map[string]any{
"page": result.Meta.Page,
"per_page": result.Meta.PerPage,
"total": result.Meta.Total,
"last_page": result.Meta.LastPage,
})
})
}
@@ -220,42 +213,6 @@ func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*Compi
fn(cc)
}
func queryList(ctx context.Context, db *gorm.DB, cc *CompiledController) ([]any, int64, error) {
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil {
return nil, 0, errors.New("cabana: admin controller has no record source")
}
model := src.NewRecord()
mt := reflect.TypeOf(model)
if mt == nil || mt.Kind() != reflect.Pointer {
return nil, 0, errors.New("cabana: admin model must be a pointer")
}
q := db.WithContext(ctx).Model(model)
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
if next := ext.ListExtendQuery(ctx, q); next != nil {
q = next
}
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, 0, err
}
per := 20
if cc.List != nil && cc.List.RecordsPerPage > 0 {
per = cc.List.RecordsPerPage
}
slice := reflect.New(reflect.SliceOf(mt.Elem()))
if err := q.Session(&gorm.Session{}).Limit(per).Find(slice.Interface()).Error; err != nil {
return nil, 0, err
}
values := slice.Elem()
out := make([]any, values.Len())
for i := 0; i < values.Len(); i++ {
out[i] = values.Index(i).Addr().Interface()
}
return out, total, nil
}
func projectRow(row any, cols []ListColumn) map[string]any {
v := reflect.ValueOf(row)
for v.Kind() == reflect.Pointer {
@@ -269,6 +226,12 @@ func projectRow(row any, cols []ListColumn) map[string]any {
out["id"] = id.Interface()
}
for _, col := range cols {
if col.Relation != "" {
if value, ok := relatedSelect(v, col); ok {
out[col.Key] = value
}
continue
}
field := fieldByColumn(v, col.Key)
if !field.IsValid() || !field.CanInterface() {
continue