Helpers SQL
Tri sur liste blanche, pagination bornée, clause WHERE paramétrée, motif LIKE échappé, lecture d'identifiant et slug libre, partagés par les listes du core et des modules.
Chaque liste de l'admin trie, filtre et pagine selon des paramètres envoyés par le navigateur. Ces helpers transforment ces paramètres en fragments SQL sûrs, avec une règle absolue : rien de ce que l'appelant envoie n'entre dans le texte de la requête. Une clé de tri passe par une liste blanche, une page et une limite deviennent des entiers bornés, une valeur voyage toujours en paramètre lié.
Les modules officiels en portaient chacun leur copie, et les copies avaient divergé. Utiliser ce sous-chemin plutôt que d'en écrire une nouvelle.
Import
import {
buildOrderBy, buildPagination, parsePagination, whereBuilder, parseId, uniqueSlug,
escapeLike, containsPattern,
DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT,
} from '@fraza/core/database/sql';Module serveur. Toutes les fonctions sauf uniqueSlug sont pures et n'ouvrent aucune connexion, mais leur place est à côté de la requête qu'elles préparent.
API
| Signature | Retour | Rôle |
|---|---|---|
buildOrderBy(sortBy, sortOrder, allowed, fallbackKey, tiebreak?) | string | Clause ORDER BY depuis une liste blanche. |
buildPagination(page, limit, { defaultLimit, max }?) | { page, limit, offset } | Page et limite bornées. |
parsePagination(source, { defaultLimit, max }?) | { page, limit, offset } | Lit page et limit dans une URL, puis les borne. |
whereBuilder(initialValues?) | { add, addIf, build } | Accumule des conditions et leurs valeurs liées. |
parseId(value, { max }?) | number | null | Identifiant entier positif d'un paramètre de route. |
escapeLike(text) | string | Texte dont %, _ et \ sont échappés pour un motif LIKE. |
containsPattern(text) | string | Motif LIKE d'une valeur qui contient text, cherché littéralement. |
uniqueSlug(options) | Promise<string> | Premier slug libre d'une table. Voir Slugs. |
buildOrderBy(sortBy, sortOrder, allowed, fallbackKey, tiebreak?)
allowed associe une clé publique à une colonne qualifiée. Une clé inconnue retombe sur fallbackKey, une direction autre que ASC vaut DESC. tiebreak ajoute une seconde colonne, pour qu'un tri sur une valeur non unique donne des pages stables.
const SORT = Object.freeze({ start: 'a.start_at', name: 'c.name' });
buildOrderBy(url.searchParams.get('sort'), url.searchParams.get('order'), SORT, 'start', 'a.id');
// 'ORDER BY a.start_at DESC, a.id DESC'La clé est cherchée avec Object.hasOwn. Une lecture allowed[sortBy] trouverait aussi ce que l'objet hérite : ?sort=constructor écrirait alors le code source de la fonction Object dans la requête. Lève si fallbackKey n'a pas de colonne.
buildPagination(page, limit, { defaultLimit, max }?)
Accepte un nombre ou une chaîne de chiffres. Une valeur absente, nulle, négative ou illisible ('10abc', '1e3', '2.5') prend le défaut plutôt que d'échouer : la liste reste servie. defaultLimit vaut 25, max vaut 200, et une limite plus grande est ramenée à max, quel que soit son nombre de chiffres : '99999999999999999999' donne max, pas le défaut.
buildPagination('3', '10'); // { page: 3, limit: 10, offset: 20 }
buildPagination('-1', '9999', { defaultLimit: 24, max: 100 }); // { page: 1, limit: 100, offset: 0 }parsePagination(source, { defaultLimit, max }?)
Même règle, en lisant page et limit dans source : une chaîne d'URL, absolue ou relative, un URL, un URLSearchParams ou une Request.
async function handleList(request) {
const { limit, offset } = parsePagination(request, { defaultLimit: 24, max: 100 });
const rows = await queryAll('SELECT * FROM fraza_forms_form ORDER BY id LIMIT $1 OFFSET $2', [limit, offset]);
return apiSuccess({ rows });
}whereBuilder(initialValues?)
Chaque condition s'écrit avec $$ à la place de chaque valeur, remplacé par le prochain paramètre positionnel. addIf(test, fragment, ...valeurs) n'ajoute la condition que si test est vrai. build() retourne clause (vide sans condition), values et nextIndex, le numéro du paramètre suivant pour un LIMIT ou un OFFSET.
const w = whereBuilder();
w.add('a.status = $$', status);
w.addIf(search, 'c.email ILIKE $$', containsPattern(search));
const { clause, values, nextIndex } = w.build();
await queryAll(
`SELECT a.* FROM fraza_booking_appointment a JOIN fraza_booking_client c ON c.id = a.client_id
${clause} LIMIT $${nextIndex} OFFSET $${nextIndex + 1}`,
[...values, limit, offset],
);Un fragment porte exactement un $$ par valeur. Une valeur de trop décalerait tous les indices suivants : le désaccord lève au lieu de produire une requête fausse.
escapeLike(text) et containsPattern(text)
Dans un motif LIKE ou ILIKE, % vaut n'importe quelle suite de caractères et _ n'importe quel caractère. Une recherche de 100% ou de photo_2026 écrite telle quelle dans le motif trouve donc bien plus que ce qui a été tapé. escapeLike les échappe d'une barre oblique inverse, la barre elle-même d'abord, et containsPattern entoure le résultat de %.
containsPattern('100%'); // '%100\\%%'
w.addIf(search, 'm.filename ILIKE $$', containsPattern(search));La barre oblique inverse est le caractère d'échappement par défaut de LIKE et ILIKE dans PostgreSQL : la requête n'a pas besoin de clause ESCAPE. Une valeur passée en paramètre lié reste un paramètre : ces fonctions protègent le sens du motif, pas la requête, que le paramètre lié protège déjà.
parseId(value, { max }?)
Retourne l'entier, ou null pour tout ce qui n'est pas une suite de chiffres strictement positive. max vaut par défaut la borne d'un integer PostgreSQL, le type d'un serial : un identifiant plus grand ne peut pas exister, et l'envoyer à la base produirait une erreur « out of range » (500) au lieu d'un 404. Passer { max: Number.MAX_SAFE_INTEGER } pour une colonne bigint.
const id = parseId(params.id);
if (!id) return apiError('Not Found', 'Rendez-vous introuvable.');Notes
Les colonnes de la liste blanche sont du SQL. Elles sont écrites telles quelles dans la requête : elles viennent du code du module, jamais d'une configuration ni d'une requête.
Le décalage reste un entier sûr. La page est bornée pour que offset ne dépasse jamais Number.MAX_SAFE_INTEGER, quelle que soit la valeur reçue.