@igojs/db 6.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1352 @@
1
+ const _ = require('lodash');
2
+ const Sql = require('./Sql');
3
+
4
+ /**
5
+ * PaginatedOptimizedSql - Générateur SQL optimisé avec pattern EXISTS
6
+ *
7
+ * Cette classe hérite de Sql et override les méthodes de génération SQL pour :
8
+ * - Remplacer les LEFT JOIN par des sous-requêtes EXISTS dans COUNT et SELECT IDS
9
+ * - Conserver uniquement les filtres sur la table principale
10
+ *
11
+ * Le pattern EXISTS est beaucoup plus performant car :
12
+ * - Il évite le produit cartésien des jointures
13
+ * - Il utilise les index de façon optimale
14
+ * - Il s'arrête dès qu'une ligne est trouvée (short-circuit)
15
+ */
16
+ module.exports = class PaginatedOptimizedSql extends Sql {
17
+
18
+ /**
19
+ * COUNT SQL optimisé avec EXISTS au lieu de LEFT JOIN
20
+ *
21
+ * Génère une requête COUNT(0) sans jointures. Les filtres sur tables jointes
22
+ * sont convertis en sous-requêtes EXISTS.
23
+ *
24
+ * Exemple de SQL généré :
25
+ *
26
+ * SELECT COUNT(0) as `count`
27
+ * FROM `folders` f
28
+ * WHERE f.type IN ('agp', 'avt')
29
+ * AND EXISTS (
30
+ * SELECT 1 FROM `applicants` a
31
+ * WHERE a.id = f.applicant_id
32
+ * AND a.last_name LIKE '%Dupont%'
33
+ * )
34
+ * AND EXISTS (
35
+ * SELECT 1 FROM `pme_folders` p
36
+ * WHERE p.id = f.pme_folder_id
37
+ * AND p.status = 'ACTIVE'
38
+ * )
39
+ */
40
+ countSQL() {
41
+ const { query, dialect } = this;
42
+ const { esc } = dialect;
43
+
44
+ // SELECT COUNT(0)
45
+ let sql = `SELECT COUNT(0) as ${esc}count${esc} `;
46
+ const params = [];
47
+
48
+ // FROM table principale
49
+ sql += `FROM ${esc}${query.table}${esc} `;
50
+
51
+ // WHERE de la table principale
52
+ sql += this.whereSQL(params);
53
+
54
+ // WHERE NOT de la table principale
55
+ sql += this.whereNotSQL(params);
56
+
57
+ // Ajouter les filterJoins en tant que EXISTS
58
+ sql += this.addFilterJoinsAsExists(params);
59
+
60
+ const ret = {
61
+ sql: sql.trim(),
62
+ params: params
63
+ };
64
+
65
+ return ret;
66
+ }
67
+
68
+ /**
69
+ * IDS SQL - Sélection des IDs uniquement avec filtres et tri
70
+ *
71
+ * Génère une requête SELECT qui retourne uniquement les IDs (clés primaires)
72
+ * de la table principale, avec tous les filtres, tris et pagination appliqués.
73
+ *
74
+ * IMPORTANT : Si le tri (ORDER BY) référence une colonne d'une table jointe,
75
+ * on utilise LEFT JOIN (pas INNER JOIN) pour préserver toutes les lignes,
76
+ * même celles sans correspondance (NULL).
77
+ *
78
+ * Exemple de SQL généré (tri sur table jointe) :
79
+ *
80
+ * SELECT f.id
81
+ * FROM `folders` f
82
+ * LEFT JOIN `applicants` a ON a.id = f.applicant_id
83
+ * WHERE f.type IN ('agp', 'avt')
84
+ * ORDER BY a.last_name ASC -- Les folders sans applicant auront NULL
85
+ * LIMIT 50 OFFSET 0
86
+ */
87
+ idsSQL() {
88
+ const { query, dialect } = this;
89
+ const { esc } = dialect;
90
+
91
+ // Détecter les tables nécessaires pour le tri
92
+ const joinTablesForSort = this._detectJoinTablesForSort();
93
+
94
+ // SELECT colonnes (IDs ou clés primaires)
95
+ let sql = 'SELECT ';
96
+ if (query.select) {
97
+ sql += query.select + ' ';
98
+ } else {
99
+ const primaryKeys = query.schema?.primary || ['id'];
100
+ sql += primaryKeys.map(key => `${esc}${query.table}${esc}.${esc}${key}${esc}`).join(', ') + ' ';
101
+ }
102
+
103
+ // FROM table principale
104
+ sql += `FROM ${esc}${query.table}${esc} `;
105
+
106
+ // LEFT JOIN pour les tables nécessaires au tri (préserve toutes les lignes)
107
+ sql += this._addJoinsForSort(joinTablesForSort);
108
+
109
+ // WHERE de la table principale
110
+ const params = [];
111
+ sql += this.whereSQL(params);
112
+
113
+ // WHERE NOT de la table principale
114
+ sql += this.whereNotSQL(params);
115
+
116
+ // Ajouter les filterJoins en tant que EXISTS
117
+ sql += this.addFilterJoinsAsExists(params);
118
+
119
+ // ORDER BY (important pour maintenir l'ordre demandé)
120
+ sql += this.orderSQL();
121
+
122
+ // LIMIT / OFFSET pour la pagination
123
+ if (query.limit) {
124
+ sql += dialect.limit(this.i++, this.i++);
125
+ params.push(query.offset || 0);
126
+ params.push(query.limit);
127
+ }
128
+
129
+ const ret = {
130
+ sql: sql.trim(),
131
+ params: params
132
+ };
133
+
134
+ return ret;
135
+ }
136
+
137
+ /**
138
+ * Détecte les tables jointes nécessaires pour le tri
139
+ *
140
+ * Analyse les clauses ORDER BY pour identifier les colonnes qui proviennent
141
+ * de tables jointes (directes ou imbriquées). Retourne les associations en cascade.
142
+ *
143
+ * Gère également les colonnes sans préfixe de table (ex: "studies_year") en cherchant
144
+ * automatiquement dans les associations belongs_to si la colonne n'existe pas dans
145
+ * la table principale.
146
+ *
147
+ * @returns {Array} Liste hiérarchique des associations nécessaires pour le tri
148
+ *
149
+ * Exemples :
150
+ * - ORDER BY "applicants.last_name ASC" → [{path: [...], pathKey: 'applicant'}]
151
+ * - ORDER BY "pmfp_folder.formationNature.name" → [{path: [...], pathKey: 'pmfp_folder.formationNature'}]
152
+ * - ORDER BY "studies_year ASC" → [{path: [...], pathKey: 'studies'}] (si studies_year existe dans une table de block)
153
+ */
154
+ _detectJoinTablesForSort() {
155
+ const { query } = this;
156
+
157
+ if (!query.order || query.order.length === 0) {
158
+ return [];
159
+ }
160
+
161
+ const joinPaths = [];
162
+ const mainTable = query.table;
163
+
164
+ _.forEach(query.order, (orderClause) => {
165
+ // Check if this is a SQL function (contains function keywords)
166
+ const hasSqlFunction = /\b(COALESCE|IFNULL|CONCAT|CONCAT_WS|UPPER|LOWER|SUBSTRING|TRIM)\s*\(/i.test(orderClause);
167
+
168
+ if (hasSqlFunction) {
169
+ // For SQL functions, extract all table references
170
+ const tableNames = this._extractTableReferencesFromOrderClause(orderClause);
171
+
172
+ _.forEach(tableNames, (tableName) => {
173
+ // Skip main table
174
+ if (tableName === mainTable) {
175
+ return;
176
+ }
177
+
178
+ // Try to find association path for this table
179
+ let path = this._findPathToTable(tableName, query.schema);
180
+
181
+ // If not found by table name, try by association name
182
+ if (!path) {
183
+ path = this._buildAssociationPath([tableName], query.schema);
184
+ }
185
+
186
+ if (path && path.length > 0) {
187
+ const pathKey = path.map(p => p.association[1]).join('.');
188
+
189
+ // Avoid duplicates
190
+ if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) {
191
+ joinPaths.push({
192
+ pathKey,
193
+ path,
194
+ tablePath: [tableName],
195
+ columnName: null // Not needed for function-based ORDER BY
196
+ });
197
+ }
198
+ }
199
+ });
200
+
201
+ // Also extract simple column names (without table prefix) in SQL functions
202
+ // and check if they belong to block tables
203
+ const simpleColumns = this._extractSimpleColumnsFromOrderClause(orderClause);
204
+
205
+ _.forEach(simpleColumns, (columnName) => {
206
+ // Check if column exists in main table
207
+ const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName];
208
+
209
+ if (!existsInMainTable) {
210
+ // Column not in main table - search in associations (blocks)
211
+ const assocPath = this._findAssociationByColumn(columnName, query.schema);
212
+
213
+ if (assocPath && assocPath.length > 0) {
214
+ const pathKey = assocPath.map(p => p.association[1]).join('.');
215
+
216
+ // Avoid duplicates
217
+ if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) {
218
+ joinPaths.push({
219
+ pathKey,
220
+ path: assocPath,
221
+ tablePath: [assocPath[assocPath.length - 1].tableName],
222
+ columnName
223
+ });
224
+ }
225
+ }
226
+ }
227
+ });
228
+ } else {
229
+ // For non-function ORDER BY, parse as nested path
230
+ const cleanedClause = orderClause.replace(/`/g, '').trim();
231
+ const parts = cleanedClause.split(/\s+/)[0].split('.'); // Take only before ASC/DESC
232
+
233
+ if (parts.length < 2) {
234
+ // No dot - could be a column on main table OR a column from a block table
235
+ const columnName = parts[0];
236
+
237
+ // Check if column exists in main table
238
+ const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName];
239
+
240
+ if (!existsInMainTable) {
241
+ // Column not in main table - search in associations (blocks)
242
+ const assocPath = this._findAssociationByColumn(columnName, query.schema);
243
+
244
+ if (assocPath && assocPath.length > 0) {
245
+ const pathKey = assocPath.map(p => p.association[1]).join('.');
246
+
247
+ // Avoid duplicates
248
+ if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) {
249
+ joinPaths.push({
250
+ pathKey,
251
+ path: assocPath,
252
+ tablePath: [assocPath[assocPath.length - 1].tableName],
253
+ columnName
254
+ });
255
+ }
256
+ }
257
+ }
258
+
259
+ // If column exists in main table or wasn't found in associations, no join needed
260
+ return;
261
+ }
262
+
263
+ // Last element is the column, rest is the path
264
+ const columnName = parts[parts.length - 1];
265
+ const tablePath = parts.slice(0, -1);
266
+
267
+ // Skip if first element is main table
268
+ if (tablePath[0] === mainTable) {
269
+ return;
270
+ }
271
+
272
+ // Try to build full association path
273
+ let path = this._buildAssociationPath(tablePath, query.schema);
274
+
275
+ // If not found by association names, try finding by table name (single level only)
276
+ if (!path && tablePath.length === 1) {
277
+ const targetTable = tablePath[0];
278
+ path = this._findPathToTable(targetTable, query.schema);
279
+ }
280
+
281
+ if (path && path.length > 0) {
282
+ const pathKey = path.map(p => p.association[1]).join('.');
283
+
284
+ // Avoid duplicates
285
+ if (!_.find(joinPaths, jp => jp.pathKey === pathKey)) {
286
+ joinPaths.push({
287
+ pathKey,
288
+ path,
289
+ tablePath,
290
+ columnName
291
+ });
292
+ }
293
+ }
294
+ }
295
+ });
296
+
297
+ return joinPaths;
298
+ }
299
+
300
+ /**
301
+ * Extract simple column names (without table prefix) from an ORDER BY clause
302
+ * Handles SQL functions like COALESCE, IFNULL, CONCAT, etc.
303
+ *
304
+ * Examples:
305
+ * - "COALESCE(`studies_year`, 'N/A')" → ["studies_year"]
306
+ * - "CONCAT(`first_name`, ' ', `last_name`)" → ["first_name", "last_name"]
307
+ * - "IFNULL(bac_year, 0)" → ["bac_year"]
308
+ *
309
+ * @param {string} orderClause - The ORDER BY clause to parse
310
+ * @returns {Array<string>} - Array of unique column names (without table prefix)
311
+ */
312
+ _extractSimpleColumnsFromOrderClause(orderClause) {
313
+ const columnNames = new Set();
314
+
315
+ // Pattern pour capturer les identifiants entre backticks qui n'ont pas de point avant
316
+ // Ex: `studies_year` mais pas `table`.`column`
317
+ const backtickPattern = /(?<!\.)(`(\w+)`)/g;
318
+ let match;
319
+ while ((match = backtickPattern.exec(orderClause)) !== null) {
320
+ const columnName = match[2];
321
+ // Vérifier qu'il n'y a pas de point juste avant (sinon c'est une référence table.colonne)
322
+ const beforeMatch = orderClause.substring(0, match.index);
323
+ if (!beforeMatch.endsWith('.')) {
324
+ columnNames.add(columnName);
325
+ }
326
+ }
327
+
328
+ // Pattern pour capturer les identifiants sans backticks qui n'ont pas de point
329
+ // et qui ne sont pas des mots-clés SQL
330
+ const sqlKeywords = new Set([
331
+ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'ASC', 'DESC',
332
+ 'COALESCE', 'IFNULL', 'CONCAT', 'CONCAT_WS', 'SUBSTRING',
333
+ 'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'DATE', 'NOW', 'NULL', 'AND', 'OR'
334
+ ]);
335
+
336
+ // Pattern pour les identifiants sans backticks (lettres, chiffres, underscores)
337
+ // qui ne sont pas suivis d'une parenthèse (pas une fonction)
338
+ const identifierPattern = /\b([a-zA-Z_]\w+)\b(?!\s*\()/g;
339
+ while ((match = identifierPattern.exec(orderClause)) !== null) {
340
+ const identifier = match[1];
341
+ // Ignorer les mots-clés SQL et les valeurs spéciales
342
+ if (!sqlKeywords.has(identifier.toUpperCase()) && identifier !== 'N' && identifier !== 'A') {
343
+ // Vérifier qu'il n'y a pas de point juste avant
344
+ const beforeMatch = orderClause.substring(0, match.index);
345
+ if (!beforeMatch.trimEnd().endsWith('.')) {
346
+ columnNames.add(identifier);
347
+ }
348
+ }
349
+ }
350
+
351
+ return Array.from(columnNames);
352
+ }
353
+
354
+ /**
355
+ * Extract all table references from an ORDER BY clause
356
+ * Handles SQL functions like COALESCE, IFNULL, CONCAT, etc.
357
+ *
358
+ * Examples:
359
+ * - "table.column DESC" → ["table"]
360
+ * - "`table1`.`col1`, `table2`.`col2`" → ["table1", "table2"]
361
+ * - "COALESCE(`t1`.`col`, `t2`.`col`)" → ["t1", "t2"]
362
+ *
363
+ * @param {string} orderClause - The ORDER BY clause to parse
364
+ * @returns {Array<string>} - Array of unique table names
365
+ */
366
+ _extractTableReferencesFromOrderClause(orderClause) {
367
+ const tableNames = new Set();
368
+
369
+ // Pattern 1: Extract backticked table references: `table`.`column`
370
+ const backtickPattern = /`(\w+)`\.`\w+`/g;
371
+ let match;
372
+ while ((match = backtickPattern.exec(orderClause)) !== null) {
373
+ tableNames.add(match[1]);
374
+ }
375
+
376
+ // Pattern 2: Extract non-backticked references: table.column
377
+ // But exclude SQL keywords and functions
378
+ const sqlKeywords = new Set([
379
+ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'ASC', 'DESC',
380
+ 'COALESCE', 'IFNULL', 'CONCAT', 'CONCAT_WS', 'SUBSTRING',
381
+ 'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'DATE', 'NOW'
382
+ ]);
383
+
384
+ const dotPattern = /\b(\w+)\.(\w+)\b/g;
385
+ while ((match = dotPattern.exec(orderClause)) !== null) {
386
+ const tableName = match[1];
387
+ if (!sqlKeywords.has(tableName.toUpperCase())) {
388
+ tableNames.add(tableName);
389
+ }
390
+ }
391
+
392
+ return Array.from(tableNames);
393
+ }
394
+
395
+ /**
396
+ * Récupère les associations d'un schema (gère le cas où associations est une fonction)
397
+ *
398
+ * @param {Object} schema - Schema
399
+ * @returns {Array|null} Tableau d'associations ou null
400
+ */
401
+ _getSchemaAssociations(schema) {
402
+ if (!schema || !schema.associations) {
403
+ return null;
404
+ }
405
+
406
+ // Si associations est une fonction, l'appeler pour obtenir le tableau
407
+ // (Normalement cela devrait déjà être fait par Schema, mais on gère le cas par sécurité)
408
+ if (_.isFunction(schema.associations)) {
409
+ return schema.associations();
410
+ }
411
+
412
+ return schema.associations;
413
+ }
414
+
415
+ /**
416
+ * Construit un chemin d'associations à partir d'un tableau de noms (association ou table)
417
+ *
418
+ * @param {Array} names - Tableau de noms d'associations ou de tables (ex: ["pmfp_folder", "formationNature"] ou ["applicants"])
419
+ * @param {Object} currentSchema - Schema de départ
420
+ * @returns {Array|null} Chemin d'associations ou null si introuvable
421
+ *
422
+ * Exemple :
423
+ * _buildAssociationPath(["pmfp_folder", "formationNature"], FolderSchema)
424
+ * → [
425
+ * {association: [...], tableName: 'pmfp_folders'},
426
+ * {association: [...], tableName: 'formation_natures'}
427
+ * ]
428
+ */
429
+ _buildAssociationPath(names, currentSchema) {
430
+ const associations = this._getSchemaAssociations(currentSchema);
431
+ if (!associations || names.length === 0) {
432
+ return null;
433
+ }
434
+
435
+ const path = [];
436
+ let schema = currentSchema;
437
+
438
+ for (const name of names) {
439
+ const schemaAssociations = this._getSchemaAssociations(schema);
440
+ if (!schemaAssociations) {
441
+ return null;
442
+ }
443
+
444
+ // Chercher l'association par nom OU par nom de table dans le schema courant
445
+ const association = _.find(schemaAssociations, (assoc) => {
446
+ const [, assocName, AssociatedModel] = assoc;
447
+
448
+ // Matcher sur le nom de l'association
449
+ if (assocName === name) {
450
+ return true;
451
+ }
452
+
453
+ // Matcher sur le nom de la table (ex: "applicants" pour l'association "applicant")
454
+ if (AssociatedModel && AssociatedModel.schema) {
455
+ return AssociatedModel.schema.table === name;
456
+ }
457
+
458
+ return false;
459
+ });
460
+
461
+ if (!association) {
462
+ // Association introuvable
463
+ return null;
464
+ }
465
+
466
+ const [, , AssociatedModel] = association;
467
+ if (!AssociatedModel || !AssociatedModel.schema) {
468
+ return null;
469
+ }
470
+
471
+ const tableName = AssociatedModel.schema.table;
472
+ path.push({
473
+ association,
474
+ tableName
475
+ });
476
+
477
+ // Passer au schema suivant pour la prochaine itération
478
+ schema = AssociatedModel.schema;
479
+ }
480
+
481
+ return path;
482
+ }
483
+
484
+ /**
485
+ * Trouve une association par nom de table
486
+ *
487
+ * @param {string} tableName - Nom de la table (ex: 'applicants')
488
+ * @returns {Array|null} Association trouvée ou null
489
+ */
490
+ _findAssociationByTable(tableName) {
491
+ const { query } = this;
492
+ const associations = this._getSchemaAssociations(query.schema);
493
+
494
+ if (!associations) {
495
+ return null;
496
+ }
497
+
498
+ // Chercher dans les associations du schema (ici on a toujours les Model classes)
499
+ const association = _.find(associations, (assoc) => {
500
+ const [, , AssociatedModel] = assoc;
501
+ if (!AssociatedModel || !AssociatedModel.schema) {
502
+ return false;
503
+ }
504
+ return AssociatedModel.schema.table === tableName;
505
+ });
506
+
507
+ return association || null;
508
+ }
509
+
510
+ /**
511
+ * Trouve une association qui contient une colonne donnée
512
+ *
513
+ * Cette méthode cherche dans les associations belongs_to directes du schema
514
+ * pour trouver laquelle contient la colonne spécifiée.
515
+ *
516
+ * Utilisé pour gérer les colonnes de "blocks" dans les ORDER BY.
517
+ *
518
+ * @param {string} columnName - Nom de la colonne à chercher (ex: 'studies_year')
519
+ * @param {Object} currentSchema - Schema actuel
520
+ * @returns {Array|null} Chemin vers l'association contenant la colonne, ou null
521
+ *
522
+ * Exemple :
523
+ * _findAssociationByColumn('studies_year', PMEFolderSchema)
524
+ * → [{association: ['belongs_to', 'studies', StudiesBlock, 'block_studies_id', 'id'], tableName: 'block_studies'}]
525
+ */
526
+ _findAssociationByColumn(columnName, currentSchema) {
527
+ const associations = this._getSchemaAssociations(currentSchema);
528
+ if (!associations) {
529
+ return null;
530
+ }
531
+
532
+ // Chercher dans les associations directes (belongs_to uniquement pour les blocks)
533
+ for (const assoc of associations) {
534
+ const [assocType, assocName, AssociatedModel, src_column, ref_column] = assoc;
535
+
536
+ // On cherche uniquement dans les belongs_to
537
+ if (assocType !== 'belongs_to' || !AssociatedModel || !AssociatedModel.schema) {
538
+ continue;
539
+ }
540
+
541
+ const assocSchema = AssociatedModel.schema;
542
+ const assocTableName = assocSchema.table;
543
+
544
+ // Vérifier si la colonne existe dans ce schema
545
+ if (assocSchema.colsByName && assocSchema.colsByName[columnName]) {
546
+ // Colonne trouvée ! Retourner le chemin
547
+ return [{
548
+ association: assoc,
549
+ tableName: assocTableName
550
+ }];
551
+ }
552
+ }
553
+
554
+ return null;
555
+ }
556
+
557
+ /**
558
+ * Trouve le chemin complet vers une table ou association (gère les associations imbriquées)
559
+ *
560
+ * Cherche récursivement dans les associations pour trouver le chemin
561
+ * complet depuis la table principale jusqu'à la cible.
562
+ *
563
+ * @param {string} target - Nom de la table OU nom de l'association cible (ex: 'companies' ou 'formationNature')
564
+ * @param {Object} currentSchema - Schema actuel
565
+ * @param {Array} currentPath - Chemin actuel (pour la récursion)
566
+ * @param {Set} visitedTables - Tables déjà visitées (pour éviter les cycles)
567
+ * @returns {Array|null} Chemin vers la table/association ou null
568
+ *
569
+ * Exemples :
570
+ * _findPathToTable('companies', FolderSchema)
571
+ * → [{association: [...], tableName: 'pme_folders'}, {association: [...], tableName: 'companies'}]
572
+ *
573
+ * _findPathToTable('formationNature', FolderSchema)
574
+ * → [{association: [...], tableName: 'pmfp_folders'}, {association: [...], tableName: 'formation_natures'}]
575
+ */
576
+ _findPathToTable(target, currentSchema, currentPath = [], visitedTables = new Set()) {
577
+ const associations = this._getSchemaAssociations(currentSchema);
578
+ if (!associations) {
579
+ return null;
580
+ }
581
+
582
+ // Ajouter la table courante aux tables visitées
583
+ const currentTable = currentSchema.table;
584
+ if (currentTable && visitedTables.has(currentTable)) {
585
+ // Cycle détecté, arrêter la recherche dans cette branche
586
+ return null;
587
+ }
588
+
589
+ // Créer un nouveau Set avec la table courante ajoutée
590
+ const newVisitedTables = new Set(visitedTables);
591
+ if (currentTable) {
592
+ newVisitedTables.add(currentTable);
593
+ }
594
+
595
+ // Chercher dans les associations directes
596
+ for (const assoc of associations) {
597
+ const [, assocName, AssociatedModel, src_column, ref_column] = assoc;
598
+
599
+ if (!AssociatedModel || !AssociatedModel.schema) {
600
+ continue;
601
+ }
602
+
603
+ const assocTableName = AssociatedModel.schema.table;
604
+
605
+ // Éviter les cycles : ne pas revisiter une table déjà dans le chemin
606
+ if (newVisitedTables.has(assocTableName)) {
607
+ continue;
608
+ }
609
+
610
+ // Matcher sur le nom de la table OU le nom de l'association
611
+ const isMatch = (assocTableName === target) || (assocName === target);
612
+
613
+ if (isMatch) {
614
+ return [...currentPath, {
615
+ association: assoc,
616
+ tableName: assocTableName
617
+ }];
618
+ }
619
+
620
+ // Sinon, chercher récursivement dans les associations de ce modèle
621
+ const nestedPath = this._findPathToTable(
622
+ target,
623
+ AssociatedModel.schema,
624
+ [...currentPath, {
625
+ association: assoc,
626
+ tableName: assocTableName
627
+ }],
628
+ newVisitedTables
629
+ );
630
+
631
+ if (nestedPath) {
632
+ return nestedPath;
633
+ }
634
+ }
635
+
636
+ return null;
637
+ }
638
+
639
+ /**
640
+ * Ajoute les LEFT JOIN nécessaires pour le tri (gère les chemins imbriqués)
641
+ *
642
+ * LEFT JOIN est utilisé (pas INNER JOIN) pour préserver toutes les lignes de la table
643
+ * principale, même celles sans correspondance (qui auront NULL pour la colonne de tri).
644
+ *
645
+ * @param {Array} joinPathsForSort - Liste des chemins vers les tables à joindre
646
+ * @returns {string} Clause SQL avec les LEFT JOIN en cascade
647
+ *
648
+ * Exemples de SQL généré :
649
+ * - Simple : LEFT JOIN `applicants` ON `applicants`.`id` = `folders`.`applicant_id`
650
+ * - Imbriqué : LEFT JOIN `pmfp_folders` ON ... LEFT JOIN `formation_natures` ON ...
651
+ */
652
+ _addJoinsForSort(joinPathsForSort) {
653
+ if (joinPathsForSort.length === 0) {
654
+ return '';
655
+ }
656
+
657
+ const { query, dialect } = this;
658
+ const { esc } = dialect;
659
+ const mainTable = query.table;
660
+
661
+ let sql = '';
662
+ const processedTables = new Set(); // Pour éviter les doublons
663
+
664
+ _.forEach(joinPathsForSort, ({ path }) => {
665
+ // Parcourir le chemin et créer les LEFT JOIN en cascade
666
+ let prevTable = mainTable;
667
+
668
+ _.forEach(path, ({ association, tableName: currentTableName }) => {
669
+ // Éviter les doublons si plusieurs ORDER BY utilisent le même chemin
670
+ if (processedTables.has(currentTableName)) {
671
+ prevTable = currentTableName;
672
+ return;
673
+ }
674
+
675
+ const [, , AssociatedModel, src_column, ref_column] = association;
676
+
677
+ // Générer le LEFT JOIN (pour préserver toutes les lignes, même celles avec NULL)
678
+ sql += `LEFT JOIN ${esc}${currentTableName}${esc} `;
679
+ sql += `ON ${esc}${currentTableName}${esc}.${esc}${ref_column}${esc} = ${esc}${prevTable}${esc}.${esc}${src_column}${esc} `;
680
+
681
+ processedTables.add(currentTableName);
682
+ prevTable = currentTableName;
683
+ });
684
+ });
685
+
686
+ return sql;
687
+ }
688
+
689
+ /**
690
+ * Génère les sous-requêtes EXISTS pour les filterJoins
691
+ *
692
+ * Cette version REFACTORISÉE gère correctement les vraies hiérarchies imbriquées.
693
+ *
694
+ * @param {Array} params - Tableau des paramètres SQL (modifié par référence)
695
+ * @returns {string} Clause SQL avec les EXISTS
696
+ *
697
+ * Logique :
698
+ * - Pour chaque filterJoin simple : créer un EXISTS plat
699
+ * - Pour chaque filterJoin nested : créer des EXISTS vraiment imbriqués
700
+ */
701
+ addFilterJoinsAsExists(params) {
702
+ const { query, dialect } = this;
703
+
704
+ if (!query.filterJoins || query.filterJoins.length === 0) {
705
+ return '';
706
+ }
707
+
708
+ let sql = '';
709
+ const hasWhere = query.where.length > 0 || query.whereNot.length > 0;
710
+
711
+ let index = 0;
712
+ _.forEach(query.filterJoins, (filterJoin) => {
713
+ // Ajouter AND si nécessaire
714
+ if (hasWhere || index > 0) {
715
+ sql += 'AND ';
716
+ } else {
717
+ sql += 'WHERE ';
718
+ }
719
+
720
+ // Distinguer les différents types de filterJoins
721
+ if (filterJoin.type === 'nested') {
722
+ // Traiter chaque branche de la hiérarchie
723
+ _.forEach(filterJoin.hierarchy, (rootNode) => {
724
+ sql += this._buildNestedExistsFromTree(rootNode, query.table, params);
725
+ sql += ' ';
726
+ });
727
+ } else if (filterJoin.type === 'or_group') {
728
+ // Groupe de conditions avec OR
729
+ sql += this._buildOrGroupExists(filterJoin.conditions, query.table, params);
730
+ } else {
731
+ // filterJoin simple (1 niveau)
732
+ sql += this._buildSimpleExists(filterJoin, query.table, params);
733
+ }
734
+
735
+ index++;
736
+ });
737
+
738
+ return sql;
739
+ }
740
+
741
+ /**
742
+ * Construit un EXISTS imbriqué à partir d'un arbre de noeuds
743
+ *
744
+ * Cette méthode est RÉCURSIVE et génère des EXISTS vraiment imbriqués.
745
+ *
746
+ * @param {Object} node - Noeud de l'arbre (avec association, conditions, children)
747
+ * @param {string} parentTable - Table parente pour la condition de jointure
748
+ * @param {Array} params - Paramètres SQL
749
+ * @returns {string} SQL de l'EXISTS (potentiellement avec EXISTS imbriqués)
750
+ *
751
+ * Exemple d'arbre :
752
+ * {
753
+ * association: ['belongs_to', 'pmfp_folder', PmfpFolder, 'pmfp_folder_id', 'id'],
754
+ * conditions: null,
755
+ * children: [{
756
+ * association: ['belongs_to', 'formation_nature', FormationNature, 'formation_nature_id', 'id'],
757
+ * conditions: { label: '%numérique%' },
758
+ * children: []
759
+ * }]
760
+ * }
761
+ *
762
+ * Génère :
763
+ * EXISTS (
764
+ * SELECT 1 FROM pmfp_folders
765
+ * WHERE pmfp_folders.id = folders.pmfp_folder_id
766
+ * AND EXISTS (
767
+ * SELECT 1 FROM formation_nature
768
+ * WHERE formation_nature.id = pmfp_folders.formation_nature_id
769
+ * AND formation_nature.label LIKE '%numérique%'
770
+ * )
771
+ * )
772
+ */
773
+ _buildNestedExistsFromTree(node, parentTable, params) {
774
+ const { dialect } = this;
775
+ const { esc } = dialect;
776
+
777
+ const { association, conditions, operator, children } = node;
778
+ const [, , AssociatedModel, src_column, ref_column] = association;
779
+ const joinTable = AssociatedModel.schema.table;
780
+
781
+ // Ouvrir l'EXISTS
782
+ let sql = `EXISTS (SELECT 1 FROM ${esc}${joinTable}${esc} `;
783
+
784
+ // Condition de jointure
785
+ sql += `WHERE ${esc}${joinTable}${esc}.${esc}${ref_column}${esc} = ${esc}${parentTable}${esc}.${esc}${src_column}${esc} `;
786
+
787
+ // Ajouter les conditions de ce niveau (si présentes)
788
+ if (conditions && !_.isEmpty(conditions)) {
789
+ sql += this.buildConditions(conditions, joinTable, params, operator);
790
+ }
791
+
792
+ // Traiter récursivement les enfants (EXISTS imbriqués)
793
+ if (children && children.length > 0) {
794
+ _.forEach(children, (childNode) => {
795
+ sql += 'AND ';
796
+ sql += this._buildNestedExistsFromTree(childNode, joinTable, params);
797
+ sql += ' ';
798
+ });
799
+ }
800
+
801
+ // Fermer l'EXISTS
802
+ sql += ')';
803
+
804
+ return sql;
805
+ }
806
+
807
+ /**
808
+ * Construit un EXISTS simple (non imbriqué)
809
+ *
810
+ * @param {Object} filterJoin - FilterJoin à traiter
811
+ * @param {string} parentTable - Table parente
812
+ * @param {Array} params - Paramètres SQL
813
+ * @returns {string} SQL de l'EXISTS
814
+ */
815
+ _buildSimpleExists(filterJoin, parentTable, params) {
816
+ const { dialect } = this;
817
+ const { esc } = dialect;
818
+
819
+ const { association, conditions, operator } = filterJoin;
820
+ const [, , AssociatedModel, src_column, ref_column] = association;
821
+ const joinTable = AssociatedModel.schema.table;
822
+
823
+ let sql = `EXISTS (SELECT 1 FROM ${esc}${joinTable}${esc} `;
824
+ sql += `WHERE ${esc}${joinTable}${esc}.${esc}${ref_column}${esc} = ${esc}${parentTable}${esc}.${esc}${src_column}${esc} `;
825
+
826
+ if (conditions && !_.isEmpty(conditions)) {
827
+ sql += this.buildConditions(conditions, joinTable, params, operator);
828
+ }
829
+
830
+ sql += ') ';
831
+
832
+ return sql;
833
+ }
834
+
835
+ /**
836
+ * Construit un groupe de EXISTS avec OR
837
+ *
838
+ * @param {Array} conditions - Array de conditions du $or
839
+ * @param {string} parentTable - Table parente
840
+ * @param {Array} params - Paramètres SQL
841
+ * @returns {string} SQL avec EXISTS joints par OR
842
+ *
843
+ * Exemple :
844
+ * Input: [
845
+ * { 'applicant.last_name': 'Dupont%' },
846
+ * { 'applicant.first_name': 'Jean%' },
847
+ * { 'beneficiary.email': 'test@test.com' }
848
+ * ]
849
+ * Output: (
850
+ * EXISTS (SELECT 1 FROM applicants WHERE applicants.id = folders.applicant_id AND applicants.last_name LIKE ?)
851
+ * OR EXISTS (SELECT 1 FROM applicants WHERE applicants.id = folders.applicant_id AND applicants.first_name LIKE ?)
852
+ * OR EXISTS (SELECT 1 FROM beneficiaries WHERE beneficiaries.id = folders.beneficiary_id AND beneficiaries.email = ?)
853
+ * )
854
+ */
855
+ _buildOrGroupExists(conditions, parentTable, params) {
856
+ const { query, dialect } = this;
857
+ const { esc } = dialect;
858
+
859
+ const existsClauses = [];
860
+
861
+ _.forEach(conditions, (cond) => {
862
+ _.forOwn(cond, (value, key) => {
863
+ // Parser le chemin (ex: 'applicant.last_name')
864
+ const parts = key.split('.');
865
+ const column = parts[parts.length - 1];
866
+ const path = parts.slice(0, -1);
867
+
868
+ // Trouver l'association
869
+ const association = this._findAssociationByPath(path, query.schema);
870
+
871
+ if (association) {
872
+ const [, , AssociatedModel, src_column, ref_column] = association;
873
+ const joinTable = AssociatedModel.schema.table;
874
+
875
+ // Construire l'EXISTS pour cette condition
876
+ let existsSQL = `EXISTS (SELECT 1 FROM ${esc}${joinTable}${esc} `;
877
+ existsSQL += `WHERE ${esc}${joinTable}${esc}.${esc}${ref_column}${esc} = ${esc}${parentTable}${esc}.${esc}${src_column}${esc} `;
878
+
879
+ // Ajouter la condition sur la colonne
880
+ const columnRef = `${esc}${joinTable}${esc}.${esc}${column}${esc}`;
881
+
882
+ if (value === null || value === undefined) {
883
+ existsSQL += `AND ${columnRef} IS NULL`;
884
+ } else if (_.isArray(value)) {
885
+ if (value.length > 0) {
886
+ existsSQL += `AND ${columnRef} IN (${dialect.param(this.i++)})`;
887
+ params.push(value);
888
+ } else {
889
+ existsSQL += 'AND FALSE';
890
+ }
891
+ } else if (_.isString(value) && value.includes('%')) {
892
+ existsSQL += `AND ${columnRef} LIKE ${dialect.param(this.i++)}`;
893
+ params.push(value);
894
+ } else {
895
+ existsSQL += `AND ${columnRef} = ${dialect.param(this.i++)}`;
896
+ params.push(value);
897
+ }
898
+
899
+ existsSQL += ')';
900
+ existsClauses.push(existsSQL);
901
+ }
902
+ });
903
+ });
904
+
905
+ if (existsClauses.length === 0) {
906
+ return '';
907
+ }
908
+
909
+ return `(${existsClauses.join(' OR ')}) `;
910
+ }
911
+
912
+ /**
913
+ * Trouve une association en suivant un chemin
914
+ *
915
+ * @param {Array} path - Chemin d'associations (ex: ['applicant'] ou ['pme_folder', 'company'])
916
+ * @param {Object} currentSchema - Schema actuel
917
+ * @returns {Array|null} Association trouvée
918
+ */
919
+ _findAssociationByPath(path, currentSchema) {
920
+ if (path.length === 0) {
921
+ return null;
922
+ }
923
+
924
+ const associations = this._getSchemaAssociations(currentSchema);
925
+ if (!associations) {
926
+ return null;
927
+ }
928
+
929
+ // Chercher la première association
930
+ const firstAssocName = path[0];
931
+ const association = _.find(associations, (assoc) => {
932
+ return assoc[1] === firstAssocName;
933
+ });
934
+
935
+ if (!association) {
936
+ return null;
937
+ }
938
+
939
+ // Si le chemin est plus long, continuer récursivement
940
+ if (path.length === 1) {
941
+ return association;
942
+ }
943
+
944
+ // Pour les chemins imbriqués, on retourne juste la dernière association
945
+ // (pour simplifier, on suppose qu'on ne gère que les chemins simples pour l'instant)
946
+ return association;
947
+ }
948
+
949
+ /**
950
+ * Construit les conditions SQL pour une sous-requête EXISTS
951
+ *
952
+ * @param {Object} conditions - Objet avec les conditions
953
+ * @param {string} tableName - Nom de la table pour qualifier les colonnes
954
+ * @param {Array} params - Tableau des paramètres SQL (modifié par référence)
955
+ * @param {string} operator - Opérateur entre les conditions ('AND' ou 'OR')
956
+ * @returns {string} Clause SQL
957
+ *
958
+ * Exemples de conditions supportées :
959
+ * - Égalité : { status: 'ACTIVE' }
960
+ * - IN : { status: ['ACTIVE', 'PENDING'] }
961
+ * - IS NULL : { email: null }
962
+ * - LIKE : { last_name: 'Dupont%' }
963
+ * - BETWEEN : { created_at: { $between: ['2024-01-01', '2024-12-31'] } }
964
+ * - >= : { created_at: { $gte: '2024-01-01' } }
965
+ * - <= : { created_at: { $lte: '2024-12-31' } }
966
+ * - > : { amount: { $gt: 100 } }
967
+ * - < : { amount: { $lt: 1000 } }
968
+ */
969
+ buildConditions(conditions, tableName, params, operator = 'AND') {
970
+ const { dialect } = this;
971
+ const { esc } = dialect;
972
+
973
+ const sqlConditions = [];
974
+
975
+ _.forOwn(conditions, (value, key) => {
976
+ let columnRef;
977
+
978
+ // Gérer les colonnes qualifiées (ex: 'applicants.last_name')
979
+ if (key.indexOf('.') > -1) {
980
+ const parts = key.split('.');
981
+ columnRef = _.map(parts, part => `${esc}${part}${esc}`).join('.');
982
+ } else {
983
+ columnRef = `${esc}${tableName}${esc}.${esc}${key}${esc}`;
984
+ }
985
+
986
+ // Générer la condition selon le type de valeur
987
+ if (value === null || value === undefined) {
988
+ sqlConditions.push(`${columnRef} IS NULL `);
989
+ } else if (_.isArray(value)) {
990
+ if (value.length === 0) {
991
+ sqlConditions.push('FALSE ');
992
+ } else {
993
+ sqlConditions.push(`${columnRef} ${dialect.in} (${dialect.param(this.i++)}) `);
994
+ params.push(value);
995
+ }
996
+ } else if (_.isObject(value) && !_.isDate(value)) {
997
+ // Opérateurs spéciaux ($between, $gte, $lte, $gt, $lt, $like)
998
+ if (value.$between && _.isArray(value.$between) && value.$between.length === 2) {
999
+ sqlConditions.push(`${columnRef} BETWEEN ${dialect.param(this.i++)} AND ${dialect.param(this.i++)} `);
1000
+ params.push(value.$between[0]);
1001
+ params.push(value.$between[1]);
1002
+ } else if (value.$gte !== undefined) {
1003
+ sqlConditions.push(`${columnRef} >= ${dialect.param(this.i++)} `);
1004
+ params.push(value.$gte);
1005
+ } else if (value.$lte !== undefined) {
1006
+ sqlConditions.push(`${columnRef} <= ${dialect.param(this.i++)} `);
1007
+ params.push(value.$lte);
1008
+ } else if (value.$gt !== undefined) {
1009
+ sqlConditions.push(`${columnRef} > ${dialect.param(this.i++)} `);
1010
+ params.push(value.$gt);
1011
+ } else if (value.$lt !== undefined) {
1012
+ sqlConditions.push(`${columnRef} < ${dialect.param(this.i++)} `);
1013
+ params.push(value.$lt);
1014
+ } else if (value.$like !== undefined) {
1015
+ sqlConditions.push(`${columnRef} LIKE ${dialect.param(this.i++)} `);
1016
+ params.push(value.$like);
1017
+ } else {
1018
+ // Objet non reconnu, traiter comme égalité
1019
+ sqlConditions.push(`${columnRef} = ${dialect.param(this.i++)} `);
1020
+ params.push(value);
1021
+ }
1022
+ } else if (_.isString(value) && value.includes('%')) {
1023
+ // Pattern LIKE détecté
1024
+ sqlConditions.push(`${columnRef} LIKE ${dialect.param(this.i++)} `);
1025
+ params.push(value);
1026
+ } else {
1027
+ sqlConditions.push(`${columnRef} = ${dialect.param(this.i++)} `);
1028
+ params.push(value);
1029
+ }
1030
+ });
1031
+
1032
+ if (sqlConditions.length === 0) {
1033
+ return '';
1034
+ }
1035
+
1036
+ return 'AND ' + sqlConditions.join(`${operator} `);
1037
+ }
1038
+
1039
+ /**
1040
+ * Override de whereSQL pour éviter d'ajouter les filterJoins dans les WHERE standards
1041
+ *
1042
+ * Cette méthode filtre les conditions pour n'inclure que celles de la table principale.
1043
+ */
1044
+ whereSQL(params, not) {
1045
+ // Appeler la méthode parente qui gère déjà les WHERE correctement
1046
+ return super.whereSQL(params, not);
1047
+ }
1048
+
1049
+ /**
1050
+ * Override de orderSQL pour transformer les noms d'associations en noms de tables SQL
1051
+ *
1052
+ * IMPORTANT : Cette transformation N'EST NÉCESSAIRE QUE pour les phases COUNT et IDS
1053
+ * car elles utilisent des INNER JOIN sans alias. La phase FULL (selectSQL) utilise
1054
+ * des LEFT JOIN avec des alias qui correspondent aux noms d'associations, donc pas
1055
+ * de transformation nécessaire.
1056
+ *
1057
+ * Par exemple :
1058
+ * - Phase IDS : "formationNature.name" → "formation_natures.name" (transformation nécessaire)
1059
+ * - Phase FULL : "formationNature.name" → reste "formationNature.name" (alias du LEFT JOIN)
1060
+ */
1061
+ orderSQL() {
1062
+ const { query } = this;
1063
+
1064
+ if (!query.order || !query.order.length) {
1065
+ return '';
1066
+ }
1067
+
1068
+ // Déterminer si on est dans une phase qui nécessite la transformation
1069
+ // COUNT et IDS utilisent des INNER JOIN avec noms de tables réels
1070
+ // SELECT (full) utilise des LEFT JOIN avec alias = noms d'associations
1071
+ const needsTransformation = (query.verb === 'count' || query.verb === 'select_ids');
1072
+
1073
+ if (!needsTransformation) {
1074
+ // Phase FULL : appeler la méthode parente (pas de transformation)
1075
+ return super.orderSQL();
1076
+ }
1077
+
1078
+ // Phases COUNT/IDS : transformer les noms d'associations en noms de tables
1079
+ const transformedOrder = query.order.map((orderClause) => {
1080
+ return this._transformOrderClause(orderClause);
1081
+ });
1082
+
1083
+ return 'ORDER BY ' + transformedOrder.join(', ') + ' ';
1084
+ }
1085
+
1086
+ /**
1087
+ * Transforme une clause ORDER BY pour la phase FULL (utilise les noms d'associations comme alias)
1088
+ *
1089
+ * @param {string} orderClause - Clause ORDER BY (ex: "pme_folder.studies.studies_year DESC" ou "studies_year")
1090
+ * @returns {string} Clause transformée avec alias (ex: "studies.studies_year DESC")
1091
+ */
1092
+ _transformOrderClauseForFullQuery(orderClause) {
1093
+ // Parser la clause ORDER BY pour extraire ASC/DESC
1094
+ const cleanedClause = orderClause.replace(/`/g, '').trim();
1095
+ const match = cleanedClause.match(/^(.+?)\s+(ASC|DESC)$/i);
1096
+
1097
+ let expression, direction;
1098
+ if (match) {
1099
+ expression = match[1];
1100
+ direction = match[2];
1101
+ } else {
1102
+ expression = cleanedClause;
1103
+ direction = '';
1104
+ }
1105
+
1106
+ // Détecter si c'est une fonction SQL
1107
+ const hasSqlFunction = /\b(COALESCE|IFNULL|CONCAT|CONCAT_WS|UPPER|LOWER|SUBSTRING|TRIM)\s*\(/i.test(expression);
1108
+
1109
+ if (hasSqlFunction) {
1110
+ // Pour les fonctions, transformer les chemins à l'intérieur
1111
+ // Note: _transformPathsInExpression utilise _transformSinglePath, donc on ne peut pas l'utiliser directement
1112
+ // Pour l'instant, on retourne tel quel (les fonctions dans FULL query sont rares avec des blocks)
1113
+ // TODO: créer une version spéciale si nécessaire
1114
+ } else {
1115
+ // Transformation simple
1116
+ expression = this._transformPathForFullQuery(expression);
1117
+ }
1118
+
1119
+ // Reconstruire avec direction
1120
+ return direction ? `${expression} ${direction}` : expression;
1121
+ }
1122
+
1123
+ /**
1124
+ * Transforme une clause ORDER BY en remplaçant les noms d'associations par les noms de tables
1125
+ *
1126
+ * Gère aussi les fonctions SQL comme COALESCE, IFNULL, CONCAT, etc.
1127
+ *
1128
+ * @param {string} orderClause - Clause ORDER BY (ex: "formationNature.name DESC" ou "COALESCE(beneficiary.name, applicant.name)")
1129
+ * @returns {string} Clause transformée (ex: "formation_natures.name DESC" ou "COALESCE(beneficiaries.name, applicants.name)")
1130
+ */
1131
+ _transformOrderClause(orderClause) {
1132
+ const { query } = this;
1133
+
1134
+ // Parser la clause ORDER BY pour extraire ASC/DESC
1135
+ const cleanedClause = orderClause.replace(/`/g, '').trim();
1136
+ const match = cleanedClause.match(/^(.+?)\s+(ASC|DESC)$/i);
1137
+
1138
+ let expression, direction;
1139
+ if (match) {
1140
+ expression = match[1];
1141
+ direction = match[2];
1142
+ } else {
1143
+ expression = cleanedClause;
1144
+ direction = '';
1145
+ }
1146
+
1147
+ // Détecter si c'est une fonction SQL (COALESCE, IFNULL, CONCAT, etc.)
1148
+ const hasSqlFunction = /\b(COALESCE|IFNULL|CONCAT|CONCAT_WS|UPPER|LOWER|SUBSTRING|TRIM)\s*\(/i.test(expression);
1149
+
1150
+ if (hasSqlFunction) {
1151
+ // Transformer tous les chemins table.colonne à l'intérieur de la fonction
1152
+ expression = this._transformPathsInExpression(expression);
1153
+ } else {
1154
+ // Transformation simple (pas de fonction)
1155
+ expression = this._transformSinglePath(expression);
1156
+ }
1157
+
1158
+ return direction ? `${expression} ${direction}` : expression;
1159
+ }
1160
+
1161
+ /**
1162
+ * Transforme tous les chemins association.colonne dans une expression SQL
1163
+ * Gère également les colonnes simples (sans préfixe) qui proviennent de blocks
1164
+ *
1165
+ * @param {string} expression - Expression SQL (ex: "COALESCE(beneficiary.name, applicant.name)" ou "COALESCE(studies_year, 'N/A')")
1166
+ * @returns {string} Expression transformée (ex: "COALESCE(beneficiaries.name, applicants.name)" ou "COALESCE(block_studies.studies_year, 'N/A')")
1167
+ */
1168
+ _transformPathsInExpression(expression) {
1169
+ const { query } = this;
1170
+
1171
+ // Mots-clés SQL à ne pas transformer
1172
+ const sqlKeywords = new Set([
1173
+ 'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'ASC', 'DESC',
1174
+ 'COALESCE', 'IFNULL', 'CONCAT', 'CONCAT_WS', 'SUBSTRING',
1175
+ 'UPPER', 'LOWER', 'TRIM', 'LENGTH', 'DATE', 'NOW', 'NULL', 'AND', 'OR'
1176
+ ]);
1177
+
1178
+ // Regex combiné qui capture TOUS les types de chemins/colonnes en une seule passe
1179
+ // Cette approche évite la re-transformation des chemins déjà transformés
1180
+ //
1181
+ // Groupe 1: paths avec points (sans backticks) comme table.column
1182
+ // Groupe 2: colonnes simples avec backticks comme `column`
1183
+ // Groupe 3: identificateurs simples (sans backticks, sans points)
1184
+ 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;
1185
+
1186
+ return expression.replace(combinedPattern, (match, plainPath, backtickColumn, plainIdentifier) => {
1187
+ // Cas 1: Chemins avec points (ex: table.column, table1.table2.column)
1188
+ if (plainPath) {
1189
+ const transformed = this._transformSinglePath(plainPath);
1190
+ return transformed;
1191
+ }
1192
+
1193
+ // Cas 2: Colonne simple avec backticks (ex: `column`)
1194
+ if (backtickColumn) {
1195
+ const columnName = backtickColumn.replace(/`/g, '');
1196
+
1197
+ // Vérifier si c'est dans la table principale
1198
+ const existsInMainTable = query.schema?.colsByName?.[columnName];
1199
+ if (existsInMainTable) {
1200
+ return match; // Garder tel quel
1201
+ }
1202
+
1203
+ // Chercher dans les associations (blocks)
1204
+ const assocPath = this._findAssociationByColumn(columnName, query.schema);
1205
+ if (assocPath && assocPath.length > 0) {
1206
+ const tableName = assocPath[0].tableName;
1207
+ return `\`${tableName}\`.\`${columnName}\``;
1208
+ }
1209
+
1210
+ return match;
1211
+ }
1212
+
1213
+ // Cas 3: Identifiant simple sans backticks (ex: column)
1214
+ if (plainIdentifier) {
1215
+ // Ignorer les mots-clés SQL
1216
+ if (sqlKeywords.has(plainIdentifier.toUpperCase())) {
1217
+ return match;
1218
+ }
1219
+
1220
+ // Vérifier si c'est dans la table principale
1221
+ const existsInMainTable = query.schema?.colsByName?.[plainIdentifier];
1222
+ if (existsInMainTable) {
1223
+ return match;
1224
+ }
1225
+
1226
+ // Chercher dans les associations (blocks)
1227
+ const assocPath = this._findAssociationByColumn(plainIdentifier, query.schema);
1228
+ if (assocPath && assocPath.length > 0) {
1229
+ const tableName = assocPath[0].tableName;
1230
+ return `${tableName}.${plainIdentifier}`;
1231
+ }
1232
+
1233
+ return match;
1234
+ }
1235
+
1236
+ return match;
1237
+ });
1238
+ }
1239
+
1240
+ /**
1241
+ * Transforme un chemin d'association pour la phase FULL (utilise les noms d'associations comme alias)
1242
+ *
1243
+ * @param {string} path - Chemin (ex: "pme_folder.studies.studies_year" ou "studies_year")
1244
+ * @returns {string} Chemin transformé avec alias (ex: "studies.studies_year")
1245
+ */
1246
+ _transformPathForFullQuery(path) {
1247
+ const { query } = this;
1248
+
1249
+ // Split le chemin par point
1250
+ const parts = path.split('.');
1251
+
1252
+ if (parts.length < 2) {
1253
+ // Pas de point - peut être une colonne simple ou une colonne de block
1254
+ const columnName = parts[0];
1255
+
1256
+ // Vérifier si la colonne existe dans la table principale
1257
+ const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName];
1258
+
1259
+ if (!existsInMainTable) {
1260
+ // Chercher dans les associations (blocks)
1261
+ const assocPath = this._findAssociationByColumn(columnName, query.schema);
1262
+
1263
+ if (assocPath && assocPath.length > 0) {
1264
+ // Colonne trouvée dans une table de block - utiliser le nom de l'association comme alias
1265
+ const assocName = assocPath[0].association[1]; // Nom de l'association
1266
+ return `${assocName}.${columnName}`;
1267
+ }
1268
+ }
1269
+
1270
+ // Colonne dans la table principale ou non trouvée - retourner tel quel
1271
+ return path;
1272
+ }
1273
+
1274
+ // Le dernier élément est la colonne
1275
+ const columnName = parts[parts.length - 1];
1276
+ const associationPath = parts.slice(0, -1);
1277
+
1278
+ // Si le premier élément est déjà la table principale, pas de transformation
1279
+ if (associationPath[0] === query.table) {
1280
+ return path;
1281
+ }
1282
+
1283
+ // Pour les chemins imbriqués, utiliser le dernier nom d'association comme alias
1284
+ // Ex: "pme_folder.studies.studies_year" -> "studies.studies_year"
1285
+ const lastAssocName = associationPath[associationPath.length - 1];
1286
+ return `${lastAssocName}.${columnName}`;
1287
+ }
1288
+
1289
+ /**
1290
+ * Transforme un seul chemin association.colonne en table.colonne
1291
+ *
1292
+ * Gère également les colonnes sans préfixe (ex: "studies_year") en cherchant
1293
+ * dans les associations si elles proviennent d'une table de block.
1294
+ *
1295
+ * @param {string} path - Chemin (ex: "beneficiary_snapshot.identity_expires_at", "pme_folder.company.name", ou "studies_year")
1296
+ * @returns {string} Chemin transformé (ex: "beneficiaries_snapshots.identity_expires_at", "companies.name", ou "block_studies.studies_year")
1297
+ */
1298
+ _transformSinglePath(path) {
1299
+ const { query } = this;
1300
+
1301
+ // Split le chemin par point
1302
+ const parts = path.split('.');
1303
+
1304
+ if (parts.length < 2) {
1305
+ // Pas de point - peut être une colonne simple ou une colonne de block
1306
+ const columnName = parts[0];
1307
+
1308
+ // Vérifier si la colonne existe dans la table principale
1309
+ const existsInMainTable = query.schema && query.schema.colsByName && query.schema.colsByName[columnName];
1310
+
1311
+ if (!existsInMainTable) {
1312
+ // Chercher dans les associations (blocks)
1313
+ const assocPath = this._findAssociationByColumn(columnName, query.schema);
1314
+
1315
+ if (assocPath && assocPath.length > 0) {
1316
+ // Colonne trouvée dans une table de block - ajouter le préfixe
1317
+ const tableName = assocPath[0].tableName;
1318
+ return `${tableName}.${columnName}`;
1319
+ }
1320
+ }
1321
+
1322
+ // Colonne dans la table principale ou non trouvée - retourner tel quel
1323
+ return path;
1324
+ }
1325
+
1326
+ // Le dernier élément est la colonne
1327
+ const columnName = parts[parts.length - 1];
1328
+ const associationPath = parts.slice(0, -1);
1329
+
1330
+ // Si le premier élément est déjà la table principale, pas de transformation
1331
+ if (associationPath[0] === query.table) {
1332
+ return path;
1333
+ }
1334
+
1335
+ // Construire le chemin d'associations pour obtenir les vrais noms de tables
1336
+ let associationChain = this._buildAssociationPath(associationPath, query.schema);
1337
+
1338
+ // Si on n'a pas trouvé, essayer avec _findPathToTable (au cas où c'est déjà un nom de table)
1339
+ if (!associationChain && associationPath.length === 1) {
1340
+ associationChain = this._findPathToTable(associationPath[0], query.schema);
1341
+ }
1342
+
1343
+ if (associationChain && associationChain.length > 0) {
1344
+ // Utiliser le nom de la dernière table du chemin
1345
+ const lastTable = associationChain[associationChain.length - 1].tableName;
1346
+ return `${lastTable}.${columnName}`;
1347
+ }
1348
+
1349
+ // Si on ne trouve pas de transformation, retourner tel quel
1350
+ return path;
1351
+ }
1352
+ };