From 7853a3f56acb732ae3fa439fd28c46ad82f981e3 Mon Sep 17 00:00:00 2001 From: Hein Date: Fri, 21 Nov 2025 09:15:40 +0200 Subject: [PATCH] cql_columns parsing and recursive preloading. Also added legacy header support for limt(s,e) ,sort(x,y,-z) --- pkg/common/types.go | 20 ++--- pkg/restheadspec/handler.go | 151 +++++++++++++++++++++++++----------- pkg/restheadspec/headers.go | 40 +++++++++- 3 files changed, 156 insertions(+), 55 deletions(-) diff --git a/pkg/common/types.go b/pkg/common/types.go index cc62b0b..330bcfa 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -32,15 +32,17 @@ type Parameter struct { } type PreloadOption struct { - Relation string `json:"relation"` - Columns []string `json:"columns"` - OmitColumns []string `json:"omit_columns"` - Sort []SortOption `json:"sort"` - Filters []FilterOption `json:"filters"` - Where string `json:"where"` - Limit *int `json:"limit"` - Offset *int `json:"offset"` - Updatable *bool `json:"updateable"` // if true, the relation can be updated + Relation string `json:"relation"` + Columns []string `json:"columns"` + OmitColumns []string `json:"omit_columns"` + Sort []SortOption `json:"sort"` + Filters []FilterOption `json:"filters"` + Where string `json:"where"` + Limit *int `json:"limit"` + Offset *int `json:"offset"` + Updatable *bool `json:"updateable"` // if true, the relation can be updated + ComputedQL map[string]string `json:"computed_ql"` // Computed columns as SQL expressions + Recursive bool `json:"recursive"` // if true, preload recursively up to 5 levels } type FilterOption struct { diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index a5e5250..f69acc2 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -360,50 +360,8 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st preload.Where = fixedWhere } - query = query.PreloadRelation(preload.Relation, func(sq common.SelectQuery) common.SelectQuery { - if len(preload.OmitColumns) > 0 { - allCols := reflection.GetModelColumns(model) - // Remove omitted columns - preload.Columns = []string{} - for _, col := range allCols { - addCols := true - for _, omitCol := range preload.OmitColumns { - if col == omitCol { - addCols = false - break - } - } - if addCols { - preload.Columns = append(preload.Columns, col) - } - } - } - - if len(preload.Columns) > 0 { - sq = sq.Column(preload.Columns...) - } - - if len(preload.Filters) > 0 { - for _, filter := range preload.Filters { - sq = h.applyFilter(sq, filter, "", false, "AND") - } - } - if len(preload.Sort) > 0 { - for _, sort := range preload.Sort { - sq = sq.Order(fmt.Sprintf("%s %s", sort.Column, sort.Direction)) - } - } - - if len(preload.Where) > 0 { - sq = sq.Where(preload.Where) - } - - if preload.Limit != nil && *preload.Limit > 0 { - sq = sq.Limit(*preload.Limit) - } - - return sq - }) + // Apply the preload with recursive support + query = h.applyPreloadWithRecursion(query, preload, model, 0) } // Apply DISTINCT if requested @@ -589,6 +547,111 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st h.sendFormattedResponse(w, modelPtr, metadata, options) } +// applyPreloadWithRecursion applies a preload with support for ComputedQL and recursive preloading +func (h *Handler) applyPreloadWithRecursion(query common.SelectQuery, preload common.PreloadOption, model interface{}, depth int) common.SelectQuery { + // Apply the preload + query = query.PreloadRelation(preload.Relation, func(sq common.SelectQuery) common.SelectQuery { + // Get the related model for column operations + relatedModel := h.getRelationModel(model, preload.Relation) + if relatedModel == nil { + logger.Warn("Could not get related model for preload: %s", preload.Relation) + relatedModel = model // fallback to parent model + } + + // If we have computed columns but no explicit columns, populate with all model columns first + // since computed columns are additions + if len(preload.Columns) == 0 && len(preload.ComputedQL) > 0 && relatedModel != nil { + logger.Debug("Populating preload columns with all model columns since computed columns are additions") + preload.Columns = reflection.GetSQLModelColumns(relatedModel) + } + + // Apply ComputedQL fields if any + if len(preload.ComputedQL) > 0 { + for colName, colExpr := range preload.ComputedQL { + logger.Debug("Applying computed column to preload %s: %s", preload.Relation, colName) + sq = sq.ColumnExpr(fmt.Sprintf("(%s) AS %s", colExpr, colName)) + // Remove the computed column from selected columns to avoid duplication + for colIndex := range preload.Columns { + if preload.Columns[colIndex] == colName { + preload.Columns = append(preload.Columns[:colIndex], preload.Columns[colIndex+1:]...) + break + } + } + } + } + + // Handle OmitColumns + if len(preload.OmitColumns) > 0 && relatedModel != nil { + allCols := reflection.GetModelColumns(relatedModel) + // Remove omitted columns + preload.Columns = []string{} + for _, col := range allCols { + addCols := true + for _, omitCol := range preload.OmitColumns { + if col == omitCol { + addCols = false + break + } + } + if addCols { + preload.Columns = append(preload.Columns, col) + } + } + } + + // Apply column selection + if len(preload.Columns) > 0 { + sq = sq.Column(preload.Columns...) + } + + // Apply filters + if len(preload.Filters) > 0 { + for _, filter := range preload.Filters { + sq = h.applyFilter(sq, filter, "", false, "AND") + } + } + + // Apply sorting + if len(preload.Sort) > 0 { + for _, sort := range preload.Sort { + sq = sq.Order(fmt.Sprintf("%s %s", sort.Column, sort.Direction)) + } + } + + // Apply WHERE clause + if len(preload.Where) > 0 { + sq = sq.Where(preload.Where) + } + + // Apply limit + if preload.Limit != nil && *preload.Limit > 0 { + sq = sq.Limit(*preload.Limit) + } + + return sq + }) + + // Handle recursive preloading + if preload.Recursive && depth < 5 { + logger.Debug("Applying recursive preload for %s at depth %d", preload.Relation, depth+1) + + // For recursive relationships, we need to get the last part of the relation path + // e.g., "MastertaskItems" -> "MastertaskItems.MastertaskItems" + relationParts := strings.Split(preload.Relation, ".") + lastRelationName := relationParts[len(relationParts)-1] + + // Create a recursive preload with the same configuration + // but with the relation path extended + recursivePreload := preload + recursivePreload.Relation = preload.Relation + "." + lastRelationName + + // Recursively apply preload until we reach depth 5 + query = h.applyPreloadWithRecursion(query, recursivePreload, model, depth+1) + } + + return query +} + func (h *Handler) handleCreate(ctx context.Context, w common.ResponseWriter, data interface{}, options ExtendedRequestOptions) { // Capture panics and return error response defer func() { diff --git a/pkg/restheadspec/headers.go b/pkg/restheadspec/headers.go index cd9580b..2930189 100644 --- a/pkg/restheadspec/headers.go +++ b/pkg/restheadspec/headers.go @@ -110,8 +110,8 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E AdvancedSQL: make(map[string]string), ComputedQL: make(map[string]string), Expand: make([]ExpandOption, 0), - ResponseFormat: "simple", // Default response format - SingleRecordAsObject: true, // Default: normalize single-element arrays to objects + ResponseFormat: "simple", // Default response format + SingleRecordAsObject: true, // Default: normalize single-element arrays to objects } // Get all headers @@ -183,14 +183,37 @@ func (h *Handler) parseOptionsFromHeaders(r common.Request, model interface{}) E // Sorting & Pagination case strings.HasPrefix(normalizedKey, "x-sort"): h.parseSorting(&options, decodedValue) + //Special cases for older clients using sort(a,b,-c) syntax + case strings.HasPrefix(normalizedKey, "sort(") && strings.Contains(normalizedKey, ")"): + sortValue := normalizedKey[strings.Index(normalizedKey, "(")+1 : strings.Index(normalizedKey, ")")] + h.parseSorting(&options, sortValue) case strings.HasPrefix(normalizedKey, "x-limit"): if limit, err := strconv.Atoi(decodedValue); err == nil { options.Limit = &limit } + //Special cases for older clients using limit(n) syntax + case strings.HasPrefix(normalizedKey, "limit(") && strings.Contains(normalizedKey, ")"): + limitValue := normalizedKey[strings.Index(normalizedKey, "(")+1 : strings.Index(normalizedKey, ")")] + limitValueParts := strings.Split(limitValue, ",") + + if len(limitValueParts) > 1 { + if offset, err := strconv.Atoi(limitValueParts[0]); err == nil { + options.Offset = &offset + } + if limit, err := strconv.Atoi(limitValueParts[1]); err == nil { + options.Limit = &limit + } + } else { + if limit, err := strconv.Atoi(limitValueParts[0]); err == nil { + options.Limit = &limit + } + } + case strings.HasPrefix(normalizedKey, "x-offset"): if offset, err := strconv.Atoi(decodedValue); err == nil { options.Offset = &offset } + case strings.HasPrefix(normalizedKey, "x-cursor-forward"): options.CursorForward = decodedValue case strings.HasPrefix(normalizedKey, "x-cursor-backward"): @@ -935,6 +958,19 @@ func (h *Handler) addXFilesPreload(xfile *XFiles, options *ExtendedRequestOption } } + // Add computed columns (CQL) -> ComputedQL + if len(xfile.CQLColumns) > 0 { + preloadOpt.ComputedQL = make(map[string]string) + for i, cqlExpr := range xfile.CQLColumns { + colName := fmt.Sprintf("cql%d", i+1) + preloadOpt.ComputedQL[colName] = cqlExpr + logger.Debug("X-Files: Added computed column %s to preload %s: %s", colName, relationPath, cqlExpr) + } + } + + // Set recursive flag + preloadOpt.Recursive = xfile.Recursive + // Add the preload option options.Preload = append(options.Preload, preloadOpt)