igo
Version:
Igo is a Node.js Web Framework based on Express
798 lines (690 loc) • 26 kB
JavaScript
const _ = require('lodash');
const Query = require('./Query');
const logger = require('../logger');
const PaginatedOptimizedSql = require('./PaginatedOptimizedSql');
/**
* PaginatedOptimizedQuery - Implémentation du pattern COUNT/IDS/FULL pour optimiser les requêtes avec jointures
*
* Ce module remplace la logique traditionnelle des LEFT JOIN par un pattern en 3 phases :
*
* 1. COUNT avec EXISTS : Compte les lignes sans faire de jointures, utilise EXISTS pour les filtres sur tables jointes
* 2. SELECT IDS : Sélectionne uniquement les IDs de la table principale avec filtres, tris et pagination
* 3. SELECT FULL : Récupère les données complètes avec LEFT JOIN uniquement pour les IDs trouvés
*
* Ce pattern améliore drastiquement les performances sur les grosses tables avec de nombreuses jointures.
*
* Exemple d'utilisation :
*
* const query = Folder.paginatedOptimized()
* .where({ type: ['agp', 'avt'] })
* .filterJoin('applicant', { last_name: 'Dupont%' }) // sera transformé en EXISTS
* .join('pme_folder') // sera un LEFT JOIN dans la phase FULL
* .order('folders.created_at DESC')
* .page(1, 50);
*
* const result = await query.execute(); // { pagination: {...}, rows: [...] }
*/
module.exports = class PaginatedOptimizedQuery extends Query {
constructor(modelClass, verb = 'select') {
super(modelClass, verb);
// Nouvelle propriété pour distinguer les joins de filtrage (→ EXISTS) des joins de données (→ LEFT JOIN)
this.query.filterJoins = [];
// Flag pour activer le mode optimisé
this.query.optimized = true;
}
/**
* Crée une PaginatedOptimizedQuery à partir d'une Query existante
* Retraite les conditions where pour extraire les dot-paths vers filterJoins
*/
static fromQuery(sourceQuery) {
const optimized = new PaginatedOptimizedQuery(sourceQuery.modelClass);
optimized.query = sourceQuery.query;
optimized.query.optimized = true;
optimized.query.filterJoins = optimized.query.filterJoins || [];
// Retraiter les where existants pour extraire les dot-paths vers filterJoins
const originalWheres = optimized.query.where;
optimized.query.where = [];
_.forEach(originalWheres, (where) => {
if (_.isArray(where) || _.isString(where)) {
// Raw SQL — garder tel quel
optimized.query.where.push(where);
} else if (_.isPlainObject(where)) {
// Objet — extraire les dot-paths
const { mainConditions, joinConditions } = optimized._extractJoinConditions(where);
if (!_.isEmpty(mainConditions)) {
optimized.query.where.push(mainConditions);
}
if (Object.keys(joinConditions).length > 0) {
optimized._buildFilterJoinsFromPaths(joinConditions);
}
} else {
optimized.query.where.push(where);
}
});
return optimized;
}
/**
* Override de join() pour supporter la notation pointée
*
* Permet d'utiliser la notation pointée pour les joins imbriqués :
* - .join('applicant') → join simple
* - .join('pme_folder.company.country') → join imbriqué
* - .join(['applicant', 'pme_folder.company']) → plusieurs joins
*
* @param {string|array|object} associations - Associations à joindre
* @returns {PaginatedOptimizedQuery} this (pour chaînage)
*/
join(associations) {
// Si c'est un objet, utiliser la syntaxe standard (Query parent)
if (_.isObject(associations) && !_.isArray(associations)) {
return super.join(associations);
}
// Si c'est une string ou un tableau, détecter la notation pointée
const assocs = _.isArray(associations) ? associations : [associations];
const transformed = [];
_.forEach(assocs, (assoc) => {
if (_.isString(assoc) && assoc.includes('.')) {
// Notation pointée détectée : transformer en structure imbriquée
transformed.push(this._transformDottedJoinPath(assoc));
} else {
// Pas de notation pointée : garder tel quel
transformed.push(assoc);
}
});
// Si on a transformé des chemins, reconstruire la structure
if (transformed.length > 0) {
return super.join(this._mergeJoinStructures(transformed));
}
return super.join(associations);
}
/**
* Transforme un chemin pointé en structure imbriquée
*
* @param {string} path - Chemin pointé (ex: 'pme_folder.company.country')
* @returns {object} Structure imbriquée
*
* Exemple :
* 'pme_folder.company.country' → { pme_folder: { company: ['country'] } }
*/
_transformDottedJoinPath(path) {
const parts = path.split('.');
// Si un seul niveau, retourner tel quel
if (parts.length === 1) {
return parts[0];
}
// Construire la structure imbriquée de droite à gauche
let result = [parts[parts.length - 1]];
for (let i = parts.length - 2; i >= 0; i--) {
result = { [parts[i]]: result };
}
return result;
}
/**
* Fusionne plusieurs structures de join
*
* @param {array} structures - Tableau de structures de join
* @returns {array|object} Structure fusionnée
*/
_mergeJoinStructures(structures) {
// Séparer les strings simples des objets
const simples = [];
const nested = {};
_.forEach(structures, (struct) => {
if (_.isString(struct)) {
simples.push(struct);
} else if (_.isObject(struct)) {
// Fusionner les objets imbriqués
_.merge(nested, struct);
}
});
// Si on a des objets imbriqués et des simples
if (!_.isEmpty(nested) && simples.length > 0) {
return [...simples, nested];
}
// Si seulement des simples
if (simples.length > 0 && _.isEmpty(nested)) {
return simples;
}
// Si seulement des imbriqués
if (_.isEmpty(simples) && !_.isEmpty(nested)) {
return nested;
}
return structures;
}
/**
* Override de where() pour détecter automatiquement les conditions sur tables jointes
*
* Cette méthode analyse les conditions pour détecter les chemins imbriqués (ex: 'applicant.last_name')
* et les convertit automatiquement en filterJoins optimisés avec EXISTS.
*
* Syntaxe supportée :
* - Table principale : { status: 'ACTIVE' }
* - Table jointe (1 niveau) : { 'applicant.last_name': 'Dupont' }
* - Tables imbriquées : { 'pme_folder.company.country.code': 'FR' }
*
* Opérateurs supportés :
* - Égalité : { 'applicant.email': 'test@test.com' }
* - LIKE : { 'applicant.last_name': { $like: 'Dup%' } }
* - IN : { 'applicant.status': ['ACTIVE', 'PENDING'] }
* - BETWEEN : { created_at: { $between: ['2024-01-01', '2024-12-31'] } }
* - Comparaisons : { amount: { $gte: 100 } }
*
* Opérateurs logiques :
* - $and : { $and: [{ status: 'ACTIVE' }, { 'applicant.last_name': 'Dupont' }] }
* - $or : { $or: [{ status: 'ACTIVE' }, { status: 'PENDING' }] }
*
* @param {object|string} where - Conditions de filtrage
* @param {any} params - Paramètres (pour compatibilité avec Query parent)
* @returns {PaginatedOptimizedQuery} this (pour chaînage)
*/
where(where, params) {
if (params !== undefined) {
return super.where(where, params);
}
if (!where || _.isEmpty(where)) {
return this;
}
// Séparer les conditions sur tables jointes (dot-path) des conditions sur la table principale
const { mainConditions, joinConditions } = this._extractJoinConditions(where);
if (!_.isEmpty(mainConditions)) {
super.where(mainConditions);
}
if (Object.keys(joinConditions).length > 0) {
this._buildFilterJoinsFromPaths(joinConditions);
}
return this;
}
/**
* _addSimpleFilterJoin - Ajoute un filtre sur une table jointe (méthode interne)
*
* Cette méthode est utilisée en interne par where() pour ajouter des filtres EXISTS.
* Les utilisateurs ne doivent pas l'appeler directement.
*
* @private
* @param {string} associationName - Nom de l'association (ex: 'applicant', 'pme_folder')
* @param {object} conditions - Conditions de filtrage
* @param {string} operator - Opérateur SQL ('AND' ou 'OR')
*/
_addSimpleFilterJoin(associationName, conditions, operator = 'AND') {
const association = this._findAssociation(associationName, this.schema);
this.query.filterJoins.push({
association,
conditions,
operator,
src_schema: this.schema
});
}
/**
* _addNestedFilterJoin - Ajoute des filtres imbriqués (méthode interne)
*
* Cette méthode est utilisée en interne par where() pour ajouter des filtres EXISTS imbriqués.
* Les utilisateurs ne doivent pas l'appeler directement.
*
* @private
* @param {object} nestedConfig - Configuration des filtres imbriqués
*/
_addNestedFilterJoin(nestedConfig) {
// Construire la hiérarchie complète d'associations
const buildHierarchy = (config, currentSchema, parentPath = null) => {
const results = [];
_.each(config, (value, associationName) => {
const association = this._findAssociation(associationName, currentSchema);
const [, , AssociatedModel] = association;
// Créer un noeud de hiérarchie
const node = {
association,
conditions: value.conditions || null,
operator: value.operator || 'AND',
src_schema: currentSchema,
parent: parentPath,
children: []
};
// Traiter récursivement les enfants
if (value.nested) {
node.children = buildHierarchy(value.nested, AssociatedModel.schema, node);
}
results.push(node);
});
return results;
};
// Construire l'arbre complet
const hierarchy = buildHierarchy(nestedConfig, this.schema);
// Stocker dans filterJoins avec une structure hiérarchique
this.query.filterJoins.push({
type: 'nested',
hierarchy: hierarchy
});
}
/**
* Phase 1 : COUNT optimisé avec EXISTS
*
* Génère une requête COUNT(0) sans LEFT JOIN. Les filtres sur tables jointes
* sont convertis en sous-requêtes EXISTS.
*
* @returns {Promise<number>} Le nombre total de lignes correspondant aux filtres
*/
async count() {
const countQuery = new PaginatedOptimizedQuery(this.modelClass);
countQuery.query = _.cloneDeep(this.query);
countQuery.query.verb = 'count';
countQuery.query.limit = 1;
delete countQuery.query.page;
delete countQuery.query.nb;
delete countQuery.query.order; // Pas besoin de ORDER BY pour un COUNT
const rows = await countQuery.runQuery();
const count = rows && rows[0] && Number(rows[0].count) || 0;
return count;
}
/**
* Phase 2 : SELECT IDS avec filtres et pagination
*
* Sélectionne uniquement les IDs de la table principale en appliquant :
* - Tous les filtres (WHERE + EXISTS pour les filterJoins)
* - Les tris (ORDER BY)
* - La pagination (LIMIT/OFFSET)
*
* @returns {Promise<Array<number>>} Liste des IDs trouvés
*/
async selectIds() {
const idsQuery = new PaginatedOptimizedQuery(this.modelClass);
idsQuery.query = _.cloneDeep(this.query);
idsQuery.query.verb = 'select_ids';
// Ne sélectionner que l'ID (ou clés primaires)
const primaryKeys = this.schema.primary || ['id'];
const { esc } = this.getDb().driver.dialect;
idsQuery.query.select = primaryKeys.map(key => `${esc}${this.schema.table}${esc}.${esc}${key}${esc}`).join(', ');
const rows = await idsQuery.runQuery();
// Retourner un tableau d'IDs (ou objets de clés composites)
if (primaryKeys.length === 1) {
return rows.map(row => row[primaryKeys[0]]);
} else {
return rows.map(row => _.pick(row, primaryKeys));
}
}
/**
* Phase 3 : SELECT FULL avec LEFT JOIN sur les IDs trouvés
*
* Récupère les données complètes avec les LEFT JOIN uniquement pour les IDs
* retournés par la phase 2. Cela limite drastiquement le nombre de lignes à joindre.
*
* @param {Array<number|object>} ids - Liste des IDs à récupérer
* @returns {Promise<Array<Object>>} Données complètes avec associations
*/
async selectFull(ids) {
if (!ids || ids.length === 0) {
return [];
}
const fullQuery = new Query(this.modelClass); // Utiliser Query standard pour le SELECT final
fullQuery.query = _.cloneDeep(this.query);
fullQuery.query.verb = 'select';
// Restaurer les joins originaux : _.cloneDeep crée de nouveaux objets pour src_schema,
// ce qui casse les lookups par identité (===) dans le Map de Query.execute()
fullQuery.query.joins = this.query.joins;
// Conserver uniquement les "vrais" joins (pas les filterJoins)
// Les filterJoins ne sont utilisés que pour COUNT et IDS
fullQuery.query.filterJoins = [];
// Remplacer les filtres par WHERE id IN (...)
const primaryKeys = this.schema.primary || ['id'];
if (primaryKeys.length === 1) {
fullQuery.query.where = [{ [primaryKeys[0]]: ids }];
} else {
// Clés composites : générer des conditions OR
const compositeConditions = ids.map(idObj => idObj);
fullQuery.query.where = compositeConditions;
}
fullQuery.query.whereNot = [];
// Supprimer la pagination (déjà appliquée dans selectIds)
delete fullQuery.query.limit;
delete fullQuery.query.offset;
delete fullQuery.query.page;
delete fullQuery.query.nb;
// Conserver l'ORDER BY pour maintenir l'ordre
// (Important car IN (...) ne garantit pas l'ordre)
// Transformer les chemins d'associations en noms d'associations (alias) pour la Query standard
if (fullQuery.query.order && fullQuery.query.order.length > 0) {
const sqlGenerator = new PaginatedOptimizedSql(this); // Utiliser 'this' (PaginatedOptimizedQuery) au lieu de 'fullQuery'
fullQuery.query.order = fullQuery.query.order.map(orderClause => {
return sqlGenerator._transformOrderClauseForFullQuery(orderClause);
});
}
const rows = await fullQuery.execute();
// Dédupliquer les résultats si nécessaire
// Les LEFT JOIN 1-N (comme beneficiary.folder_id = folders.id) créent des doublons
// qu'on doit éliminer manuellement en gardant la première occurrence de chaque ID
const uniqueRows = this._deduplicateRows(rows, ids.length);
return uniqueRows;
}
/**
* Déduplique les lignes retournées par SELECT FULL en gardant la première occurrence de chaque ID
*
* Cette méthode est nécessaire car les LEFT JOIN 1-N (ex: beneficiary.folder_id = folders.id)
* créent plusieurs lignes pour le même objet principal. On garde uniquement la première occurrence.
*
* @param {Array} rows - Lignes retournées par SELECT FULL
* @param {number} expectedCount - Nombre d'IDs attendus (pour logging)
* @returns {Array} Lignes dédupliquées
*/
_deduplicateRows(rows, expectedCount) {
if (!rows || rows.length === 0) {
return rows;
}
// Si le nombre de lignes correspond au nombre d'IDs, pas de doublons
if (rows.length === expectedCount) {
return rows;
}
const primaryKeys = this.schema.primary || ['id'];
const seenIds = new Set();
const uniqueRows = [];
for (const row of rows) {
// Construire la clé composite si nécessaire
let key;
if (primaryKeys.length === 1) {
key = row[primaryKeys[0]];
} else {
key = primaryKeys.map(k => row[k]).join('|');
}
// Garder seulement la première occurrence de chaque ID
if (!seenIds.has(key)) {
seenIds.add(key);
uniqueRows.push(row);
}
}
// Logger si des doublons ont été éliminés (uniquement en cas de doublons)
const duplicatesRemoved = rows.length - uniqueRows.length;
if (duplicatesRemoved > 0) {
logger.info(`[PaginatedOptimizedQuery] ${duplicatesRemoved} duplicate(s) removed by LEFT JOIN 1-N deduplication`);
}
return uniqueRows;
}
/**
* Orchestrateur principal : exécute les 3 phases
*
* Cette méthode est appelée automatiquement par execute() quand le mode optimisé est activé.
*
* @returns {Promise<Object|Array>} Résultat de la requête (avec ou sans pagination)
*/
async executeOptimized() {
const { query } = this;
// Si pas de pagination demandée, on peut simplifier
if (!query.page) {
// Phase 1 : COUNT (optionnel si pas de pagination)
// On peut le sauter pour gagner du temps
// Phase 2 : SELECT IDS
const ids = await this.selectIds();
// Phase 3 : SELECT FULL
const rows = await this.selectFull(ids);
if (query.limit === 1) {
return rows[0] || null;
}
return rows;
}
// Avec pagination : les 3 phases complètes
// Phase 1 : COUNT
const count = await this.count();
// Calculer la pagination
const nb_pages = Math.ceil(count / query.nb);
query.page = Math.min(query.page, nb_pages);
query.page = Math.max(query.page, 1);
query.offset = (query.page - 1) * query.nb;
query.limit = query.nb;
// Phase 2 : SELECT IDS
const ids = await this.selectIds();
// Phase 3 : SELECT FULL
const rows = await this.selectFull(ids);
// Construire l'objet pagination
const page = query.page;
const links = [];
const start = Math.max(1, page - 5);
for (let i = 0; i < 10; i++) {
const p = start + i;
if (p <= nb_pages) {
links.push({ page: p, current: page === p });
}
}
const pagination = {
page: query.page,
nb: query.nb,
previous: page > 1 ? page - 1 : null,
next: page < nb_pages ? page + 1 : null,
start: query.offset + 1,
end: query.offset + Math.min(query.nb, count - query.offset),
nb_pages,
count,
links,
};
return { pagination, rows };
}
/**
* Override de execute() pour utiliser executeOptimized() en mode optimisé
*/
async execute() {
if (this.query.optimized && this.query.verb === 'select') {
return await this.executeOptimized();
}
// Fallback vers l'implémentation standard pour les autres verbes (update, delete, etc.)
return await super.execute();
}
/**
* Extrait les conditions sur tables jointes (dot-path) d'une expression
*
* Parcourt récursivement $and/$or pour séparer :
* - mainConditions : objet à passer à super.where() (géré par Sql.whereSQL())
* - joinConditions : map des conditions dot-path pour filterJoins
*
* @param {object} expr - Expression de conditions
* @returns {{ mainConditions: object, joinConditions: object }}
*/
_extractJoinConditions(expr) {
const joinConditions = {};
const mainConditions = {};
if (!expr || _.isEmpty(expr)) {
return { mainConditions, joinConditions };
}
_.forOwn(expr, (value, key) => {
if (key === '$and' && _.isArray(value)) {
// Récurser dans chaque enfant du $and
const mainChildren = [];
_.forEach(value, (child) => {
const extracted = this._extractJoinConditions(child);
if (!_.isEmpty(extracted.mainConditions)) {
mainChildren.push(extracted.mainConditions);
}
_.assign(joinConditions, extracted.joinConditions);
});
if (mainChildren.length > 0) {
mainConditions.$and = mainChildren;
}
} else if (key === '$or' && _.isArray(value)) {
// Séparer les branches avec/sans conditions jointes
const mainBranches = [];
const joinedBranches = [];
_.forEach(value, (child) => {
const hasJoinedKey = _.isObject(child) && !_.isArray(child) &&
_.some(_.keys(child), k => k.includes('.') && !k.startsWith('$'));
if (hasJoinedKey) {
joinedBranches.push(child);
} else {
mainBranches.push(child);
}
});
// Branches jointes → or_group filterJoin
if (joinedBranches.length > 0) {
this.query.filterJoins.push({
type: 'or_group',
conditions: joinedBranches
});
}
if (mainBranches.length > 0) {
mainConditions.$or = mainBranches;
}
} else if (key.includes('.') && !key.startsWith('$')) {
// Condition sur table jointe
const keyParts = key.split('.');
const column = keyParts[keyParts.length - 1];
const path = keyParts.slice(0, -1);
joinConditions[key] = { value, path, column };
} else {
// Condition sur table principale
mainConditions[key] = value;
}
});
return { mainConditions, joinConditions };
}
/**
* Construit automatiquement les filterJoins à partir des chemins détectés
*
* Cette méthode regroupe les conditions par chemin d'association et génère
* les filterJoinNested appropriés.
*
* @param {object} joinConditions - Conditions sur les tables jointes
*
* Exemple :
* Input: {
* 'applicant.last_name': { value: 'Dupont', path: ['applicant'], column: 'last_name' },
* 'applicant.email': { value: 'test@test.com', path: ['applicant'], column: 'email' },
* 'pme_folder.company.country.code': { value: 'FR', path: ['pme_folder', 'company', 'country'], column: 'code' }
* }
*
* Output: Appelle filterJoinNested() avec :
* {
* applicant: {
* conditions: { last_name: 'Dupont', email: 'test@test.com' }
* },
* pme_folder: {
* nested: {
* company: {
* nested: {
* country: {
* conditions: { code: 'FR' }
* }
* }
* }
* }
* }
* }
*/
_buildFilterJoinsFromPaths(joinConditions) {
// Regrouper les conditions par racine d'association
const groupedByRoot = {};
_.forOwn(joinConditions, ({ value, path, column }, fullPath) => {
const root = path[0];
if (!groupedByRoot[root]) {
groupedByRoot[root] = [];
}
groupedByRoot[root].push({
path: path.slice(1), // Enlever la racine
column,
value,
fullPath
});
});
// Construire les filterJoinNested pour chaque racine
_.forOwn(groupedByRoot, (conditions, root) => {
// Si toutes les conditions sont au niveau racine (path vide), utiliser filterJoin simple
const allAtRoot = _.every(conditions, c => c.path.length === 0);
if (allAtRoot) {
// Filtre simple (1 niveau)
const simpleConditions = {};
_.forEach(conditions, ({ column, value }) => {
simpleConditions[column] = value;
});
this._addSimpleFilterJoin(root, simpleConditions);
} else {
// Filtre imbriqué (plusieurs niveaux)
const nestedConfig = this._buildNestedConfig(conditions);
this._addNestedFilterJoin({ [root]: nestedConfig });
}
});
}
/**
* Construit la configuration imbriquée pour filterJoinNested
*
* @param {Array} conditions - Liste des conditions à imbriquer
* @returns {object} Configuration imbriquée
*
* Exemple :
* Input: [
* { path: ['company', 'country'], column: 'code', value: 'FR' },
* { path: ['company'], column: 'siret', value: '123%' }
* ]
*
* Output: {
* nested: {
* company: {
* conditions: { siret: '123%' },
* nested: {
* country: {
* conditions: { code: 'FR' }
* }
* }
* }
* }
* }
*/
_buildNestedConfig(conditions) {
const config = {
conditions: {},
nested: {}
};
// Séparer les conditions : celles au niveau actuel vs celles à imbriquer
const currentLevelConditions = [];
const nestedConditions = {};
_.forEach(conditions, (cond) => {
if (cond.path.length === 0) {
// Condition au niveau actuel
currentLevelConditions.push(cond);
} else {
// Condition à imbriquer
const nextLevel = cond.path[0];
if (!nestedConditions[nextLevel]) {
nestedConditions[nextLevel] = [];
}
nestedConditions[nextLevel].push({
path: cond.path.slice(1),
column: cond.column,
value: cond.value
});
}
});
// Ajouter les conditions au niveau actuel
_.forEach(currentLevelConditions, ({ column, value }) => {
config.conditions[column] = value;
});
// Si pas de conditions au niveau actuel, supprimer la clé
if (_.isEmpty(config.conditions)) {
delete config.conditions;
}
// Construire récursivement les niveaux imbriqués
_.forOwn(nestedConditions, (nestedConds, assocName) => {
config.nested[assocName] = this._buildNestedConfig(nestedConds);
});
// Si pas de nested, supprimer la clé
if (_.isEmpty(config.nested)) {
delete config.nested;
}
return config;
}
/**
* Génère le SQL pour la requête optimisée
*
* Cette méthode override toSQL() pour générer le SQL approprié selon le verb.
*/
toSQL() {
const { query } = this;
const db = this.getDb();
// Utiliser PaginatedOptimizedSql pour les requêtes optimisées
if (query.optimized && (query.verb === 'count' || query.verb === 'select_ids')) {
// Ajouter le schema au query object pour que PaginatedOptimizedSql puisse y accéder
query.schema = this.schema;
const PaginatedOptimizedSql = require('./PaginatedOptimizedSql');
const sql = new PaginatedOptimizedSql(query, db.driver.dialect);
if (query.verb === 'count') {
return sql.countSQL();
} else if (query.verb === 'select_ids') {
return sql.idsSQL();
}
}
// Sinon, utiliser Sql standard
return super.toSQL();
}
};