feat(handler): implement default sort configuration for models

* Add SetDefaultSort method to configure default sort order
* Implement getDefaultSort method to retrieve configured defaults
* Update handleRead to apply default sort when none specified
* Add tests for default sort functionality
This commit is contained in:
Hein
2026-07-29 10:05:24 +02:00
parent a70e3e02d0
commit 7c737afc5a
6 changed files with 203 additions and 0 deletions
+24
View File
@@ -90,6 +90,30 @@ type SortOption struct {
Direction string `json:"direction"` Direction string `json:"direction"`
} }
// PrimaryKeySortColumn is a sentinel SortOption.Column value that gets
// resolved to a model's actual primary key column name at query time.
// This lets a single global default sort (e.g. set once via
// Handler.SetDefaultSort) work across models with different primary keys,
// e.g. common.SortOption{Column: common.PrimaryKeySortColumn, Direction: "asc"}.
const PrimaryKeySortColumn = "$pk"
// ResolveSortColumns returns a copy of sort with any PrimaryKeySortColumn
// entries replaced by pkName. If pkName is empty, matching entries are
// dropped since there is no column to sort by.
func ResolveSortColumns(sort []SortOption, pkName string) []SortOption {
resolved := make([]SortOption, 0, len(sort))
for _, s := range sort {
if s.Column == PrimaryKeySortColumn {
if pkName == "" {
continue
}
s.Column = pkName
}
resolved = append(resolved, s)
}
return resolved
}
type CustomOperator struct { type CustomOperator struct {
Name string `json:"name"` Name string `json:"name"`
SQL string `json:"sql"` SQL string `json:"sql"`
+46
View File
@@ -0,0 +1,46 @@
package common
import (
"reflect"
"testing"
)
func TestResolveSortColumns(t *testing.T) {
sort := []SortOption{
{Column: PrimaryKeySortColumn, Direction: "asc"},
{Column: "name", Direction: "desc"},
}
got := ResolveSortColumns(sort, "user_id")
want := []SortOption{
{Column: "user_id", Direction: "asc"},
{Column: "name", Direction: "desc"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("ResolveSortColumns() = %v, want %v", got, want)
}
// Original slice must not be mutated
if sort[0].Column != PrimaryKeySortColumn {
t.Errorf("ResolveSortColumns() mutated input slice: %v", sort)
}
}
func TestResolveSortColumnsEmptyPK(t *testing.T) {
sort := []SortOption{
{Column: PrimaryKeySortColumn, Direction: "asc"},
{Column: "name", Direction: "desc"},
}
got := ResolveSortColumns(sort, "")
want := []SortOption{{Column: "name", Direction: "desc"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("ResolveSortColumns() with empty pk = %v, want %v", got, want)
}
}
func TestResolveSortColumnsNil(t *testing.T) {
if got := ResolveSortColumns(nil, "id"); len(got) != 0 {
t.Errorf("ResolveSortColumns(nil) = %v, want empty", got)
}
}
+32
View File
@@ -30,6 +30,7 @@ type Handler struct {
hooks *HookRegistry hooks *HookRegistry
fallbackHandler FallbackHandler fallbackHandler FallbackHandler
openAPIGenerator func() (string, error) openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
} }
// NewHandler creates a new API handler with database and registry abstractions // NewHandler creates a new API handler with database and registry abstractions
@@ -56,6 +57,32 @@ func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
h.fallbackHandler = fallback h.fallbackHandler = fallback
} }
// SetDefaultSort sets the sort order applied to list ("read") results when a
// request does not specify its own sort. Pass an empty schema and name to set
// a global default used by any model without a more specific default.
func (h *Handler) SetDefaultSort(schema, name string, sort ...common.SortOption) {
if h.defaultSort == nil {
h.defaultSort = make(map[string][]common.SortOption)
}
h.defaultSort[defaultSortKey(schema, name)] = sort
}
// getDefaultSort returns the configured default sort for a model, falling
// back to the global default (registered with an empty schema and name).
func (h *Handler) getDefaultSort(schema, name string) []common.SortOption {
if h.defaultSort == nil {
return nil
}
if sort, ok := h.defaultSort[defaultSortKey(schema, name)]; ok {
return sort
}
return h.defaultSort[defaultSortKey("", "")]
}
func defaultSortKey(schema, name string) string {
return fmt.Sprintf("%s.%s", schema, name)
}
// GetDatabase returns the underlying database connection // GetDatabase returns the underlying database connection
// Implements common.SpecHandler interface // Implements common.SpecHandler interface
func (h *Handler) GetDatabase() common.Database { func (h *Handler) GetDatabase() common.Database {
@@ -345,6 +372,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Where(customOp.SQL) query = query.Where(customOp.SQL)
} }
// Fall back to the configured default sort when the request didn't specify one
if len(options.Sort) == 0 {
options.Sort = common.ResolveSortColumns(h.getDefaultSort(schema, entity), reflection.GetPrimaryKeyName(model))
}
// Apply sorting // Apply sorting
for _, sort := range options.Sort { for _, sort := range options.Sort {
direction := "ASC" direction := "ASC"
+30
View File
@@ -41,6 +41,36 @@ func TestSetFallbackHandler(t *testing.T) {
} }
} }
func TestSetDefaultSort(t *testing.T) {
handler := NewHandler(nil, nil)
// No default configured yet
if got := handler.getDefaultSort("public", "users"); got != nil {
t.Errorf("Expected no default sort, got %v", got)
}
// Global default
global := []common.SortOption{{Column: "created_at", Direction: "desc"}}
handler.SetDefaultSort("", "", global...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v, got %v", global, got)
}
// Per-model default overrides the global default
perModel := []common.SortOption{{Column: "name", Direction: "asc"}}
handler.SetDefaultSort("public", "users", perModel...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, perModel) {
t.Errorf("Expected per-model default sort %v, got %v", perModel, got)
}
// Other models still fall back to the global default
if got := handler.getDefaultSort("public", "orders"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v for unrelated model, got %v", global, got)
}
}
func TestGetDatabase(t *testing.T) { func TestGetDatabase(t *testing.T) {
handler := NewHandler(nil, nil) handler := NewHandler(nil, nil)
db := handler.GetDatabase() db := handler.GetDatabase()
+38
View File
@@ -0,0 +1,38 @@
package restheadspec
import (
"reflect"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/common"
)
func TestSetDefaultSort(t *testing.T) {
handler := NewHandler(nil, nil)
// No default configured yet
if got := handler.getDefaultSort("public", "users"); got != nil {
t.Errorf("Expected no default sort, got %v", got)
}
// Global default
global := []common.SortOption{{Column: "created_at", Direction: "desc"}}
handler.SetDefaultSort("", "", global...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v, got %v", global, got)
}
// Per-model default overrides the global default
perModel := []common.SortOption{{Column: "name", Direction: "asc"}}
handler.SetDefaultSort("public", "users", perModel...)
if got := handler.getDefaultSort("public", "users"); !reflect.DeepEqual(got, perModel) {
t.Errorf("Expected per-model default sort %v, got %v", perModel, got)
}
// Other models still fall back to the global default
if got := handler.getDefaultSort("public", "orders"); !reflect.DeepEqual(got, global) {
t.Errorf("Expected global default sort %v for unrelated model, got %v", global, got)
}
}
+33
View File
@@ -32,6 +32,7 @@ type Handler struct {
nestedProcessor *common.NestedCUDProcessor nestedProcessor *common.NestedCUDProcessor
fallbackHandler FallbackHandler fallbackHandler FallbackHandler
openAPIGenerator func() (string, error) openAPIGenerator func() (string, error)
defaultSort map[string][]common.SortOption
} }
// NewHandler creates a new API handler with database and registry abstractions // NewHandler creates a new API handler with database and registry abstractions
@@ -64,6 +65,33 @@ func (h *Handler) SetFallbackHandler(fallback FallbackHandler) {
h.fallbackHandler = fallback h.fallbackHandler = fallback
} }
// SetDefaultSort sets the sort order applied to list ("read") results when a
// request does not specify its own sort (no x-sort header / sort query param).
// Pass an empty schema and name to set a global default used by any model
// without a more specific default.
func (h *Handler) SetDefaultSort(schema, name string, sort ...common.SortOption) {
if h.defaultSort == nil {
h.defaultSort = make(map[string][]common.SortOption)
}
h.defaultSort[defaultSortKey(schema, name)] = sort
}
// getDefaultSort returns the configured default sort for a model, falling
// back to the global default (registered with an empty schema and name).
func (h *Handler) getDefaultSort(schema, name string) []common.SortOption {
if h.defaultSort == nil {
return nil
}
if sort, ok := h.defaultSort[defaultSortKey(schema, name)]; ok {
return sort
}
return h.defaultSort[defaultSortKey("", "")]
}
func defaultSortKey(schema, name string) string {
return fmt.Sprintf("%s.%s", schema, name)
}
// handlePanic is a helper function to handle panics with stack traces // handlePanic is a helper function to handle panics with stack traces
func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) { func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) {
stack := debug.Stack() stack := debug.Stack()
@@ -645,6 +673,11 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
query = query.Where(fmt.Sprintf("%s.%s = ?", common.QuoteIdent(tableAlias), common.QuoteIdent(pkName)), id) query = query.Where(fmt.Sprintf("%s.%s = ?", common.QuoteIdent(tableAlias), common.QuoteIdent(pkName)), id)
} }
// Fall back to the configured default sort when the request didn't specify one
if len(options.Sort) == 0 {
options.Sort = common.ResolveSortColumns(h.getDefaultSort(schema, entity), reflection.GetPrimaryKeyName(model))
}
// Apply sorting // Apply sorting
tableAlias := reflection.ExtractTableNameOnly(tableName) tableAlias := reflection.ExtractTableNameOnly(tableName)
for _, sort := range options.Sort { for _, sort := range options.Sort {