From 7c737afc5a885841124b79e4e84049cab164c0f8 Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 29 Jul 2026 10:05:24 +0200 Subject: [PATCH] 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 --- pkg/common/types.go | 24 ++++++++++++++ pkg/common/types_test.go | 46 +++++++++++++++++++++++++++ pkg/resolvespec/handler.go | 32 +++++++++++++++++++ pkg/resolvespec/handler_test.go | 30 +++++++++++++++++ pkg/restheadspec/default_sort_test.go | 38 ++++++++++++++++++++++ pkg/restheadspec/handler.go | 33 +++++++++++++++++++ 6 files changed, 203 insertions(+) create mode 100644 pkg/common/types_test.go create mode 100644 pkg/restheadspec/default_sort_test.go diff --git a/pkg/common/types.go b/pkg/common/types.go index 42537a6..4a4a683 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -90,6 +90,30 @@ type SortOption struct { 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 { Name string `json:"name"` SQL string `json:"sql"` diff --git a/pkg/common/types_test.go b/pkg/common/types_test.go new file mode 100644 index 0000000..30c4cb1 --- /dev/null +++ b/pkg/common/types_test.go @@ -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) + } +} diff --git a/pkg/resolvespec/handler.go b/pkg/resolvespec/handler.go index a4cd531..45aeb70 100644 --- a/pkg/resolvespec/handler.go +++ b/pkg/resolvespec/handler.go @@ -30,6 +30,7 @@ type Handler struct { hooks *HookRegistry fallbackHandler FallbackHandler openAPIGenerator func() (string, error) + defaultSort map[string][]common.SortOption } // NewHandler creates a new API handler with database and registry abstractions @@ -56,6 +57,32 @@ func (h *Handler) SetFallbackHandler(fallback FallbackHandler) { 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 // Implements common.SpecHandler interface 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) } + // 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 for _, sort := range options.Sort { direction := "ASC" diff --git a/pkg/resolvespec/handler_test.go b/pkg/resolvespec/handler_test.go index d57e49d..b4100f0 100644 --- a/pkg/resolvespec/handler_test.go +++ b/pkg/resolvespec/handler_test.go @@ -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) { handler := NewHandler(nil, nil) db := handler.GetDatabase() diff --git a/pkg/restheadspec/default_sort_test.go b/pkg/restheadspec/default_sort_test.go new file mode 100644 index 0000000..2532ee3 --- /dev/null +++ b/pkg/restheadspec/default_sort_test.go @@ -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) + } +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 22d6bdf..4d6c6e3 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -32,6 +32,7 @@ type Handler struct { nestedProcessor *common.NestedCUDProcessor fallbackHandler FallbackHandler openAPIGenerator func() (string, error) + defaultSort map[string][]common.SortOption } // NewHandler creates a new API handler with database and registry abstractions @@ -64,6 +65,33 @@ func (h *Handler) SetFallbackHandler(fallback FallbackHandler) { 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 func (h *Handler) handlePanic(w common.ResponseWriter, method string, err interface{}) { 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) } + // 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 tableAlias := reflection.ExtractTableNameOnly(tableName) for _, sort := range options.Sort {