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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user