Introducción
En sistemas de software donde se trabaja con múltiples bases de datos y APIs, es común la necesidad de realizar consultas dinámicas y complejas que involucran distintos tipos de filtros. Sin embargo, estos filtros y operadores suelen estar “atados” a la tecnología subyacente (por ejemplo, MongoDB o SQL), lo que limita la reutilización del código y reduce la flexibilidad del sistema. En este artículo, abordamos la creación de un sistema abstracto de filtros utilizando estructuras de datos genéricas en Go. Este enfoque permite construir filtros que pueden ser traducidos a diferentes fuentes de datos sin “casarse” con ninguna en particular, haciendo que el sistema sea extensible y adaptable.
Contexto y Motivación
El uso de operadores específicos de base de datos, como $eq, $gt en MongoDB o = y LIKE en SQL, crea un problema cuando se desea utilizar la misma lógica de búsqueda en diferentes sistemas. Para resolver esto, necesitamos una solución que permita definir consultas de filtrado de manera abstracta, usando operadores genéricos que luego puedan ser traducidos según la fuente de datos. Esto no solo mejora la reutilización del código, sino que también permite una mayor flexibilidad a la hora de integrar nuevas tecnologías.
Estructuras de Datos Genéricas para Filtros
El primer paso para lograr esta independencia es definir estructuras de datos que representen los filtros de manera abstracta. Para este propósito, definimos las siguientes estructuras:
package models
type SearchQuery struct {
LogicalOperator string `json:"logicalOperator"` // AND / OR
Filters []FilterCondition `json:"filters"`
}
type FilterCondition struct {
Field string `json:"field"`
Operator Operator `json:"operator"`
TargetValue interface{} `json:"value"`
NestedFilters []FilterCondition `json:"nestedFilters,omitempty"`
}
type Operator string
const (
Equals Operator = "EQUALS"
GreaterThan Operator = "GREATER_THAN"
LessThan Operator = "LESS_THAN"
Contains Operator = "CONTAINS"
In Operator = "IN"
NotEquals Operator = "NOT_EQUALS"
GreaterOrEqual Operator = "GREATER_OR_EQUAL"
LessOrEqual Operator = "LESS_OR_EQUAL"
ElementMatch Operator = "ELEMENT_MATCH" // Para arrays o subcolecciones
)
Esta estructura define:
SearchQuery: Representa una consulta de búsqueda que puede incluir múltiples filtros y un operador lógico para combinarlos (ANDoOR).FilterCondition: Cada condición de filtro incluye un campo, un operador genérico (Equals,GreaterThan, etc.), y un valor de destino. Además, puede incluir filtros anidados, permitiendo búsquedas recursivas y más complejas.Operator: Se define un conjunto de operadores genéricos que son abstractos y pueden ser traducidos a operadores específicos de base de datos o API en capas posteriores.
Traducción de Operadores Genéricos
Para que el SearchQuery sea compatible con diversas fuentes de datos, necesitamos traducir los operadores abstractos a los operadores específicos de cada tecnología. Por ejemplo, en MongoDB, el operador EQUALS se traduciría a $eq, mientras que en SQL se traduciría a =.
Traducción para MongoDB
En el caso de MongoDB, los operadores deben traducirse a los operadores esperados por su sintaxis ($eq, $gt, etc.). A continuación se muestra una función que realiza esta traducción:
func translateToMongoOperator(operator models.Operator) string {
switch operator {
case models.Equals:
return "$eq"
case models.GreaterThan:
return "$gt"
case models.LessThan:
return "$lt"
case models.Contains:
return "$regex"
case models.In:
return "$in"
case models.NotEquals:
return "$ne"
case models.GreaterOrEqual:
return "$gte"
case models.LessOrEqual:
return "$lte"
case models.ElementMatch:
return "$elemMatch"
default:
return "$eq" // Valor predeterminado si no se reconoce el operador
}
}
Traducción para SQL
Del mismo modo, en SQL, los operadores genéricos deben traducirse a los equivalentes SQL:
func translateToSQLOperator(operator models.Operator) string {
switch operator {
case models.Equals:
return "="
case models.GreaterThan:
return ">"
case models.LessThan:
return "<"
case models.Contains:
return "LIKE"
case models.In:
return "IN"
case models.NotEquals:
return "!="
case models.GreaterOrEqual:
return ">="
case models.LessOrEqual:
return "<="
default:
return "=" // Valor predeterminado
}
}
Construcción del Filtro Dinámico en MongoDB
Con la traducción de operadores implementada, podemos construir los filtros específicos de MongoDB a partir de la estructura genérica de SearchQuery. La recursividad es clave aquí, permitiendo manejar filtros anidados con operadores lógicos.
func buildMongoFilter(query models.SearchQuery) (bson.M, error) {
if err := validateFilters(query.Filters); err != nil {
return nil, err
}
filter := bson.M{}
var conditions []bson.M
for _, f := range query.Filters {
var currentFilter bson.M
if len(f.NestedFilters) > 0 {
subFilter, err := buildMongoFilter(models.SearchQuery{
LogicalOperator: query.LogicalOperator,
Filters: f.NestedFilters,
})
if err != nil {
return nil, err
}
currentFilter = bson.M{f.Field: bson.M{translateToMongoOperator(f.Operator): subFilter}}
} else {
currentFilter = bson.M{f.Field: bson.M{translateToMongoOperator(f.Operator): f.TargetValue}}
}
conditions = append(conditions, currentFilter)
}
if len(conditions) > 0 {
switch query.LogicalOperator {
case "OR":
filter["$or"] = conditions
case "AND":
filter["$and"] = conditions
default:
filter["$and"] = conditions
}
}
return filter, nil
}
Construcción del Filtro Dinámico en SQL
El mismo enfoque puede ser aplicado para SQL. Aquí, los filtros se construyen concatenando las condiciones y traduciendo los operadores genéricos a operadores SQL.
func buildSQLFilter(query models.SearchQuery) (string, error) {
if err := validateFilters(query.Filters); err != nil {
return "", err
}
var conditions []string
for _, f := range query.Filters {
if len(f.NestedFilters) > 0 {
subFilter, err := buildSQLFilter(models.SearchQuery{
LogicalOperator: query.LogicalOperator,
Filters: f.NestedFilters,
})
if err != nil {
return "", err
}
conditions = append(conditions, fmt.Sprintf("(%s)", subFilter))
} else {
operator := translateToSQLOperator(f.Operator)
conditions = append(conditions, fmt.Sprintf("%s %s '%v'", f.Field, operator, f.TargetValue))
}
}
filter := strings.Join(conditions, fmt.Sprintf(" %s ", query.LogicalOperator))
return filter, nil
}
Ejemplos de Uso
Ejemplo en MongoDB
Supongamos que queremos buscar clientes mayores de 25 años, que vivan en Nueva York, y que tengan pedidos cuyo total sea mayor a 100 o cuyo estado sea completed. El SearchQuery se vería así:
searchQuery := models.SearchQuery{
LogicalOperator: "AND",
Filters: []models.FilterCondition{
{
Field: "age",
Operator: models.GreaterThan,
TargetValue: 25,
},
{
Field: "city",
Operator: models.Equals,
TargetValue: "New York",
},
{
Field: "orders",
Operator: models.ElementMatch,
NestedFilters: []models.FilterCondition{
{
Field: "orderTotal",
Operator: models.GreaterThan,
TargetValue: 100,
},
{
Field: "orderStatus",
Operator: models.Equals,
TargetValue: "completed",
},
},
},
},
}
En el repositorio MongoDB, podríamos usar esta estructura para generar el filtro y realizar la consulta:
func (r *CustomerTidyMongoDBRepository) FindFilteredCustomers(ctx context.Context, searchQuery models.SearchQuery) ([]*models.Customer, error) {
filter, err := buildMongoFilter(searchQuery)
if err != nil {
return nil, fmt.Errorf("error building filter: %v", err)
}
var customers []*models.Customer
cursor, err := r.collection.Find(ctx, filter)
if err != nil {
return nil, err
}
if err := cursor.All(ctx, &customers); err != nil {
return nil, err
}
return customers, nil
}
Ejemplo en SQL
Usando el mismo SearchQuery, en un entorno SQL se podría construir la consulta de la siguiente manera:
func (r *CustomerSQLRepository) FindFilteredCustomers(ctx context.Context, searchQuery models.SearchQuery) ([]*models.Customer, error) {
filter, err := buildSQLFilter(searchQuery)
if err != nil {
return nil, fmt.Errorf("error building filter: %v", err)
}
query := fmt.Sprintf("SELECT * FROM customers WHERE %s", filter)
rows, err := r.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []*models.Customer
for rows.Next() {
var customer models.Customer
if err := rows.Scan(&customer); err != nil {
return nil, err
}
customers = append(customers, &customer)
}
return customers, nil
}
Conclusión
El enfoque presentado en este artículo ofrece una solución abstracta y flexible para la construcción de filtros de búsqueda que pueden ser reutilizados en diferentes fuentes de datos. Al desacoplar los operadores específicos de la base de datos y utilizar operadores genéricos que pueden traducirse a MongoDB, SQL o cualquier otra fuente de datos, este diseño permite una mayor modularidad y extensibilidad en el sistema. Este tipo de arquitectura no solo mejora la reutilización del código, sino que también facilita la integración de nuevas tecnologías sin necesidad de realizar cambios significativos en la lógica de búsqueda.
Este sistema permite, a largo plazo, una flexibilidad que habilita la implementación de nuevas fuentes de datos de manera ágil, reduciendo costos de desarrollo y asegurando una arquitectura más limpia y escalable.