UNPKG

igo

Version:

Igo is a Node.js Web Framework based on Express

798 lines (690 loc) 26 kB
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(); } };