UNPKG

igo

Version:

Igo is a Node.js Web Framework based on Express

1,374 lines (1,160 loc) 48.2 kB
const _ = require('lodash'); const Sql = require('./Sql'); const { compileCondition } = require('./OperatorCompiler'); /** * PaginatedOptimizedSql - Générateur SQL optimisé avec pattern EXISTS * * Cette classe hérite de Sql et override les méthodes de génération SQL pour : * - Remplacer les LEFT JOIN par des sous-requêtes EXISTS dans COUNT et SELECT IDS * - Conserver uniquement les filtres sur la table principale * * Le pattern EXISTS est beaucoup plus performant car : * - Il évite le produit cartésien des jointures * - Il utilise les index de façon optimale * - Il s'arrête dès qu'une ligne est trouvée (short-circuit) */ module.exports = class PaginatedOptimizedSql extends Sql { /** * COUNT SQL optimisé avec EXISTS au lieu de LEFT JOIN * * Génère une requête COUNT(0) sans jointures. Les filtres sur tables jointes * sont convertis en sous-requêtes EXISTS. * * Exemple de SQL généré : * * SELECT COUNT(0) as `count` * FROM `folders` f * WHERE f.type IN ('agp', 'avt') * AND EXISTS ( * SELECT 1 FROM `applicants` a * WHERE a.id = f.applicant_id * AND a.last_name LIKE '%Dupont%' * ) * AND EXISTS ( * SELECT 1 FROM `pme_folders` p * WHERE p.id = f.pme_folder_id * AND p.status = 'ACTIVE' * ) */ countSQL() { const { query, dialect } = this; const { esc } = dialect; // SELECT COUNT(0) let sql = `SELECT COUNT(0) as ${esc}count${esc} `; const params = []; // FROM table principale sql += `FROM ${esc}${query.table}${esc} `; // WHERE de la table principale sql += this.whereSQL(params); // WHERE NOT de la table principale sql += this.whereNotSQL(params); // Ajouter les filterJoins en tant que EXISTS sql += this.addFilterJoinsAsExists(params); const ret = { sql: sql.trim(), params: params }; return ret; } /** * IDS SQL - Sélection des IDs uniquement avec filtres et tri * * Génère une requête SELECT qui retourne uniquement les IDs (clés primaires) * de la table principale, avec tous les filtres, tris et pagination appliqués. * * IMPORTANT : Si le tri (ORDER BY) référence une colonne d'une table jointe, * on utilise LEFT JOIN (pas INNER JOIN) pour préserver toutes les lignes, * même celles sans correspondance (NULL). * * Exemple de SQL généré (tri sur table jointe) : * * SELECT f.id * FROM `folders` f * LEFT JOIN `applicants` a ON a.id = f.applicant_id * WHERE f.type IN ('agp', 'avt') * ORDER BY a.last_name ASC -- Les folders sans applicant auront NULL * LIMIT 50 OFFSET 0 */ idsSQL() { const { query, dialect } = this; const { esc } = dialect; // Détecter les tables nécessaires pour le tri const joinTablesForSort = this._detectJoinTablesForSort(); // SELECT colonnes (IDs ou clés primaires) let sql = 'SELECT '; if (query.select) { sql += query.select + ' '; } else { const primaryKeys = query.schema?.primary || ['id']; sql += primaryKeys.map(key => `${esc}${query.table}${esc}.${esc}${key}${esc}`).join(', ') + ' '; } // FROM table principale sql += `FROM ${esc}${query.table}${esc} `; // LEFT JOIN pour les tables nécessaires au tri (préserve toutes les lignes) sql += this._addJoinsForSort(joinTablesForSort); // WHERE de la table principale const params = []; // Ajouter les params des jointures de tri (extraWhere) if (this._sortJoinParams && this._sortJoinParams.length > 0) { params.push(...this._sortJoinParams); } sql += this.whereSQL(params); // WHERE NOT de la table principale sql += this.whereNotSQL(params); // Ajouter les filterJoins en tant que EXISTS sql += this.addFilterJoinsAsExists(params); // ORDER BY (important pour maintenir l'ordre demandé) sql += this.orderSQL(); // LIMIT / OFFSET pour la pagination if (query.limit) { sql += dialect.limit(this.i++, this.i++); params.push(query.offset || 0); params.push(query.limit); } const ret = { sql: sql.trim(), params: params }; return ret; } /** * Détecte les tables jointes nécessaires pour le tri * * Analyse les clauses ORDER BY pour identifier les colonnes qui proviennent * de tables jointes (directes ou imbriquées). Retourne les associations en cascade. * * Gère également les colonnes sans préfixe de table (ex: "studies_year") en cherchant * automatiquement dans les associations belongs_to si la colonne n'existe pas dans * la table principale. * * @returns {Array} Liste hiérarchique des associations nécessaires pour le tri * * Exemples : * - ORDER BY "applicants.last_name ASC" → [{path: [...], pathKey: 'applicant'}] * - ORDER BY "pmfp_folder.formationNature.name" → [{path: [...], pathKey: 'pmfp_folder.formationNature'}] * - ORDER BY "studies_year ASC" → [{path: [...], pathKey: 'studies'}] (si studies_year existe dans une table de block) */ _detectJoinTablesForSort() { const { query } = this; if (!query.order || query.order.length === 0) { return []; } const joinPaths = []; const mainTable = query.table; _.forEach(query.order, (orderClause) => { // Check if this is a SQL function (contains function keywords) const hasSqlFunction = /\b(COALESCE|IFNULL|CONCAT|CONCAT_WS|UPPER|LOWER|SUBSTRING|TRIM)\s*\(/i.test(orderClause); if (hasSqlFunction) { // For SQL functions, extract all table references const tableNames = this._extractTableReferencesFromOrderClause(orderClause); _.forEach(tableNames, (tableName) => { // Skip main table if (tableName === mainTable) { return; } // Try to find association path for this table let path = this._findPathToTable(tableName, query.schema); // If not found by table name, try by association name if (!path) { path = this._buildAssociationPath([tableName], query.schema); } if (path && path.length > 0) { const pathKey = path.map(p => p.association[1]).join('.'); // Avoid duplicates if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) { joinPaths.push({ pathKey, path, tablePath: [tableName], columnName: null // Not needed for function-based ORDER BY }); } } }); // Also extract simple column names (without table prefix) in SQL functions // and check if they belong to block tables const simpleColumns = this._extractSimpleColumnsFromOrderClause(orderClause); _.forEach(simpleColumns, (columnName) => { // Check if column exists in main table const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName]; if (!existsInMainTable) { // Column not in main table - search in associations (blocks) const assocPath = this._findAssociationByColumn(columnName, query.schema); if (assocPath && assocPath.length > 0) { const pathKey = assocPath.map(p => p.association[1]).join('.'); // Avoid duplicates if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) { joinPaths.push({ pathKey, path: assocPath, tablePath: [assocPath[assocPath.length - 1].tableName], columnName }); } } } }); } else { // For non-function ORDER BY, parse as nested path const cleanedClause = orderClause.replace(/`/g, '').trim(); const parts = cleanedClause.split(/\s+/)[0].split('.'); // Take only before ASC/DESC if (parts.length < 2) { // No dot - could be a column on main table OR a column from a joined/block table const columnName = parts[0]; // Check if column exists in main table const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName]; if (!existsInMainTable) { // Search in associations (blocks) let assocPath = this._findAssociationByColumn(columnName, query.schema); // Search in explicitly declared joins if (!assocPath && query.joins && query.joins.length > 0) { assocPath = this._findColumnInJoins(columnName, query.joins); } if (assocPath && assocPath.length > 0) { const pathKey = assocPath.map(p => p.association[1]).join('.'); // Avoid duplicates if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) { joinPaths.push({ pathKey, path: assocPath, tablePath: [assocPath[assocPath.length - 1].tableName], columnName }); } } } // If column exists in main table or wasn't found in associations/joins, no join needed return; } // Last element is the column, rest is the path const columnName = parts[parts.length - 1]; const tablePath = parts.slice(0, -1); // Skip if first element is main table if (tablePath[0] === mainTable) { return; } // Try to build full association path let path = this._buildAssociationPath(tablePath, query.schema); // If not found by association names, try finding by table name (single level only) if (!path && tablePath.length === 1) { const targetTable = tablePath[0]; path = this._findPathToTable(targetTable, query.schema); } if (path && path.length > 0) { const pathKey = path.map(p => p.association[1]).join('.'); // Avoid duplicates if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) { joinPaths.push({ pathKey, path, tablePath, columnName }); } } } }); return joinPaths; } /** * Extract simple column names (without table prefix) from an ORDER BY clause * Handles SQL functions like COALESCE, IFNULL, CONCAT, etc. * * Examples: * - "COALESCE(`studies_year`, 'N/A')" → ["studies_year"] * - "CONCAT(`first_name`, ' ', `last_name`)" → ["first_name", "last_name"] * - "IFNULL(bac_year, 0)" → ["bac_year"] * * @param {string} orderClause - The ORDER BY clause to parse * @returns {Array<string>} - Array of unique column names (without table prefix) */ _extractSimpleColumnsFromOrderClause(orderClause) { const columnNames = new Set(); // Pattern pour capturer les identifiants entre backticks qui n'ont pas de point avant // Ex: `studies_year` mais pas `table`.`column` const backtickPattern = /(?<!\.)(`(\w+)`)/g; let match; while ((match = backtickPattern.exec(orderClause)) !== null) { const columnName = match[2]; // Vérifier qu'il n'y a pas de point juste avant (sinon c'est une référence table.colonne) const beforeMatch = orderClause.substring(0, match.index); if (!beforeMatch.endsWith('.')) { columnNames.add(columnName); } } // Pattern pour capturer les identifiants sans backticks qui n'ont pas de point // et qui ne sont pas des mots-clés SQL const sqlKeywords = new Set([ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'ASC', 'DESC', 'COALESCE', 'IFNULL', 'CONCAT', 'CONCAT_WS', 'SUBSTRING', 'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'DATE', 'NOW', 'NULL', 'AND', 'OR' ]); // Pattern pour les identifiants sans backticks (lettres, chiffres, underscores) // qui ne sont pas suivis d'une parenthèse (pas une fonction) const identifierPattern = /\b([a-zA-Z_]\w+)\b(?!\s*\()/g; while ((match = identifierPattern.exec(orderClause)) !== null) { const identifier = match[1]; // Ignorer les mots-clés SQL et les valeurs spéciales if (!sqlKeywords.has(identifier.toUpperCase()) && identifier !== 'N' && identifier !== 'A') { // Vérifier qu'il n'y a pas de point juste avant const beforeMatch = orderClause.substring(0, match.index); if (!beforeMatch.trimEnd().endsWith('.')) { columnNames.add(identifier); } } } return Array.from(columnNames); } /** * Extract all table references from an ORDER BY clause * Handles SQL functions like COALESCE, IFNULL, CONCAT, etc. * * Examples: * - "table.column DESC" → ["table"] * - "`table1`.`col1`, `table2`.`col2`" → ["table1", "table2"] * - "COALESCE(`t1`.`col`, `t2`.`col`)" → ["t1", "t2"] * * @param {string} orderClause - The ORDER BY clause to parse * @returns {Array<string>} - Array of unique table names */ _extractTableReferencesFromOrderClause(orderClause) { const tableNames = new Set(); // Pattern 1: Extract backticked table references: `table`.`column` const backtickPattern = /`(\w+)`\.`\w+`/g; let match; while ((match = backtickPattern.exec(orderClause)) !== null) { tableNames.add(match[1]); } // Pattern 2: Extract non-backticked references: table.column // But exclude SQL keywords and functions const sqlKeywords = new Set([ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'ASC', 'DESC', 'COALESCE', 'IFNULL', 'CONCAT', 'CONCAT_WS', 'SUBSTRING', 'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'DATE', 'NOW' ]); const dotPattern = /\b(\w+)\.(\w+)\b/g; while ((match = dotPattern.exec(orderClause)) !== null) { const tableName = match[1]; if (!sqlKeywords.has(tableName.toUpperCase())) { tableNames.add(tableName); } } return Array.from(tableNames); } /** * Récupère les associations d'un schema (gère le cas où associations est une fonction) * * @param {Object} schema - Schema * @returns {Array|null} Tableau d'associations ou null */ _getSchemaAssociations(schema) { if (!schema || !schema.associations) { return null; } // Si associations est une fonction, l'appeler pour obtenir le tableau // (Normalement cela devrait déjà être fait par Schema, mais on gère le cas par sécurité) if (_.isFunction(schema.associations)) { return schema.associations(); } return schema.associations; } /** * Construit un chemin d'associations à partir d'un tableau de noms (association ou table) * * @param {Array} names - Tableau de noms d'associations ou de tables (ex: ["pmfp_folder", "formationNature"] ou ["applicants"]) * @param {Object} currentSchema - Schema de départ * @returns {Array|null} Chemin d'associations ou null si introuvable * * Exemple : * _buildAssociationPath(["pmfp_folder", "formationNature"], FolderSchema) * → [ * {association: [...], tableName: 'pmfp_folders'}, * {association: [...], tableName: 'formation_natures'} * ] */ _buildAssociationPath(names, currentSchema) { const associations = this._getSchemaAssociations(currentSchema); if (!associations || names.length === 0) { return null; } const path = []; let schema = currentSchema; for (const name of names) { const schemaAssociations = this._getSchemaAssociations(schema); if (!schemaAssociations) { return null; } // Chercher l'association par nom OU par nom de table dans le schema courant const association = _.find(schemaAssociations, (assoc) => { const [, assocName, AssociatedModel] = assoc; // Matcher sur le nom de l'association if (assocName === name) { return true; } // Matcher sur le nom de la table (ex: "applicants" pour l'association "applicant") if (AssociatedModel && AssociatedModel.schema) { return AssociatedModel.schema.table === name; } return false; }); if (!association) { // Association introuvable return null; } const [, , AssociatedModel] = association; if (!AssociatedModel || !AssociatedModel.schema) { return null; } const tableName = AssociatedModel.schema.table; path.push({ association, tableName }); // Passer au schema suivant pour la prochaine itération schema = AssociatedModel.schema; } return path; } /** * Trouve une association par nom de table * * @param {string} tableName - Nom de la table (ex: 'applicants') * @returns {Array|null} Association trouvée ou null */ _findAssociationByTable(tableName) { const { query } = this; const associations = this._getSchemaAssociations(query.schema); if (!associations) { return null; } // Chercher dans les associations du schema (ici on a toujours les Model classes) const association = _.find(associations, (assoc) => { const [, , AssociatedModel] = assoc; if (!AssociatedModel || !AssociatedModel.schema) { return false; } return AssociatedModel.schema.table === tableName; }); return association || null; } /** * Trouve une association qui contient une colonne donnée * * Cette méthode cherche dans les associations belongs_to directes du schema * pour trouver laquelle contient la colonne spécifiée. * * Utilisé pour gérer les colonnes de "blocks" dans les ORDER BY. * * @param {string} columnName - Nom de la colonne à chercher (ex: 'studies_year') * @param {Object} currentSchema - Schema actuel * @returns {Array|null} Chemin vers l'association contenant la colonne, ou null * * Exemple : * _findAssociationByColumn('studies_year', PMEFolderSchema) * → [{association: ['belongs_to', 'studies', StudiesBlock, 'block_studies_id', 'id'], tableName: 'block_studies'}] */ _findAssociationByColumn(columnName, currentSchema) { const associations = this._getSchemaAssociations(currentSchema); if (!associations) { return null; } // Chercher dans les associations directes (belongs_to uniquement pour les blocks) for (const assoc of associations) { const [assocType, assocName, AssociatedModel, src_column, ref_column] = assoc; // On cherche uniquement dans les belongs_to if (assocType !== 'belongs_to' || !AssociatedModel || !AssociatedModel.schema) { continue; } const assocSchema = AssociatedModel.schema; const assocTableName = assocSchema.table; // Vérifier si la colonne existe dans ce schema if (assocSchema.colsByName && assocSchema.colsByName[columnName]) { // Colonne trouvée ! Retourner le chemin return [{ association: assoc, tableName: assocTableName }]; } } return null; } /** * Cherche une colonne dans les tables explicitement jointes (query.joins) * * @param {string} columnName - Nom de la colonne à chercher * @param {Array} joins - Liste des joins déclarés sur la query * @returns {Array|null} Chemin vers l'association contenant la colonne, ou null */ _findColumnInJoins(columnName, joins) { for (const join of joins) { const { association } = join; const [, , AssociatedModel] = association; if (!AssociatedModel || !AssociatedModel.schema) { continue; } const assocSchema = AssociatedModel.schema; if (assocSchema.colsByName && assocSchema.colsByName[columnName]) { return [{ association, tableName: assocSchema.table }]; } } return null; } /** * Trouve le chemin complet vers une table ou association (gère les associations imbriquées) * * Cherche récursivement dans les associations pour trouver le chemin * complet depuis la table principale jusqu'à la cible. * * @param {string} target - Nom de la table OU nom de l'association cible (ex: 'companies' ou 'formationNature') * @param {Object} currentSchema - Schema actuel * @param {Array} currentPath - Chemin actuel (pour la récursion) * @param {Set} visitedTables - Tables déjà visitées (pour éviter les cycles) * @returns {Array|null} Chemin vers la table/association ou null * * Exemples : * _findPathToTable('companies', FolderSchema) * → [{association: [...], tableName: 'pme_folders'}, {association: [...], tableName: 'companies'}] * * _findPathToTable('formationNature', FolderSchema) * → [{association: [...], tableName: 'pmfp_folders'}, {association: [...], tableName: 'formation_natures'}] */ _findPathToTable(target, currentSchema, currentPath = [], visitedTables = new Set()) { const associations = this._getSchemaAssociations(currentSchema); if (!associations) { return null; } // Ajouter la table courante aux tables visitées const currentTable = currentSchema.table; if (currentTable && visitedTables.has(currentTable)) { // Cycle détecté, arrêter la recherche dans cette branche return null; } // Créer un nouveau Set avec la table courante ajoutée const newVisitedTables = new Set(visitedTables); if (currentTable) { newVisitedTables.add(currentTable); } // Chercher dans les associations directes for (const assoc of associations) { const [, assocName, AssociatedModel, src_column, ref_column] = assoc; if (!AssociatedModel || !AssociatedModel.schema) { continue; } const assocTableName = AssociatedModel.schema.table; // Éviter les cycles : ne pas revisiter une table déjà dans le chemin if (newVisitedTables.has(assocTableName)) { continue; } // Matcher sur le nom de la table OU le nom de l'association const isMatch = (assocTableName === target) || (assocName === target); if (isMatch) { return [...currentPath, { association: assoc, tableName: assocTableName }]; } // Sinon, chercher récursivement dans les associations de ce modèle const nestedPath = this._findPathToTable( target, AssociatedModel.schema, [...currentPath, { association: assoc, tableName: assocTableName }], newVisitedTables ); if (nestedPath) { return nestedPath; } } return null; } /** * Ajoute les LEFT JOIN nécessaires pour le tri (gère les chemins imbriqués) * * LEFT JOIN est utilisé (pas INNER JOIN) pour préserver toutes les lignes de la table * principale, même celles sans correspondance (qui auront NULL pour la colonne de tri). * * @param {Array} joinPathsForSort - Liste des chemins vers les tables à joindre * @returns {string} Clause SQL avec les LEFT JOIN en cascade * * Exemples de SQL généré : * - Simple : LEFT JOIN `applicants` ON `applicants`.`id` = `folders`.`applicant_id` * - Imbriqué : LEFT JOIN `pmfp_folders` ON ... LEFT JOIN `formation_natures` ON ... */ _addJoinsForSort(joinPathsForSort) { if (joinPathsForSort.length === 0) { return ''; } const { query, dialect } = this; const { esc } = dialect; const mainTable = query.table; const params = []; let sql = ''; const processedTables = new Set(); // Pour éviter les doublons _.forEach(joinPathsForSort, ({ path }) => { // Parcourir le chemin et créer les LEFT JOIN en cascade let prevTable = mainTable; _.forEach(path, ({ association, tableName: currentTableName }) => { // Éviter les doublons si plusieurs ORDER BY utilisent le même chemin if (processedTables.has(currentTableName)) { prevTable = currentTableName; return; } const [, , AssociatedModel, src_column, ref_column, extraWhere] = association; // Générer le LEFT JOIN (pour préserver toutes les lignes, même celles avec NULL) let joinSql = `LEFT JOIN ${esc}${currentTableName}${esc} `; joinSql += `ON ${esc}${currentTableName}${esc}.${esc}${ref_column}${esc} = ${esc}${prevTable}${esc}.${esc}${src_column}${esc}`; // Ajouter les conditions extraWhere si présentes if (extraWhere) { _.forOwn(extraWhere, (value, key) => { joinSql += ` AND ${esc}${currentTableName}${esc}.${esc}${key}${esc} = ${dialect.param(this.i++)}`; params.push(value); }); } sql += joinSql + ' '; processedTables.add(currentTableName); prevTable = currentTableName; }); }); // Retourner à la fois le SQL et les params pour qu'ils soient ajoutés this._sortJoinParams = params; return sql; } /** * Génère les sous-requêtes EXISTS pour les filterJoins * * Cette version REFACTORISÉE gère correctement les vraies hiérarchies imbriquées. * * @param {Array} params - Tableau des paramètres SQL (modifié par référence) * @returns {string} Clause SQL avec les EXISTS * * Logique : * - Pour chaque filterJoin simple : créer un EXISTS plat * - Pour chaque filterJoin nested : créer des EXISTS vraiment imbriqués */ addFilterJoinsAsExists(params) { const { query, dialect } = this; if (!query.filterJoins || query.filterJoins.length === 0) { return ''; } let sql = ''; const hasWhere = query.where.length > 0 || query.whereNot.length > 0; let index = 0; _.forEach(query.filterJoins, (filterJoin) => { // Ajouter AND si nécessaire if (hasWhere || index > 0) { sql += 'AND '; } else { sql += 'WHERE '; } // Distinguer les différents types de filterJoins if (filterJoin.type === 'nested') { // Traiter chaque branche de la hiérarchie _.forEach(filterJoin.hierarchy, (rootNode) => { sql += this._buildNestedExistsFromTree(rootNode, query.table, params); sql += ' '; }); } else if (filterJoin.type === 'or_group') { // Groupe de conditions avec OR sql += this._buildOrGroupExists(filterJoin.conditions, query.table, params); } else { // filterJoin simple (1 niveau) sql += this._buildSimpleExists(filterJoin, query.table, params); } index++; }); return sql; } /** * Construit un EXISTS imbriqué à partir d'un arbre de noeuds * * Cette méthode est RÉCURSIVE et génère des EXISTS vraiment imbriqués. * * @param {Object} node - Noeud de l'arbre (avec association, conditions, children) * @param {string} parentTable - Table parente pour la condition de jointure * @param {Array} params - Paramètres SQL * @returns {string} SQL de l'EXISTS (potentiellement avec EXISTS imbriqués) * * Exemple d'arbre : * { * association: ['belongs_to', 'pmfp_folder', PmfpFolder, 'pmfp_folder_id', 'id'], * conditions: null, * children: [{ * association: ['belongs_to', 'formation_nature', FormationNature, 'formation_nature_id', 'id'], * conditions: { label: '%numérique%' }, * children: [] * }] * } * * Génère : * EXISTS ( * SELECT 1 FROM pmfp_folders * WHERE pmfp_folders.id = folders.pmfp_folder_id * AND EXISTS ( * SELECT 1 FROM formation_nature * WHERE formation_nature.id = pmfp_folders.formation_nature_id * AND formation_nature.label LIKE '%numérique%' * ) * ) */ _buildNestedExistsFromTree(node, parentTable, params) { const { dialect } = this; const { esc } = dialect; const { association, conditions, operator, children } = node; const [, , AssociatedModel, src_column, ref_column, extraWhere] = association; const joinTable = AssociatedModel.schema.table; // Ouvrir l'EXISTS let sql = `EXISTS (SELECT 1 FROM ${esc}${joinTable}${esc} `; // Condition de jointure sql += `WHERE ${esc}${joinTable}${esc}.${esc}${ref_column}${esc} = ${esc}${parentTable}${esc}.${esc}${src_column}${esc} `; // Ajouter les conditions extraWhere si présentes if (extraWhere) { _.forOwn(extraWhere, (value, key) => { sql += `AND ${esc}${joinTable}${esc}.${esc}${key}${esc} = ${dialect.param(this.i++)} `; params.push(value); }); } // Ajouter les conditions de ce niveau (si présentes) if (conditions && !_.isEmpty(conditions)) { sql += this.buildConditions(conditions, joinTable, params, operator); } // Traiter récursivement les enfants (EXISTS imbriqués) if (children && children.length > 0) { _.forEach(children, (childNode) => { sql += 'AND '; sql += this._buildNestedExistsFromTree(childNode, joinTable, params); sql += ' '; }); } // Fermer l'EXISTS sql += ')'; return sql; } /** * Construit un EXISTS simple (non imbriqué) * * @param {Object} filterJoin - FilterJoin à traiter * @param {string} parentTable - Table parente * @param {Array} params - Paramètres SQL * @returns {string} SQL de l'EXISTS */ _buildSimpleExists(filterJoin, parentTable, params) { const { dialect } = this; const { esc } = dialect; const { association, conditions, operator } = filterJoin; const [, , AssociatedModel, src_column, ref_column, extraWhere] = association; const joinTable = AssociatedModel.schema.table; let sql = `EXISTS (SELECT 1 FROM ${esc}${joinTable}${esc} `; sql += `WHERE ${esc}${joinTable}${esc}.${esc}${ref_column}${esc} = ${esc}${parentTable}${esc}.${esc}${src_column}${esc} `; // Ajouter les conditions extraWhere si présentes if (extraWhere) { _.forOwn(extraWhere, (value, key) => { sql += `AND ${esc}${joinTable}${esc}.${esc}${key}${esc} = ${dialect.param(this.i++)} `; params.push(value); }); } if (conditions && !_.isEmpty(conditions)) { sql += this.buildConditions(conditions, joinTable, params, operator); } sql += ') '; return sql; } /** * Construit un groupe de EXISTS avec OR * * @param {Array} conditions - Array de conditions du $or * @param {string} parentTable - Table parente * @param {Array} params - Paramètres SQL * @returns {string} SQL avec EXISTS joints par OR * * Exemple : * Input: [ * { 'applicant.last_name': { $like: 'Dupont%' } }, * { 'applicant.first_name': { $like: 'Jean%' } }, * { 'beneficiary.email': 'test@test.com' } * ] * Output: ( * EXISTS (SELECT 1 FROM applicants WHERE applicants.id = folders.applicant_id AND applicants.last_name LIKE ?) * OR EXISTS (SELECT 1 FROM applicants WHERE applicants.id = folders.applicant_id AND applicants.first_name LIKE ?) * OR EXISTS (SELECT 1 FROM beneficiaries WHERE beneficiaries.id = folders.beneficiary_id AND beneficiaries.email = ?) * ) */ _buildOrGroupExists(conditions, parentTable, params) { const { query, dialect } = this; const { esc } = dialect; const existsClauses = []; _.forEach(conditions, (cond) => { _.forOwn(cond, (value, key) => { // Parser le chemin (ex: 'applicant.last_name') const parts = key.split('.'); const column = parts[parts.length - 1]; const path = parts.slice(0, -1); // Trouver l'association const association = this._findAssociationByPath(path, query.schema); if (association) { const [, , AssociatedModel, src_column, ref_column, extraWhere] = association; const joinTable = AssociatedModel.schema.table; // Construire l'EXISTS pour cette condition let existsSQL = `EXISTS (SELECT 1 FROM ${esc}${joinTable}${esc} `; existsSQL += `WHERE ${esc}${joinTable}${esc}.${esc}${ref_column}${esc} = ${esc}${parentTable}${esc}.${esc}${src_column}${esc} `; // Ajouter les conditions extraWhere si présentes if (extraWhere) { _.forOwn(extraWhere, (ewValue, ewKey) => { existsSQL += `AND ${esc}${joinTable}${esc}.${esc}${ewKey}${esc} = ${dialect.param(this.i++)} `; params.push(ewValue); }); } // Ajouter la condition sur la colonne const columnRef = `${esc}${joinTable}${esc}.${esc}${column}${esc}`; const compiled = compileCondition(columnRef, value, dialect, this.i); this.i = compiled.i; existsSQL += `AND ${compiled.sql}`; params.push(...compiled.params); existsSQL += ')'; existsClauses.push(existsSQL); } }); }); if (existsClauses.length === 0) { return ''; } return `(${existsClauses.join(' OR ')}) `; } /** * Trouve une association en suivant un chemin * * @param {Array} path - Chemin d'associations (ex: ['applicant'] ou ['pme_folder', 'company']) * @param {Object} currentSchema - Schema actuel * @returns {Array|null} Association trouvée */ _findAssociationByPath(path, currentSchema) { if (path.length === 0) { return null; } const associations = this._getSchemaAssociations(currentSchema); if (!associations) { return null; } // Chercher la première association const firstAssocName = path[0]; const association = _.find(associations, (assoc) => { return assoc[1] === firstAssocName; }); if (!association) { return null; } // Si le chemin est plus long, continuer récursivement if (path.length === 1) { return association; } // Pour les chemins imbriqués, on retourne juste la dernière association // (pour simplifier, on suppose qu'on ne gère que les chemins simples pour l'instant) return association; } /** * Construit les conditions SQL pour une sous-requête EXISTS * * @param {Object} conditions - Objet avec les conditions * @param {string} tableName - Nom de la table pour qualifier les colonnes * @param {Array} params - Tableau des paramètres SQL (modifié par référence) * @param {string} operator - Opérateur entre les conditions ('AND' ou 'OR') * @returns {string} Clause SQL * * Exemples de conditions supportées : * - Égalité : { status: 'ACTIVE' } * - IN : { status: ['ACTIVE', 'PENDING'] } * - IS NULL : { email: null } * - LIKE : { last_name: { $like: 'Dupont%' } } * - BETWEEN : { created_at: { $between: ['2024-01-01', '2024-12-31'] } } * - >= : { created_at: { $gte: '2024-01-01' } } * - <= : { created_at: { $lte: '2024-12-31' } } * - > : { amount: { $gt: 100 } } * - < : { amount: { $lt: 1000 } } */ buildConditions(conditions, tableName, params, operator = 'AND') { const { dialect } = this; const { esc } = dialect; const sqlConditions = []; _.forOwn(conditions, (value, key) => { let columnRef; if (key.indexOf('.') > -1) { const parts = key.split('.'); columnRef = _.map(parts, part => `${esc}${part}${esc}`).join('.'); } else { columnRef = `${esc}${tableName}${esc}.${esc}${key}${esc}`; } const compiled = compileCondition(columnRef, value, dialect, this.i); this.i = compiled.i; sqlConditions.push(compiled.sql + ' '); params.push(...compiled.params); }); if (sqlConditions.length === 0) { return ''; } return 'AND ' + sqlConditions.join(`${operator} `); } /** * Override de whereSQL pour éviter d'ajouter les filterJoins dans les WHERE standards * * Cette méthode filtre les conditions pour n'inclure que celles de la table principale. */ whereSQL(params, not) { // Appeler la méthode parente qui gère déjà les WHERE correctement return super.whereSQL(params, not); } /** * Override de orderSQL pour transformer les noms d'associations en noms de tables SQL * * IMPORTANT : Cette transformation N'EST NÉCESSAIRE QUE pour les phases COUNT et IDS * car elles utilisent des INNER JOIN sans alias. La phase FULL (selectSQL) utilise * des LEFT JOIN avec des alias qui correspondent aux noms d'associations, donc pas * de transformation nécessaire. * * Par exemple : * - Phase IDS : "formationNature.name" → "formation_natures.name" (transformation nécessaire) * - Phase FULL : "formationNature.name" → reste "formationNature.name" (alias du LEFT JOIN) */ orderSQL() { const { query } = this; if (!query.order || !query.order.length) { return ''; } // Déterminer si on est dans une phase qui nécessite la transformation // COUNT et IDS utilisent des INNER JOIN avec noms de tables réels // SELECT (full) utilise des LEFT JOIN avec alias = noms d'associations const needsTransformation = (query.verb === 'count' || query.verb === 'select_ids'); if (!needsTransformation) { // Phase FULL : appeler la méthode parente (pas de transformation) return super.orderSQL(); } // Phases COUNT/IDS : transformer les noms d'associations en noms de tables const transformedOrder = query.order.map((orderClause) => { return this._transformOrderClause(orderClause); }); return 'ORDER BY ' + transformedOrder.join(', ') + ' '; } /** * Transforme une clause ORDER BY pour la phase FULL (utilise les noms d'associations comme alias) * * @param {string} orderClause - Clause ORDER BY (ex: "pme_folder.studies.studies_year DESC" ou "studies_year") * @returns {string} Clause transformée avec alias (ex: "studies.studies_year DESC") */ _transformOrderClauseForFullQuery(orderClause) { // Parser la clause ORDER BY pour extraire ASC/DESC const cleanedClause = orderClause.replace(/`/g, '').trim(); const match = cleanedClause.match(/^(.+?)\s+(ASC|DESC)$/i); let expression, direction; if (match) { expression = match[1]; direction = match[2]; } else { expression = cleanedClause; direction = ''; } // Détecter si c'est une fonction SQL const hasSqlFunction = /\b(COALESCE|IFNULL|CONCAT|CONCAT_WS|UPPER|LOWER|SUBSTRING|TRIM)\s*\(/i.test(expression); if (hasSqlFunction) { // Pour les fonctions, transformer les chemins à l'intérieur // Note: _transformPathsInExpression utilise _transformSinglePath, donc on ne peut pas l'utiliser directement // Pour l'instant, on retourne tel quel (les fonctions dans FULL query sont rares avec des blocks) // TODO: créer une version spéciale si nécessaire } else { // Transformation simple expression = this._transformPathForFullQuery(expression); } // Reconstruire avec direction return direction ? `${expression} ${direction}` : expression; } /** * Transforme une clause ORDER BY en remplaçant les noms d'associations par les noms de tables * * Gère aussi les fonctions SQL comme COALESCE, IFNULL, CONCAT, etc. * * @param {string} orderClause - Clause ORDER BY (ex: "formationNature.name DESC" ou "COALESCE(beneficiary.name, applicant.name)") * @returns {string} Clause transformée (ex: "formation_natures.name DESC" ou "COALESCE(beneficiaries.name, applicants.name)") */ _transformOrderClause(orderClause) { const { query } = this; // Parser la clause ORDER BY pour extraire ASC/DESC const cleanedClause = orderClause.replace(/`/g, '').trim(); const match = cleanedClause.match(/^(.+?)\s+(ASC|DESC)$/i); let expression, direction; if (match) { expression = match[1]; direction = match[2]; } else { expression = cleanedClause; direction = ''; } // Détecter si c'est une fonction SQL (COALESCE, IFNULL, CONCAT, etc.) const hasSqlFunction = /\b(COALESCE|IFNULL|CONCAT|CONCAT_WS|UPPER|LOWER|SUBSTRING|TRIM)\s*\(/i.test(expression); if (hasSqlFunction) { // Transformer tous les chemins table.colonne à l'intérieur de la fonction expression = this._transformPathsInExpression(expression); } else { // Transformation simple (pas de fonction) expression = this._transformSinglePath(expression); } return direction ? `${expression} ${direction}` : expression; } /** * Transforme tous les chemins association.colonne dans une expression SQL * Gère également les colonnes simples (sans préfixe) qui proviennent de blocks * * @param {string} expression - Expression SQL (ex: "COALESCE(beneficiary.name, applicant.name)" ou "COALESCE(studies_year, 'N/A')") * @returns {string} Expression transformée (ex: "COALESCE(beneficiaries.name, applicants.name)" ou "COALESCE(block_studies.studies_year, 'N/A')") */ _transformPathsInExpression(expression) { const { query } = this; // Mots-clés SQL à ne pas transformer const sqlKeywords = new Set([ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'ASC', 'DESC', 'COALESCE', 'IFNULL', 'CONCAT', 'CONCAT_WS', 'SUBSTRING', 'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'DATE', 'NOW', 'NULL', 'AND', 'OR' ]); // Regex combiné qui capture TOUS les types de chemins/colonnes en une seule passe // Cette approche évite la re-transformation des chemins déjà transformés // // Groupe 1: paths avec points (sans backticks) comme table.column // Groupe 2: colonnes simples avec backticks comme `column` // Groupe 3: identificateurs simples (sans backticks, sans points) const combinedPattern = /(\b[a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+\b)|(`[a-zA-Z_][a-zA-Z0-9_]*`)(?!\s*\.)|(\b[a-zA-Z_][a-zA-Z0-9_]*\b)(?!\s*[\.\(])/g; return expression.replace(combinedPattern, (match, plainPath, backtickColumn, plainIdentifier) => { // Cas 1: Chemins avec points (ex: table.column, table1.table2.column) if (plainPath) { const transformed = this._transformSinglePath(plainPath); return transformed; } // Cas 2: Colonne simple avec backticks (ex: `column`) if (backtickColumn) { const columnName = backtickColumn.replace(/`/g, ''); // Vérifier si c'est dans la table principale const existsInMainTable = query.schema?.colsByName?.[columnName]; if (existsInMainTable) { return match; // Garder tel quel } // Chercher dans les associations (blocks) const assocPath = this._findAssociationByColumn(columnName, query.schema); if (assocPath && assocPath.length > 0) { const tableName = assocPath[0].tableName; return `\`${tableName}\`.\`${columnName}\``; } return match; } // Cas 3: Identifiant simple sans backticks (ex: column) if (plainIdentifier) { // Ignorer les mots-clés SQL if (sqlKeywords.has(plainIdentifier.toUpperCase())) { return match; } // Vérifier si c'est dans la table principale const existsInMainTable = query.schema?.colsByName?.[plainIdentifier]; if (existsInMainTable) { return match; } // Chercher dans les associations (blocks) const assocPath = this._findAssociationByColumn(plainIdentifier, query.schema); if (assocPath && assocPath.length > 0) { const tableName = assocPath[0].tableName; return `${tableName}.${plainIdentifier}`; } return match; } return match; }); } /** * Transforme un chemin d'association pour la phase FULL (utilise les noms d'associations comme alias) * * @param {string} path - Chemin (ex: "pme_folder.studies.studies_year" ou "studies_year") * @returns {string} Chemin transformé avec alias (ex: "studies.studies_year") */ _transformPathForFullQuery(path) { const { query } = this; // Split le chemin par point const parts = path.split('.'); if (parts.length < 2) { // Pas de point - peut être une colonne simple ou une colonne de block const columnName = parts[0]; // Vérifier si la colonne existe dans la table principale const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName]; if (!existsInMainTable) { // Chercher dans les associations (blocks) const assocPath = this._findAssociationByColumn(columnName, query.schema); if (assocPath && assocPath.length > 0) { // Colonne trouvée dans une table de block - utiliser le nom de l'association comme alias const assocName = assocPath[0].association[1]; // Nom de l'association return `${assocName}.${columnName}`; } } // Colonne dans la table principale ou non trouvée - retourner tel quel return path; } // Le dernier élément est la colonne const columnName = parts[parts.length - 1]; const associationPath = parts.slice(0, -1); // Si le premier élément est déjà la table principale, pas de transformation if (associationPath[0] === query.table) { return path; } // Pour les chemins imbriqués, utiliser le dernier nom d'association comme alias // Ex: "pme_folder.studies.studies_year" -> "studies.studies_year" const lastAssocName = associationPath[associationPath.length - 1]; return `${lastAssocName}.${columnName}`; } /** * Transforme un seul chemin association.colonne en table.colonne * * Gère également les colonnes sans préfixe (ex: "studies_year") en cherchant * dans les associations si elles proviennent d'une table de block. * * @param {string} path - Chemin (ex: "beneficiary_snapshot.identity_expires_at", "pme_folder.company.name", ou "studies_year") * @returns {string} Chemin transformé (ex: "beneficiaries_snapshots.identity_expires_at", "companies.name", ou "block_studies.studies_year") */ _transformSinglePath(path) { const { query } = this; // Split le chemin par point const parts = path.split('.'); if (parts.length < 2) { // Pas de point - peut être une colonne simple ou une colonne de block const columnName = parts[0]; // Vérifier si la colonne existe dans la table principale const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName]; if (!existsInMainTable) { // Chercher dans les associations (blocks) const assocPath = this._findAssociationByColumn(columnName, query.schema); if (assocPath && assocPath.length > 0) { // Colonne trouvée dans une table de block - ajouter le préfixe const tableName = assocPath[0].tableName; return `${tableName}.${columnName}`; } } // Colonne dans la table principale ou non trouvée - retourner tel quel return path; } // Le dernier élément est la colonne const columnName = parts[parts.length - 1]; const associationPath = parts.slice(0, -1); // Si le premier élément est déjà la table principale, pas de transformation if (associationPath[0] === query.table) { return path; } // Construire le chemin d'associations pour obtenir les vrais noms de tables let associationChain = this._buildAssociationPath(associationPath, query.schema); // Si on n'a pas trouvé, essayer avec _findPathToTable (au cas où c'est déjà un nom de table) if (!associationChain && associationPath.length === 1) { associationChain = this._findPathToTable(associationPath[0], query.schema); } if (associationChain && associationChain.length > 0) { // Utiliser le nom de la dernière table du chemin const lastTable = associationChain[associationChain.length - 1].tableName; return `${lastTable}.${columnName}`; } // Si on ne trouve pas de transformation, retourner tel quel return path; } };