refactor(10.2-01): nest framework packages under modules

- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

544
modules/cabana/query.go Normal file
View File

@@ -0,0 +1,544 @@
package cabana
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ListInput is the raw admin list query. Identifiers are matched later
// against the compiled schema; nothing here is SQL.
type ListInput struct {
Search string
Sort string
Dir string
Page string
PerPage string
Filters map[string]string
}
// ListMeta is the D-11 pagination block.
type ListMeta struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
LastPage int `json:"last_page"`
}
// ListResult is one page of projected rows.
type ListResult struct {
Data []map[string]any
Meta ListMeta
}
// ListValidationError is a D-10 validation_failed failure.
type ListValidationError struct {
Details map[string]any
}
func (e *ListValidationError) Error() string { return "validation_failed" }
func listInvalid(field, message string) *ListValidationError {
return &ListValidationError{Details: map[string]any{field: []string{message}}}
}
func listQueryFromRequest(r *http.Request) ListInput {
q := r.URL.Query()
filters := map[string]string{}
for key, vals := range q {
if !strings.HasPrefix(key, "filter[") || !strings.HasSuffix(key, "]") || len(key) <= len("filter[]") {
continue
}
name := key[len("filter[") : len(key)-1]
if len(vals) == 0 {
filters[name] = ""
continue
}
filters[name] = vals[len(vals)-1]
}
return ListInput{
Search: q.Get(listSearchTerm),
Sort: q.Get("sort"),
Dir: q.Get("dir"),
Page: q.Get("page"),
PerPage: q.Get("per_page"),
Filters: filters,
}
}
// ExecuteList runs an allowlisted search, sort, filter, and page against db.
func ExecuteList(ctx context.Context, db *gorm.DB, cc *CompiledController, in ListInput) (*ListResult, error) {
if cc == nil || cc.List == nil {
return nil, errors.New("cabana: list schema is missing")
}
if db == nil {
return nil, errors.New("cabana: database is not configured")
}
src, ok := cc.Controller.(pact.AdminRecordSource)
if !ok || src == nil || src.NewRecord() == nil {
return nil, errors.New("cabana: admin controller has no record source")
}
model := src.NewRecord()
mt := reflect.TypeOf(model)
if mt == nil || mt.Kind() != reflect.Pointer || mt.Elem().Kind() != reflect.Struct {
return nil, errors.New("cabana: admin model must be a pointer")
}
if ctx == nil {
ctx = context.Background()
}
page, per, err := normalizePage(cc.List, in)
if err != nil {
return nil, err
}
sortCol, sortDesc, err := normalizeSort(cc.List, in)
if err != nil {
return nil, err
}
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
}
}
q, err = applyListSearch(q, cc.Controller, cc.List, model, in.Search)
if err != nil {
return nil, err
}
q, err = applyListFilters(q, cc.List, model, in.Filters)
if err != nil {
return nil, err
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
return nil, err
}
q = applyListOrder(q, cc.Controller, cc.List, model, sortCol, sortDesc)
for _, col := range cc.List.Columns {
if col.Relation == "" {
continue
}
if field, ok := relationFieldName(model, col.Relation); ok {
q = q.Preload(field)
}
}
offset := (page - 1) * per
slice := reflect.New(reflect.SliceOf(mt.Elem()))
if err := q.Offset(offset).Limit(per).Find(slice.Interface()).Error; err != nil {
return nil, err
}
values := slice.Elem()
data := make([]map[string]any, 0, values.Len())
for i := 0; i < values.Len(); i++ {
data = append(data, projectRow(values.Index(i).Addr().Interface(), cc.Controller, cc.List.Columns))
}
paged := lagoon.Paginate(data, page, per, total)
return &ListResult{
Data: paged.Data,
Meta: ListMeta{
Page: paged.Meta.CurrentPage,
PerPage: paged.Meta.PerPage,
Total: paged.Meta.Total,
LastPage: paged.Meta.LastPage,
},
}, nil
}
func normalizePage(schema *ListSchema, in ListInput) (int, int, error) {
page := 1
if in.Page != "" {
n, err := strconv.Atoi(in.Page)
if err != nil || n < 1 {
return 0, 0, listInvalid("page", "must be a positive integer")
}
page = n
}
per := schema.RecordsPerPage
if per < 1 {
per = 20
}
if in.PerPage != "" {
n, err := strconv.Atoi(in.PerPage)
if err != nil || !pageSizeAllowed(schema, n) {
return 0, 0, listInvalid("per_page", "is not an allowed page size")
}
per = n
}
return page, per, nil
}
func pageSizeAllowed(schema *ListSchema, n int) bool {
if n < 1 {
return false
}
options := schema.PerPageOptions
if len(options) == 0 {
return n == schema.RecordsPerPage || (schema.RecordsPerPage < 1 && n == 20)
}
for _, option := range options {
if option == n {
return true
}
}
return false
}
func normalizeSort(schema *ListSchema, in ListInput) (string, bool, error) {
if in.Sort == "" {
if schema.DefaultSort == nil {
return "", false, nil
}
return schema.DefaultSort.Column, schema.DefaultSort.Direction == "desc", nil
}
if !schema.ShowSorting {
return "", false, listInvalid("sort", "is not sortable")
}
var match *ListColumn
for i := range schema.Columns {
if schema.Columns[i].Key == in.Sort {
match = &schema.Columns[i]
break
}
}
if match == nil || !match.Sortable {
return "", false, listInvalid("sort", "is not a sortable column")
}
dir := in.Dir
if dir == "" {
dir = "asc"
}
if dir != "asc" && dir != "desc" {
return "", false, listInvalid("dir", "must be asc or desc")
}
return match.Key, dir == "desc", nil
}
func applyListOrder(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, column string, desc bool) *gorm.DB {
table := tableName(model)
pk := primaryColumn(model)
if column != "" {
orderTable := table
orderColumn := column
for _, col := range schema.Columns {
if col.Key != column || col.Relation == "" {
continue
}
field, ok := relationFieldName(model, col.Relation)
if !ok {
continue
}
orderTable = field
orderColumn = listRelationColumn(controller, col.Relation, col.Select)
db = db.Joins(field)
break
}
db = db.Order(clause.OrderByColumn{
Column: clause.Column{Table: orderTable, Name: orderColumn},
Desc: desc,
})
}
if column != pk {
db = db.Order(clause.OrderByColumn{Column: clause.Column{Table: table, Name: pk}})
}
return db
}
func applyListSearch(db *gorm.DB, controller pact.AdminController, schema *ListSchema, model any, term string) (*gorm.DB, error) {
term = strings.TrimSpace(term)
if term == "" {
return db, nil
}
var cols []ListColumn
for _, col := range schema.Columns {
if col.Searchable {
cols = append(cols, col)
}
}
if len(cols) == 0 {
return nil, listInvalid("search", "is not searchable")
}
pattern := "%" + escapeLike(strings.ToLower(term)) + "%"
joined := map[string]struct{}{}
parts := make([]string, 0, len(cols))
args := make([]any, 0, len(cols))
main := tableName(model)
for _, col := range cols {
table := main
column := col.Key
if col.Relation != "" {
field, ok := relationFieldName(model, col.Relation)
physical := listRelationColumn(controller, col.Relation, col.Select)
if !ok || !identifier(physical) {
return nil, listInvalid("search", "is not searchable")
}
// GORM aliases the joined association with the Go field name.
table = field
column = physical
if _, done := joined[field]; !done {
db = db.Joins(field)
joined[field] = struct{}{}
}
} else if !identifier(column) {
return nil, listInvalid("search", "is not searchable")
}
parts = append(parts, fmt.Sprintf("LOWER(%s) LIKE ? ESCAPE '\\'", qualifiedColumn(db, table, column)))
args = append(args, pattern)
}
return db.Where(strings.Join(parts, " OR "), args...), nil
}
func applyListFilters(db *gorm.DB, schema *ListSchema, model any, filters map[string]string) (*gorm.DB, error) {
if len(filters) == 0 {
return db, nil
}
byName := map[string]ListFilter{}
for _, filter := range schema.Filters {
byName[filter.Name] = filter
}
table := tableName(model)
var provider pact.FilterScope
if scope, ok := model.(pact.FilterScope); ok {
provider = scope
}
for name, raw := range filters {
filter, ok := byName[name]
if !ok || !identifier(name) {
return nil, listInvalid("filter", "is not a declared filter")
}
switch filter.Type {
case "switch":
value, ok := switchArgument(filter, raw)
if !ok {
return nil, listInvalid("filter", "is not a declared value")
}
db = db.Where(clause.Eq{Column: clause.Column{Table: table, Name: filter.Column}, Value: value})
case "daterange":
start, end, err := parseDateRange(raw)
if err != nil {
return nil, listInvalid("filter", "is not a valid date range")
}
db = db.Where(clause.Gte{Column: clause.Column{Table: table, Name: filter.Column}, Value: start})
db = db.Where(clause.Lt{Column: clause.Column{Table: table, Name: filter.Column}, Value: end})
case "scope":
arg, err := scopeArgument(raw)
if err != nil {
return nil, listInvalid("filter", "is not a declared value")
}
if provider == nil {
return nil, errors.New("cabana: filter scope provider is missing")
}
next := provider.FilterScope(filter.Scope, db, arg)
if next == nil {
return nil, errors.New("cabana: filter scope returned nil")
}
db = next
default:
return nil, listInvalid("filter", "is not a declared filter")
}
}
return db, nil
}
func switchArgument(filter ListFilter, raw string) (any, bool) {
if len(filter.Options) > 0 {
for _, opt := range filter.Options {
if string(opt.Value.raw) == raw {
return scalarValue(opt.Value), true
}
}
return nil, false
}
if filter.TrueValue != nil && string(filter.TrueValue.raw) == raw {
return scalarValue(*filter.TrueValue), true
}
if filter.FalseValue != nil && string(filter.FalseValue.raw) == raw {
return scalarValue(*filter.FalseValue), true
}
return nil, false
}
func scalarValue(s jsonScalar) any {
var v any
if err := json.Unmarshal(s.raw, &v); err != nil {
return string(s.raw)
}
return v
}
func parseDateRange(raw string) (time.Time, time.Time, error) {
startText, endText, ok := strings.Cut(raw, "..")
if !ok || startText == "" || endText == "" {
return time.Time{}, time.Time{}, errors.New("range")
}
start, err := time.ParseInLocation("2006-01-02", startText, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, err
}
end, err := time.ParseInLocation("2006-01-02", endText, time.UTC)
if err != nil {
return time.Time{}, time.Time{}, err
}
if end.Before(start) {
return time.Time{}, time.Time{}, errors.New("range order")
}
return start, end.AddDate(0, 0, 1), nil
}
func scopeArgument(raw string) (any, error) {
if raw == "" || strings.ContainsAny(raw, " \t;'\"\\") || strings.Contains(raw, "--") {
return nil, errors.New("value")
}
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
return n, nil
}
if !identifier(raw) {
return nil, errors.New("value")
}
return raw, nil
}
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func qualifiedColumn(db *gorm.DB, table, column string) string {
return quotedIdent(db, table) + "." + quotedIdent(db, column)
}
func quotedIdent(db *gorm.DB, name string) string {
if db == nil || db.Dialector == nil || !identifier(name) && !strings.Contains(name, "_") {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
var b strings.Builder
db.Dialector.QuoteTo(&b, name)
return b.String()
}
func tableName(model any) string {
if model == nil {
return ""
}
if namer, ok := model.(interface{ TableName() string }); ok {
return namer.TableName()
}
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil {
return ""
}
ptr := reflect.New(t)
if namer, ok := ptr.Interface().(interface{ TableName() string }); ok {
return namer.TableName()
}
return ""
}
// relationFieldName resolves a Winter relation key onto the exported Go field.
// Exact names win; otherwise the match is case-insensitive, so relation: genre
// selects field Genre.
func relationFieldName(model any, relation string) (string, bool) {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct || relation == "" {
return "", false
}
if field, ok := t.FieldByName(relation); ok && isListRelation(field.Type) {
return field.Name, true
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" || !isListRelation(field.Type) {
continue
}
if strings.EqualFold(field.Name, relation) {
return field.Name, true
}
}
return "", false
}
func primaryColumn(model any) string {
t := reflect.TypeOf(model)
for t != nil && t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil || t.Kind() != reflect.Struct {
return "id"
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" {
continue
}
if strings.Contains(field.Tag.Get("gorm"), "primaryKey") {
if name := gormColumn(field); name != "" {
return name
}
return field.Name
}
}
return "id"
}
func relatedSelect(row reflect.Value, controller pact.AdminController, col ListColumn) (any, bool) {
for row.Kind() == reflect.Pointer {
if row.IsNil() {
return nil, false
}
row = row.Elem()
}
if row.Kind() != reflect.Struct || col.Relation == "" {
return nil, false
}
name, ok := relationFieldName(row.Interface(), col.Relation)
if !ok {
return nil, false
}
field := row.FieldByName(name)
if !field.IsValid() {
return nil, false
}
for field.Kind() == reflect.Pointer {
if field.IsNil() {
return nil, false
}
field = field.Elem()
}
if field.Kind() != reflect.Struct {
return nil, false
}
selected := fieldByColumn(field, listRelationColumn(controller, col.Relation, col.Select))
if !selected.IsValid() || !selected.CanInterface() {
return nil, false
}
return selected.Interface(), true
}
func listRelationColumn(controller pact.AdminController, relation, column string) string {
mapper, ok := controller.(pact.ListRelationColumnMapper)
if !ok || mapper == nil {
return column
}
mapped, ok := mapper.ListRelationColumn(relation, column)
if !ok || mapped == "" {
return column
}
return mapped
}