Files
2026-04-14 01:21:54 +00:00

462 lines
14 KiB
Go

package query
import (
"fmt"
"reflect"
"strings"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"
)
// MongoQueryBuilderImpl implements MongoQueryBuilder interface
type MongoQueryBuilderImpl struct {
allowedFields map[string]bool // Security: only allow specified fields
allowedCollections map[string]bool // Security: only allow specified collections
// Security settings
enableSecurityChecks bool
maxAllowedDocs int
// Query logging
enableQueryLogging bool
// Connection timeout settings
queryTimeout int
}
// NewMongoQueryBuilder creates a new MongoDB query builder instance
func NewMongoQueryBuilder() *MongoQueryBuilderImpl {
return &MongoQueryBuilderImpl{
allowedFields: make(map[string]bool),
allowedCollections: make(map[string]bool),
enableSecurityChecks: true,
maxAllowedDocs: 10000,
enableQueryLogging: true,
queryTimeout: 30,
}
}
// SetSecurityOptions configures security settings
func (mqb *MongoQueryBuilderImpl) SetSecurityOptions(enableChecks bool, maxDocs int) MongoQueryBuilder {
mqb.enableSecurityChecks = enableChecks
mqb.maxAllowedDocs = maxDocs
return mqb
}
// SetAllowedFields sets the list of allowed fields for security
func (mqb *MongoQueryBuilderImpl) SetAllowedFields(fields []string) MongoQueryBuilder {
mqb.allowedFields = make(map[string]bool)
for _, field := range fields {
mqb.allowedFields[field] = true
}
return mqb
}
// SetAllowedCollections sets the list of allowed collections for security
func (mqb *MongoQueryBuilderImpl) SetAllowedCollections(collections []string) MongoQueryBuilder {
mqb.allowedCollections = make(map[string]bool)
for _, collection := range collections {
mqb.allowedCollections[collection] = true
}
return mqb
}
// SetQueryLogging enables or disables query logging
func (mqb *MongoQueryBuilderImpl) SetQueryLogging(enable bool) MongoQueryBuilder {
mqb.enableQueryLogging = enable
return mqb
}
// SetQueryTimeout sets the default query timeout
func (mqb *MongoQueryBuilderImpl) SetQueryTimeout(timeout time.Duration) MongoQueryBuilder {
mqb.queryTimeout = int(timeout.Seconds())
return mqb
}
// BuildFindQuery builds a MongoDB find query from DynamicQuery
func (mqb *MongoQueryBuilderImpl) BuildFindQuery(query DynamicQuery) (interface{}, interface{}, error) {
filter := bson.M{}
findOptions := options.Find()
// Security check for limit
if mqb.enableSecurityChecks && query.Limit > mqb.maxAllowedDocs {
return nil, nil, fmt.Errorf("requested limit %d exceeds maximum allowed %d", query.Limit, mqb.maxAllowedDocs)
}
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[query.From] {
return nil, nil, fmt.Errorf("disallowed collection: %s", query.From)
}
// Build filter from DynamicQuery filters
if len(query.Filters) > 0 {
mongoFilter, err := mqb.buildFilter(query.Filters)
if err != nil {
return nil, nil, err
}
filter = mongoFilter
}
// Set projection from fields
if len(query.Fields) > 0 {
projection := bson.M{}
for _, field := range query.Fields {
if field.Expression == "*" {
// Include all fields
continue
}
fieldName := field.Expression
if field.Alias != "" {
fieldName = field.Alias
}
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
return nil, nil, fmt.Errorf("disallowed field: %s", fieldName)
}
projection[fieldName] = 1
}
if len(projection) > 0 {
findOptions.SetProjection(projection)
}
}
// Set sort
if len(query.Sort) > 0 {
sort := bson.D{}
for _, sortField := range query.Sort {
fieldName := sortField.Column
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
return nil, nil, fmt.Errorf("disallowed field: %s", fieldName)
}
order := 1 // ASC
if strings.ToUpper(sortField.Order) == "DESC" {
order = -1 // DESC
}
sort = append(sort, bson.E{Key: fieldName, Value: order})
}
findOptions.SetSort(sort)
}
// Set limit and offset
if query.Limit > 0 {
findOptions.SetLimit(int64(query.Limit))
}
if query.Offset > 0 {
findOptions.SetSkip(int64(query.Offset))
}
return filter, findOptions, nil
}
// BuildAggregateQuery builds a MongoDB aggregation pipeline from DynamicQuery
func (mqb *MongoQueryBuilderImpl) BuildAggregateQuery(query DynamicQuery) (interface{}, error) {
pipeline := []bson.D{}
// Security check for collection name
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[query.From] {
return nil, fmt.Errorf("disallowed collection: %s", query.From)
}
// Handle CTEs as stages in the pipeline
if len(query.CTEs) > 0 {
for _, cte := range query.CTEs {
// Security check for CTE collection
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[cte.Query.From] {
return nil, fmt.Errorf("disallowed collection in CTE: %s", cte.Query.From)
}
subPipeline, err := mqb.BuildAggregateQuery(cte.Query)
if err != nil {
return nil, fmt.Errorf("failed to build CTE '%s': %w", cte.Name, err)
}
// Add $lookup stage for joins
if len(cte.Query.Joins) > 0 {
for _, join := range cte.Query.Joins {
// Security check for joined collection
if mqb.enableSecurityChecks && len(mqb.allowedCollections) > 0 && !mqb.allowedCollections[join.Table] {
return nil, fmt.Errorf("disallowed collection in join: %s", join.Table)
}
lookupStage := bson.D{
{Key: "$lookup", Value: bson.D{
{Key: "from", Value: join.Table},
{Key: "localField", Value: join.Alias},
{Key: "foreignField", Value: "_id"},
{Key: "as", Value: join.Alias},
}},
}
pipeline = append(pipeline, lookupStage)
}
}
// Add the sub-pipeline
if subPipelineSlice, ok := subPipeline.([]bson.D); ok {
pipeline = append(pipeline, subPipelineSlice...)
}
}
}
// Match stage for filters
if len(query.Filters) > 0 {
filter, err := mqb.buildFilter(query.Filters)
if err != nil {
return nil, err
}
pipeline = append(pipeline, bson.D{{Key: "$match", Value: filter}})
}
// Group stage for GROUP BY
if len(query.GroupBy) > 0 {
groupID := bson.D{}
for _, field := range query.GroupBy {
if mqb.allowedFields != nil && !mqb.allowedFields[field] {
return nil, fmt.Errorf("disallowed field: %s", field)
}
groupID = append(groupID, bson.E{Key: field, Value: "$" + field})
}
groupStage := bson.D{
{Key: "$group", Value: bson.D{
{Key: "_id", Value: groupID},
}},
}
// Add any aggregations from fields
for _, field := range query.Fields {
if strings.Contains(field.Expression, "(") && strings.Contains(field.Expression, ")") {
// This is an aggregation function
funcName := strings.Split(field.Expression, "(")[0]
funcField := strings.TrimSuffix(strings.Split(field.Expression, "(")[1], ")")
if mqb.allowedFields != nil && !mqb.allowedFields[funcField] {
return nil, fmt.Errorf("disallowed field: %s", funcField)
}
switch strings.ToLower(funcName) {
case "count":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$sum", Value: 1}},
})
case "sum":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$sum", Value: "$" + funcField}},
})
case "avg":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$avg", Value: "$" + funcField}},
})
case "min":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$min", Value: "$" + funcField}},
})
case "max":
groupStage = append(groupStage, bson.E{
Key: field.Alias, Value: bson.D{{Key: "$max", Value: "$" + funcField}},
})
}
}
}
pipeline = append(pipeline, groupStage)
}
// Sort stage
if len(query.Sort) > 0 {
sort := bson.D{}
for _, sortField := range query.Sort {
fieldName := sortField.Column
if mqb.allowedFields != nil && !mqb.allowedFields[fieldName] {
return nil, fmt.Errorf("disallowed field: %s", fieldName)
}
order := 1 // ASC
if strings.ToUpper(sortField.Order) == "DESC" {
order = -1 // DESC
}
sort = append(sort, bson.E{Key: fieldName, Value: order})
}
pipeline = append(pipeline, bson.D{{Key: "$sort", Value: sort}})
}
// Skip and limit stages
if query.Offset > 0 {
pipeline = append(pipeline, bson.D{{Key: "$skip", Value: query.Offset}})
}
if query.Limit > 0 {
pipeline = append(pipeline, bson.D{{Key: "$limit", Value: query.Limit}})
}
return pipeline, nil
}
// buildFilter builds a MongoDB filter from FilterGroups
func (mqb *MongoQueryBuilderImpl) buildFilter(filterGroups []FilterGroup) (bson.M, error) {
if len(filterGroups) == 0 {
return bson.M{}, nil
}
var result bson.M
var err error
for i, group := range filterGroups {
if len(group.Filters) == 0 {
continue
}
groupFilter, err := mqb.buildFilterGroup(group)
if err != nil {
return nil, err
}
if i == 0 {
result = groupFilter
} else {
logicOp := "$and"
if group.LogicOp != "" {
switch strings.ToUpper(group.LogicOp) {
case "OR":
logicOp = "$or"
}
}
result = bson.M{logicOp: []bson.M{result, groupFilter}}
}
}
return result, err
}
// buildFilterGroup builds a filter for a single filter group
func (mqb *MongoQueryBuilderImpl) buildFilterGroup(group FilterGroup) (bson.M, error) {
var filters []bson.M
logicOp := "$and"
if group.LogicOp != "" {
switch strings.ToUpper(group.LogicOp) {
case "OR":
logicOp = "$or"
}
}
for _, filter := range group.Filters {
fieldFilter, err := mqb.buildFilterCondition(filter)
if err != nil {
return nil, err
}
filters = append(filters, fieldFilter)
}
if len(filters) == 1 {
return filters[0], nil
}
return bson.M{logicOp: filters}, nil
}
// buildFilterCondition builds a single filter condition for MongoDB
func (mqb *MongoQueryBuilderImpl) buildFilterCondition(filter DynamicFilter) (bson.M, error) {
field := filter.Column
if mqb.allowedFields != nil && !mqb.allowedFields[field] {
return nil, fmt.Errorf("disallowed field: %s", field)
}
switch filter.Operator {
case OpEqual:
return bson.M{field: filter.Value}, nil
case OpNotEqual:
return bson.M{field: bson.M{"$ne": filter.Value}}, nil
case OpIn:
values := mqb.parseArrayValue(filter.Value)
return bson.M{field: bson.M{"$in": values}}, nil
case OpNotIn:
values := mqb.parseArrayValue(filter.Value)
return bson.M{field: bson.M{"$nin": values}}, nil
case OpGreaterThan:
return bson.M{field: bson.M{"$gt": filter.Value}}, nil
case OpGreaterThanEqual:
return bson.M{field: bson.M{"$gte": filter.Value}}, nil
case OpLessThan:
return bson.M{field: bson.M{"$lt": filter.Value}}, nil
case OpLessThanEqual:
return bson.M{field: bson.M{"$lte": filter.Value}}, nil
case OpLike:
// Convert SQL LIKE to MongoDB regex
pattern := filter.Value.(string)
pattern = strings.ReplaceAll(pattern, "%", ".*")
pattern = strings.ReplaceAll(pattern, "_", ".")
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
case OpILike:
// Case-insensitive like
pattern := filter.Value.(string)
pattern = strings.ReplaceAll(pattern, "%", ".*")
pattern = strings.ReplaceAll(pattern, "_", ".")
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
case OpContains:
// Contains substring
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$regex": pattern, "$options": "i"}}, nil
case OpNotContains:
// Does not contain substring
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$not": bson.M{"$regex": pattern, "$options": "i"}}}, nil
case OpStartsWith:
// Starts with
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$regex": "^" + pattern, "$options": "i"}}, nil
case OpEndsWith:
// Ends with
pattern := filter.Value.(string)
return bson.M{field: bson.M{"$regex": pattern + "$", "$options": "i"}}, nil
case OpNull:
return bson.M{field: bson.M{"$exists": false}}, nil
case OpNotNull:
return bson.M{field: bson.M{"$exists": true}}, nil
case OpJsonContains:
// JSON contains
return bson.M{field: bson.M{"$elemMatch": filter.Value}}, nil
case OpJsonNotContains:
// JSON does not contain
return bson.M{field: bson.M{"$not": bson.M{"$elemMatch": filter.Value}}}, nil
case OpJsonExists:
// JSON path exists
return bson.M{field + "." + filter.Options["path"].(string): bson.M{"$exists": true}}, nil
case OpJsonNotExists:
// JSON path does not exist
return bson.M{field + "." + filter.Options["path"].(string): bson.M{"$exists": false}}, nil
case OpArrayContains:
// Array contains
return bson.M{field: bson.M{"$elemMatch": bson.M{"$eq": filter.Value}}}, nil
case OpArrayNotContains:
// Array does not contain
return bson.M{field: bson.M{"$not": bson.M{"$elemMatch": bson.M{"$eq": filter.Value}}}}, nil
case OpArrayLength:
// Array length
if lengthOption, ok := filter.Options["length"].(int); ok {
return bson.M{field: bson.M{"$size": lengthOption}}, nil
}
return nil, fmt.Errorf("array_length operator requires 'length' option")
default:
return nil, fmt.Errorf("unsupported operator: %s", filter.Operator)
}
}
// parseArrayValue parses an array value for MongoDB
func (mqb *MongoQueryBuilderImpl) parseArrayValue(value interface{}) []interface{} {
if value == nil {
return nil
}
if reflect.TypeOf(value).Kind() == reflect.Slice {
v := reflect.ValueOf(value)
result := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
result[i] = v.Index(i).Interface()
}
return result
}
if str, ok := value.(string); ok {
if strings.Contains(str, ",") {
parts := strings.Split(str, ",")
result := make([]interface{}, len(parts))
for i, part := range parts {
result[i] = strings.TrimSpace(part)
}
return result
}
return []interface{}{str}
}
return []interface{}{value}
}