@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,1183 @@
1
+ // Tests unitaires sans dépendance sur la base de données
2
+ // On teste uniquement la génération SQL, pas l'exécution
3
+
4
+ const assert = require('assert');
5
+
6
+ const PaginatedOptimizedQuery = require('@igojs/db').PaginatedOptimizedQuery;
7
+ const Model = require('@igojs/db').Model;
8
+
9
+ // Helper pour mocker getDb
10
+ const mockGetDb = (query) => {
11
+ query.getDb = () => ({
12
+ driver: {
13
+ dialect: {
14
+ esc: '`',
15
+ param: (i) => '?',
16
+ in: 'IN',
17
+ notin: 'NOT IN',
18
+ limit: (offsetParam, limitParam) => `LIMIT ?, ?`
19
+ }
20
+ }
21
+ });
22
+ return query;
23
+ };
24
+
25
+ //
26
+ describe('db.PaginatedOptimizedQuery', function() {
27
+
28
+ // Définition des modèles de test
29
+ class Country extends Model({
30
+ table: 'countries',
31
+ columns: {
32
+ id: 'integer',
33
+ code: 'string',
34
+ name: 'string'
35
+ }
36
+ }) {}
37
+
38
+ class Company extends Model({
39
+ table: 'companies',
40
+ primary: ['id'],
41
+ columns: {
42
+ id: 'integer',
43
+ name: 'string',
44
+ siret: 'string',
45
+ country_id: 'integer'
46
+ },
47
+ associations: [
48
+ ['belongs_to', 'country', Country, 'country_id', 'id']
49
+ ]
50
+ }) {}
51
+
52
+ class PmeFolder extends Model({
53
+ table: 'pme_folders',
54
+ primary: ['id'],
55
+ columns: {
56
+ id: 'integer',
57
+ status: 'string',
58
+ company_id: 'integer',
59
+ company_name: 'string',
60
+ block_studies_id: 'integer'
61
+ },
62
+ associations: [
63
+ ['belongs_to', 'company', Company, 'company_id', 'id']
64
+ ]
65
+ }) {}
66
+
67
+ // Separate class to avoid circular dependencies in test setup
68
+ // This will be used for testing nested block paths
69
+ class PmeFolderWithBlocks extends PmeFolder {}
70
+
71
+ // Define associations after class declaration to handle forward references
72
+ PmeFolderWithBlocks.schema.columns.block_studies_id = 'integer';
73
+ PmeFolderWithBlocks.schema.columns.block_travel_wishes_id = 'integer';
74
+ PmeFolderWithBlocks.schema.colsByName = PmeFolderWithBlocks.schema.colsByName || {};
75
+ PmeFolderWithBlocks.schema.colsByName.block_studies_id = 'integer';
76
+ PmeFolderWithBlocks.schema.colsByName.block_travel_wishes_id = 'integer';
77
+
78
+ class Applicant extends Model({
79
+ table: 'applicants',
80
+ primary: ['id'],
81
+ columns: {
82
+ id: 'integer',
83
+ first_name: 'string',
84
+ last_name: 'string',
85
+ email: 'string'
86
+ }
87
+ }) {}
88
+
89
+ class Folder extends Model({
90
+ table: 'folders',
91
+ primary: ['id'],
92
+ columns: {
93
+ id: 'integer',
94
+ type: 'string',
95
+ status: 'string',
96
+ applicant_id: 'integer',
97
+ pme_folder_id: 'integer',
98
+ created_at: 'datetime'
99
+ },
100
+ associations: [
101
+ ['belongs_to', 'applicant', Applicant, 'applicant_id', 'id'],
102
+ ['belongs_to', 'pme_folder', PmeFolder, 'pme_folder_id', 'id']
103
+ ]
104
+ }) {}
105
+
106
+ // Modèles de test pour les "blocks" (sous-tables)
107
+ class StudiesBlock extends Model({
108
+ table: 'block_studies',
109
+ primary: ['id'],
110
+ columns: [
111
+ 'id',
112
+ 'ine_number',
113
+ 'student_status',
114
+ 'studies_year',
115
+ 'studies_field',
116
+ { name: 'bac_year', type: 'integer' }
117
+ ]
118
+ }) {}
119
+
120
+ class TravelWishesBlock extends Model({
121
+ table: 'block_travel_wishes',
122
+ primary: ['id'],
123
+ columns: [
124
+ 'id',
125
+ { name: 'departure_date', type: 'date' },
126
+ { name: 'return_date', type: 'date' },
127
+ 'destination'
128
+ ]
129
+ }) {}
130
+
131
+ // Now add block associations to PmeFolderWithBlocks (must be after block classes are defined)
132
+ PmeFolderWithBlocks.schema.associations = [
133
+ ['belongs_to', 'company', Company, 'company_id', 'id'],
134
+ ['belongs_to', 'block_study', StudiesBlock, 'block_studies_id', 'id'],
135
+ ['belongs_to', 'block_travel_wish', TravelWishesBlock, 'block_travel_wishes_id', 'id']
136
+ ];
137
+
138
+ // Folder variant with PmeFolderWithBlocks for testing nested block paths
139
+ class FolderWithNestedBlocks extends Model({
140
+ table: 'folders',
141
+ primary: ['id'],
142
+ columns: {
143
+ id: 'integer',
144
+ type: 'string',
145
+ status: 'string',
146
+ applicant_id: 'integer',
147
+ pme_folder_id: 'integer',
148
+ created_at: 'datetime'
149
+ },
150
+ associations: [
151
+ ['belongs_to', 'applicant', Applicant, 'applicant_id', 'id'],
152
+ ['belongs_to', 'pme_folder', PmeFolderWithBlocks, 'pme_folder_id', 'id']
153
+ ]
154
+ }) {}
155
+
156
+ class PMEFolderWithBlocks extends Model({
157
+ table: 'pme_folders_with_blocks',
158
+ primary: ['id'],
159
+ columns: [
160
+ 'id',
161
+ { name: 'block_studies_id', type: 'integer' },
162
+ { name: 'block_travel_wishes_id', type: 'integer' },
163
+ 'professional_activity',
164
+ { name: 'is_initial', type: 'boolean' }
165
+ ],
166
+ associations: () => [
167
+ ['belongs_to', 'studies', StudiesBlock, 'block_studies_id', 'id'],
168
+ ['belongs_to', 'travel_wishes', TravelWishesBlock, 'block_travel_wishes_id', 'id']
169
+ ]
170
+ }) {}
171
+
172
+ //
173
+ describe('Simplified Syntax - where() with nested paths', function() {
174
+ it('should detect simple path (1 level)', () => {
175
+ const query = new PaginatedOptimizedQuery(Folder);
176
+ query.where({
177
+ status: 'SUBMITTED',
178
+ 'applicant.last_name': 'Dupont'
179
+ });
180
+
181
+ // Vérifier que filterJoins a été créé
182
+ assert.ok(query.query.filterJoins.length > 0, 'filterJoins should contain at least one element');
183
+
184
+ // Vérifier que le filtre sur la table principale est dans where
185
+ assert.ok(query.query.where.length > 0, 'where should contain the condition on status');
186
+ });
187
+
188
+ it('should detect nested path (3 levels)', () => {
189
+ const query = new PaginatedOptimizedQuery(Folder);
190
+ query.where({
191
+ 'pme_folder.company.country.code': 'FR'
192
+ });
193
+
194
+ // Vérifier que filterJoins nested a été créé
195
+ assert.ok(query.query.filterJoins.length > 0, 'filterJoins should be created');
196
+ assert.equal(query.query.filterJoins[0].type, 'nested', 'filterJoin should be of type nested');
197
+ });
198
+
199
+ it('should group conditions on the same table', () => {
200
+ const query = new PaginatedOptimizedQuery(Folder);
201
+ query.where({
202
+ 'applicant.last_name': 'Dupont',
203
+ 'applicant.first_name': 'Jean',
204
+ 'applicant.email': 'test@test.com'
205
+ });
206
+
207
+ // Vérifier qu'un seul filterJoin a été créé (regroupement)
208
+ assert.equal(query.query.filterJoins.length, 1, 'Should create a single filterJoin for applicant');
209
+ });
210
+ });
211
+
212
+ //
213
+ describe('countSQL with EXISTS', function() {
214
+ it('should generate COUNT SQL with EXISTS for nested paths', () => {
215
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
216
+ query.query.verb = 'count';
217
+ query.where({
218
+ type: ['agp', 'avt'],
219
+ 'applicant.last_name': 'Dupont%'
220
+ });
221
+
222
+ const sql = query.toSQL();
223
+
224
+ // Vérifier que le SQL contient EXISTS
225
+ assert.ok(sql.sql.includes('EXISTS'), 'SQL should contain EXISTS');
226
+ assert.ok(sql.sql.includes('SELECT 1 FROM'), 'SQL should contain SELECT 1 FROM');
227
+ assert.ok(sql.sql.includes('applicants'), 'SQL should reference applicants table');
228
+ assert.ok(sql.sql.includes('COUNT(0)'), 'SQL should contain COUNT(0)');
229
+
230
+ // Vérifier qu'il n'y a pas de LEFT JOIN
231
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'SQL should not contain LEFT JOIN');
232
+ });
233
+
234
+ it('should generate COUNT SQL with multiple EXISTS', () => {
235
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
236
+ query.query.verb = 'count';
237
+ query.where({
238
+ type: 'agp',
239
+ 'applicant.last_name': 'Dupont%',
240
+ 'pme_folder.status': 'ACTIVE'
241
+ });
242
+
243
+ const sql = query.toSQL();
244
+
245
+ // Vérifier qu'il y a 2 EXISTS
246
+ const existsCount = (sql.sql.match(/EXISTS/g) || []).length;
247
+ assert.strictEqual(existsCount, 2, 'SQL should contain 2 EXISTS clauses');
248
+
249
+ // Vérifier les tables
250
+ assert.ok(sql.sql.includes('applicants'), 'SQL should reference applicants');
251
+ assert.ok(sql.sql.includes('pme_folders'), 'SQL should reference pme_folders');
252
+ });
253
+
254
+ it('should generate nested EXISTS for deep paths', () => {
255
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
256
+ query.query.verb = 'count';
257
+ query.where({
258
+ 'pme_folder.company.country.code': 'FR'
259
+ });
260
+
261
+ const sql = query.toSQL();
262
+
263
+ // Vérifier que des EXISTS imbriqués sont générés
264
+ const existsCount = (sql.sql.match(/EXISTS/g) || []).length;
265
+ assert.ok(existsCount >= 3, 'SQL should contain at least 3 nested EXISTS');
266
+
267
+ // Vérifier que les tables sont mentionnées
268
+ assert.ok(sql.sql.includes('pme_folders'), 'SQL should reference pme_folders');
269
+ assert.ok(sql.sql.includes('companies'), 'SQL should reference companies');
270
+ assert.ok(sql.sql.includes('countries'), 'SQL should reference countries');
271
+ });
272
+ });
273
+
274
+ //
275
+ describe('idsSQL', function() {
276
+ it('should generate SELECT IDs SQL with EXISTS', () => {
277
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
278
+ query.query.verb = 'select_ids';
279
+ query.where({
280
+ type: 'agp',
281
+ 'applicant.last_name': 'Dupont%'
282
+ })
283
+ .order('folders.created_at DESC')
284
+ .limit(50);
285
+
286
+ const sql = query.toSQL();
287
+
288
+ // Vérifier la structure
289
+ assert.ok(sql.sql.includes('SELECT'), 'SQL should contain SELECT');
290
+ assert.ok(sql.sql.includes('`folders`.`id`'), 'SQL should select folders.id');
291
+ assert.ok(sql.sql.includes('EXISTS'), 'SQL should contain EXISTS');
292
+ assert.ok(sql.sql.includes('ORDER BY'), 'SQL should contain ORDER BY');
293
+ assert.ok(sql.sql.includes('LIMIT'), 'SQL should contain LIMIT');
294
+
295
+ // Pas de LEFT JOIN
296
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'SQL should not contain LEFT JOIN');
297
+ });
298
+ });
299
+
300
+ //
301
+ describe('Model.paginatedOptimized()', function() {
302
+ it('should return PaginatedOptimizedQuery instance', () => {
303
+ const query = Folder.paginatedOptimized();
304
+ assert.ok(query instanceof PaginatedOptimizedQuery, 'Should return PaginatedOptimizedQuery instance');
305
+ assert.strictEqual(query.query.optimized, true, 'Query should be marked as optimized');
306
+ });
307
+
308
+ it('should support method chaining with new syntax', () => {
309
+ const query = Folder.paginatedOptimized()
310
+ .where({
311
+ type: 'agp',
312
+ 'applicant.last_name': 'Dupont%'
313
+ })
314
+ .order('folders.created_at DESC');
315
+
316
+ assert.strictEqual(query.query.where.length, 1);
317
+ assert.strictEqual(query.query.filterJoins.length, 1);
318
+ assert.strictEqual(query.query.order.length, 1);
319
+ });
320
+ });
321
+
322
+ //
323
+ describe('SQL generation patterns', function() {
324
+ it('should generate correct WHERE EXISTS clause', () => {
325
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
326
+ query.query.verb = 'count';
327
+ query.where({
328
+ status: 'SUBMITTED',
329
+ 'applicant.last_name': 'Dupont%',
330
+ 'applicant.email': 'test@example.com'
331
+ });
332
+
333
+ const sql = query.toSQL();
334
+
335
+ // Vérifier la structure de EXISTS
336
+ assert.ok(sql.sql.includes('WHERE `folders`.`status`'), 'Should have WHERE on folders.status');
337
+ assert.ok(sql.sql.includes('AND EXISTS (SELECT 1 FROM `applicants`'), 'Should have EXISTS with applicants');
338
+ assert.ok(sql.sql.includes('WHERE `applicants`.`id` = `folders`.`applicant_id`'), 'Should have join condition');
339
+ });
340
+
341
+ it('should handle LIKE patterns with %', () => {
342
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
343
+ query.query.verb = 'count';
344
+ query.where({
345
+ 'applicant.last_name': 'Dupont%'
346
+ });
347
+
348
+ const sql = query.toSQL();
349
+
350
+ // Le % dans la valeur devrait générer un LIKE
351
+ assert.ok(sql.sql.includes('LIKE'), 'Should generate LIKE for patterns with %');
352
+ });
353
+
354
+ it('should handle IN arrays', () => {
355
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
356
+ query.query.verb = 'count';
357
+ query.where({
358
+ status: ['SUBMITTED', 'VALIDATED', 'APPROVED']
359
+ });
360
+
361
+ const sql = query.toSQL();
362
+
363
+ // Les tableaux devraient générer un IN
364
+ assert.ok(sql.sql.includes('IN'), 'Should generate IN for arrays');
365
+ });
366
+ });
367
+
368
+ //
369
+ describe('Performance optimization verification', function() {
370
+ it('COUNT should not include LEFT JOIN', () => {
371
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
372
+ query
373
+ .where({
374
+ type: 'agp',
375
+ 'applicant.last_name': 'Dupont%'
376
+ })
377
+ .join('pme_folder'); // Ce join ne doit pas apparaître dans COUNT
378
+
379
+ query.query.verb = 'count';
380
+ const sql = query.toSQL();
381
+
382
+ // Le COUNT ne doit pas avoir de LEFT JOIN
383
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'COUNT should not have LEFT JOIN');
384
+
385
+ // Mais doit avoir EXISTS pour le filtre
386
+ assert.ok(sql.sql.includes('EXISTS'), 'COUNT should have EXISTS for filter');
387
+ });
388
+
389
+ it('IDS query should be minimal', () => {
390
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
391
+ query.query.verb = 'select_ids';
392
+ query.where({
393
+ type: 'agp',
394
+ 'applicant.last_name': 'Dupont%'
395
+ })
396
+ .limit(50);
397
+
398
+ const sql = query.toSQL();
399
+
400
+ // Vérifier que seuls les IDs sont sélectionnés
401
+ assert.ok(sql.sql.includes('SELECT `folders`.`id`'), 'Should only select IDs');
402
+
403
+ // Pas de sélection d'autres colonnes
404
+ assert.ok(!sql.sql.includes('`folders`.*'), 'Should not select all columns');
405
+
406
+ // Pas de LEFT JOIN
407
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'Should not have LEFT JOIN');
408
+ });
409
+ });
410
+
411
+ //
412
+ describe('Complex scenarios', function() {
413
+ it('should handle $and with multiple conditions', () => {
414
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
415
+ query.query.verb = 'count';
416
+ query.where({
417
+ $and: [
418
+ { status: 'SUBMITTED' },
419
+ { 'applicant.last_name': 'Dupont%' },
420
+ { 'applicant.first_name': 'Jean%' }
421
+ ]
422
+ });
423
+
424
+ const sql = query.toSQL();
425
+
426
+ // Vérifier que les conditions sont présentes
427
+ assert.ok(sql.sql.includes('status'), 'Should include status condition');
428
+ assert.ok(sql.sql.includes('EXISTS'), 'Should include EXISTS for applicant');
429
+ });
430
+
431
+ it('should combine main table and joined table filters', () => {
432
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
433
+ query.query.verb = 'count';
434
+ query.where({
435
+ type: ['agp', 'avt', 'cga'],
436
+ status: 'SUBMITTED',
437
+ 'applicant.last_name': 'Dupont%',
438
+ 'pme_folder.status': 'ACTIVE'
439
+ });
440
+
441
+ const sql = query.toSQL();
442
+
443
+ // Vérifier que tous les WHERE sont présents
444
+ assert.ok(sql.sql.includes('`folders`.`type`'), 'Should include type filter');
445
+ assert.ok(sql.sql.includes('`folders`.`status`'), 'Should include status filter');
446
+ assert.ok(sql.sql.includes('EXISTS'), 'Should include EXISTS for filterJoin');
447
+
448
+ // Vérifier qu'il y a 2 EXISTS (applicant + pme_folder)
449
+ const existsCount = (sql.sql.match(/EXISTS/g) || []).length;
450
+ assert.ok(existsCount >= 2, 'Should have at least 2 EXISTS');
451
+ });
452
+ });
453
+
454
+ //
455
+ describe('Sorting on joined tables', function() {
456
+ it('should add INNER JOIN when sorting on joined table column', () => {
457
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
458
+ query.query.verb = 'select_ids';
459
+ query
460
+ .where({
461
+ type: ['agp', 'avt']
462
+ })
463
+ .order('applicants.last_name ASC')
464
+ .join('applicant')
465
+ .limit(50);
466
+
467
+ const sql = query.toSQL();
468
+
469
+ // Vérifier que l'INNER JOIN est présent
470
+ assert.ok(sql.sql.includes('LEFT JOIN'), 'SQL should contain LEFT JOIN for sorting on joined table');
471
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should INNER JOIN the applicants table');
472
+ assert.ok(sql.sql.includes('ON `applicants`.`id` = `folders`.`applicant_id`'), 'SQL should have correct join condition');
473
+
474
+ // Vérifier que le ORDER BY est présent
475
+ assert.ok(sql.sql.includes('ORDER BY applicants.last_name ASC'), 'SQL should have ORDER BY clause');
476
+ });
477
+
478
+ it('should NOT add INNER JOIN when sorting on main table column', () => {
479
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
480
+ query.query.verb = 'select_ids';
481
+ query
482
+ .where({ type: 'agp' })
483
+ .order('folders.created_at DESC')
484
+ .limit(50);
485
+
486
+ const sql = query.toSQL();
487
+
488
+ // Vérifier qu'il n'y a PAS de JOIN
489
+ assert.ok(!sql.sql.includes('INNER JOIN'), 'SQL should not contain INNER JOIN when sorting on main table');
490
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'SQL should not contain LEFT JOIN when sorting on main table');
491
+
492
+ // Vérifier que le ORDER BY est présent
493
+ assert.ok(sql.sql.includes('ORDER BY'), 'SQL should have ORDER BY clause');
494
+ });
495
+
496
+ it('should add INNER JOIN for nested sorted columns', () => {
497
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
498
+ query.query.verb = 'select_ids';
499
+ query
500
+ .where({
501
+ type: 'agp',
502
+ 'pme_folder.company.country.code': 'FR'
503
+ })
504
+ .order('companies.name ASC') // Tri sur une table imbriquée
505
+ .join('pme_folder.company.country')
506
+ .limit(50);
507
+
508
+ const sql = query.toSQL();
509
+
510
+ // Vérifier que les INNER JOIN en cascade sont présents
511
+ assert.ok(sql.sql.includes('LEFT JOIN `pme_folders`'), 'SQL should INNER JOIN pme_folders');
512
+ assert.ok(sql.sql.includes('LEFT JOIN `companies`'), 'SQL should INNER JOIN companies');
513
+
514
+ // Vérifier le ORDER BY
515
+ assert.ok(sql.sql.includes('ORDER BY companies.name ASC'), 'SQL should sort by companies.name');
516
+ });
517
+
518
+ it('should combine INNER JOIN for sorting with EXISTS for filtering', () => {
519
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
520
+ query.query.verb = 'select_ids';
521
+ query
522
+ .where({
523
+ type: 'agp',
524
+ 'applicant.email': '%@example.com'
525
+ })
526
+ .order('applicants.last_name ASC')
527
+ .join('applicant')
528
+ .limit(50);
529
+
530
+ const sql = query.toSQL();
531
+
532
+ // Vérifier l'INNER JOIN pour le tri
533
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should have INNER JOIN for sorting');
534
+
535
+ // Vérifier EXISTS pour le filtre
536
+ assert.ok(sql.sql.includes('EXISTS'), 'SQL should have EXISTS for filtering');
537
+ assert.ok(sql.sql.includes('email'), 'SQL should filter on email');
538
+
539
+ // Vérifier ORDER BY
540
+ assert.ok(sql.sql.includes('ORDER BY applicants.last_name ASC'), 'SQL should sort by applicants.last_name');
541
+ });
542
+
543
+ it('should NOT add INNER JOIN in COUNT phase even with sort on joined table', () => {
544
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
545
+ query.query.verb = 'count';
546
+ query
547
+ .where({ type: 'agp' })
548
+ .order('applicants.last_name ASC')
549
+ .join('applicant')
550
+ .limit(50);
551
+
552
+ const sql = query.toSQL();
553
+
554
+ // COUNT ne devrait PAS avoir de JOIN (même si on trie sur une table jointe)
555
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'COUNT should not have LEFT JOIN for sorting');
556
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'COUNT should not have LEFT JOIN');
557
+
558
+ // COUNT ne devrait PAS avoir de ORDER BY
559
+ assert.ok(!sql.sql.includes('ORDER BY'), 'COUNT should not have ORDER BY');
560
+ });
561
+
562
+ it('should transform association names to table names in ORDER BY (pmfp_folder.company case)', () => {
563
+ // Test pour vérifier que "pmfp_folder.company.name" devient "companies.name"
564
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
565
+ query.query.verb = 'select_ids';
566
+ query
567
+ .where({ type: 'agp' })
568
+ .order('pme_folder.company.name ASC')
569
+ .join('pme_folder.company')
570
+ .limit(50);
571
+
572
+ const sql = query.toSQL();
573
+
574
+ // Vérifier les INNER JOIN en cascade
575
+ assert.ok(sql.sql.includes('LEFT JOIN `pme_folders`'), 'SQL should INNER JOIN pme_folders');
576
+ assert.ok(sql.sql.includes('LEFT JOIN `companies`'), 'SQL should INNER JOIN companies');
577
+
578
+ // Vérifier que le ORDER BY utilise le nom de TABLE (companies) et non d'association (company)
579
+ assert.ok(sql.sql.includes('ORDER BY companies.name ASC'), 'SQL should use table name "companies" in ORDER BY');
580
+ assert.ok(!sql.sql.includes('company.name'), 'SQL should NOT use association name "company" in ORDER BY');
581
+ });
582
+ });
583
+
584
+ describe('ORDER BY avec Fonctions SQL', function() {
585
+ it('devrait gérer COALESCE avec plusieurs colonnes de la même table', () => {
586
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
587
+ query.query.verb = 'select_ids';
588
+ query
589
+ .where({ type: 'agp' })
590
+ .join('applicant')
591
+ .order('COALESCE(`applicant`.`last_name`, `applicant`.`first_name`) ASC')
592
+ .limit(50);
593
+
594
+ const sql = query.toSQL();
595
+
596
+ // Doit contenir l'INNER JOIN
597
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should INNER JOIN applicants');
598
+
599
+ // Doit conserver la fonction COALESCE dans ORDER BY
600
+ assert.ok(sql.sql.includes('ORDER BY COALESCE'), 'SQL should preserve COALESCE function');
601
+ assert.ok(sql.sql.includes('applicants.last_name'), 'SQL should include last_name column (transformed from association to table name)');
602
+ assert.ok(sql.sql.includes('applicants.first_name'), 'SQL should include first_name column (transformed from association to table name)');
603
+ });
604
+
605
+ it('devrait gérer IFNULL avec table imbriquée', () => {
606
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
607
+ query.query.verb = 'select_ids';
608
+ query
609
+ .where({ type: 'pme' })
610
+ .join('pme_folder.company')
611
+ .order('IFNULL(`companies`.`name`, "N/A") ASC')
612
+ .limit(50);
613
+
614
+ const sql = query.toSQL();
615
+
616
+ // Doit contenir les INNER JOIN pour la chaîne d'associations
617
+ assert.ok(sql.sql.includes('LEFT JOIN `pme_folders`'), 'SQL should INNER JOIN pme_folders');
618
+ assert.ok(sql.sql.includes('LEFT JOIN `companies`'), 'SQL should INNER JOIN companies');
619
+
620
+ // Doit conserver IFNULL dans ORDER BY
621
+ assert.ok(sql.sql.includes('ORDER BY IFNULL'), 'SQL should preserve IFNULL function');
622
+ });
623
+
624
+ it('devrait gérer CONCAT avec colonnes multiples', () => {
625
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
626
+ query.query.verb = 'select_ids';
627
+ query
628
+ .where({ type: 'agp' })
629
+ .join('applicant')
630
+ .order('CONCAT(`applicant`.`last_name`, " ", `applicant`.`first_name`) ASC')
631
+ .limit(50);
632
+
633
+ const sql = query.toSQL();
634
+
635
+ // Doit contenir l'INNER JOIN
636
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should INNER JOIN applicants');
637
+
638
+ // Doit conserver CONCAT dans ORDER BY
639
+ assert.ok(sql.sql.includes('ORDER BY CONCAT'), 'SQL should preserve CONCAT function');
640
+ assert.ok(sql.sql.includes('applicants.last_name'), 'SQL should include last_name column (transformed from association to table name)');
641
+ assert.ok(sql.sql.includes('applicants.first_name'), 'SQL should include first_name column (transformed from association to table name)');
642
+ });
643
+
644
+ it('devrait gérer COALESCE avec tables imbriquées', () => {
645
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
646
+ query.query.verb = 'select_ids';
647
+ query
648
+ .where({ type: 'pme' })
649
+ .join('applicant')
650
+ .join('pme_folder.company')
651
+ .order('COALESCE(`applicant`.`last_name`, `companies`.`name`) ASC')
652
+ .limit(50);
653
+
654
+ const sql = query.toSQL();
655
+
656
+ // Doit contenir les INNER JOIN
657
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should INNER JOIN applicants');
658
+ assert.ok(sql.sql.includes('LEFT JOIN `pme_folders`'), 'SQL should INNER JOIN pme_folders');
659
+ assert.ok(sql.sql.includes('LEFT JOIN `companies`'), 'SQL should INNER JOIN companies');
660
+
661
+ // Doit conserver COALESCE dans ORDER BY
662
+ assert.ok(sql.sql.includes('ORDER BY COALESCE'), 'SQL should preserve COALESCE function');
663
+ });
664
+
665
+ it('ne devrait pas ajouter de JOIN pour les fonctions sur la table principale', () => {
666
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
667
+ query.query.verb = 'select_ids';
668
+ query
669
+ .where({ type: 'agp' })
670
+ .order('UPPER(`folders`.`name`) ASC')
671
+ .limit(50);
672
+
673
+ const sql = query.toSQL();
674
+
675
+ // Ne doit pas contenir de INNER JOIN (seulement la table principale)
676
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'SQL should not contain LEFT JOIN for functions on main table');
677
+
678
+ // Doit conserver la fonction UPPER
679
+ assert.ok(sql.sql.includes('ORDER BY UPPER'), 'SQL should preserve UPPER function');
680
+ });
681
+
682
+ it('should not double-transform paths in COALESCE (idempotence)', () => {
683
+ // Test pour vérifier qu'il n'y a pas de double-transformation
684
+ // Ce test vérifie le bug: applicant.last_name dans un COALESCE
685
+ // ne doit PAS être transformé 2 fois et devenir applicants.something.last_name
686
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
687
+ query.query.verb = 'select_ids';
688
+ query
689
+ .where({ type: 'agp' })
690
+ .join('applicant')
691
+ .join('pme_folder.company')
692
+ .order('COALESCE(applicant.last_name, applicant.first_name, pme_folder.company_name, companies.name) ASC')
693
+ .limit(25);
694
+
695
+ const sql = query.toSQL();
696
+
697
+ // Doit transformer correctement SANS double transformation
698
+ assert.ok(sql.sql.includes('applicants.last_name'), 'Should transform applicant to applicants');
699
+ assert.ok(sql.sql.includes('applicants.first_name'), 'Should transform applicant fields');
700
+ assert.ok(sql.sql.includes('pme_folders.company_name'), 'Should transform pme_folder to pme_folders');
701
+ assert.ok(sql.sql.includes('companies.name'), 'Should transform company to companies');
702
+
703
+ // NE DOIT PAS contenir de chemins avec 3 niveaux (double transformation)
704
+ assert.ok(!sql.sql.includes('.applicants.'), 'Should NOT have double-transformed paths like table.applicants.column');
705
+ assert.ok(!sql.sql.includes('.companies.'), 'Should NOT have double-transformed paths like table.companies.column');
706
+ assert.ok(!sql.sql.includes('.pme_folders.'), 'Should NOT have double-transformed paths like table.pme_folders.column');
707
+ });
708
+
709
+ it('transformation should be idempotent (_transformSinglePath)', () => {
710
+ // Test pour vérifier que transformer 2 fois donne le même résultat
711
+ const PaginatedOptimizedSql = require('@igojs/db').PaginatedOptimizedSql;
712
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
713
+ const sqlGenerator = new PaginatedOptimizedSql(query);
714
+
715
+ const original = 'applicant.last_name';
716
+ const transformed1 = sqlGenerator._transformSinglePath(original);
717
+ const transformed2 = sqlGenerator._transformSinglePath(transformed1);
718
+
719
+ assert.strictEqual(transformed1, 'applicants.last_name',
720
+ 'First transformation should give correct result');
721
+ assert.strictEqual(transformed1, transformed2,
722
+ 'Transforming twice should give the same result (idempotent)');
723
+ });
724
+ });
725
+
726
+ describe('LEFT JOIN preserves rows with NULL', function() {
727
+ it('should use LEFT JOIN (not INNER JOIN) when sorting on joined table', () => {
728
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
729
+ query.query.verb = 'select_ids';
730
+ query
731
+ .where({ type: 'agp' })
732
+ .order('applicants.last_name ASC')
733
+ .limit(50);
734
+
735
+ const sql = query.toSQL();
736
+
737
+ // Doit utiliser LEFT JOIN, pas INNER JOIN
738
+ assert.ok(sql.sql.includes('LEFT JOIN'), 'SQL should use LEFT JOIN');
739
+ assert.ok(!sql.sql.includes('INNER JOIN'), 'SQL should not use INNER JOIN');
740
+
741
+ // Vérifier que c'est bien un LEFT JOIN sur applicants
742
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should LEFT JOIN applicants');
743
+
744
+ // Le ORDER BY doit être présent
745
+ assert.ok(sql.sql.includes('ORDER BY applicants.last_name ASC'), 'SQL should have ORDER BY');
746
+ });
747
+
748
+ it('should use LEFT JOIN for nested table sorting', () => {
749
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
750
+ query.query.verb = 'select_ids';
751
+ query
752
+ .where({ type: 'pme' })
753
+ .order('pme_folder.company.name ASC')
754
+ .limit(50);
755
+
756
+ const sql = query.toSQL();
757
+
758
+ // Tous les JOIN doivent être des LEFT JOIN
759
+ assert.ok(sql.sql.includes('LEFT JOIN'), 'SQL should use LEFT JOIN');
760
+ assert.ok(!sql.sql.includes('INNER JOIN'), 'SQL should not use INNER JOIN');
761
+
762
+ // Vérifier les LEFT JOIN en cascade
763
+ const leftJoinCount = (sql.sql.match(/LEFT JOIN/g) || []).length;
764
+ assert.ok(leftJoinCount >= 2, 'SQL should have at least 2 LEFT JOINs for nested path');
765
+ });
766
+
767
+ it('should use LEFT JOIN with SQL functions (COALESCE)', () => {
768
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
769
+ query.query.verb = 'select_ids';
770
+ query
771
+ .where({ type: 'agp' })
772
+ .join('applicant')
773
+ .order('COALESCE(`applicant`.`last_name`, "Unknown") ASC')
774
+ .limit(50);
775
+
776
+ const sql = query.toSQL();
777
+
778
+ // Doit utiliser LEFT JOIN même avec des fonctions SQL
779
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should LEFT JOIN applicants');
780
+ assert.ok(!sql.sql.includes('INNER JOIN'), 'SQL should not use INNER JOIN');
781
+ });
782
+
783
+ it('should verify LEFT JOIN preserves all rows conceptually', () => {
784
+ // Ce test vérifie que le SQL généré utilise LEFT JOIN
785
+ // qui préserve toutes les lignes, même celles avec NULL
786
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
787
+ query.query.verb = 'select_ids';
788
+ query
789
+ .where({ type: 'agp' })
790
+ .order('applicants.last_name ASC')
791
+ .limit(50);
792
+
793
+ const sql = query.toSQL();
794
+
795
+ // Vérifier structure SQL complète
796
+ assert.ok(sql.sql.includes('SELECT'), 'SQL should have SELECT');
797
+ assert.ok(sql.sql.includes('FROM `folders`'), 'SQL should have FROM folders');
798
+ assert.ok(sql.sql.includes('LEFT JOIN `applicants`'), 'SQL should have LEFT JOIN applicants');
799
+ assert.ok(sql.sql.includes('WHERE `folders`.`type` = ?'), 'SQL should have WHERE clause');
800
+ assert.ok(sql.sql.includes('ORDER BY applicants.last_name ASC'), 'SQL should have ORDER BY');
801
+ assert.ok(sql.sql.includes('LIMIT'), 'SQL should have LIMIT');
802
+
803
+ // Avec LEFT JOIN, les folders sans applicant seront inclus avec last_name=NULL
804
+ // Ce comportement est correct et préserve toutes les lignes
805
+ });
806
+ });
807
+
808
+ //
809
+ describe('Advanced operators', function() {
810
+ it('should handle LIKE operator with % wildcard', () => {
811
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
812
+ query.query.verb = 'count';
813
+ query.where({
814
+ 'applicant.last_name': 'Dupont%'
815
+ });
816
+
817
+ const sql = query.toSQL();
818
+
819
+ assert.ok(sql.sql.includes('LIKE'), 'Should generate LIKE for patterns with %');
820
+ assert.ok(sql.params.includes('Dupont%'), 'Should include the pattern in params');
821
+ });
822
+
823
+ it('should handle BETWEEN operator', () => {
824
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
825
+ query.query.verb = 'count';
826
+ query.where({
827
+ created_at: { $between: ['2024-01-01', '2024-12-31'] }
828
+ });
829
+
830
+ const sql = query.toSQL();
831
+
832
+ assert.ok(sql.sql.includes('BETWEEN'), 'Should generate BETWEEN operator');
833
+ assert.ok(sql.params.includes('2024-01-01'), 'Should include start date in params');
834
+ assert.ok(sql.params.includes('2024-12-31'), 'Should include end date in params');
835
+ });
836
+
837
+ it('should handle $gte operator', () => {
838
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
839
+ query.query.verb = 'count';
840
+ query.where({
841
+ created_at: { $gte: '2024-01-01' }
842
+ });
843
+
844
+ const sql = query.toSQL();
845
+
846
+ assert.ok(sql.sql.includes('>='), 'Should generate >= operator');
847
+ assert.ok(sql.params.includes('2024-01-01'), 'Should include date in params');
848
+ });
849
+
850
+ it('should handle $lte operator', () => {
851
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
852
+ query.query.verb = 'count';
853
+ query.where({
854
+ created_at: { $lte: '2024-12-31' }
855
+ });
856
+
857
+ const sql = query.toSQL();
858
+
859
+ assert.ok(sql.sql.includes('<='), 'Should generate <= operator');
860
+ assert.ok(sql.params.includes('2024-12-31'), 'Should include date in params');
861
+ });
862
+
863
+ it('should handle $like operator explicitly', () => {
864
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
865
+ query.query.verb = 'count';
866
+ query.where({
867
+ 'applicant.last_name': { $like: 'Dup%' }
868
+ });
869
+
870
+ const sql = query.toSQL();
871
+
872
+ assert.ok(sql.sql.includes('LIKE'), 'Should generate LIKE');
873
+ assert.ok(sql.params.includes('Dup%'), 'Should include pattern in params');
874
+ });
875
+
876
+ it('should combine multiple advanced operators', () => {
877
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
878
+ query.query.verb = 'count';
879
+ query.where({
880
+ created_at: { $between: ['2024-01-01', '2024-12-31'] },
881
+ status: ['SUBMITTED', 'VALIDATED'],
882
+ 'applicant.last_name': 'Dupont%',
883
+ 'applicant.email': '%@example.com'
884
+ });
885
+
886
+ const sql = query.toSQL();
887
+
888
+ assert.ok(sql.sql.includes('LIKE'), 'Should generate LIKE');
889
+ assert.ok(sql.sql.includes('BETWEEN'), 'Should generate BETWEEN');
890
+ assert.ok(sql.sql.includes('IN'), 'Should generate IN');
891
+ });
892
+ });
893
+
894
+ //
895
+ describe('Edge cases', function() {
896
+ it('should handle null values', () => {
897
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
898
+ query.query.verb = 'count';
899
+ query.where({
900
+ status: 'SUBMITTED',
901
+ deleted_at: null
902
+ });
903
+
904
+ const sql = query.toSQL();
905
+
906
+ assert.ok(sql.sql.includes('IS NULL'), 'Should generate IS NULL for null values');
907
+ });
908
+
909
+ it('should handle empty array', () => {
910
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
911
+ query.query.verb = 'count';
912
+ query.where({
913
+ status: []
914
+ });
915
+
916
+ const sql = query.toSQL();
917
+
918
+ // Un tableau vide devrait générer une condition qui est toujours fausse
919
+ // (Query.js le gère automatiquement)
920
+ assert.ok(sql.sql, 'Should generate SQL even with empty array');
921
+ });
922
+
923
+ it('should handle empty where object', () => {
924
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
925
+ query.query.verb = 'count';
926
+ query.where({});
927
+
928
+ const sql = query.toSQL();
929
+
930
+ // Devrait générer un SQL valide sans conditions
931
+ assert.ok(sql.sql.includes('SELECT COUNT(0)'), 'Should generate COUNT SQL');
932
+ assert.ok(!sql.sql.includes('WHERE'), 'Should not have WHERE clause for empty conditions');
933
+ });
934
+ });
935
+
936
+ //
937
+ describe('Block tables (sub-tables) support', function() {
938
+ it('should detect ORDER BY on block column without prefix and add LEFT JOIN', () => {
939
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
940
+ query.query.verb = 'select_ids';
941
+ query
942
+ .where({ is_initial: true })
943
+ .order('studies_year DESC') // Colonne dans block_studies, sans préfixe
944
+ .limit(50);
945
+
946
+ const sql = query.toSQL();
947
+
948
+ // Doit ajouter un LEFT JOIN vers block_studies
949
+ assert.ok(sql.sql.includes('LEFT JOIN `block_studies`'), 'SQL should LEFT JOIN block_studies for sorting on block column');
950
+ assert.ok(sql.sql.includes('ON `block_studies`.`id` = `pme_folders_with_blocks`.`block_studies_id`'), 'SQL should have correct join condition');
951
+
952
+ // Doit transformer ORDER BY en block_studies.studies_year
953
+ assert.ok(sql.sql.includes('ORDER BY block_studies.studies_year DESC'), 'SQL should prefix column with table name');
954
+ });
955
+
956
+ it('should handle multiple block columns in ORDER BY', () => {
957
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
958
+ query.query.verb = 'select_ids';
959
+ query
960
+ .where({ is_initial: true })
961
+ .order('studies_year DESC')
962
+ .order('bac_year ASC') // Autre colonne du même block
963
+ .limit(50);
964
+
965
+ const sql = query.toSQL();
966
+
967
+ // Un seul LEFT JOIN doit être ajouté (pas de duplication)
968
+ const leftJoinCount = (sql.sql.match(/LEFT JOIN `block_studies`/g) || []).length;
969
+ assert.strictEqual(leftJoinCount, 1, 'Should have exactly one LEFT JOIN to block_studies');
970
+
971
+ // Les deux colonnes doivent être préfixées
972
+ assert.ok(sql.sql.includes('ORDER BY block_studies.studies_year DESC'), 'Should prefix studies_year');
973
+ assert.ok(sql.sql.includes('block_studies.bac_year ASC'), 'Should prefix bac_year');
974
+ });
975
+
976
+ it('should handle ORDER BY on different blocks', () => {
977
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
978
+ query.query.verb = 'select_ids';
979
+ query
980
+ .where({ is_initial: true })
981
+ .order('studies_year DESC') // Colonne dans block_studies
982
+ .order('destination ASC') // Colonne dans block_travel_wishes
983
+ .limit(50);
984
+
985
+ const sql = query.toSQL();
986
+
987
+ // Doit ajouter deux LEFT JOIN (un pour chaque block)
988
+ assert.ok(sql.sql.includes('LEFT JOIN `block_studies`'), 'Should LEFT JOIN block_studies');
989
+ assert.ok(sql.sql.includes('LEFT JOIN `block_travel_wishes`'), 'Should LEFT JOIN block_travel_wishes');
990
+
991
+ // Les colonnes doivent être préfixées correctement
992
+ assert.ok(sql.sql.includes('ORDER BY block_studies.studies_year DESC'), 'Should prefix studies_year with block_studies');
993
+ assert.ok(sql.sql.includes('block_travel_wishes.destination ASC'), 'Should prefix destination with block_travel_wishes');
994
+ });
995
+
996
+ it('should NOT add JOIN if column exists in main table', () => {
997
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
998
+ query.query.verb = 'select_ids';
999
+ query
1000
+ .where({ is_initial: true })
1001
+ .order('professional_activity DESC') // Colonne dans la table principale
1002
+ .limit(50);
1003
+
1004
+ const sql = query.toSQL();
1005
+
1006
+ // Ne doit PAS ajouter de LEFT JOIN
1007
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'Should not add LEFT JOIN for main table column');
1008
+
1009
+ // ORDER BY doit rester simple
1010
+ assert.ok(sql.sql.includes('ORDER BY professional_activity DESC'), 'Should keep simple ORDER BY for main table column');
1011
+ });
1012
+
1013
+ it('should handle COUNT without JOIN for block ORDER BY', () => {
1014
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
1015
+ query.query.verb = 'count';
1016
+ query
1017
+ .where({ is_initial: true })
1018
+ .order('studies_year DESC') // ORDER BY est ignoré dans COUNT
1019
+ .limit(50);
1020
+
1021
+ const sql = query.toSQL();
1022
+
1023
+ // COUNT ne doit PAS avoir de LEFT JOIN
1024
+ assert.ok(!sql.sql.includes('LEFT JOIN'), 'COUNT should not have LEFT JOIN');
1025
+
1026
+ // COUNT ne doit PAS avoir de ORDER BY
1027
+ assert.ok(!sql.sql.includes('ORDER BY'), 'COUNT should not have ORDER BY');
1028
+ });
1029
+
1030
+ it('should combine block ORDER BY with WHERE filters', () => {
1031
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
1032
+ query.query.verb = 'select_ids';
1033
+ query
1034
+ .where({
1035
+ is_initial: true,
1036
+ professional_activity: 'STUDENT'
1037
+ })
1038
+ .order('studies_year DESC')
1039
+ .limit(50);
1040
+
1041
+ const sql = query.toSQL();
1042
+
1043
+ // Doit avoir WHERE sur la table principale
1044
+ assert.ok(sql.sql.includes('WHERE'), 'Should have WHERE clause');
1045
+ assert.ok(sql.sql.includes('`pme_folders_with_blocks`.`is_initial`'), 'Should filter on is_initial');
1046
+ assert.ok(sql.sql.includes('`pme_folders_with_blocks`.`professional_activity`'), 'Should filter on professional_activity');
1047
+
1048
+ // Doit avoir LEFT JOIN pour le tri
1049
+ assert.ok(sql.sql.includes('LEFT JOIN `block_studies`'), 'Should LEFT JOIN for ORDER BY');
1050
+
1051
+ // Doit avoir ORDER BY
1052
+ assert.ok(sql.sql.includes('ORDER BY block_studies.studies_year DESC'), 'Should have ORDER BY on block column');
1053
+ });
1054
+
1055
+ it('should handle SQL functions with block columns', () => {
1056
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
1057
+ query.query.verb = 'select_ids';
1058
+ query
1059
+ .where({ is_initial: true })
1060
+ .order('COALESCE(`studies_year`, "N/A") DESC')
1061
+ .limit(50);
1062
+
1063
+ const sql = query.toSQL();
1064
+
1065
+ // Doit ajouter LEFT JOIN pour block_studies
1066
+ assert.ok(sql.sql.includes('LEFT JOIN `block_studies`'), 'Should LEFT JOIN block_studies');
1067
+
1068
+ // Doit transformer studies_year en block_studies.studies_year dans la fonction
1069
+ assert.ok(sql.sql.includes('COALESCE'), 'Should preserve COALESCE function');
1070
+ assert.ok(sql.sql.includes('block_studies.studies_year'), 'Should prefix column in function with table name');
1071
+ });
1072
+
1073
+ it('should work with explicit join() on block associations', () => {
1074
+ const query = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
1075
+ query.query.verb = 'select_ids';
1076
+ query
1077
+ .where({ is_initial: true })
1078
+ .join('studies') // Join explicite sur l'association
1079
+ .order('studies_year DESC')
1080
+ .limit(50);
1081
+
1082
+ const sql = query.toSQL();
1083
+
1084
+ // Même comportement : LEFT JOIN ajouté
1085
+ assert.ok(sql.sql.includes('LEFT JOIN `block_studies`'), 'Should LEFT JOIN block_studies');
1086
+ assert.ok(sql.sql.includes('ORDER BY block_studies.studies_year DESC'), 'Should prefix column with table name');
1087
+ });
1088
+
1089
+ it('should transform nested association paths in ORDER BY for phase FULL', () => {
1090
+ // Test pour vérifier que les chemins d'associations imbriqués sont transformés
1091
+ // dans la phase FULL (qui utilise Query standard, pas PaginatedOptimizedSql)
1092
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
1093
+ query.query.verb = 'select_ids';
1094
+ query
1095
+ .where({ type: 'pme' })
1096
+ .join('pme_folder.company')
1097
+ .order('pme_folder.company.name ASC') // Chemin d'association imbriqué
1098
+ .limit(50);
1099
+
1100
+ const sql = query.toSQL();
1101
+
1102
+ // Dans la phase IDS, le ORDER BY doit être transformé
1103
+ assert.ok(sql.sql.includes('ORDER BY companies.name ASC'), 'Should transform nested path to table name in IDS phase');
1104
+ });
1105
+
1106
+ it('should transform nested block paths with dot notation (association.block.column)', () => {
1107
+ // Test pour vérifier que les chemins imbriqués vers des blocks sont transformés
1108
+ // Exemple : pme_folder.company.name doit être transformé en companies.name
1109
+ const query = mockGetDb(new PaginatedOptimizedQuery(Folder));
1110
+ query.query.verb = 'select_ids';
1111
+ query
1112
+ .where({ type: 'pme' })
1113
+ .join('pme_folder.company')
1114
+ .order('pme_folder.company.name ASC')
1115
+ .limit(50);
1116
+
1117
+ const sql = query.toSQL();
1118
+
1119
+ // Le ORDER BY doit être transformé
1120
+ assert.ok(sql.sql.includes('ORDER BY companies.name ASC'),
1121
+ 'Should transform pme_folder.company.name to companies.name in ORDER BY');
1122
+ });
1123
+
1124
+ it('should handle nested path to block with 3 levels (folder.pme_folder.block_study.column)', () => {
1125
+ // Test pour la hiérarchie complète : Folder -> PMEFolder -> StudiesBlock
1126
+ // Ce test correspond au cas réel de l'utilisateur
1127
+ const query = mockGetDb(new PaginatedOptimizedQuery(FolderWithNestedBlocks));
1128
+ query.query.verb = 'select_ids';
1129
+ query
1130
+ .where({ type: ['agp', 'avt'] })
1131
+ .order('pme_folder.block_study.bac_year DESC') // Chemin imbriqué vers un block
1132
+ .limit(50);
1133
+
1134
+ const sql = query.toSQL();
1135
+
1136
+ // Doit ajouter les LEFT JOIN nécessaires
1137
+ assert.ok(sql.sql.includes('LEFT JOIN `pme_folders`'), 'Should LEFT JOIN pme_folders');
1138
+ assert.ok(sql.sql.includes('LEFT JOIN `block_studies`'), 'Should LEFT JOIN block_studies');
1139
+
1140
+ // Doit avoir les bonnes conditions de jointure
1141
+ assert.ok(sql.sql.includes('ON `pme_folders`.`id` = `folders`.`pme_folder_id`'),
1142
+ 'Should have correct join condition for pme_folders');
1143
+ assert.ok(sql.sql.includes('ON `block_studies`.`id` = `pme_folders`.`block_studies_id`'),
1144
+ 'Should have correct join condition for block_studies');
1145
+
1146
+ // Le ORDER BY doit être transformé
1147
+ assert.ok(sql.sql.includes('ORDER BY block_studies.bac_year DESC'),
1148
+ 'Should transform pme_folder.block_study.bac_year to block_studies.bac_year');
1149
+ });
1150
+
1151
+ it('should transform ORDER BY correctly for FULL phase with block columns', () => {
1152
+ // Test pour vérifier que la transformation pour la phase FULL utilise les alias (noms d'associations)
1153
+ // au lieu des noms de tables
1154
+ const PaginatedOptimizedSql = require('@igojs/db').PaginatedOptimizedSql;
1155
+ const query = mockGetDb(new PaginatedOptimizedQuery(FolderWithNestedBlocks));
1156
+
1157
+ query
1158
+ .where({ type: ['agp', 'avt'] })
1159
+ .order('pme_folder.block_study.bac_year') // Chemin imbriqué
1160
+ .order('created_at DESC') // Colonne de la table principale
1161
+ .limit(50);
1162
+
1163
+ const sqlGenerator = new PaginatedOptimizedSql(query);
1164
+
1165
+ // Test de transformation pour phase IDS (noms de tables)
1166
+ const transformedForIDS = sqlGenerator._transformOrderClause('pme_folder.block_study.bac_year');
1167
+ assert.strictEqual(transformedForIDS, 'block_studies.bac_year',
1168
+ 'IDS phase should use table name (block_studies)');
1169
+
1170
+ // Test de transformation pour phase FULL (noms d'associations = alias)
1171
+ const transformedForFULL = sqlGenerator._transformOrderClauseForFullQuery('pme_folder.block_study.bac_year');
1172
+ assert.strictEqual(transformedForFULL, 'block_study.bac_year',
1173
+ 'FULL phase should use association name as alias (block_study)');
1174
+
1175
+ // Test avec colonne simple de block (uniquement pour modèles avec associations directes)
1176
+ const queryWithBlocks = mockGetDb(new PaginatedOptimizedQuery(PMEFolderWithBlocks));
1177
+ const sqlGeneratorWithBlocks = new PaginatedOptimizedSql(queryWithBlocks);
1178
+ const transformedSimpleForFULL = sqlGeneratorWithBlocks._transformOrderClauseForFullQuery('studies_year');
1179
+ assert.strictEqual(transformedSimpleForFULL, 'studies.studies_year',
1180
+ 'FULL phase should find block association for simple column name in direct associations');
1181
+ });
1182
+ });
1183
+ });