Aller au contenu

Accès aux données

Le backend n’utilise pas d’ORM. Il utilise Kysely avec un type Database généré qui reflète le catalogue PostgreSQL. La frontière base de données vit dans src/db/ et est garantie par DbAccess, AccessScope et TxAccess.

src/db/pool.ts crée un pg.Pool unique et enregistre des parseurs de types pour que date, time et timestamp without time zone restent des chaînes :

import pg from 'pg';
pg.types.setTypeParser(1082, (v) => v); // date
pg.types.setTypeParser(1083, (v) => v); // time
pg.types.setTypeParser(1114, (v) => v); // timestamp without time zone

Ces chaînes sont marquées DateStr, TimeStr et TimestampStr dans src/db/generated.ts.

DatabaseModule est @Global() et exporte quatre primitives :

@Global()
@Module({
providers: [
{
provide: Kysely,
useFactory: () =>
new Kysely<Database>({
dialect: new PostgresDialect({ pool }),
}),
},
DbAccess,
{
provide: DB_FUNCTIONS,
useFactory: (db: Kysely<Database>) => makeFunctions(db),
inject: [Kysely],
},
AccessScope,
],
exports: [Kysely, DbAccess, DB_FUNCTIONS, AccessScope],
})
export class DatabaseModule {}

DbAccess restreint les mutations aux vraies tables uniquement :

@Injectable()
export class DbAccess {
selectFrom<K extends keyof Database>(table: K) { /* tables + vues */ }
insertInto<K extends TableKeys>(table: K) { /* tables uniquement */ }
updateTable<K extends TableKeys>(table: K) { /* tables uniquement */ }
deleteFrom<K extends TableKeys>(table: K) { /* tables uniquement */ }
}

TableKeys = Exclude<keyof Database, ViewKeys>, donc une vue ne peut pas être insérée ou supprimée accidentellement via le système de types.

TxAccess lie un DbAccess et des fonctions SQL typées à un exécuteur de requêtes (racine ou transaction). Cela empêche les écritures dans une transaction d’utiliser accidentellement une autre connexion du pool.

export interface TxAccess {
acc: DbAccess;
fn: DbFunctions;
}

AccessScope est l’API injectable des repositories :

@Injectable()
export class AccessScope {
readonly root: TxAccess;
audit<T>(
idUtilisateur: bigint | string | null,
work: (a: TxAccess, tx: Kysely<Database>) => Promise<T>,
): Promise<T> { /* ... */ }
}
  • Utiliser this.scope.root pour les lectures.
  • Utiliser this.scope.audit(user.id, async (a) => …) pour les écritures.

Les DTO réseau restent en chaînes. La couche service caste vers les types DB brandés à la frontière du repository :

date_naissance: (dto.date_naissance ?? null) as DateStr | null;
montant_loyer: dto.montant_loyer as Money;
id_logement: dto.id_logement as BigIntStr;
date_enregistrement: new Date().toISOString() as TimestampStr;

Cela garde la couche de transport exempte de marques pendant que le repository reçoit des types d’insert/update sûrs à la compilation.

src/db/list-query.ts fournit des helpers explicites que les repositories appellent directement :

Helper Rôle
applyPagination limit (max 200) et offset
applySearch ilike sur une liste blanche de colonnes
applySort sort + order en liste blanche avec colonne par défaut
applyDateRange du/au sur une colonne date/timestamp
applyEquality filtres d’égalité exacte sur des champs en liste blanche

Une méthode de repository de liste typique ressemble à :

async listerClients(a: TxAccess, filtres: ListerClientsFiltres) {
let q = a.acc.selectFrom('client.client').selectAll();
q = applyEquality(q, filtres, {
type_client: 'type_client',
code: 'code',
nom: 'nom',
// ...
});
q = applySearch(q, filtres.recherche, SEARCH_COLUMNS);
q = applySort(q, filtres.sort, filtres.order, SORT_COLUMNS, 'nom');
q = applyPagination(q, filtres);
return q.execute();
}

Les fonctions PostgreSQL appelables sont typées et exposées via TxAccess.fn :

await a.fn.residence.encaisserLoyer({
p_id_echeance: id,
p_montant: montant,
p_id_moyen: idMoyen,
p_id_utilisateur: user.id,
});

Fonctions encapsulées :

  • audit.masquer_donnees
  • market.reesolde_stock
  • residence.encaisser_loyer, residence.encaisser_loyer_lot, residence.generer_echeances, residence.reviser_loyer
  • rh.recalculer_paie

assertNonEmptyPatch transforme un UPDATE ... SET vide en 400 BadRequestException claire :

export function assertNonEmptyPatch(patch: Record<string, unknown>): void {
if (Object.keys(patch).length === 0) {
throw new BadRequestException('Aucun champ à mettre à jour (corps vide ou content-type incorrect).');
}
}
  1. Toujours lire via this.scope.root.
  2. Toujours écrire via this.scope.audit(user.id, async (a) => …).
  3. Passer a: TxAccess comme premier argument des méthodes d’écriture du repository.
  4. Caster les chaînes du DTO vers les types brandés dans le service, pas dans le contrôleur.
  5. Utiliser sql.ref pour les identifiants ; lier les valeurs avec sql${value}`.
  6. Ne pas écrire dans les ViewKeys ; DbAccess l’empêche à la compilation.
  7. Régénérer generated.ts après chaque migration et exécuter pnpm db:verify.