Escribí varias entradas (1), 2), 3) y 4)) sobre mis aprendizajes creando componentes de tabla, y este post puede considerarse la tercera parte, ya que desarrollé un generador de consultas (query builder) para ese componente, aunque puede utilizarse en otros casos de uso.
Un generador de consultas proporciona una interfaz conveniente y (normalmente) sencilla para crear y ejecutar consultas (filtrado) sobre un conjunto de datos.
En TypeScript (o cualquier otro lenguaje), si quieres filtrar un array de objetos como este:
const data = [
{ field1: 1, field2: "a", field3 ....},
{ field1: 2, field2: "b", field3 ....}
...
]
Debemos escribir un código como este, con la lógica para realizar el filtrado:
const filteredData = data.filter(row => {
return row.field1 > 10 && row.field2 === "b" || ....
})
El objetivo es definir una forma dinámica de establecer los filtros sin cambiar el código.
Caracterizando un filtro
Un filtro simple es, básicamente, una fuente de valor (el nombre del atributo), un valor con el que comparar y el operador de comparación.
Usando TypeScript podemos modelarlo de la siguiente manera:
NOTA: No voy a realizar un tipado complejo para simplificar el código, pero en producción es necesario tipar el campo para asegurar que es una clave de los datos y validar el tipo del valor.
type FilterComparationOperator =
| 'eq'
| 'neq'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'notContains'
| 'startsWith'
| 'endsWith';
interface Filter {
field: PropertyKey;
value: unknown;
operator: FilterComparationOperator;
caseSensitive?: boolean;
}
Primero, modelamos en el tipo FilterComparationOperator las diferentes formas de comparar el valor del campo y el valor del filtro:
- equal (eq): igual a
- non-equal (neq): no igual a
- great than (gt): mayor que
- great than or equal (gte): mayor o igual que
- less than (lt): menor que
- less than or equal (lte): menor o igual que
- contains the value (contains): contiene el valor
- non-contains the value (notContains): no contiene el valor
- starts with the value (startsWith): empieza con el valor
- endsWith (endsWith): termina con el valor
- Puedes pensar en más operadores si los necesitas
Luego modelamos el filtro, con el campo que usaremos para obtener el valor en los datos, el valor para el filtro, el operador de comparación y un par de flags para refinar el comportamiento del filtro, como si debe ser sensible a mayúsculas (case-sensitive), siendo por defecto insensible a ellas.
Con esto, escribiremos una función para comparar 2 valores usando el operador y las restricciones del filtro, como si la comparativa debe ser case-sensitive o no:
function compare<T = unknown>(
value: T,
operator: FilterComparationOperator,
filterValue: T,
caseSensitive = false
): boolean {
function isString(value: unknown): value is string {
return typeof value === 'string';
}
// If the filter value is a string and the filter is not caseSensitive
// converts the values to lowercase for a case insensitive comparison
const filterValueComp =
isString(filterValue) && !caseSensitive ? filterValue.toLocaleLowerCase() : filterValue;
const valueComp =
typeof value === 'string' && !caseSensitive ? String(value).toLocaleLowerCase() : value;
// Checks if we can do numeric comparations with the values
function isComparable(value: unknown): value is string | number {
return typeof value === 'string' || typeof value === 'number';
}
type OrString<TValue> = TValue | string;
const comparationFuncs: Record<
FilterComparationOperator,
(value: OrString<T>, filterValue: OrString<T>) => boolean
> = {
// Note the == instead of ===. We want to allow to compare 1 and "1" as the same value
eq: (value, filterValue) => value == filterValue,
neq: (value, filterValue) => value != filterValue,
contains: (value, filterValue) => String(value).includes(String(filterValue)),
notContains: (value, filterValue) => !String(value).includes(String(filterValue)),
startsWith: (value, filterValue) => String(value).startsWith(String(filterValue)),
endsWith: (value, filterValue) => String(value).endsWith(String(filterValue)),
gt: (value, filterValue) =>
isComparable(value) && isComparable(filterValue) ? value > filterValue : false,
gte: (value, filterValue) =>
isComparable(value) && isComparable(filterValue) ? value >= filterValue : false,
lt: (value, filterValue) =>
isComparable(value) && isComparable(filterValue) ? value < filterValue : false,
lte: (value, filterValue) =>
isComparable(value) && isComparable(filterValue) ? value <= filterValue : false,
};
return comparationFuncs[operator]
? comparationFuncs[operator](valueComp, filterValueComp)
: false;
}
console.log(compare(12, 'gt', 8)); //True
console.log(compare('lorem ipsum dolor est', 'contains', 'DOloR')); //True
console.log(compare('lorem ipsum dolor est', 'contains', 'DOloR', true)); //False
Manejando múltiples filtros y relaciones complejas entre ellos
Queremos permitir que el usuario cree comparativas complejas como: si la (temperatura es menor que 20 y la temperatura es mayor que 0) o la temperatura es -999 o ((el nombre de la ciudad es Vigo y el país es España) o (el nombre de la ciudad es Santiago y el país es Chile)).
Fíjate en los paréntesis que agrupan la lógica, ya que no es lo mismo A y B o C (igual que (A y B) o C) que A y (B o C). Lee más sobre el orden de las operaciones.
Por lo tanto, necesitamos definir una estructura para modelar estos grupos y su relación.
interface GroupFilter {
filters?: Filter[];
groups?: GroupFilter[];
operator: 'and' | 'or';
}
Este “grupo de filtros” puede contener filtros y otros grupos (que a su vez pueden contener más filtros y más grupos…) y el operador para definir las relaciones entre ellos. Por ejemplo:
const group: GroupFilter = {
operator: 'or',
filters: [
{ field: 'temperature', value: 20, operator: 'lt' },
{ field: 'temperature', value: 0, operator: 'gte' },
],
};
define un grupo de filtros que comprueba si la temperatura es menor que 20 O mayor (o igual) que 0.
Con esta estructura anidada simple, podemos crear filtros complejos como el del ejemplo:
// if the (temperature is lower than 20 and temperature is higher than 0) or the temperature is -999 or ((the city name is Vigo and country is Spain) or (city name is Santiago and country is Chile))
const filters: GroupFilter = {
operator: 'or',
filters: [{ field: 'temperature', operator: 'eq', value: -999 }],
groups: [
{
operator: 'and',
filters: [
{ field: 'temperature', operator: 'lt', value: 20 },
{ field: 'temperature', operator: 'gt', value: 0 },
],
},
{
operator: 'or',
groups: [
{
operator: 'and',
filters: [
{ field: 'city', operator: 'eq', value: 'Vigo' },
{ field: 'country', operator: 'eq', value: 'Spain' },
],
},
{
operator: 'and',
filters: [
{ field: 'city', operator: 'eq', value: 'Santiago' },
{ field: 'country', operator: 'eq', value: 'Chile' },
],
},
],
},
],
};
La última pieza que necesitamos es una función para filtrar las filas de datos:
function filter<T extends Record<PropertyKey, any>>(data: T[], filterGroup: GroupFilter): T[] {
// If the no rows, we don't need to continue
if (data.length === 0) {
return data;
}
// Check if a row fulfill the constraints
const filterRow = (row: T, filterGroup: GroupFilter | undefined): boolean => {
// Exit condition for this recursive function
if (!filterGroup) {
return true;
}
// Checks if a col in the row matches a filter
const filterColFunc = (filter: Filter): boolean => {
// If the row has no a field with the field name continue
if (!row[filter.field]) {
return true;
}
return compare(row[filter.field], filter.operator, filter.value, filter.caseSensitive);
};
const groups = filterGroup.groups || [];
const filters = filterGroup.filters || [];
if (filterGroup.operator === 'and') {
// For the and operator, we should return true if all of the filters and groups are fulfilled. If no filters or no groups we consider it a match
return (
(filters.length === 0 || filters.every(filterColFunc)) &&
(groups.length === 0 ||
// For the groups we apply again the filterRow function to get the result for the group
groups.every((group: GroupFilter) => filterRow(row, group)))
);
} else {
// For or operator, we should return true if any of the filters or groups fulfill or they are empty
return (
filters.some(filterColFunc) ||
groups.some((group: GroupFilter) => filterRow(row, group)) ||
(filters.length === 0 && groups.length === 0)
);
}
};
// Loops the rows and filter them
return [...data].filter((row) => filterRow(row, filterGroup));
}
Ahora podemos ejecutar nuestro filtro simplemente haciendo:
const filteredData = filter(data, filters);
Poniéndolo todo junto
Ejecútalo en el playground de TS
Reflexiones finales
El código que te muestro aquí es solo una base; necesita mejoras para estar listo para producción, por ejemplo: los tipados, la comparación entre números y cadenas (actualmente 1 !== “1” y quizás quieras considerar que es lo mismo para fines de filtrado), el rendimiento, etc.
Pero mi objetivo es mostrarte lo sencillo que es crear un filtro dinámico que permita al usuario definir cómo filtrar los datos. Por ejemplo, el usuario puede generar los grupos de filtros usando una interfaz de query builder o simplemente un formulario para seleccionar un par de valores, pero el desarrollador puede definir los operadores dependiendo del campo sin necesidad de escribirlos directamente en el código.
Lee el código con atención y observa lo potentes que son las funciones recursivas; pueden parecer complicadas al principio, pero cuando empiezas a entenderlas, hacen que el código sea más simple y flexible.
Sergio Carracedo