mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2025-12-31 00:34:25 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26ea3cd61 | ||
|
|
a5d97cc07b | ||
|
|
0899ba5029 |
@@ -37,9 +37,10 @@ type PreloadOption struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FilterOption struct {
|
type FilterOption struct {
|
||||||
Column string `json:"column"`
|
Column string `json:"column"`
|
||||||
Operator string `json:"operator"`
|
Operator string `json:"operator"`
|
||||||
Value interface{} `json:"value"`
|
Value interface{} `json:"value"`
|
||||||
|
LogicOperator string `json:"logic_operator"` // "AND" or "OR" - how this filter combines with previous filters
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortOption struct {
|
type SortOption struct {
|
||||||
|
|||||||
@@ -175,11 +175,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
|
sliceType := reflect.SliceOf(reflect.PointerTo(modelType))
|
||||||
modelPtr := reflect.New(sliceType).Interface()
|
modelPtr := reflect.New(sliceType).Interface()
|
||||||
|
|
||||||
// Start with Model() to avoid "Model(nil)" errors in Count()
|
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
|
||||||
query := h.db.NewSelect().Model(model)
|
// Bun's Model() accepts both single pointers and slice pointers
|
||||||
|
query := h.db.NewSelect().Model(modelPtr)
|
||||||
|
|
||||||
// Only set Table() if the model doesn't provide a table name
|
// Only set Table() if the model doesn't provide a table name via the underlying type
|
||||||
if provider, ok := model.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
// Create a temporary instance to check for TableNameProvider
|
||||||
|
tempInstance := reflect.New(modelType).Interface()
|
||||||
|
if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
||||||
query = query.Table(tableName)
|
query = query.Table(tableName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,11 +201,14 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
|
|
||||||
logger.Info("Reading records from %s.%s", schema, entity)
|
logger.Info("Reading records from %s.%s", schema, entity)
|
||||||
|
|
||||||
// Start with Model() to avoid "Model(nil)" errors in Count()
|
// Start with Model() using the slice pointer to avoid "Model(nil)" errors in Count()
|
||||||
query := h.db.NewSelect().Model(model)
|
// Bun's Model() accepts both single pointers and slice pointers
|
||||||
|
query := h.db.NewSelect().Model(modelPtr)
|
||||||
|
|
||||||
// Only set Table() if the model doesn't provide a table name
|
// Only set Table() if the model doesn't provide a table name via the underlying type
|
||||||
if provider, ok := model.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
// Create a temporary instance to check for TableNameProvider
|
||||||
|
tempInstance := reflect.New(modelType).Interface()
|
||||||
|
if provider, ok := tempInstance.(common.TableNameProvider); !ok || provider.TableName() == "" {
|
||||||
query = query.Table(tableName)
|
query = query.Table(tableName)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +239,21 @@ func (h *Handler) handleRead(ctx context.Context, w common.ResponseWriter, id st
|
|||||||
// This may need to be handled differently per database adapter
|
// This may need to be handled differently per database adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters - validate and adjust for column types first
|
||||||
for _, filter := range options.Filters {
|
for i := range options.Filters {
|
||||||
logger.Debug("Applying filter: %s %s %v", filter.Column, filter.Operator, filter.Value)
|
filter := &options.Filters[i]
|
||||||
query = h.applyFilter(query, filter)
|
|
||||||
|
// Validate and adjust filter based on column type
|
||||||
|
castInfo := h.ValidateAndAdjustFilterForColumnType(filter, model)
|
||||||
|
|
||||||
|
// Default to AND if LogicOperator is not set
|
||||||
|
logicOp := filter.LogicOperator
|
||||||
|
if logicOp == "" {
|
||||||
|
logicOp = "AND"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug("Applying filter: %s %s %v (needsCast=%v, logic=%s)", filter.Column, filter.Operator, filter.Value, castInfo.NeedsCast, logicOp)
|
||||||
|
query = h.applyFilter(query, *filter, tableName, castInfo.NeedsCast, logicOp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom SQL WHERE clause (AND condition)
|
// Apply custom SQL WHERE clause (AND condition)
|
||||||
@@ -488,55 +502,96 @@ func (h *Handler) handleDelete(ctx context.Context, w common.ResponseWriter, id
|
|||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption) common.SelectQuery {
|
// qualifyColumnName ensures column name is fully qualified with table name if not already
|
||||||
|
func (h *Handler) qualifyColumnName(columnName, fullTableName string) string {
|
||||||
|
// Check if column already has a table/schema prefix (contains a dot)
|
||||||
|
if strings.Contains(columnName, ".") {
|
||||||
|
return columnName
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no table name provided, return column as-is
|
||||||
|
if fullTableName == "" {
|
||||||
|
return columnName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the table name from "schema.table" format
|
||||||
|
// Only use the table name part, not the schema
|
||||||
|
tableOnly := fullTableName
|
||||||
|
if idx := strings.LastIndex(fullTableName, "."); idx != -1 {
|
||||||
|
tableOnly = fullTableName[idx+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return column qualified with just the table name
|
||||||
|
return fmt.Sprintf("%s.%s", tableOnly, columnName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyFilter(query common.SelectQuery, filter common.FilterOption, tableName string, needsCast bool, logicOp string) common.SelectQuery {
|
||||||
|
// Qualify the column name with table name if not already qualified
|
||||||
|
qualifiedColumn := h.qualifyColumnName(filter.Column, tableName)
|
||||||
|
|
||||||
|
// Apply casting to text if needed for non-numeric columns or non-numeric values
|
||||||
|
if needsCast {
|
||||||
|
qualifiedColumn = fmt.Sprintf("CAST(%s AS TEXT)", qualifiedColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to apply the correct Where method based on logic operator
|
||||||
|
applyWhere := func(condition string, args ...interface{}) common.SelectQuery {
|
||||||
|
if logicOp == "OR" {
|
||||||
|
return query.WhereOr(condition, args...)
|
||||||
|
}
|
||||||
|
return query.Where(condition, args...)
|
||||||
|
}
|
||||||
|
|
||||||
switch strings.ToLower(filter.Operator) {
|
switch strings.ToLower(filter.Operator) {
|
||||||
case "eq", "equals":
|
case "eq", "equals":
|
||||||
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
||||||
case "neq", "not_equals", "ne":
|
case "neq", "not_equals", "ne":
|
||||||
return query.Where(fmt.Sprintf("%s != ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s != ?", qualifiedColumn), filter.Value)
|
||||||
case "gt", "greater_than":
|
case "gt", "greater_than":
|
||||||
return query.Where(fmt.Sprintf("%s > ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s > ?", qualifiedColumn), filter.Value)
|
||||||
case "gte", "greater_than_equals", "ge":
|
case "gte", "greater_than_equals", "ge":
|
||||||
return query.Where(fmt.Sprintf("%s >= ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s >= ?", qualifiedColumn), filter.Value)
|
||||||
case "lt", "less_than":
|
case "lt", "less_than":
|
||||||
return query.Where(fmt.Sprintf("%s < ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s < ?", qualifiedColumn), filter.Value)
|
||||||
case "lte", "less_than_equals", "le":
|
case "lte", "less_than_equals", "le":
|
||||||
return query.Where(fmt.Sprintf("%s <= ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s <= ?", qualifiedColumn), filter.Value)
|
||||||
case "like":
|
case "like":
|
||||||
return query.Where(fmt.Sprintf("%s LIKE ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s LIKE ?", qualifiedColumn), filter.Value)
|
||||||
case "ilike":
|
case "ilike":
|
||||||
// Use ILIKE for case-insensitive search (PostgreSQL)
|
// Use ILIKE for case-insensitive search (PostgreSQL)
|
||||||
// For other databases, cast to citext or use LOWER()
|
// Column is already cast to TEXT if needed
|
||||||
return query.Where(fmt.Sprintf("CAST(%s AS TEXT) ILIKE ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s ILIKE ?", qualifiedColumn), filter.Value)
|
||||||
case "in":
|
case "in":
|
||||||
return query.Where(fmt.Sprintf("%s IN (?)", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s IN (?)", qualifiedColumn), filter.Value)
|
||||||
case "between":
|
case "between":
|
||||||
// Handle between operator - exclusive (> val1 AND < val2)
|
// Handle between operator - exclusive (> val1 AND < val2)
|
||||||
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s > ? AND %s < ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s > ? AND %s < ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s > ? AND %s < ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s > ? AND %s < ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
}
|
}
|
||||||
logger.Warn("Invalid BETWEEN filter value format")
|
logger.Warn("Invalid BETWEEN filter value format")
|
||||||
return query
|
return query
|
||||||
case "between_inclusive":
|
case "between_inclusive":
|
||||||
// Handle between inclusive operator - inclusive (>= val1 AND <= val2)
|
// Handle between inclusive operator - inclusive (>= val1 AND <= val2)
|
||||||
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
if values, ok := filter.Value.([]interface{}); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s >= ? AND %s <= ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s >= ? AND %s <= ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
} else if values, ok := filter.Value.([]string); ok && len(values) == 2 {
|
||||||
return query.Where(fmt.Sprintf("%s >= ? AND %s <= ?", filter.Column, filter.Column), values[0], values[1])
|
return applyWhere(fmt.Sprintf("%s >= ? AND %s <= ?", qualifiedColumn, qualifiedColumn), values[0], values[1])
|
||||||
}
|
}
|
||||||
logger.Warn("Invalid BETWEEN INCLUSIVE filter value format")
|
logger.Warn("Invalid BETWEEN INCLUSIVE filter value format")
|
||||||
return query
|
return query
|
||||||
case "is_null", "isnull":
|
case "is_null", "isnull":
|
||||||
// Check for NULL values
|
// Check for NULL values - don't use cast for NULL checks
|
||||||
return query.Where(fmt.Sprintf("(%s IS NULL OR %s = '')", filter.Column, filter.Column))
|
colName := h.qualifyColumnName(filter.Column, tableName)
|
||||||
|
return applyWhere(fmt.Sprintf("(%s IS NULL OR %s = '')", colName, colName))
|
||||||
case "is_not_null", "isnotnull":
|
case "is_not_null", "isnotnull":
|
||||||
// Check for NOT NULL values
|
// Check for NOT NULL values - don't use cast for NULL checks
|
||||||
return query.Where(fmt.Sprintf("(%s IS NOT NULL AND %s != '')", filter.Column, filter.Column))
|
colName := h.qualifyColumnName(filter.Column, tableName)
|
||||||
|
return applyWhere(fmt.Sprintf("(%s IS NOT NULL AND %s != '')", colName, colName))
|
||||||
default:
|
default:
|
||||||
logger.Warn("Unknown filter operator: %s, defaulting to equals", filter.Operator)
|
logger.Warn("Unknown filter operator: %s, defaulting to equals", filter.Operator)
|
||||||
return query.Where(fmt.Sprintf("%s = ?", filter.Column), filter.Value)
|
return applyWhere(fmt.Sprintf("%s = ?", qualifiedColumn), filter.Value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -235,9 +236,10 @@ func (h *Handler) parseNotSelectFields(options *ExtendedRequestOptions, value st
|
|||||||
func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, value string) {
|
func (h *Handler) parseFieldFilter(options *ExtendedRequestOptions, headerKey, value string) {
|
||||||
colName := strings.TrimPrefix(headerKey, "x-fieldfilter-")
|
colName := strings.TrimPrefix(headerKey, "x-fieldfilter-")
|
||||||
options.Filters = append(options.Filters, common.FilterOption{
|
options.Filters = append(options.Filters, common.FilterOption{
|
||||||
Column: colName,
|
Column: colName,
|
||||||
Operator: "eq",
|
Operator: "eq",
|
||||||
Value: value,
|
Value: value,
|
||||||
|
LogicOperator: "AND", // Default to AND
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,9 +248,10 @@ func (h *Handler) parseSearchFilter(options *ExtendedRequestOptions, headerKey,
|
|||||||
colName := strings.TrimPrefix(headerKey, "x-searchfilter-")
|
colName := strings.TrimPrefix(headerKey, "x-searchfilter-")
|
||||||
// Use ILIKE for fuzzy search
|
// Use ILIKE for fuzzy search
|
||||||
options.Filters = append(options.Filters, common.FilterOption{
|
options.Filters = append(options.Filters, common.FilterOption{
|
||||||
Column: colName,
|
Column: colName,
|
||||||
Operator: "ilike",
|
Operator: "ilike",
|
||||||
Value: "%" + value + "%",
|
Value: "%" + value + "%",
|
||||||
|
LogicOperator: "AND", // Default to AND
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,70 +280,68 @@ func (h *Handler) parseSearchOp(options *ExtendedRequestOptions, headerKey, valu
|
|||||||
colName := parts[1]
|
colName := parts[1]
|
||||||
|
|
||||||
// Map operator names to filter operators
|
// Map operator names to filter operators
|
||||||
filterOp := h.mapSearchOperator(operator, value)
|
filterOp := h.mapSearchOperator(colName, operator, value)
|
||||||
|
|
||||||
|
// Set the logic operator (AND or OR)
|
||||||
|
filterOp.LogicOperator = logicOp
|
||||||
|
|
||||||
options.Filters = append(options.Filters, filterOp)
|
options.Filters = append(options.Filters, filterOp)
|
||||||
|
|
||||||
// Note: OR logic would need special handling in query builder
|
logger.Debug("%s logic filter: %s %s %v", logicOp, colName, filterOp.Operator, filterOp.Value)
|
||||||
// For now, we'll add a comment to indicate OR logic
|
|
||||||
if logicOp == "OR" {
|
|
||||||
// TODO: Implement OR logic in query builder
|
|
||||||
logger.Debug("OR logic filter: %s %s %v", colName, filterOp.Operator, filterOp.Value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapSearchOperator maps search operator names to filter operators
|
// mapSearchOperator maps search operator names to filter operators
|
||||||
func (h *Handler) mapSearchOperator(operator, value string) common.FilterOption {
|
func (h *Handler) mapSearchOperator(colName, operator, value string) common.FilterOption {
|
||||||
operator = strings.ToLower(operator)
|
operator = strings.ToLower(operator)
|
||||||
|
|
||||||
switch operator {
|
switch operator {
|
||||||
case "contains":
|
case "contains", "contain", "like":
|
||||||
return common.FilterOption{Operator: "ilike", Value: "%" + value + "%"}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value + "%"}
|
||||||
case "beginswith", "startswith":
|
case "beginswith", "startswith":
|
||||||
return common.FilterOption{Operator: "ilike", Value: value + "%"}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: value + "%"}
|
||||||
case "endswith":
|
case "endswith":
|
||||||
return common.FilterOption{Operator: "ilike", Value: "%" + value}
|
return common.FilterOption{Column: colName, Operator: "ilike", Value: "%" + value}
|
||||||
case "equals", "eq":
|
case "equals", "eq", "=":
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
case "notequals", "neq", "ne":
|
case "notequals", "neq", "ne", "!=", "<>":
|
||||||
return common.FilterOption{Operator: "neq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "neq", Value: value}
|
||||||
case "greaterthan", "gt":
|
case "greaterthan", "gt", ">":
|
||||||
return common.FilterOption{Operator: "gt", Value: value}
|
return common.FilterOption{Column: colName, Operator: "gt", Value: value}
|
||||||
case "lessthan", "lt":
|
case "lessthan", "lt", "<":
|
||||||
return common.FilterOption{Operator: "lt", Value: value}
|
return common.FilterOption{Column: colName, Operator: "lt", Value: value}
|
||||||
case "greaterthanorequal", "gte", "ge":
|
case "greaterthanorequal", "gte", "ge", ">=":
|
||||||
return common.FilterOption{Operator: "gte", Value: value}
|
return common.FilterOption{Column: colName, Operator: "gte", Value: value}
|
||||||
case "lessthanorequal", "lte", "le":
|
case "lessthanorequal", "lte", "le", "<=":
|
||||||
return common.FilterOption{Operator: "lte", Value: value}
|
return common.FilterOption{Column: colName, Operator: "lte", Value: value}
|
||||||
case "between":
|
case "between":
|
||||||
// Parse between values (format: "value1,value2")
|
// Parse between values (format: "value1,value2")
|
||||||
// Between is exclusive (> value1 AND < value2)
|
// Between is exclusive (> value1 AND < value2)
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
return common.FilterOption{Operator: "between", Value: parts}
|
return common.FilterOption{Column: colName, Operator: "between", Value: parts}
|
||||||
}
|
}
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
case "betweeninclusive":
|
case "betweeninclusive":
|
||||||
// Parse between values (format: "value1,value2")
|
// Parse between values (format: "value1,value2")
|
||||||
// Between inclusive is >= value1 AND <= value2
|
// Between inclusive is >= value1 AND <= value2
|
||||||
parts := strings.Split(value, ",")
|
parts := strings.Split(value, ",")
|
||||||
if len(parts) == 2 {
|
if len(parts) == 2 {
|
||||||
return common.FilterOption{Operator: "between_inclusive", Value: parts}
|
return common.FilterOption{Column: colName, Operator: "between_inclusive", Value: parts}
|
||||||
}
|
}
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
case "in":
|
case "in":
|
||||||
// Parse IN values (format: "value1,value2,value3")
|
// Parse IN values (format: "value1,value2,value3")
|
||||||
values := strings.Split(value, ",")
|
values := strings.Split(value, ",")
|
||||||
return common.FilterOption{Operator: "in", Value: values}
|
return common.FilterOption{Column: colName, Operator: "in", Value: values}
|
||||||
case "empty", "isnull", "null":
|
case "empty", "isnull", "null":
|
||||||
// Check for NULL or empty string
|
// Check for NULL or empty string
|
||||||
return common.FilterOption{Operator: "is_null", Value: nil}
|
return common.FilterOption{Column: colName, Operator: "is_null", Value: nil}
|
||||||
case "notempty", "isnotnull", "notnull":
|
case "notempty", "isnotnull", "notnull":
|
||||||
// Check for NOT NULL
|
// Check for NOT NULL
|
||||||
return common.FilterOption{Operator: "is_not_null", Value: nil}
|
return common.FilterOption{Column: colName, Operator: "is_not_null", Value: nil}
|
||||||
default:
|
default:
|
||||||
logger.Warn("Unknown search operator: %s, defaulting to equals", operator)
|
logger.Warn("Unknown search operator: %s, defaulting to equals", operator)
|
||||||
return common.FilterOption{Operator: "eq", Value: value}
|
return common.FilterOption{Column: colName, Operator: "eq", Value: value}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,10 +428,16 @@ func (h *Handler) parseSorting(options *ExtendedRequestOptions, value string) {
|
|||||||
} else if strings.HasPrefix(field, "+") {
|
} else if strings.HasPrefix(field, "+") {
|
||||||
direction = "ASC"
|
direction = "ASC"
|
||||||
colName = strings.TrimPrefix(field, "+")
|
colName = strings.TrimPrefix(field, "+")
|
||||||
|
} else if strings.HasSuffix(field, " desc") {
|
||||||
|
direction = "DESC"
|
||||||
|
colName = strings.TrimSuffix(field, "desc")
|
||||||
|
} else if strings.HasSuffix(field, " asc") {
|
||||||
|
direction = "ASC"
|
||||||
|
colName = strings.TrimSuffix(field, "asc")
|
||||||
}
|
}
|
||||||
|
|
||||||
options.Sort = append(options.Sort, common.SortOption{
|
options.Sort = append(options.Sort, common.SortOption{
|
||||||
Column: colName,
|
Column: strings.Trim(colName, " "),
|
||||||
Direction: direction,
|
Direction: direction,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -462,3 +469,235 @@ func (h *Handler) parseJSONHeader(value string) (map[string]interface{}, error)
|
|||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getColumnTypeFromModel uses reflection to determine the Go type of a column in a model
|
||||||
|
func (h *Handler) getColumnTypeFromModel(model interface{}, colName string) reflect.Kind {
|
||||||
|
if model == nil {
|
||||||
|
return reflect.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
modelType := reflect.TypeOf(model)
|
||||||
|
// Dereference pointer if needed
|
||||||
|
if modelType.Kind() == reflect.Ptr {
|
||||||
|
modelType = modelType.Elem()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's a struct
|
||||||
|
if modelType.Kind() != reflect.Struct {
|
||||||
|
return reflect.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the field by JSON tag or field name
|
||||||
|
for i := 0; i < modelType.NumField(); i++ {
|
||||||
|
field := modelType.Field(i)
|
||||||
|
|
||||||
|
// Check JSON tag
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
if jsonTag != "" {
|
||||||
|
// Parse JSON tag (format: "name,omitempty")
|
||||||
|
parts := strings.Split(jsonTag, ",")
|
||||||
|
if parts[0] == colName {
|
||||||
|
return field.Type.Kind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check field name (case-insensitive)
|
||||||
|
if strings.EqualFold(field.Name, colName) {
|
||||||
|
return field.Type.Kind()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check snake_case conversion
|
||||||
|
snakeCaseName := toSnakeCase(field.Name)
|
||||||
|
if snakeCaseName == colName {
|
||||||
|
return field.Type.Kind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reflect.Invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// toSnakeCase converts a string from CamelCase to snake_case
|
||||||
|
func toSnakeCase(s string) string {
|
||||||
|
var result strings.Builder
|
||||||
|
for i, r := range s {
|
||||||
|
if i > 0 && r >= 'A' && r <= 'Z' {
|
||||||
|
result.WriteRune('_')
|
||||||
|
}
|
||||||
|
result.WriteRune(r)
|
||||||
|
}
|
||||||
|
return strings.ToLower(result.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumericType checks if a reflect.Kind is a numeric type
|
||||||
|
func isNumericType(kind reflect.Kind) bool {
|
||||||
|
return kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 ||
|
||||||
|
kind == reflect.Int32 || kind == reflect.Int64 || kind == reflect.Uint ||
|
||||||
|
kind == reflect.Uint8 || kind == reflect.Uint16 || kind == reflect.Uint32 ||
|
||||||
|
kind == reflect.Uint64 || kind == reflect.Float32 || kind == reflect.Float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStringType checks if a reflect.Kind is a string type
|
||||||
|
func isStringType(kind reflect.Kind) bool {
|
||||||
|
return kind == reflect.String
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBoolType checks if a reflect.Kind is a boolean type
|
||||||
|
func isBoolType(kind reflect.Kind) bool {
|
||||||
|
return kind == reflect.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToNumericType converts a string value to the appropriate numeric type
|
||||||
|
func convertToNumericType(value string, kind reflect.Kind) (interface{}, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||||
|
// Parse as integer
|
||||||
|
bitSize := 64
|
||||||
|
switch kind {
|
||||||
|
case reflect.Int8:
|
||||||
|
bitSize = 8
|
||||||
|
case reflect.Int16:
|
||||||
|
bitSize = 16
|
||||||
|
case reflect.Int32:
|
||||||
|
bitSize = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
intVal, err := strconv.ParseInt(value, 10, bitSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid integer value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the appropriate type
|
||||||
|
switch kind {
|
||||||
|
case reflect.Int:
|
||||||
|
return int(intVal), nil
|
||||||
|
case reflect.Int8:
|
||||||
|
return int8(intVal), nil
|
||||||
|
case reflect.Int16:
|
||||||
|
return int16(intVal), nil
|
||||||
|
case reflect.Int32:
|
||||||
|
return int32(intVal), nil
|
||||||
|
case reflect.Int64:
|
||||||
|
return intVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
|
// Parse as unsigned integer
|
||||||
|
bitSize := 64
|
||||||
|
switch kind {
|
||||||
|
case reflect.Uint8:
|
||||||
|
bitSize = 8
|
||||||
|
case reflect.Uint16:
|
||||||
|
bitSize = 16
|
||||||
|
case reflect.Uint32:
|
||||||
|
bitSize = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
uintVal, err := strconv.ParseUint(value, 10, bitSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid unsigned integer value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the appropriate type
|
||||||
|
switch kind {
|
||||||
|
case reflect.Uint:
|
||||||
|
return uint(uintVal), nil
|
||||||
|
case reflect.Uint8:
|
||||||
|
return uint8(uintVal), nil
|
||||||
|
case reflect.Uint16:
|
||||||
|
return uint16(uintVal), nil
|
||||||
|
case reflect.Uint32:
|
||||||
|
return uint32(uintVal), nil
|
||||||
|
case reflect.Uint64:
|
||||||
|
return uintVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case reflect.Float32, reflect.Float64:
|
||||||
|
// Parse as float
|
||||||
|
bitSize := 64
|
||||||
|
if kind == reflect.Float32 {
|
||||||
|
bitSize = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
floatVal, err := strconv.ParseFloat(value, bitSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid float value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if kind == reflect.Float32 {
|
||||||
|
return float32(floatVal), nil
|
||||||
|
}
|
||||||
|
return floatVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unsupported numeric type: %v", kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNumericValue checks if a string value can be parsed as a number
|
||||||
|
func isNumericValue(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
_, err := strconv.ParseFloat(value, 64)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColumnCastInfo holds information about whether a column needs casting
|
||||||
|
type ColumnCastInfo struct {
|
||||||
|
NeedsCast bool
|
||||||
|
IsNumericType bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAndAdjustFilterForColumnType validates and adjusts a filter based on column type
|
||||||
|
// Returns ColumnCastInfo indicating whether the column should be cast to text in SQL
|
||||||
|
func (h *Handler) ValidateAndAdjustFilterForColumnType(filter *common.FilterOption, model interface{}) ColumnCastInfo {
|
||||||
|
if filter == nil || model == nil {
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
colType := h.getColumnTypeFromModel(model, filter.Column)
|
||||||
|
if colType == reflect.Invalid {
|
||||||
|
// Column not found in model, no casting needed
|
||||||
|
logger.Debug("Column %s not found in model, skipping type validation", filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the input value is numeric
|
||||||
|
valueIsNumeric := false
|
||||||
|
if strVal, ok := filter.Value.(string); ok {
|
||||||
|
strVal = strings.Trim(strVal, "%")
|
||||||
|
valueIsNumeric = isNumericValue(strVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust based on column type
|
||||||
|
switch {
|
||||||
|
case isNumericType(colType):
|
||||||
|
// Column is numeric
|
||||||
|
if valueIsNumeric {
|
||||||
|
// Value is numeric - try to convert it
|
||||||
|
if strVal, ok := filter.Value.(string); ok {
|
||||||
|
strVal = strings.Trim(strVal, "%")
|
||||||
|
numericVal, err := convertToNumericType(strVal, colType)
|
||||||
|
if err != nil {
|
||||||
|
logger.Debug("Failed to convert value '%s' to numeric type for column %s, will use text cast", strVal, filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: true, IsNumericType: true}
|
||||||
|
}
|
||||||
|
filter.Value = numericVal
|
||||||
|
}
|
||||||
|
// No cast needed - numeric column with numeric value
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: true}
|
||||||
|
} else {
|
||||||
|
// Value is not numeric - cast column to text for comparison
|
||||||
|
logger.Debug("Non-numeric value for numeric column %s, will cast to text", filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: true, IsNumericType: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
case isStringType(colType):
|
||||||
|
// String columns don't need casting
|
||||||
|
return ColumnCastInfo{NeedsCast: false, IsNumericType: false}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// For bool, time.Time, and other complex types - cast to text
|
||||||
|
logger.Debug("Complex type column %s, will cast to text", filter.Column)
|
||||||
|
return ColumnCastInfo{NeedsCast: true, IsNumericType: false}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
152
todo.md
Normal file
152
todo.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# ResolveSpec - TODO List
|
||||||
|
|
||||||
|
This document tracks incomplete features and improvements for the ResolveSpec project.
|
||||||
|
|
||||||
|
## Core Features to Implement
|
||||||
|
|
||||||
|
### 1. Column Selection and Filtering for Preloads
|
||||||
|
**Location:** `pkg/resolvespec/handler.go:730`
|
||||||
|
**Status:** Not Implemented
|
||||||
|
**Description:** Currently, preloads are applied without any column selection or filtering. This feature would allow clients to:
|
||||||
|
- Select specific columns for preloaded relationships
|
||||||
|
- Apply filters to preloaded data
|
||||||
|
- Reduce payload size and improve performance
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
// For now, we'll preload without conditions
|
||||||
|
// TODO: Implement column selection and filtering for preloads
|
||||||
|
// This requires a more sophisticated approach with callbacks or query builders
|
||||||
|
query = query.Preload(relationFieldName)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Add support for column selection in preloaded relationships
|
||||||
|
- Implement filtering conditions for preloaded data
|
||||||
|
- Design a callback or query builder approach that works across different ORMs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Recursive JSON Cleaning
|
||||||
|
**Location:** `pkg/restheadspec/handler.go:796`
|
||||||
|
**Status:** Partially Implemented (Simplified)
|
||||||
|
**Description:** The current `cleanJSON` function returns data as-is without recursively removing null and empty fields from nested structures.
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
// This is a simplified implementation
|
||||||
|
// A full implementation would recursively clean nested structures
|
||||||
|
// For now, we'll return the data as-is
|
||||||
|
// TODO: Implement recursive cleaning
|
||||||
|
return data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Recursively traverse nested structures (maps, slices, structs)
|
||||||
|
- Remove null values
|
||||||
|
- Remove empty objects and arrays
|
||||||
|
- Handle edge cases (circular references, pointers, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Custom SQL Join Support
|
||||||
|
**Location:** `pkg/restheadspec/headers.go:159`
|
||||||
|
**Status:** Not Implemented
|
||||||
|
**Description:** Support for custom SQL joins via the `X-Custom-SQL-Join` header is currently logged but not executed.
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
case strings.HasPrefix(normalizedKey, "x-custom-sql-join"):
|
||||||
|
// TODO: Implement custom SQL join
|
||||||
|
logger.Debug("Custom SQL join not yet implemented: %s", decodedValue)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Parse custom SQL join expressions from headers
|
||||||
|
- Apply joins to the query builder
|
||||||
|
- Ensure security (SQL injection prevention)
|
||||||
|
- Support for different join types (INNER, LEFT, RIGHT, FULL)
|
||||||
|
- Works across different database adapters (GORM, Bun)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Proper Condition Handling for Bun Preloads
|
||||||
|
**Location:** `pkg/common/adapters/database/bun.go:202`
|
||||||
|
**Status:** Partially Implemented
|
||||||
|
**Description:** The Bun adapter's `Preload` method currently ignores conditions passed to it.
|
||||||
|
|
||||||
|
**Current Limitation:**
|
||||||
|
```go
|
||||||
|
func (b *BunSelectQuery) Preload(relation string, conditions ...interface{}) common.SelectQuery {
|
||||||
|
// Bun uses Relation() method for preloading
|
||||||
|
// For now, we'll just pass the relation name without conditions
|
||||||
|
// TODO: Implement proper condition handling for Bun
|
||||||
|
b.query = b.query.Relation(relation)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Required Implementation:**
|
||||||
|
- Properly handle condition parameters in Bun's Relation() method
|
||||||
|
- Support filtering on preloaded relationships
|
||||||
|
- Ensure compatibility with GORM's condition syntax where possible
|
||||||
|
- Test with various condition types
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality Improvements
|
||||||
|
|
||||||
|
### 5. Modernize Go Type Declarations
|
||||||
|
**Location:** `pkg/common/types.go:5, 42, 64, 79`
|
||||||
|
**Status:** Pending
|
||||||
|
**Priority:** Low
|
||||||
|
**Description:** Replace legacy `interface{}` with modern `any` type alias (Go 1.18+).
|
||||||
|
|
||||||
|
**Affected Lines:**
|
||||||
|
- Line 5: Function parameter or return type
|
||||||
|
- Line 42: Function parameter or return type
|
||||||
|
- Line 64: Function parameter or return type
|
||||||
|
- Line 79: Function parameter or return type
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- More modern and idiomatic Go code
|
||||||
|
- Better readability
|
||||||
|
- Aligns with current Go best practices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Considerations
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Ensure all new features are documented in README.md
|
||||||
|
- Update examples to showcase new functionality
|
||||||
|
- Add migration notes if any breaking changes are introduced
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- Add unit tests for each new feature
|
||||||
|
- Add integration tests for database adapter compatibility
|
||||||
|
- Ensure backward compatibility is maintained
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Profile preload performance with column selection and filtering
|
||||||
|
- Optimize recursive JSON cleaning for large payloads
|
||||||
|
- Benchmark custom SQL join performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Ranking
|
||||||
|
|
||||||
|
1. **High Priority**
|
||||||
|
- Column Selection and Filtering for Preloads (#1)
|
||||||
|
- Proper Condition Handling for Bun Preloads (#4)
|
||||||
|
|
||||||
|
2. **Medium Priority**
|
||||||
|
- Custom SQL Join Support (#3)
|
||||||
|
- Recursive JSON Cleaning (#2)
|
||||||
|
|
||||||
|
3. **Low Priority**
|
||||||
|
- Modernize Go Type Declarations (#5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** 2025-11-07
|
||||||
Reference in New Issue
Block a user