07 - Filtrage et tri sans prise de tête
Ce que tu vas apprendre
- Comment désigner des filtres propres via query params
- Les opérateurs de comparaison (gte, lte, like) et leur syntaxe
- Le tri multi-colonnes ascendant et descendant
- La recherche full-text et la sélection de champs
- Comment combiner le tout sans créer un monstre
Prerequisites
- Avoir lu l'article sur la pagination
- Connaitre les bases de SQL (WHERE, ORDER BY)
Le filtre a un million de if
Sur un projet e-commerce, le endpoint /api/products avait accumule 47 query params au fil des mois. Chaque nouveau besoin ajoutait un if (req.query.xxx) dans le controller. Le code ressemblait a un arbre de Noel de conditions imbriquees. Impossible a tester, impossible a documenter. Quand le PM a demande "filtre par fourchette de prix ET par note minimum", le dev a pose sa dem.
Le filtrage, c'est un problème résolu. Il suffit de choisir une convention et de s'y tenir.
Filtrage par query params
Egalite simple
Le cas le plus courant. Un param = un filtre.
httpGET /api/products?status=active
GET /api/products?category=electronics&brand=sony
GET /api/users?role=admin&status=active
Operateurs de comparaison
Pour les fourchettes de prix, les dates, les quantités, il faut des opérateurs. Plusieurs conventions existent. Je préféré le suffixe entre crochets :
http# Prix entre 10 et 100
GET /api/products?price[gte]=10&price[lte]=100
# Cree apres une date
GET /api/users?createdAt[gte]=2026-01-01
# Note superieure a 4
GET /api/products?rating[gt]=4
En TypeScript, un parser générique :
typescriptinterface FilterOperators {
eq?: string;
gt?: string;
gte?: string;
lt?: string;
lte?: string;
like?: string;
}
function parseFilters(query: Record<string, string>): Record<string, FilterOperators> {
const filters: Record<string, FilterOperators> = {};
for (const [key, value] of Object.entries(query)) {
const match = key.match(/^(\w+)\[(\w+)\]$/);
if (match) {
const [, field, operator] = match;
filters[field] = filters[field] || {};
filters[field][operator as keyof FilterOperators] = value;
} else if (!["sort", "page", "limit", "fields", "search"].includes(key)) {
filters[key] = { eq: value };
}
}
return filters;
}
// Utilisation
// ?status=active&price[gte]=10&price[lte]=100
// => { status: { eq: "active" }, price: { gte: "10", lte: "100" } }
Valeurs multiples
Pour filtrer sur plusieurs valeurs d'un meme champ, j'utilise la virgule :
http# Produits en statut active OU draft
GET /api/products?status=active,draft
# Categories electronics OU books
GET /api/products?category=electronics,books
typescriptfunction buildWhereClause(filters: Record<string, FilterOperators>) {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
for (const [field, ops] of Object.entries(filters)) {
if (ops.eq && ops.eq.includes(",")) {
const values = ops.eq.split(",");
conditions.push(`${field} = ANY($${paramIndex})`);
params.push(values);
paramIndex++;
} else if (ops.eq) {
conditions.push(`${field} = $${paramIndex}`);
params.push(ops.eq);
paramIndex++;
}
if (ops.gte) {
conditions.push(`${field} >= $${paramIndex}`);
params.push(ops.gte);
paramIndex++;
}
if (ops.lte) {
conditions.push(`${field} <= $${paramIndex}`);
params.push(ops.lte);
paramIndex++;
}
}
return {
where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "",
params,
};
}
Tri
Le tri se fait avec un query param sort. Le prefixe - indique l'ordre descendant.
http# Trier par prix croissant
GET /api/products?sort=price
# Trier par prix decroissant
GET /api/products?sort=-price
# Tri multi-colonnes : par categorie puis par prix decroissant
GET /api/products?sort=category,-price
typescriptfunction parseSortParam(sort: string): { field: string; order: "ASC" | "DESC" }[] {
const allowedFields = ["price", "name", "createdAt", "rating", "category"];
return sort.split(",").map((s) => {
const desc = s.startsWith("-");
const field = desc ? s.slice(1) : s;
if (!allowedFields.includes(field)) {
throw new Error(`Sort field not allowed: ${field}`);
}
return { field, order: desc ? "DESC" : "ASC" };
});
}
// sort=-price,name => [{ field: "price", order: "DESC" }, { field: "name", order: "ASC" }]
Le whitelist des champs triables est obligatoire. Sans ca, un utilisateur peut trier sur une colonne sans index et mettre ta base a genoux.
Recherche full-text
Pour la recherche, un param search ou q qui cherche dans plusieurs champs.
httpGET /api/products?search=wireless+headphones
GET /api/users?q=alice
typescriptrouter.get("/api/products", async (req, res) => {
const search = req.query.search ? String(req.query.search) : null;
let query = "SELECT * FROM products";
const params: unknown[] = [];
if (search) {
query += ` WHERE to_tsvector('french', name || ' ' || description)
@@ plainto_tsquery('french', $1)`;
params.push(search);
}
const products = await db.query(query, params);
res.json({ data: products.rows });
});
Pour les petites tables, un ILIKE '%terme%' suffit. Pour les grosses tables, PostgreSQL tsvector ou un moteur dédié (Elasticsearch, Meilisearch) sera nécessaire.
Selection de champs
Permet au client de demander seulement les champs dont il a besoin. Reduit la bande passante et ameliore les performances.
http# Seulement id, name et price
GET /api/products?fields=id,name,price
typescriptfunction parseFields(fields: string | undefined, allowed: string[]): string[] {
if (!fields) return allowed;
return fields
.split(",")
.filter((f) => allowed.includes(f));
}
router.get("/api/products", async (req, res) => {
const allowed = ["id", "name", "price", "category", "status", "createdAt"];
const fields = parseFields(req.query.fields as string, allowed);
const products = await db.query(
`SELECT ${fields.join(", ")} FROM products LIMIT 20`
);
res.json({ data: products.rows });
});
Attention : ne construis jamais la liste de colonnes SQL directement depuis l'input utilisateur sans whitelist. C'est une injection SQL en attente.
Tout combiner
Un endpoint complet avec filtrage, tri, recherche, sélection de champs et pagination :
httpGET /api/products?category=electronics&price[gte]=50&price[lte]=500&sort=-rating,price&fields=id,name,price,rating&search=bluetooth&page=2&limit=20
Ca parait complexe, mais chaque concern est isole dans sa propre fonction. Le controller reste propre :
typescriptrouter.get("/api/products", async (req, res) => {
const filters = parseFilters(req.query);
const sort = parseSortParam(String(req.query.sort || "createdAt"));
const fields = parseFields(req.query.fields as string, ALLOWED_FIELDS);
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(Number(req.query.limit) || 20, 100);
const result = await productService.findAll({ filters, sort, fields, page, limit });
res.json(result);
});
Retrouve des exemples de filtrage avances sur paltemps.fr.
Résumé
- Utilise les query params pour le filtrage avec une syntaxe coherente (crochets pour les opérateurs)
- Le tri avec
-pour le descendant et la virgule pour le multi-colonnes - Whitelist obligatoire pour les champs triables et selectionnables
- La recherche full-text merite un param dédié (
searchouq) - Chaque concern (filtre, tri, champs, pagination) dans sa propre fonction
Precedent : La pagination | Suivant : La validation
Sources
- Zalando RESTful API Guidelines -- Filtering. https://opensource.zalando.com/restful-api-guidelines/#137
- JSON:API Spécification -- Filtering. https://jsonapi.org/format/#fetching-filtering
- PostgreSQL Full Text Search. https://www.postgresql.org/docs/current/textsearch.html