@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,902 @@
1
+ const _ = require('lodash');
2
+ const Query = require('./Query');
3
+ const context = require('./context');
4
+ const PaginatedOptimizedSql = require('./PaginatedOptimizedSql');
5
+
6
+ /**
7
+ * PaginatedOptimizedQuery - Implémentation du pattern COUNT/IDS/FULL pour optimiser les requêtes avec jointures
8
+ *
9
+ * Ce module remplace la logique traditionnelle des LEFT JOIN par un pattern en 3 phases :
10
+ *
11
+ * 1. COUNT avec EXISTS : Compte les lignes sans faire de jointures, utilise EXISTS pour les filtres sur tables jointes
12
+ * 2. SELECT IDS : Sélectionne uniquement les IDs de la table principale avec filtres, tris et pagination
13
+ * 3. SELECT FULL : Récupère les données complètes avec LEFT JOIN uniquement pour les IDs trouvés
14
+ *
15
+ * Ce pattern améliore drastiquement les performances sur les grosses tables avec de nombreuses jointures.
16
+ *
17
+ * Exemple d'utilisation :
18
+ *
19
+ * const query = Folder.paginatedOptimized()
20
+ * .where({ type: ['agp', 'avt'] })
21
+ * .filterJoin('applicant', { last_name: 'Dupont%' }) // sera transformé en EXISTS
22
+ * .join('pme_folder') // sera un LEFT JOIN dans la phase FULL
23
+ * .order('folders.created_at DESC')
24
+ * .page(1, 50);
25
+ *
26
+ * const result = await query.execute(); // { pagination: {...}, rows: [...] }
27
+ */
28
+ module.exports = class PaginatedOptimizedQuery extends Query {
29
+
30
+ constructor(modelClass, verb = 'select') {
31
+ super(modelClass, verb);
32
+
33
+ // Nouvelle propriété pour distinguer les joins de filtrage (→ EXISTS) des joins de données (→ LEFT JOIN)
34
+ this.query.filterJoins = [];
35
+
36
+ // Flag pour activer le mode optimisé
37
+ this.query.optimized = true;
38
+ }
39
+
40
+ /**
41
+ * Override de join() pour supporter la notation pointée
42
+ *
43
+ * Permet d'utiliser la notation pointée pour les joins imbriqués :
44
+ * - .join('applicant') → join simple
45
+ * - .join('pme_folder.company.country') → join imbriqué
46
+ * - .join(['applicant', 'pme_folder.company']) → plusieurs joins
47
+ *
48
+ * @param {string|array|object} associations - Associations à joindre
49
+ * @returns {PaginatedOptimizedQuery} this (pour chaînage)
50
+ */
51
+ join(associations) {
52
+ // Si c'est un objet, utiliser la syntaxe standard (Query parent)
53
+ if (_.isObject(associations) && !_.isArray(associations)) {
54
+ return super.join(associations);
55
+ }
56
+
57
+ // Si c'est une string ou un tableau, détecter la notation pointée
58
+ const assocs = _.isArray(associations) ? associations : [associations];
59
+ const transformed = [];
60
+
61
+ _.forEach(assocs, (assoc) => {
62
+ if (_.isString(assoc) && assoc.includes('.')) {
63
+ // Notation pointée détectée : transformer en structure imbriquée
64
+ transformed.push(this._transformDottedJoinPath(assoc));
65
+ } else {
66
+ // Pas de notation pointée : garder tel quel
67
+ transformed.push(assoc);
68
+ }
69
+ });
70
+
71
+ // Si on a transformé des chemins, reconstruire la structure
72
+ if (transformed.length > 0) {
73
+ return super.join(this._mergeJoinStructures(transformed));
74
+ }
75
+
76
+ return super.join(associations);
77
+ }
78
+
79
+ /**
80
+ * Transforme un chemin pointé en structure imbriquée
81
+ *
82
+ * @param {string} path - Chemin pointé (ex: 'pme_folder.company.country')
83
+ * @returns {object} Structure imbriquée
84
+ *
85
+ * Exemple :
86
+ * 'pme_folder.company.country' → { pme_folder: { company: ['country'] } }
87
+ */
88
+ _transformDottedJoinPath(path) {
89
+ const parts = path.split('.');
90
+
91
+ // Si un seul niveau, retourner tel quel
92
+ if (parts.length === 1) {
93
+ return parts[0];
94
+ }
95
+
96
+ // Construire la structure imbriquée de droite à gauche
97
+ let result = [parts[parts.length - 1]];
98
+
99
+ for (let i = parts.length - 2; i >= 0; i--) {
100
+ result = { [parts[i]]: result };
101
+ }
102
+
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Fusionne plusieurs structures de join
108
+ *
109
+ * @param {array} structures - Tableau de structures de join
110
+ * @returns {array|object} Structure fusionnée
111
+ */
112
+ _mergeJoinStructures(structures) {
113
+ // Séparer les strings simples des objets
114
+ const simples = [];
115
+ const nested = {};
116
+
117
+ _.forEach(structures, (struct) => {
118
+ if (_.isString(struct)) {
119
+ simples.push(struct);
120
+ } else if (_.isObject(struct)) {
121
+ // Fusionner les objets imbriqués
122
+ _.merge(nested, struct);
123
+ }
124
+ });
125
+
126
+ // Si on a des objets imbriqués et des simples
127
+ if (!_.isEmpty(nested) && simples.length > 0) {
128
+ return [...simples, nested];
129
+ }
130
+
131
+ // Si seulement des simples
132
+ if (simples.length > 0 && _.isEmpty(nested)) {
133
+ return simples;
134
+ }
135
+
136
+ // Si seulement des imbriqués
137
+ if (_.isEmpty(simples) && !_.isEmpty(nested)) {
138
+ return nested;
139
+ }
140
+
141
+ return structures;
142
+ }
143
+
144
+ /**
145
+ * Override de where() pour détecter automatiquement les conditions sur tables jointes
146
+ *
147
+ * Cette méthode analyse les conditions pour détecter les chemins imbriqués (ex: 'applicant.last_name')
148
+ * et les convertit automatiquement en filterJoins optimisés avec EXISTS.
149
+ *
150
+ * Syntaxe supportée :
151
+ * - Table principale : { status: 'ACTIVE' }
152
+ * - Table jointe (1 niveau) : { 'applicant.last_name': 'Dupont' }
153
+ * - Tables imbriquées : { 'pme_folder.company.country.code': 'FR' }
154
+ *
155
+ * Opérateurs supportés :
156
+ * - Égalité : { 'applicant.email': 'test@test.com' }
157
+ * - LIKE : { 'applicant.last_name': { $like: 'Dup%' } }
158
+ * - IN : { 'applicant.status': ['ACTIVE', 'PENDING'] }
159
+ * - BETWEEN : { created_at: { $between: ['2024-01-01', '2024-12-31'] } }
160
+ * - Comparaisons : { amount: { $gte: 100 } }
161
+ *
162
+ * Opérateurs logiques :
163
+ * - $and : { $and: [{ status: 'ACTIVE' }, { 'applicant.last_name': 'Dupont' }] }
164
+ * - $or : { $or: [{ status: 'ACTIVE' }, { status: 'PENDING' }] }
165
+ *
166
+ * @param {object|string} where - Conditions de filtrage
167
+ * @param {any} params - Paramètres (pour compatibilité avec Query parent)
168
+ * @returns {PaginatedOptimizedQuery} this (pour chaînage)
169
+ */
170
+ where(where, params) {
171
+ // Appeler le parent pour gérer les cas simples (string avec params)
172
+ if (params !== undefined) {
173
+ return super.where(where, params);
174
+ }
175
+
176
+ // Cas spécial : $or avec des conditions sur tables jointes
177
+ // Ces conditions doivent être transformées en filterJoin avec OR
178
+ if (where.$or && _.isArray(where.$or)) {
179
+ this._handleOrWithJoinedTables(where.$or);
180
+ return this;
181
+ }
182
+
183
+ // Analyser et transformer les conditions
184
+ const { mainConditions, joinConditions } = this._parseWhereConditions(where);
185
+
186
+ // Ajouter les conditions sur la table principale
187
+ // mainConditions est maintenant un tableau de conditions
188
+ if (mainConditions && mainConditions.length > 0) {
189
+ _.forEach(mainConditions, (cond) => {
190
+ // Cas 1 : c'est un array d'objets (vient d'un $or)
191
+ // Il faut le transformer en SQL avec OR car Query ne gère pas $or
192
+ if (_.isArray(cond) && cond.length > 0) {
193
+ // Construire manuellement la clause OR
194
+ const orClauses = [];
195
+ const orParams = [];
196
+
197
+ _.forEach(cond, (orCond) => {
198
+ if (_.isObject(orCond)) {
199
+ const condClauses = [];
200
+ _.forOwn(orCond, (value, key) => {
201
+ const columnRef = `\`${this.query.table}\`.\`${key}\``;
202
+
203
+ if (value === null || value === undefined) {
204
+ condClauses.push(`${columnRef} IS NULL`);
205
+ } else if (_.isArray(value)) {
206
+ if (value.length === 0) {
207
+ condClauses.push('FALSE');
208
+ } else {
209
+ condClauses.push(`${columnRef} IN ($?)`);
210
+ orParams.push(value);
211
+ }
212
+ } else if (_.isString(value) && value.includes('%')) {
213
+ // Pattern LIKE détecté
214
+ condClauses.push(`${columnRef} LIKE $?`);
215
+ orParams.push(value);
216
+ } else {
217
+ condClauses.push(`${columnRef} = $?`);
218
+ orParams.push(value);
219
+ }
220
+ });
221
+
222
+ if (condClauses.length > 0) {
223
+ orClauses.push(condClauses.length > 1 ? `(${condClauses.join(' AND ')})` : condClauses[0]);
224
+ }
225
+ }
226
+ });
227
+
228
+ if (orClauses.length > 0) {
229
+ const orSQL = `(${orClauses.join(' OR ')})`;
230
+ super.where(orSQL, orParams);
231
+ }
232
+ }
233
+ // Cas 2 : c'est un objet avec des valeurs SQL transformées
234
+ else if (_.isObject(cond) && !_.isArray(cond)) {
235
+ const normalizedCond = {};
236
+ _.forOwn(cond, (value, key) => {
237
+ // Si la valeur est un tableau [sql, params] avec $? dans le SQL
238
+ if (_.isArray(value) && value.length === 2 && _.isString(value[0]) && value[0].includes('$?')) {
239
+ super.where(value[0], value[1]);
240
+ } else {
241
+ normalizedCond[key] = value;
242
+ }
243
+ });
244
+
245
+ // Ajouter les conditions normales si présentes
246
+ if (!_.isEmpty(normalizedCond)) {
247
+ super.where(normalizedCond);
248
+ }
249
+ }
250
+ // Cas 3 : autre format (ne devrait pas arriver)
251
+ else {
252
+ super.where(cond);
253
+ }
254
+ });
255
+ }
256
+
257
+ // Générer automatiquement les filterJoins pour les conditions sur tables jointes
258
+ if (joinConditions && Object.keys(joinConditions).length > 0) {
259
+ this._buildFilterJoinsFromPaths(joinConditions);
260
+ }
261
+
262
+ return this;
263
+ }
264
+
265
+ /**
266
+ * _addSimpleFilterJoin - Ajoute un filtre sur une table jointe (méthode interne)
267
+ *
268
+ * Cette méthode est utilisée en interne par where() pour ajouter des filtres EXISTS.
269
+ * Les utilisateurs ne doivent pas l'appeler directement.
270
+ *
271
+ * @private
272
+ * @param {string} associationName - Nom de l'association (ex: 'applicant', 'pme_folder')
273
+ * @param {object} conditions - Conditions de filtrage
274
+ * @param {string} operator - Opérateur SQL ('AND' ou 'OR')
275
+ */
276
+ _addSimpleFilterJoin(associationName, conditions, operator = 'AND') {
277
+ const association = this._findAssociation(associationName, this.schema);
278
+
279
+ this.query.filterJoins.push({
280
+ association,
281
+ conditions,
282
+ operator,
283
+ src_schema: this.schema
284
+ });
285
+ }
286
+
287
+ /**
288
+ * _addNestedFilterJoin - Ajoute des filtres imbriqués (méthode interne)
289
+ *
290
+ * Cette méthode est utilisée en interne par where() pour ajouter des filtres EXISTS imbriqués.
291
+ * Les utilisateurs ne doivent pas l'appeler directement.
292
+ *
293
+ * @private
294
+ * @param {object} nestedConfig - Configuration des filtres imbriqués
295
+ */
296
+ _addNestedFilterJoin(nestedConfig) {
297
+ // Construire la hiérarchie complète d'associations
298
+ const buildHierarchy = (config, currentSchema, parentPath = null) => {
299
+ const results = [];
300
+
301
+ _.each(config, (value, associationName) => {
302
+ const association = this._findAssociation(associationName, currentSchema);
303
+ const [, , AssociatedModel] = association;
304
+
305
+ // Créer un noeud de hiérarchie
306
+ const node = {
307
+ association,
308
+ conditions: value.conditions || null,
309
+ operator: value.operator || 'AND',
310
+ src_schema: currentSchema,
311
+ parent: parentPath,
312
+ children: []
313
+ };
314
+
315
+ // Traiter récursivement les enfants
316
+ if (value.nested) {
317
+ node.children = buildHierarchy(value.nested, AssociatedModel.schema, node);
318
+ }
319
+
320
+ results.push(node);
321
+ });
322
+
323
+ return results;
324
+ };
325
+
326
+ // Construire l'arbre complet
327
+ const hierarchy = buildHierarchy(nestedConfig, this.schema);
328
+
329
+ // Stocker dans filterJoins avec une structure hiérarchique
330
+ this.query.filterJoins.push({
331
+ type: 'nested',
332
+ hierarchy: hierarchy
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Phase 1 : COUNT optimisé avec EXISTS
338
+ *
339
+ * Génère une requête COUNT(0) sans LEFT JOIN. Les filtres sur tables jointes
340
+ * sont convertis en sous-requêtes EXISTS.
341
+ *
342
+ * @returns {Promise<number>} Le nombre total de lignes correspondant aux filtres
343
+ */
344
+ async count() {
345
+ const countQuery = new PaginatedOptimizedQuery(this.modelClass);
346
+ countQuery.query = _.cloneDeep(this.query);
347
+ countQuery.query.verb = 'count';
348
+ countQuery.query.limit = 1;
349
+ delete countQuery.query.page;
350
+ delete countQuery.query.nb;
351
+ delete countQuery.query.order; // Pas besoin de ORDER BY pour un COUNT
352
+
353
+ const rows = await countQuery.runQuery();
354
+ const count = rows && rows[0] && Number(rows[0].count) || 0;
355
+ return count;
356
+ }
357
+
358
+ /**
359
+ * Phase 2 : SELECT IDS avec filtres et pagination
360
+ *
361
+ * Sélectionne uniquement les IDs de la table principale en appliquant :
362
+ * - Tous les filtres (WHERE + EXISTS pour les filterJoins)
363
+ * - Les tris (ORDER BY)
364
+ * - La pagination (LIMIT/OFFSET)
365
+ *
366
+ * @returns {Promise<Array<number>>} Liste des IDs trouvés
367
+ */
368
+ async selectIds() {
369
+ const idsQuery = new PaginatedOptimizedQuery(this.modelClass);
370
+ idsQuery.query = _.cloneDeep(this.query);
371
+ idsQuery.query.verb = 'select_ids';
372
+
373
+ // Ne sélectionner que l'ID (ou clés primaires)
374
+ const primaryKeys = this.schema.primary || ['id'];
375
+ idsQuery.query.select = primaryKeys.map(key => `\`${this.schema.table}\`.\`${key}\``).join(', ');
376
+
377
+ const rows = await idsQuery.runQuery();
378
+
379
+ // Retourner un tableau d'IDs (ou objets de clés composites)
380
+ if (primaryKeys.length === 1) {
381
+ return rows.map(row => row[primaryKeys[0]]);
382
+ }
383
+ return rows.map(row => _.pick(row, primaryKeys));
384
+ }
385
+
386
+ /**
387
+ * Phase 3 : SELECT FULL avec LEFT JOIN sur les IDs trouvés
388
+ *
389
+ * Récupère les données complètes avec les LEFT JOIN uniquement pour les IDs
390
+ * retournés par la phase 2. Cela limite drastiquement le nombre de lignes à joindre.
391
+ *
392
+ * @param {Array<number|object>} ids - Liste des IDs à récupérer
393
+ * @returns {Promise<Array<Object>>} Données complètes avec associations
394
+ */
395
+ async selectFull(ids) {
396
+ if (!ids || ids.length === 0) {
397
+ return [];
398
+ }
399
+
400
+ const fullQuery = new Query(this.modelClass); // Utiliser Query standard pour le SELECT final
401
+ fullQuery.query = _.cloneDeep(this.query);
402
+ fullQuery.query.verb = 'select';
403
+
404
+ // Conserver uniquement les "vrais" joins (pas les filterJoins)
405
+ // Les filterJoins ne sont utilisés que pour COUNT et IDS
406
+ fullQuery.query.filterJoins = [];
407
+
408
+ // Remplacer les filtres par WHERE id IN (...)
409
+ const primaryKeys = this.schema.primary || ['id'];
410
+ if (primaryKeys.length === 1) {
411
+ fullQuery.query.where = [{ [primaryKeys[0]]: ids }];
412
+ } else {
413
+ // Clés composites : générer des conditions OR
414
+ const compositeConditions = ids.map(idObj => idObj);
415
+ fullQuery.query.where = compositeConditions;
416
+ }
417
+
418
+ fullQuery.query.whereNot = [];
419
+
420
+ // Supprimer la pagination (déjà appliquée dans selectIds)
421
+ delete fullQuery.query.limit;
422
+ delete fullQuery.query.offset;
423
+ delete fullQuery.query.page;
424
+ delete fullQuery.query.nb;
425
+
426
+ // Conserver l'ORDER BY pour maintenir l'ordre
427
+ // (Important car IN (...) ne garantit pas l'ordre)
428
+ // Transformer les chemins d'associations en noms d'associations (alias) pour la Query standard
429
+ if (fullQuery.query.order && fullQuery.query.order.length > 0) {
430
+ const sqlGenerator = new PaginatedOptimizedSql(this); // Utiliser 'this' (PaginatedOptimizedQuery) au lieu de 'fullQuery'
431
+ fullQuery.query.order = fullQuery.query.order.map(orderClause => {
432
+ return sqlGenerator._transformOrderClauseForFullQuery(orderClause);
433
+ });
434
+ }
435
+
436
+ const rows = await fullQuery.execute();
437
+ return rows;
438
+ }
439
+
440
+ /**
441
+ * Orchestrateur principal : exécute les 3 phases
442
+ *
443
+ * Cette méthode est appelée automatiquement par execute() quand le mode optimisé est activé.
444
+ *
445
+ * @returns {Promise<Object|Array>} Résultat de la requête (avec ou sans pagination)
446
+ */
447
+ async executeOptimized() {
448
+ const { query } = this;
449
+
450
+ // Si pas de pagination demandée, on peut simplifier
451
+ if (!query.page) {
452
+ // Phase 1 : COUNT (optionnel si pas de pagination)
453
+ // On peut le sauter pour gagner du temps
454
+
455
+ // Phase 2 : SELECT IDS
456
+ const ids = await this.selectIds();
457
+
458
+ // Phase 3 : SELECT FULL
459
+ const rows = await this.selectFull(ids);
460
+
461
+ if (query.limit === 1) {
462
+ return rows[0] || null;
463
+ }
464
+
465
+ return rows;
466
+ }
467
+
468
+ // Avec pagination : les 3 phases complètes
469
+
470
+ // Phase 1 : COUNT
471
+ const count = await this.count();
472
+
473
+ // Calculer la pagination
474
+ const nb_pages = Math.ceil(count / query.nb);
475
+ query.page = Math.min(query.page, nb_pages);
476
+ query.page = Math.max(query.page, 1);
477
+ query.offset = (query.page - 1) * query.nb;
478
+ query.limit = query.nb;
479
+
480
+ // Phase 2 : SELECT IDS
481
+ const ids = await this.selectIds();
482
+
483
+ // Phase 3 : SELECT FULL
484
+ const rows = await this.selectFull(ids);
485
+
486
+ // Construire l'objet pagination
487
+ const page = query.page;
488
+ const links = [];
489
+ const start = Math.max(1, page - 5);
490
+ for (let i = 0; i < 10; i++) {
491
+ const p = start + i;
492
+ if (p <= nb_pages) {
493
+ links.push({ page: p, current: page === p });
494
+ }
495
+ }
496
+
497
+ const pagination = {
498
+ page: query.page,
499
+ nb: query.nb,
500
+ previous: page > 1 ? page - 1 : null,
501
+ next: page < nb_pages ? page + 1 : null,
502
+ start: query.offset + 1,
503
+ end: query.offset + Math.min(query.nb, count - query.offset),
504
+ nb_pages,
505
+ count,
506
+ links,
507
+ };
508
+
509
+ return { pagination, rows };
510
+ }
511
+
512
+ /**
513
+ * Override de execute() pour utiliser executeOptimized() en mode optimisé
514
+ */
515
+ async execute() {
516
+ if (this.query.optimized && this.query.verb === 'select') {
517
+ return await this.executeOptimized();
518
+ }
519
+
520
+ // Fallback vers l'implémentation standard pour les autres verbes (update, delete, etc.)
521
+ return await super.execute();
522
+ }
523
+
524
+ /**
525
+ * Gère les conditions $or qui contiennent des tables jointes
526
+ *
527
+ * Crée un filterJoin spécial de type 'or_group' qui combine toutes les
528
+ * conditions avec OR au lieu de AND.
529
+ *
530
+ * @param {Array} orConditions - Array de conditions du $or
531
+ */
532
+ _handleOrWithJoinedTables(orConditions) {
533
+ // Séparer les conditions principales et jointes
534
+ const mainTableConditions = [];
535
+ const joinedTableConditions = [];
536
+
537
+ _.forEach(orConditions, (cond) => {
538
+ const hasJoinedTable = _.some(_.keys(cond), key => key.includes('.'));
539
+
540
+ if (hasJoinedTable) {
541
+ // Condition sur table jointe
542
+ joinedTableConditions.push(cond);
543
+ } else {
544
+ // Condition sur table principale
545
+ mainTableConditions.push(cond);
546
+ }
547
+ });
548
+
549
+ // Traiter les conditions sur table principale avec OR
550
+ if (mainTableConditions.length > 0) {
551
+ const orClauses = [];
552
+ const orParams = [];
553
+
554
+ _.forEach(mainTableConditions, (cond) => {
555
+ _.forOwn(cond, (value, key) => {
556
+ const columnRef = `\`${this.query.table}\`.\`${key}\``;
557
+
558
+ if (value === null || value === undefined) {
559
+ orClauses.push(`${columnRef} IS NULL`);
560
+ } else if (_.isArray(value)) {
561
+ if (value.length === 0) {
562
+ orClauses.push('FALSE');
563
+ } else {
564
+ orClauses.push(`${columnRef} IN ($?)`);
565
+ orParams.push(value);
566
+ }
567
+ } else if (_.isString(value) && value.includes('%')) {
568
+ orClauses.push(`${columnRef} LIKE $?`);
569
+ orParams.push(value);
570
+ } else {
571
+ orClauses.push(`${columnRef} = $?`);
572
+ orParams.push(value);
573
+ }
574
+ });
575
+ });
576
+
577
+ if (orClauses.length > 0) {
578
+ const orSQL = `(${orClauses.join(' OR ')})`;
579
+ super.where(orSQL, orParams);
580
+ }
581
+ }
582
+
583
+ // Traiter les conditions sur tables jointes
584
+ // Créer un filterJoin spécial qui sera traité comme un groupe OR
585
+ if (joinedTableConditions.length > 0) {
586
+ this.query.filterJoins.push({
587
+ type: 'or_group',
588
+ conditions: joinedTableConditions
589
+ });
590
+ }
591
+ }
592
+
593
+ /**
594
+ * Parse les conditions where pour séparer :
595
+ * - Les conditions sur la table principale
596
+ * - Les conditions sur les tables jointes (chemins avec points)
597
+ *
598
+ * @param {object} conditions - Objet de conditions
599
+ * @returns {object} { mainConditions, joinConditions }
600
+ *
601
+ * Exemple :
602
+ * Input: {
603
+ * $and: [
604
+ * { status: 'ACTIVE' },
605
+ * { 'applicant.last_name': 'Dupont' },
606
+ * { 'pme_folder.company.country.code': 'FR' }
607
+ * ]
608
+ * }
609
+ *
610
+ * Output: {
611
+ * mainConditions: [{ status: 'ACTIVE' }], // Tableau de conditions
612
+ * joinConditions: {
613
+ * 'applicant.last_name': { value: 'Dupont', path: ['applicant'], column: 'last_name' },
614
+ * 'pme_folder.company.country.code': { value: 'FR', path: ['pme_folder', 'company', 'country'], column: 'code' }
615
+ * }
616
+ * }
617
+ */
618
+ _parseWhereConditions(conditions) {
619
+ const mainConditions = [];
620
+ const joinConditions = {};
621
+
622
+ // Gérer les opérateurs logiques $and et $or
623
+ if (conditions.$and) {
624
+ // $and : aplatir les conditions sur la table principale
625
+ _.forEach(conditions.$and, (cond) => {
626
+ const parsed = this._parseConditionObject(cond);
627
+ if (parsed.main && !_.isEmpty(parsed.main)) {
628
+ mainConditions.push(parsed.main);
629
+ }
630
+ Object.assign(joinConditions, parsed.join);
631
+ });
632
+ } else if (conditions.$or) {
633
+ // $or : gérer les conditions imbriquées
634
+ // Note: Si toutes les conditions du $or sont sur la table principale,
635
+ // on peut les regrouper. Sinon, on doit les traiter séparément.
636
+ const orMainConditions = [];
637
+ _.forEach(conditions.$or, (cond) => {
638
+ const parsed = this._parseConditionObject(cond);
639
+ if (parsed.main && !_.isEmpty(parsed.main)) {
640
+ orMainConditions.push(parsed.main);
641
+ }
642
+ Object.assign(joinConditions, parsed.join);
643
+ });
644
+
645
+ // Si on a des conditions sur la table principale dans le $or
646
+ if (orMainConditions.length > 0) {
647
+ mainConditions.push(orMainConditions);
648
+ }
649
+ } else {
650
+ // Cas simple : objet de conditions
651
+ const parsed = this._parseConditionObject(conditions);
652
+ if (parsed.main && !_.isEmpty(parsed.main)) {
653
+ mainConditions.push(parsed.main);
654
+ }
655
+ Object.assign(joinConditions, parsed.join);
656
+ }
657
+
658
+ return { mainConditions, joinConditions };
659
+ }
660
+
661
+ /**
662
+ * Parse un objet de conditions pour séparer les colonnes principales et jointes
663
+ *
664
+ * @param {object} conditionObj - Objet de conditions { column: value, ... }
665
+ * @returns {object} { main: {...}, join: {...} }
666
+ */
667
+ _parseConditionObject(conditionObj) {
668
+ const main = {};
669
+ const join = {};
670
+
671
+ _.forOwn(conditionObj, (value, key) => {
672
+ // Détecter si la clé contient un point (chemin vers table jointe)
673
+ if (key.includes('.')) {
674
+ // Extraire le chemin et la colonne finale
675
+ const parts = key.split('.');
676
+ const column = parts[parts.length - 1];
677
+ const path = parts.slice(0, -1); // ['applicant'] ou ['pme_folder', 'company', 'country']
678
+
679
+ join[key] = {
680
+ value,
681
+ path,
682
+ column
683
+ };
684
+ } else {
685
+ // Condition sur la table principale
686
+ // Transformer les opérateurs spéciaux en SQL avant de les ajouter
687
+ main[key] = this._transformOperator(key, value);
688
+ }
689
+ });
690
+
691
+ return { main, join };
692
+ }
693
+
694
+ /**
695
+ * Transforme les opérateurs spéciaux en SQL
696
+ *
697
+ * @param {string} column - Nom de la colonne
698
+ * @param {any} value - Valeur (peut contenir des opérateurs)
699
+ * @returns {any} Valeur transformée ou SQL string avec params
700
+ */
701
+ _transformOperator(column, value) {
702
+ // Si la valeur n'est pas un objet, la retourner telle quelle
703
+ if (!_.isObject(value) || _.isArray(value) || _.isDate(value)) {
704
+ return value;
705
+ }
706
+
707
+ // Transformer les opérateurs spéciaux en SQL
708
+ if (value.$between && _.isArray(value.$between) && value.$between.length === 2) {
709
+ return [`\`${this.schema.table}\`.\`${column}\` BETWEEN $? AND $?`, value.$between];
710
+ } else if (value.$gte !== undefined) {
711
+ return [`\`${this.schema.table}\`.\`${column}\` >= $?`, value.$gte];
712
+ } else if (value.$lte !== undefined) {
713
+ return [`\`${this.schema.table}\`.\`${column}\` <= $?`, value.$lte];
714
+ } else if (value.$gt !== undefined) {
715
+ return [`\`${this.schema.table}\`.\`${column}\` > $?`, value.$gt];
716
+ } else if (value.$lt !== undefined) {
717
+ return [`\`${this.schema.table}\`.\`${column}\` < $?`, value.$lt];
718
+ } else if (value.$like !== undefined) {
719
+ return [`\`${this.schema.table}\`.\`${column}\` LIKE $?`, value.$like];
720
+ }
721
+
722
+ // Sinon, retourner la valeur telle quelle
723
+ return value;
724
+ }
725
+
726
+ /**
727
+ * Construit automatiquement les filterJoins à partir des chemins détectés
728
+ *
729
+ * Cette méthode regroupe les conditions par chemin d'association et génère
730
+ * les filterJoinNested appropriés.
731
+ *
732
+ * @param {object} joinConditions - Conditions sur les tables jointes
733
+ *
734
+ * Exemple :
735
+ * Input: {
736
+ * 'applicant.last_name': { value: 'Dupont', path: ['applicant'], column: 'last_name' },
737
+ * 'applicant.email': { value: 'test@test.com', path: ['applicant'], column: 'email' },
738
+ * 'pme_folder.company.country.code': { value: 'FR', path: ['pme_folder', 'company', 'country'], column: 'code' }
739
+ * }
740
+ *
741
+ * Output: Appelle filterJoinNested() avec :
742
+ * {
743
+ * applicant: {
744
+ * conditions: { last_name: 'Dupont', email: 'test@test.com' }
745
+ * },
746
+ * pme_folder: {
747
+ * nested: {
748
+ * company: {
749
+ * nested: {
750
+ * country: {
751
+ * conditions: { code: 'FR' }
752
+ * }
753
+ * }
754
+ * }
755
+ * }
756
+ * }
757
+ * }
758
+ */
759
+ _buildFilterJoinsFromPaths(joinConditions) {
760
+ // Regrouper les conditions par racine d'association
761
+ const groupedByRoot = {};
762
+
763
+ _.forOwn(joinConditions, ({ value, path, column }, fullPath) => {
764
+ const root = path[0];
765
+
766
+ if (!groupedByRoot[root]) {
767
+ groupedByRoot[root] = [];
768
+ }
769
+
770
+ groupedByRoot[root].push({
771
+ path: path.slice(1), // Enlever la racine
772
+ column,
773
+ value,
774
+ fullPath
775
+ });
776
+ });
777
+
778
+ // Construire les filterJoinNested pour chaque racine
779
+ _.forOwn(groupedByRoot, (conditions, root) => {
780
+ // Si toutes les conditions sont au niveau racine (path vide), utiliser filterJoin simple
781
+ const allAtRoot = _.every(conditions, c => c.path.length === 0);
782
+
783
+ if (allAtRoot) {
784
+ // Filtre simple (1 niveau)
785
+ const simpleConditions = {};
786
+ _.forEach(conditions, ({ column, value }) => {
787
+ simpleConditions[column] = value;
788
+ });
789
+
790
+ this._addSimpleFilterJoin(root, simpleConditions);
791
+ } else {
792
+ // Filtre imbriqué (plusieurs niveaux)
793
+ const nestedConfig = this._buildNestedConfig(conditions);
794
+ this._addNestedFilterJoin({ [root]: nestedConfig });
795
+ }
796
+ });
797
+ }
798
+
799
+ /**
800
+ * Construit la configuration imbriquée pour filterJoinNested
801
+ *
802
+ * @param {Array} conditions - Liste des conditions à imbriquer
803
+ * @returns {object} Configuration imbriquée
804
+ *
805
+ * Exemple :
806
+ * Input: [
807
+ * { path: ['company', 'country'], column: 'code', value: 'FR' },
808
+ * { path: ['company'], column: 'siret', value: '123%' }
809
+ * ]
810
+ *
811
+ * Output: {
812
+ * nested: {
813
+ * company: {
814
+ * conditions: { siret: '123%' },
815
+ * nested: {
816
+ * country: {
817
+ * conditions: { code: 'FR' }
818
+ * }
819
+ * }
820
+ * }
821
+ * }
822
+ * }
823
+ */
824
+ _buildNestedConfig(conditions) {
825
+ const config = {
826
+ conditions: {},
827
+ nested: {}
828
+ };
829
+
830
+ // Séparer les conditions : celles au niveau actuel vs celles à imbriquer
831
+ const currentLevelConditions = [];
832
+ const nestedConditions = {};
833
+
834
+ _.forEach(conditions, (cond) => {
835
+ if (cond.path.length === 0) {
836
+ // Condition au niveau actuel
837
+ currentLevelConditions.push(cond);
838
+ } else {
839
+ // Condition à imbriquer
840
+ const nextLevel = cond.path[0];
841
+ if (!nestedConditions[nextLevel]) {
842
+ nestedConditions[nextLevel] = [];
843
+ }
844
+ nestedConditions[nextLevel].push({
845
+ path: cond.path.slice(1),
846
+ column: cond.column,
847
+ value: cond.value
848
+ });
849
+ }
850
+ });
851
+
852
+ // Ajouter les conditions au niveau actuel
853
+ _.forEach(currentLevelConditions, ({ column, value }) => {
854
+ config.conditions[column] = value;
855
+ });
856
+
857
+ // Si pas de conditions au niveau actuel, supprimer la clé
858
+ if (_.isEmpty(config.conditions)) {
859
+ delete config.conditions;
860
+ }
861
+
862
+ // Construire récursivement les niveaux imbriqués
863
+ _.forOwn(nestedConditions, (nestedConds, assocName) => {
864
+ config.nested[assocName] = this._buildNestedConfig(nestedConds);
865
+ });
866
+
867
+ // Si pas de nested, supprimer la clé
868
+ if (_.isEmpty(config.nested)) {
869
+ delete config.nested;
870
+ }
871
+
872
+ return config;
873
+ }
874
+
875
+ /**
876
+ * Génère le SQL pour la requête optimisée
877
+ *
878
+ * Cette méthode override toSQL() pour générer le SQL approprié selon le verb.
879
+ */
880
+ toSQL() {
881
+ const { query } = this;
882
+ const db = this.getDb();
883
+
884
+ // Utiliser PaginatedOptimizedSql pour les requêtes optimisées
885
+ if (query.optimized && (query.verb === 'count' || query.verb === 'select_ids')) {
886
+ // Ajouter le schema au query object pour que PaginatedOptimizedSql puisse y accéder
887
+ query.schema = this.schema;
888
+
889
+ const PaginatedOptimizedSql = require('./PaginatedOptimizedSql');
890
+ const sql = new PaginatedOptimizedSql(query, db.driver.dialect);
891
+
892
+ if (query.verb === 'count') {
893
+ return sql.countSQL();
894
+ } else if (query.verb === 'select_ids') {
895
+ return sql.idsSQL();
896
+ }
897
+ }
898
+
899
+ // Sinon, utiliser Sql standard
900
+ return super.toSQL();
901
+ }
902
+ };