• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bpatrik / pigallery2 / 17530592184

07 Sep 2025 03:41PM UTC coverage: 65.073% (+0.07%) from 65.007%
17530592184

push

github

bpatrik
fix tests #1015

1261 of 2199 branches covered (57.34%)

Branch coverage included in aggregate %.

4658 of 6897 relevant lines covered (67.54%)

4419.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.38
/src/backend/model/database/SearchManager.ts
1
/* eslint-disable no-case-declarations */
2
import {AutoCompleteItem} from '../../../common/entities/AutoCompleteItem';
1✔
3
import {SearchResultDTO} from '../../../common/entities/SearchResultDTO';
4
import {SQLConnection} from './SQLConnection';
1✔
5
import {PhotoEntity} from './enitites/PhotoEntity';
1✔
6
import {DirectoryEntity} from './enitites/DirectoryEntity';
1✔
7
import {MediaEntity} from './enitites/MediaEntity';
1✔
8
import {PersonEntry} from './enitites/PersonEntry';
1✔
9
import {Brackets, SelectQueryBuilder, WhereExpression} from 'typeorm';
1✔
10
import {Config} from '../../../common/config/private/Config';
1✔
11
import {
1✔
12
  ANDSearchQuery,
13
  DatePatternFrequency,
14
  DatePatternSearch,
15
  DistanceSearch,
16
  FromDateSearch,
17
  MaxPersonCountSearch,
18
  MaxRatingSearch,
19
  MaxResolutionSearch,
20
  MinPersonCountSearch,
21
  MinRatingSearch,
22
  MinResolutionSearch,
23
  OrientationSearch,
24
  ORSearchQuery,
25
  SearchListQuery,
26
  SearchQueryDTO,
27
  SearchQueryTypes,
28
  SomeOfSearchQuery,
29
  TextSearch,
30
  TextSearchQueryMatchTypes,
31
  ToDateSearch,
32
} from '../../../common/entities/SearchQueryDTO';
33
import {GalleryManager} from './GalleryManager';
1✔
34
import {ObjectManagers} from '../ObjectManagers';
1✔
35
import {DatabaseType} from '../../../common/config/private/PrivateConfig';
1✔
36
import {Utils} from '../../../common/Utils';
1✔
37
import {FileEntity} from './enitites/FileEntity';
1✔
38
import {SQL_COLLATE} from './enitites/EntityUtils';
1✔
39
import {GroupSortByTypes, SortByTypes, SortingMethod} from '../../../common/entities/SortingMethods';
1✔
40
import {SessionContext} from '../SessionContext';
41

42
export class SearchManager {
1✔
43
  private DIRECTORY_SELECT = [
259✔
44
    'directory.id',
45
    'directory.name',
46
    'directory.path',
47
  ];
48
  // makes all search query params unique, so typeorm won't mix them
49
  private queryIdBase = 0;
259✔
50

51
  private static autoCompleteItemsUnique(
52
    array: Array<AutoCompleteItem>
53
  ): Array<AutoCompleteItem> {
54
    const a = array.concat();
38✔
55
    for (let i = 0; i < a.length; ++i) {
38✔
56
      for (let j = i + 1; j < a.length; ++j) {
68✔
57
        if (a[i].equals(a[j])) {
96✔
58
          a.splice(j--, 1);
19✔
59
        }
60
      }
61
    }
62

63
    return a;
38✔
64
  }
65

66
  async autocomplete(
67
    session: SessionContext,
68
    text: string,
69
    type: SearchQueryTypes
70
  ): Promise<AutoCompleteItem[]> {
71
    const connection = await SQLConnection.getConnection();
38✔
72

73
    const photoRepository = connection.getRepository(PhotoEntity);
38✔
74
    const mediaRepository = connection.getRepository(MediaEntity);
38✔
75
    const personRepository = connection.getRepository(PersonEntry);
38✔
76
    const directoryRepository = connection.getRepository(DirectoryEntity);
38✔
77

78
    const partialResult: AutoCompleteItem[][] = [];
38✔
79

80
    if (
38✔
81
      type === SearchQueryTypes.any_text ||
52✔
82
      type === SearchQueryTypes.keyword
83
    ) {
84
      const acList: AutoCompleteItem[] = [];
28✔
85
      const q = photoRepository
28✔
86
        .createQueryBuilder('media')
87
        .select('DISTINCT(media.metadata.keywords)')
88
        .where('media.metadata.keywords LIKE :textKW COLLATE ' + SQL_COLLATE, {
89
          textKW: '%' + text + '%',
90
        });
91

92
      if (session.projectionQuery) {
28✔
93
        if (session.hasDirectoryProjection) {
6✔
94
          q.leftJoin('media.directory', 'directory');
2✔
95
        }
96
        q.andWhere(session.projectionQuery);
6✔
97
      }
98

99
      q.limit(Config.Search.AutoComplete.ItemsPerCategory.keyword);
28✔
100
      (await q.getRawMany())
28✔
101
        .map(
102
          (r): Array<string> =>
103
            (r.metadataKeywords as string).split(',') as Array<string>
42✔
104
        )
105
        .forEach((keywords): void => {
106
          acList.push(
42✔
107
            ...this.encapsulateAutoComplete(
108
              keywords.filter(
109
                (k): boolean =>
110
                  k.toLowerCase().indexOf(text.toLowerCase()) !== -1
150✔
111
              ),
112
              SearchQueryTypes.keyword
113
            )
114
          );
115
        });
116
      partialResult.push(acList);
28✔
117
    }
118

119
    if (
38✔
120
      type === SearchQueryTypes.any_text ||
52✔
121
      type === SearchQueryTypes.person
122
    ) {
123
      partialResult.push(
24✔
124
        this.encapsulateAutoComplete(
125
          (
126
            await personRepository
127
              .createQueryBuilder('person')
128
              .select('DISTINCT(person.name), person.count')
129
              .where('person.name LIKE :text COLLATE ' + SQL_COLLATE, {
130
                text: '%' + text + '%',
131
              })
132
              .limit(
133
                Config.Search.AutoComplete.ItemsPerCategory.person
134
              )
135
              .orderBy('person.count', 'DESC')
136
              .getRawMany()
137
          ).map((r) => r.name),
14✔
138
          SearchQueryTypes.person
139
        )
140
      );
141
    }
142

143
    if (
38✔
144
      type === SearchQueryTypes.any_text ||
66✔
145
      type === SearchQueryTypes.position ||
146
      type === SearchQueryTypes.distance
147
    ) {
148
      const acList: AutoCompleteItem[] = [];
24✔
149
      const q = photoRepository
24✔
150
        .createQueryBuilder('media')
151
        .select(
152
          'media.metadata.positionData.country as country, ' +
153
          'media.metadata.positionData.state as state, media.metadata.positionData.city as city'
154
        );
155
      const b = new Brackets((q) => {
24✔
156
        q.where(
24✔
157
          'media.metadata.positionData.country LIKE :text COLLATE ' +
158
          SQL_COLLATE,
159
          {text: '%' + text + '%'}
160
        ).orWhere(
161
          'media.metadata.positionData.state LIKE :text COLLATE ' +
162
          SQL_COLLATE,
163
          {text: '%' + text + '%'}
164
        ).orWhere(
165
          'media.metadata.positionData.city LIKE :text COLLATE ' +
166
          SQL_COLLATE,
167
          {text: '%' + text + '%'}
168
        );
169
      });
170
      q.where(b);
24✔
171

172
      if (session.projectionQuery) {
24✔
173
        if (session.hasDirectoryProjection) {
4!
174
          q.leftJoin('media.directory', 'directory');
×
175
        }
176
        q.andWhere(session.projectionQuery);
4✔
177
      }
178

179
      q.groupBy(
24✔
180
        'media.metadata.positionData.country, media.metadata.positionData.state, media.metadata.positionData.city'
181
      )
182
        .limit(Config.Search.AutoComplete.ItemsPerCategory.position);
183
      (
24✔
184

185
        await q.getRawMany()
186
      )
187
        .filter((pm): boolean => !!pm)
10✔
188
        .map(
189
          (pm): Array<string> =>
190
            [pm.city || '', pm.country || '', pm.state || ''] as Array<string>
10!
191
        )
192
        .forEach((positions): void => {
193
          acList.push(
10✔
194
            ...this.encapsulateAutoComplete(
195
              positions.filter(
196
                (p): boolean =>
197
                  p.toLowerCase().indexOf(text.toLowerCase()) !== -1
30✔
198
              ),
199
              type === SearchQueryTypes.distance
10!
200
                ? type
201
                : SearchQueryTypes.position
202
            )
203
          );
204
        });
205
      partialResult.push(acList);
24✔
206
    }
207

208
    if (
38✔
209
      type === SearchQueryTypes.any_text ||
52✔
210
      type === SearchQueryTypes.file_name
211
    ) {
212
      const q = mediaRepository
28✔
213
        .createQueryBuilder('media')
214
        .select('DISTINCT(media.name)')
215
        .where('media.name LIKE :text COLLATE ' + SQL_COLLATE, {
216
          text: '%' + text + '%',
217
        });
218

219

220
      if (session.projectionQuery) {
28✔
221
        if (session.hasDirectoryProjection) {
6!
222
          q.leftJoin('media.directory', 'directory');
×
223
        }
224
        q.andWhere(session.projectionQuery);
6✔
225
      }
226
      q.limit(
28✔
227
        Config.Search.AutoComplete.ItemsPerCategory.fileName
228
      );
229
      partialResult.push(
28✔
230
        this.encapsulateAutoComplete(
231
          (
232
            await q.getRawMany()
233
          ).map((r) => r.name),
24✔
234
          SearchQueryTypes.file_name
235
        )
236
      );
237
    }
238

239
    if (
38✔
240
      type === SearchQueryTypes.any_text ||
52✔
241
      type === SearchQueryTypes.caption
242
    ) {
243
      const q = photoRepository
24✔
244
        .createQueryBuilder('media')
245
        .select('DISTINCT(media.metadata.caption) as caption')
246
        .where(
247
          'media.metadata.caption LIKE :text COLLATE ' + SQL_COLLATE,
248
          {text: '%' + text + '%'}
249
        );
250

251
      if (session.projectionQuery) {
24✔
252
        if (session.hasDirectoryProjection) {
4!
253
          q.leftJoin('media.directory', 'directory');
×
254
        }
255
        q.andWhere(session.projectionQuery);
4✔
256
      }
257
      q.limit(
24✔
258
        Config.Search.AutoComplete.ItemsPerCategory.caption
259
      );
260
      partialResult.push(
24✔
261
        this.encapsulateAutoComplete(
262
          (
263
            await q.getRawMany()
264
          ).map((r) => r.caption),
6✔
265
          SearchQueryTypes.caption
266
        )
267
      );
268
    }
269

270
    if (
38✔
271
      type === SearchQueryTypes.any_text ||
52✔
272
      type === SearchQueryTypes.directory
273
    ) {
274
      const dirs = await directoryRepository
30✔
275
        .createQueryBuilder('directory')
276
        .leftJoinAndSelect('directory.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: session.user.projectionKey})
277
        .where('directory.name LIKE :text COLLATE ' + SQL_COLLATE, {
278
          text: '%' + text + '%',
279
        })
280
        .andWhere('(cache.recursiveMediaCount > 0 OR cache.id is NULL)')
281
        .limit(
282
          Config.Search.AutoComplete.ItemsPerCategory.directory
283
        )
284
        .getMany();
285
      // fill cache as we need it for this autocomplete search
286
      for (const dir of dirs) {
30✔
287
        if (!dir.cache?.valid) {
12✔
288
          dir.cache = await ObjectManagers.getInstance().ProjectedCacheManager.setAndGetCacheForDirectory(connection, session, dir);
12✔
289
        }
290
      }
291
      partialResult.push(
30✔
292
        this.encapsulateAutoComplete(
293
          dirs.filter(d => d.cache.valid && d.cache.recursiveMediaCount > 0).map((r) => r.name),
12✔
294
          SearchQueryTypes.directory
295
        )
296
      );
297
    }
298

299
    const result: AutoCompleteItem[] = [];
38✔
300

301
    while (result.length < Config.Search.AutoComplete.ItemsPerCategory.maxItems) {
38✔
302
      let adding = false;
102✔
303
      for (let i = 0; i < partialResult.length; ++i) {
102✔
304
        if (partialResult[i].length <= 0) {
462✔
305
          continue;
375✔
306
        }
307
        result.push(partialResult[i].pop());
87✔
308
        adding = true;
87✔
309
      }
310
      if (!adding) {
102✔
311
        break;
31✔
312
      }
313
    }
314

315

316
    return SearchManager.autoCompleteItemsUnique(result);
38✔
317
  }
318

319
  async search(session: SessionContext, queryIN: SearchQueryDTO): Promise<SearchResultDTO> {
320
    const query = await this.prepareQuery(queryIN);
180✔
321
    const connection = await SQLConnection.getConnection();
180✔
322

323
    const result: SearchResultDTO = {
180✔
324
      searchQuery: queryIN,
325
      directories: [],
326
      media: [],
327
      metaFile: [],
328
      resultOverflow: false,
329
    };
330

331
    const q = connection
180✔
332
      .getRepository(MediaEntity)
333
      .createQueryBuilder('media')
334
      .select(['media', ...this.DIRECTORY_SELECT])
335
      .where(this.buildWhereQuery(query))
336
      .leftJoin('media.directory', 'directory')
337
      .limit(Config.Search.maxMediaResult + 1);
338

339
    if (session.projectionQuery) {
180✔
340
      q.andWhere(session.projectionQuery);
6✔
341
    }
342

343
    result.media = await q.getMany();
180✔
344

345
    if (result.media.length > Config.Search.maxMediaResult) {
180!
346
      result.resultOverflow = true;
×
347
    }
348

349

350
    if (Config.Search.listMetafiles === true) {
180✔
351
      const dIds = Array.from(new Set(result.media.map(m => (m.directory as unknown as { id: number }).id)));
6✔
352
      result.metaFile = await connection
2✔
353
        .getRepository(FileEntity)
354
        .createQueryBuilder('file')
355
        .select(['file', ...this.DIRECTORY_SELECT])
356
        .where(`file.directoryId IN(${dIds})`)
357
        .leftJoin('file.directory', 'directory')
358
        .getMany();
359
    }
360

361
    if (Config.Search.listDirectories === true) {
180✔
362
      const dirQuery = this.filterDirectoryQuery(query);
6✔
363
      if (dirQuery !== null) {
6✔
364
        result.directories = await connection
6✔
365
          .getRepository(DirectoryEntity)
366
          .createQueryBuilder('directory')
367
          .where(this.buildWhereQuery(dirQuery, true))
368
          .leftJoin('directory.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: session.user.projectionKey})
369
          .leftJoin('cache.cover', 'cover')
370
          .leftJoin('cover.directory', 'coverDirectory')
371
          .limit(Config.Search.maxDirectoryResult + 1)
372
          .select([
373
            'directory',
374
            'cache.oldestMedia',
375
            'cache.youngestMedia',
376
            'cache.mediaCount',
377
            'cache.recursiveMediaCount',
378
            'cover.name',
379
            'coverDirectory.name',
380
            'coverDirectory.path',
381
          ])
382
          .getMany();
383

384
        // setting covers
385
        if (result.directories) {
6✔
386
          for (const item of result.directories) {
6✔
387
            await ObjectManagers.getInstance().GalleryManager.fillCacheForSubDir(connection, session, item as DirectoryEntity);
10✔
388
          }
389
        }
390
        // do not show empty directories in search results
391
        result.directories = result.directories.filter(d => d.cache.recursiveMediaCount > 0);
10✔
392
        if (
6!
393
          result.directories.length > Config.Search.maxDirectoryResult
394
        ) {
395
          result.resultOverflow = true;
×
396
        }
397
      }
398
    }
399

400
    return result;
180✔
401
  }
402

403

404
  public static setSorting<T>(
405
    query: SelectQueryBuilder<T>,
406
    sortings: SortingMethod[]
407
  ): SelectQueryBuilder<T> {
408
    if (!sortings || !Array.isArray(sortings)) {
379!
409
      return query;
×
410
    }
411
    if (sortings.findIndex(s => s.method == SortByTypes.Random) !== -1 && sortings.length > 1) {
693!
412
      throw new Error('Error during applying sorting: Can\' randomize and also sort the result. Bad input:' + sortings.map(s => GroupSortByTypes[s.method]).join(', '));
×
413
    }
414
    for (const sort of sortings) {
379✔
415
      switch (sort.method) {
693!
416
        case SortByTypes.Date:
417
          if (Config.Gallery.ignoreTimestampOffset === true) {
267✔
418
            query.addOrderBy('media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)', sort.ascending ? 'ASC' : 'DESC');
49!
419
          } else {
420
            query.addOrderBy('media.metadata.creationDate', sort.ascending ? 'ASC' : 'DESC');
218!
421
          }
422
          break;
267✔
423
        case SortByTypes.Rating:
424
          query.addOrderBy('media.metadata.rating', sort.ascending ? 'ASC' : 'DESC');
359!
425
          break;
359✔
426
        case SortByTypes.Name:
427
          query.addOrderBy('media.name', sort.ascending ? 'ASC' : 'DESC');
12!
428
          break;
12✔
429
        case SortByTypes.PersonCount:
430
          query.addOrderBy('media.metadata.personsLength', sort.ascending ? 'ASC' : 'DESC');
49!
431
          break;
49✔
432
        case SortByTypes.FileSize:
433
          query.addOrderBy('media.metadata.fileSize', sort.ascending ? 'ASC' : 'DESC');
×
434
          break;
×
435
        case SortByTypes.Random:
436
          if (Config.Database.type === DatabaseType.mysql) {
6✔
437
            query.groupBy('RAND(), media.id');
3✔
438
          } else {
439
            query.groupBy('RANDOM()');
3✔
440
          }
441
          break;
6✔
442
      }
443
    }
444

445
    return query;
379✔
446
  }
447

448
  public async getNMedia(session: SessionContext, query: SearchQueryDTO, sortings: SortingMethod[], take: number, photoOnly = false) {
×
449
    const connection = await SQLConnection.getConnection();
6✔
450
    const sqlQuery: SelectQueryBuilder<PhotoEntity> = connection
6✔
451
      .getRepository(photoOnly ? PhotoEntity : MediaEntity)
6!
452
      .createQueryBuilder('media')
453
      .select(['media', ...this.DIRECTORY_SELECT])
454
      .innerJoin('media.directory', 'directory')
455
      .where(await this.prepareAndBuildWhereQuery(query));
456

457
    if (session.projectionQuery) {
6✔
458
      sqlQuery.andWhere(session.projectionQuery);
2✔
459
    }
460
    SearchManager.setSorting(sqlQuery, sortings);
6✔
461

462
    return sqlQuery.limit(take).getMany();
6✔
463

464
  }
465

466
  public async getCount(session: SessionContext, query: SearchQueryDTO): Promise<number> {
467
    const connection = await SQLConnection.getConnection();
26✔
468

469
    const q = connection
26✔
470
      .getRepository(MediaEntity)
471
      .createQueryBuilder('media')
472
      .innerJoin('media.directory', 'directory')
473
      .where(await this.prepareAndBuildWhereQuery(query));
474
    if (session.projectionQuery) {
26✔
475
      q.andWhere(session.projectionQuery);
2✔
476
    }
477
    return await q.getCount();
26✔
478
  }
479

480
  public async prepareAndBuildWhereQuery(
481
    queryIN: SearchQueryDTO, directoryOnly = false,
173✔
482
    aliases: { [key: string]: string } = {}): Promise<Brackets> {
173✔
483
    let query = await this.prepareQuery(queryIN);
188✔
484
    if (directoryOnly) {
188✔
485
      query = this.filterDirectoryQuery(query);
15✔
486
    }
487
    return this.buildWhereQuery(query, directoryOnly, aliases);
188✔
488
  }
489

490
  public async prepareQuery(queryIN: SearchQueryDTO): Promise<SearchQueryDTO> {
491
    let query: SearchQueryDTO = this.assignQueryIDs(Utils.clone(queryIN)); // assign local ids before flattening SOME_OF queries
368✔
492
    query = this.flattenSameOfQueries(query);
368✔
493
    query = await this.getGPSData(query);
368✔
494
    return query;
368✔
495
  }
496

497
  /**
498
   * Builds the SQL Where query from search query
499
   * @param query input search query
500
   * @param directoryOnly Only builds directory related queries
501
   * @param aliases for SQL alias mapping
502
   * @private
503
   */
504
  public buildWhereQuery(
505
    query: SearchQueryDTO,
506
    directoryOnly = false,
180✔
507
    aliases: { [key: string]: string } = {}
74,272✔
508
  ): Brackets {
509
    const queryId = (query as SearchQueryDTOWithID).queryId;
74,460✔
510
    switch (query.type) {
74,460!
511
      case SearchQueryTypes.AND:
512
        return new Brackets((q): unknown => {
11,648✔
513
          (query as ANDSearchQuery).list.forEach((sq) => {
11,648✔
514
              q.andWhere(this.buildWhereQuery(sq, directoryOnly));
23,642✔
515
            }
516
          );
517
          return q;
11,648✔
518
        });
519
      case SearchQueryTypes.OR:
520
        return new Brackets((q): unknown => {
9,718✔
521
          (query as ANDSearchQuery).list.forEach((sq) => {
9,718✔
522
              q.orWhere(this.buildWhereQuery(sq, directoryOnly));
50,444✔
523
            }
524
          );
525
          return q;
9,718✔
526
        });
527

528
      case SearchQueryTypes.distance:
529
        if (directoryOnly) {
10!
530
          throw new Error('not supported in directoryOnly mode');
×
531
        }
532
        /**
533
         * This is a best effort calculation, not fully accurate in order to have higher performance.
534
         * see: https://stackoverflow.com/a/50506609
535
         */
536
        const earth = 6378.137; // radius of the earth in kilometer
10✔
537
        const latDelta = 1 / (((2 * Math.PI) / 360) * earth); // 1 km in degree
10✔
538
        const lonDelta = 1 / (((2 * Math.PI) / 360) * earth); // 1 km in degree
10✔
539

540
        // TODO: properly handle latitude / longitude boundaries
541
        const trimRange = (value: number, min: number, max: number): number => {
10✔
542
          return Math.min(Math.max(value, min), max);
40✔
543
        };
544

545
        const minLat = trimRange(
10✔
546
          (query as DistanceSearch).from.GPSData.latitude -
547
          (query as DistanceSearch).distance * latDelta,
548
          -90,
549
          90
550
        );
551
        const maxLat = trimRange(
10✔
552
          (query as DistanceSearch).from.GPSData.latitude +
553
          (query as DistanceSearch).distance * latDelta,
554
          -90,
555
          90
556
        );
557
        const minLon = trimRange(
10✔
558
          (query as DistanceSearch).from.GPSData.longitude -
559
          ((query as DistanceSearch).distance * lonDelta) /
560
          Math.cos(minLat * (Math.PI / 180)),
561
          -180,
562
          180
563
        );
564
        const maxLon = trimRange(
10✔
565
          (query as DistanceSearch).from.GPSData.longitude +
566
          ((query as DistanceSearch).distance * lonDelta) /
567
          Math.cos(maxLat * (Math.PI / 180)),
568
          -180,
569
          180
570
        );
571

572
        return new Brackets((q): unknown => {
10✔
573
          const textParam: { [key: string]: number | string } = {};
10✔
574
          textParam['maxLat' + queryId] = maxLat;
10✔
575
          textParam['minLat' + queryId] = minLat;
10✔
576
          textParam['maxLon' + queryId] = maxLon;
10✔
577
          textParam['minLon' + queryId] = minLon;
10✔
578
          if (!(query as DistanceSearch).negate) {
10✔
579
            q.where(
8✔
580
              `media.metadata.positionData.GPSData.latitude < :maxLat${queryId}`,
581
              textParam
582
            );
583
            q.andWhere(
8✔
584
              `media.metadata.positionData.GPSData.latitude > :minLat${queryId}`,
585
              textParam
586
            );
587
            q.andWhere(
8✔
588
              `media.metadata.positionData.GPSData.longitude < :maxLon${queryId}`,
589
              textParam
590
            );
591
            q.andWhere(
8✔
592
              `media.metadata.positionData.GPSData.longitude > :minLon${queryId}`,
593
              textParam
594
            );
595
          } else {
596
            q.where(
2✔
597
              `media.metadata.positionData.GPSData.latitude > :maxLat${queryId}`,
598
              textParam
599
            );
600
            q.orWhere(
2✔
601
              `media.metadata.positionData.GPSData.latitude < :minLat${queryId}`,
602
              textParam
603
            );
604
            q.orWhere(
2✔
605
              `media.metadata.positionData.GPSData.longitude > :maxLon${queryId}`,
606
              textParam
607
            );
608
            q.orWhere(
2✔
609
              `media.metadata.positionData.GPSData.longitude < :minLon${queryId}`,
610
              textParam
611
            );
612
          }
613
          return q;
10✔
614
        });
615

616
      case SearchQueryTypes.from_date:
617
        if (directoryOnly) {
4!
618
          throw new Error('not supported in directoryOnly mode');
×
619
        }
620
        return new Brackets((q): unknown => {
4✔
621
          if (typeof (query as FromDateSearch).value === 'undefined') {
4!
622
            throw new Error(
×
623
              'Invalid search query: Date Query should contain from value'
624
            );
625
          }
626
          const relation = (query as TextSearch).negate ? '<' : '>=';
4✔
627

628
          const textParam: { [key: string]: unknown } = {};
4✔
629
          textParam['from' + queryId] = (query as FromDateSearch).value;
4✔
630
          if (Config.Gallery.ignoreTimestampOffset === true) {
4!
631
            q.where(
×
632
              `(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) ${relation} :from${queryId}`,
633
              textParam
634
            );
635
          } else {
636
            q.where(
4✔
637
              `media.metadata.creationDate ${relation} :from${queryId}`,
638
              textParam
639
            );
640
          }
641

642
          return q;
4✔
643
        });
644

645
      case SearchQueryTypes.to_date:
646
        if (directoryOnly) {
4!
647
          throw new Error('not supported in directoryOnly mode');
×
648
        }
649
        return new Brackets((q): unknown => {
4✔
650
          if (typeof (query as ToDateSearch).value === 'undefined') {
4!
651
            throw new Error(
×
652
              'Invalid search query: Date Query should contain to value'
653
            );
654
          }
655
          const relation = (query as TextSearch).negate ? '>' : '<=';
4!
656

657
          const textParam: { [key: string]: unknown } = {};
4✔
658
          textParam['to' + queryId] = (query as ToDateSearch).value;
4✔
659
          if (Config.Gallery.ignoreTimestampOffset === true) {
4!
660
            q.where(
×
661
              `(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) ${relation} :to${queryId}`,
662
              textParam
663
            );
664
          } else {
665
            q.where(
4✔
666
              `media.metadata.creationDate ${relation} :to${queryId}`,
667
              textParam
668
            );
669

670
          }
671

672
          return q;
4✔
673
        });
674

675
      case SearchQueryTypes.min_rating:
676
        if (directoryOnly) {
4!
677
          throw new Error('not supported in directoryOnly mode');
×
678
        }
679
        return new Brackets((q): unknown => {
4✔
680
          if (typeof (query as MinRatingSearch).value === 'undefined') {
4!
681
            throw new Error(
×
682
              'Invalid search query: Rating Query should contain minvalue'
683
            );
684
          }
685

686
          const relation = (query as TextSearch).negate ? '<' : '>=';
4✔
687

688
          const textParam: { [key: string]: unknown } = {};
4✔
689
          textParam['min' + queryId] = (query as MinRatingSearch).value;
4✔
690
          q.where(
4✔
691
            `media.metadata.rating ${relation}  :min${queryId}`,
692
            textParam
693
          );
694

695
          return q;
4✔
696
        });
697
      case SearchQueryTypes.max_rating:
698
        if (directoryOnly) {
6!
699
          throw new Error('not supported in directoryOnly mode');
×
700
        }
701
        return new Brackets((q): unknown => {
6✔
702
          if (typeof (query as MaxRatingSearch).value === 'undefined') {
6!
703
            throw new Error(
×
704
              'Invalid search query: Rating Query should contain  max value'
705
            );
706
          }
707

708
          const relation = (query as TextSearch).negate ? '>' : '<=';
6✔
709

710
          if (typeof (query as MaxRatingSearch).value !== 'undefined') {
6✔
711
            const textParam: { [key: string]: unknown } = {};
6✔
712
            textParam['max' + queryId] = (query as MaxRatingSearch).value;
6✔
713
            q.where(
6✔
714
              `media.metadata.rating ${relation}  :max${queryId}`,
715
              textParam
716
            );
717
          }
718
          return q;
6✔
719
        });
720

721
      case SearchQueryTypes.min_person_count:
722
        if (directoryOnly) {
6!
723
          throw new Error('not supported in directoryOnly mode');
×
724
        }
725
        return new Brackets((q): unknown => {
6✔
726
          if (typeof (query as MinPersonCountSearch).value === 'undefined') {
6!
727
            throw new Error(
×
728
              'Invalid search query: Person count Query should contain minvalue'
729
            );
730
          }
731

732
          const relation = (query as TextSearch).negate ? '<' : '>=';
6✔
733

734
          const textParam: { [key: string]: unknown } = {};
6✔
735
          textParam['min' + queryId] = (query as MinPersonCountSearch).value;
6✔
736
          q.where(
6✔
737
            `media.metadata.personsLength ${relation}  :min${queryId}`,
738
            textParam
739
          );
740

741
          return q;
6✔
742
        });
743
      case SearchQueryTypes.max_person_count:
744
        if (directoryOnly) {
8!
745
          throw new Error('not supported in directoryOnly mode');
×
746
        }
747
        return new Brackets((q): unknown => {
8✔
748
          if (typeof (query as MaxPersonCountSearch).value === 'undefined') {
8!
749
            throw new Error(
×
750
              'Invalid search query: Person count Query should contain max value'
751
            );
752
          }
753

754
          const relation = (query as TextSearch).negate ? '>' : '<=';
8✔
755

756
          if (typeof (query as MaxRatingSearch).value !== 'undefined') {
8✔
757
            const textParam: { [key: string]: unknown } = {};
8✔
758
            textParam['max' + queryId] = (query as MaxPersonCountSearch).value;
8✔
759
            q.where(
8✔
760
              `media.metadata.personsLength ${relation}  :max${queryId}`,
761
              textParam
762
            );
763
          }
764
          return q;
8✔
765
        });
766

767
      case SearchQueryTypes.min_resolution:
768
        if (directoryOnly) {
4!
769
          throw new Error('not supported in directoryOnly mode');
×
770
        }
771
        return new Brackets((q): unknown => {
4✔
772
          if (typeof (query as MinResolutionSearch).value === 'undefined') {
4!
773
            throw new Error(
×
774
              'Invalid search query: Resolution Query should contain min value'
775
            );
776
          }
777

778
          const relation = (query as TextSearch).negate ? '<' : '>=';
4✔
779

780
          const textParam: { [key: string]: unknown } = {};
4✔
781
          textParam['min' + queryId] =
4✔
782
            (query as MinResolutionSearch).value * 1000 * 1000;
783
          q.where(
4✔
784
            `media.metadata.size.width * media.metadata.size.height ${relation} :min${queryId}`,
785
            textParam
786
          );
787

788
          return q;
4✔
789
        });
790

791
      case SearchQueryTypes.max_resolution:
792
        if (directoryOnly) {
6!
793
          throw new Error('not supported in directoryOnly mode');
×
794
        }
795
        return new Brackets((q): unknown => {
6✔
796
          if (typeof (query as MaxResolutionSearch).value === 'undefined') {
6!
797
            throw new Error(
×
798
              'Invalid search query: Rating Query should contain min or max value'
799
            );
800
          }
801

802
          const relation = (query as TextSearch).negate ? '>' : '<=';
6✔
803

804
          const textParam: { [key: string]: unknown } = {};
6✔
805
          textParam['max' + queryId] =
6✔
806
            (query as MaxResolutionSearch).value * 1000 * 1000;
807
          q.where(
6✔
808
            `media.metadata.size.width * media.metadata.size.height ${relation} :max${queryId}`,
809
            textParam
810
          );
811

812
          return q;
6✔
813
        });
814

815
      case SearchQueryTypes.orientation:
816
        if (directoryOnly) {
4!
817
          throw new Error('not supported in directoryOnly mode');
×
818
        }
819
        return new Brackets((q): unknown => {
4✔
820
          if ((query as OrientationSearch).landscape) {
4✔
821
            q.where('media.metadata.size.width >= media.metadata.size.height');
2✔
822
          } else {
823
            q.where('media.metadata.size.width <= media.metadata.size.height');
2✔
824
          }
825
          return q;
4✔
826
        });
827

828
      case SearchQueryTypes.date_pattern: {
829
        if (directoryOnly) {
34!
830
          throw new Error('not supported in directoryOnly mode');
×
831
        }
832
        const tq = query as DatePatternSearch;
34✔
833

834
        return new Brackets((q): unknown => {
34✔
835
          // Fixed frequency
836
          if ((tq.frequency === DatePatternFrequency.years_ago ||
34✔
837
            tq.frequency === DatePatternFrequency.months_ago ||
838
            tq.frequency === DatePatternFrequency.weeks_ago ||
839
            tq.frequency === DatePatternFrequency.days_ago)) {
840

841
            if (isNaN(tq.agoNumber)) {
12!
842
              throw new Error('ago number is missing on date pattern search query with frequency: ' + DatePatternFrequency[tq.frequency] + ', ago number: ' + tq.agoNumber);
×
843
            }
844
            const to = new Date();
12✔
845
            to.setHours(0, 0, 0, 0);
12✔
846
            to.setUTCDate(to.getUTCDate() + 1);
12✔
847

848
            switch (tq.frequency) {
12!
849
              case DatePatternFrequency.days_ago:
850
                to.setUTCDate(to.getUTCDate() - tq.agoNumber);
4✔
851
                break;
4✔
852
              case DatePatternFrequency.weeks_ago:
853
                to.setUTCDate(to.getUTCDate() - tq.agoNumber * 7);
×
854
                break;
×
855

856
              case DatePatternFrequency.months_ago:
857
                to.setTime(Utils.addMonthToDate(to, -1 * tq.agoNumber).getTime());
8✔
858
                break;
8✔
859

860
              case DatePatternFrequency.years_ago:
861
                to.setUTCFullYear(to.getUTCFullYear() - tq.agoNumber);
×
862
                break;
×
863
            }
864
            const from = new Date(to);
12✔
865
            from.setUTCDate(from.getUTCDate() - tq.daysLength);
12✔
866

867
            const textParam: { [key: string]: unknown } = {};
12✔
868
            textParam['to' + queryId] = to.getTime();
12✔
869
            textParam['from' + queryId] = from.getTime();
12✔
870
            if (tq.negate) {
12✔
871
              if (Config.Gallery.ignoreTimestampOffset === true) {
6!
872
                q.where(
×
873
                  `(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) >= :to${queryId}`,
874
                  textParam
875
                ).orWhere(`(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) < :from${queryId}`,
876
                  textParam);
877
              } else {
878
                q.where(
6✔
879
                  `media.metadata.creationDate >= :to${queryId}`,
880
                  textParam
881
                ).orWhere(`media.metadata.creationDate < :from${queryId}`,
882
                  textParam);
883

884
              }
885
            } else {
886
              if (Config.Gallery.ignoreTimestampOffset === true) {
6!
887
                q.where(
×
888
                  `(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) < :to${queryId}`,
889
                  textParam
890
                ).andWhere(`(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) >= :from${queryId}`,
891
                  textParam);
892
              } else {
893
                q.where(
6✔
894
                  `media.metadata.creationDate < :to${queryId}`,
895
                  textParam
896
                ).andWhere(`media.metadata.creationDate >= :from${queryId}`,
897
                  textParam);
898
              }
899
            }
900

901
          } else {
902
            // recurring
903

904
            const textParam: { [key: string]: unknown } = {};
22✔
905
            textParam['diff' + queryId] = tq.daysLength;
22✔
906
            const addWhere = (duration: string, crossesDateBoundary: boolean) => {
22✔
907

908
              const relationEql = tq.negate ? '!=' : '=';
16✔
909

910
              // reminder: !(a&&b) = (!a || !b), that is what happening here if negate is true
911
              const relationTop = tq.negate ? '>' : '<=';
16✔
912
              const relationBottom = tq.negate ? '<=' : '>';
16✔
913
              // this is an XoR. during date boundary crossing we swap boolean logic again
914
              const whereFN = !!tq.negate !== crossesDateBoundary ? 'orWhere' : 'andWhere';
16✔
915

916

917
              if (Config.Database.type === DatabaseType.sqlite) {
16✔
918
                if (tq.daysLength == 0) {
8✔
919
                  if (Config.Gallery.ignoreTimestampOffset === true) {
2!
920
                    q.where(
×
921
                      `CAST(strftime('${duration}',(media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, 'unixepoch') AS INTEGER) ${relationEql} CAST(strftime('${duration}','now') AS INTEGER)`
922
                    );
923
                  } else {
924
                    q.where(
2✔
925
                      `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationEql} CAST(strftime('${duration}','now') AS INTEGER)`
926
                    );
927
                  }
928
                } else {
929
                  if (Config.Gallery.ignoreTimestampOffset === true) {
6!
930
                    q.where(
×
931
                      `CAST(strftime('${duration}',(media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, 'unixepoch') AS INTEGER) ${relationTop} CAST(strftime('${duration}','now') AS INTEGER)`
932
                    )[whereFN](`CAST(strftime('${duration}',(media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, 'unixepoch') AS INTEGER) ${relationBottom} CAST(strftime('${duration}','now','-:diff${queryId} day') AS INTEGER)`,
933
                      textParam);
934
                  } else {
935
                    q.where(
6✔
936
                      `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationTop} CAST(strftime('${duration}','now') AS INTEGER)`
937
                    )[whereFN](`CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationBottom} CAST(strftime('${duration}','now','-:diff${queryId} day') AS INTEGER)`,
938
                      textParam);
939
                  }
940
                }
941
              } else {
942
                if (tq.daysLength == 0) {
8✔
943
                  if (Config.Gallery.ignoreTimestampOffset === true) {
2!
944
                    q.where(
×
945
                      `CAST(FROM_UNIXTIME((media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, '${duration}') AS SIGNED) ${relationEql} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
946
                    );
947
                  } else {
948
                    q.where(
2✔
949
                      `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationEql} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
950
                    );
951
                  }
952
                } else {
953
                  if (Config.Gallery.ignoreTimestampOffset === true) {
6!
954
                    q.where(
×
955
                      `CAST(FROM_UNIXTIME((media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, '${duration}') AS SIGNED) ${relationTop} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
956
                    )[whereFN](`CAST(FROM_UNIXTIME((media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, '${duration}') AS SIGNED) ${relationBottom} CAST(DATE_FORMAT((DATE_ADD(curdate(), INTERVAL -:diff${queryId} DAY)),'${duration}') AS SIGNED)`,
957
                      textParam);
958
                  } else {
959
                    q.where(
6✔
960
                      `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationTop} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
961
                    )[whereFN](`CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationBottom} CAST(DATE_FORMAT((DATE_ADD(curdate(), INTERVAL -:diff${queryId} DAY)),'${duration}') AS SIGNED)`,
962
                      textParam);
963
                  }
964
                }
965
              }
966
            };
967
            switch (tq.frequency) {
22!
968
              case DatePatternFrequency.every_year:
969
                const d = new Date();
20✔
970
                if (tq.daysLength >= (Utils.isDateFromLeapYear(d) ? 366 : 365)) { // trivial result includes all photos
20!
971
                  if (tq.negate) {
4✔
972
                    q.andWhere('FALSE');
2✔
973
                  }
974
                  return q;
4✔
975
                }
976

977
                const dayOfYear = Utils.getDayOfYear(d);
16✔
978
                addWhere('%m%d', dayOfYear - tq.daysLength < 0);
16✔
979
                break;
16✔
980
              case DatePatternFrequency.every_month:
981
                if (tq.daysLength >= 31) { // trivial result includes all photos
2✔
982
                  if (tq.negate) {
2!
983
                    q.andWhere('FALSE');
×
984
                  }
985
                  return q;
2✔
986
                }
987
                addWhere('%d', (new Date()).getUTCDate() - tq.daysLength < 0);
×
988
                break;
×
989
              case DatePatternFrequency.every_week:
990
                if (tq.daysLength >= 7) { // trivial result includes all photos
×
991
                  if (tq.negate) {
×
992
                    q.andWhere('FALSE');
×
993
                  }
994
                  return q;
×
995
                }
996
                addWhere('%w', (new Date()).getUTCDay() - tq.daysLength < 0);
×
997
                break;
×
998
            }
999

1000

1001
          }
1002

1003
          return q;
28✔
1004
        });
1005
      }
1006

1007
      case SearchQueryTypes.SOME_OF:
1008
        throw new Error('Some of not supported');
×
1009
    }
1010

1011
    return new Brackets((q: WhereExpression) => {
53,004✔
1012
      const createMatchString = (str: string): string => {
53,114✔
1013
        if (
53,502✔
1014
          (query as TextSearch).matchType ===
1015
          TextSearchQueryMatchTypes.exact_match
1016
        ) {
1017
          return str;
130✔
1018
        }
1019
        // MySQL uses C escape syntax in strings, details:
1020
        // https://stackoverflow.com/questions/14926386/how-to-search-for-slash-in-mysql-and-why-escaping-not-required-for-wher
1021
        if (Config.Database.type === DatabaseType.mysql) {
53,372✔
1022
          /// this reqExp replaces the "\\" to "\\\\\"
1023
          return '%' + str.replace(new RegExp('\\\\', 'g'), '\\\\') + '%';
26,701✔
1024
        }
1025
        return `%${str}%`;
26,671✔
1026
      };
1027

1028
      const LIKE = (query as TextSearch).negate ? 'NOT LIKE' : 'LIKE';
53,114✔
1029
      // if the expression is negated, we use AND instead of OR as nowhere should that match
1030
      const whereFN = (query as TextSearch).negate ? 'andWhere' : 'orWhere';
53,114✔
1031
      const whereFNRev = (query as TextSearch).negate ? 'orWhere' : 'andWhere';
53,114✔
1032

1033
      const textParam: { [key: string]: unknown } = {};
53,114✔
1034
      textParam['text' + queryId] = createMatchString(
53,114✔
1035
        (query as TextSearch).text
1036
      );
1037

1038
      if (
53,114✔
1039
        query.type === SearchQueryTypes.any_text ||
106,096✔
1040
        query.type === SearchQueryTypes.directory
1041
      ) {
1042
        const dirPathStr = (query as TextSearch).text.replace(
192✔
1043
          new RegExp('\\\\', 'g'),
1044
          '/'
1045
        );
1046
        const alias = aliases['directory'] ?? 'directory';
192✔
1047
        textParam['fullPath' + queryId] = createMatchString(dirPathStr);
192✔
1048
        q[whereFN](
192✔
1049
          `${alias}.path ${LIKE} :fullPath${queryId} COLLATE ` + SQL_COLLATE,
1050
          textParam
1051
        );
1052

1053
        const directoryPath = GalleryManager.parseRelativeDirPath(dirPathStr);
192✔
1054
        q[whereFN](
192✔
1055
          new Brackets((dq): unknown => {
1056
            textParam['dirName' + queryId] = createMatchString(
192✔
1057
              directoryPath.name
1058
            );
1059
            dq[whereFNRev](
192✔
1060
              `${alias}.name ${LIKE} :dirName${queryId} COLLATE ${SQL_COLLATE}`,
1061
              textParam
1062
            );
1063
            if (dirPathStr.includes('/')) {
192✔
1064
              textParam['parentName' + queryId] = createMatchString(
4✔
1065
                directoryPath.parent
1066
              );
1067
              dq[whereFNRev](
4✔
1068
                `${alias}.path ${LIKE} :parentName${queryId} COLLATE ${SQL_COLLATE}`,
1069
                textParam
1070
              );
1071
            }
1072
            return dq;
192✔
1073
          })
1074
        );
1075
      }
1076

1077
      if (
53,114✔
1078
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,234✔
1079
        query.type === SearchQueryTypes.file_name
1080
      ) {
1081
        q[whereFN](
52,854✔
1082
          `media.name ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1083
          textParam
1084
        );
1085
      }
1086

1087
      if (
53,114✔
1088
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,234✔
1089
        query.type === SearchQueryTypes.caption
1090
      ) {
1091
        q[whereFN](
144✔
1092
          `media.metadata.caption ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1093
          textParam
1094
        );
1095
      }
1096

1097
      if (
53,114✔
1098
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,234✔
1099
        query.type === SearchQueryTypes.position
1100
      ) {
1101
        q[whereFN](
134✔
1102
          `media.metadata.positionData.country ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1103
          textParam
1104
        )[whereFN](
1105
          `media.metadata.positionData.state ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1106
          textParam
1107
        )[whereFN](
1108
          `media.metadata.positionData.city ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1109
          textParam
1110
        );
1111
      }
1112

1113
      // Matching for array type fields
1114
      const matchArrayField = (fieldName: string): void => {
53,114✔
1115
        q[whereFN](
420✔
1116
          new Brackets((qbr): void => {
1117
            if (
420✔
1118
              (query as TextSearch).matchType !==
1119
              TextSearchQueryMatchTypes.exact_match
1120
            ) {
1121
              qbr[whereFN](
364✔
1122
                `${fieldName} ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1123
                textParam
1124
              );
1125
            } else {
1126
              qbr[whereFN](
56✔
1127
                new Brackets((qb): void => {
1128
                  textParam['CtextC' + queryId] = `%,${
56✔
1129
                    (query as TextSearch).text
1130
                  },%`;
1131
                  textParam['Ctext' + queryId] = `%,${
56✔
1132
                    (query as TextSearch).text
1133
                  }`;
1134
                  textParam['textC' + queryId] = `${
56✔
1135
                    (query as TextSearch).text
1136
                  },%`;
1137
                  textParam['text_exact' + queryId] = `${
56✔
1138
                    (query as TextSearch).text
1139
                  }`;
1140

1141
                  qb[whereFN](
56✔
1142
                    `${fieldName} ${LIKE} :CtextC${queryId} COLLATE ${SQL_COLLATE}`,
1143
                    textParam
1144
                  );
1145
                  qb[whereFN](
56✔
1146
                    `${fieldName} ${LIKE} :Ctext${queryId} COLLATE ${SQL_COLLATE}`,
1147
                    textParam
1148
                  );
1149
                  qb[whereFN](
56✔
1150
                    `${fieldName} ${LIKE} :textC${queryId} COLLATE ${SQL_COLLATE}`,
1151
                    textParam
1152
                  );
1153
                  qb[whereFN](
56✔
1154
                    `${fieldName} ${LIKE} :text_exact${queryId} COLLATE ${SQL_COLLATE}`,
1155
                    textParam
1156
                  );
1157
                })
1158
              );
1159
            }
1160
            if ((query as TextSearch).negate) {
420✔
1161
              qbr.orWhere(`${fieldName} IS NULL`);
12✔
1162
            }
1163
          })
1164
        );
1165
      };
1166

1167
      if (
53,114✔
1168
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,234✔
1169
        query.type === SearchQueryTypes.person
1170
      ) {
1171
        matchArrayField('media.metadata.persons');
202✔
1172
      }
1173

1174
      if (
53,114✔
1175
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,234✔
1176
        query.type === SearchQueryTypes.keyword
1177
      ) {
1178
        matchArrayField('media.metadata.keywords');
218✔
1179
      }
1180
      return q;
53,114✔
1181
    });
1182
  }
1183

1184
  protected flattenSameOfQueries(query: SearchQueryDTO): SearchQueryDTO {
1185
    switch (query.type) {
1,447,766✔
1186
      case SearchQueryTypes.AND:
1187
      case SearchQueryTypes.OR:
1188
        return {
530,848✔
1189
          type: query.type,
1190
          list: ((query as SearchListQuery).list || []).map(
530,848!
1191
            (q): SearchQueryDTO => this.flattenSameOfQueries(q)
1,447,350✔
1192
          ),
1193
        } as SearchListQuery;
1194
      case SearchQueryTypes.SOME_OF:
1195
        const someOfQ = query as SomeOfSearchQuery;
30✔
1196
        someOfQ.min = someOfQ.min || 1;
30✔
1197

1198
        if (someOfQ.min === 1) {
30✔
1199
          return this.flattenSameOfQueries({
6✔
1200
            type: SearchQueryTypes.OR,
1201
            list: (someOfQ as SearchListQuery).list,
1202
          } as ORSearchQuery);
1203
        }
1204

1205
        if (someOfQ.min === ((query as SearchListQuery).list || []).length) {
24!
1206
          return this.flattenSameOfQueries({
2✔
1207
            type: SearchQueryTypes.AND,
1208
            list: (someOfQ as SearchListQuery).list,
1209
          } as ANDSearchQuery);
1210
        }
1211

1212
        const getAllCombinations = (
22✔
1213
          num: number,
1214
          arr: SearchQueryDTO[],
1215
          start = 0
22✔
1216
        ): SearchQueryDTO[] => {
1217
          if (num <= 0 || num > arr.length || start >= arr.length) {
435,914✔
1218
            return null;
84,396✔
1219
          }
1220
          if (num <= 1) {
351,518✔
1221
            return arr.slice(start);
193,128✔
1222
          }
1223
          if (num === arr.length - start) {
158,390✔
1224
            return [
30,094✔
1225
              {
1226
                type: SearchQueryTypes.AND,
1227
                list: arr.slice(start),
1228
              } as ANDSearchQuery,
1229
            ];
1230
          }
1231
          const ret: ANDSearchQuery[] = [];
128,296✔
1232
          for (let i = start; i < arr.length; ++i) {
128,296✔
1233
            const subRes = getAllCombinations(num - 1, arr, i + 1);
435,892✔
1234
            if (subRes === null) {
435,892✔
1235
              break;
128,296✔
1236
            }
1237
            const and: ANDSearchQuery = {
307,596✔
1238
              type: SearchQueryTypes.AND,
1239
              list: [arr[i]],
1240
            };
1241
            if (subRes.length === 1) {
307,596✔
1242
              if (subRes[0].type === SearchQueryTypes.AND) {
84,396✔
1243
                and.list.push(...(subRes[0] as ANDSearchQuery).list);
30,094✔
1244
              } else {
1245
                and.list.push(subRes[0]);
54,302✔
1246
              }
1247
            } else {
1248
              and.list.push({
223,200✔
1249
                type: SearchQueryTypes.OR,
1250
                list: subRes,
1251
              } as ORSearchQuery);
1252
            }
1253
            ret.push(and);
307,596✔
1254
          }
1255

1256
          if (ret.length === 0) {
128,296✔
1257
            return null;
43,900✔
1258
          }
1259
          return ret;
84,396✔
1260
        };
1261

1262
        return this.flattenSameOfQueries({
22✔
1263
          type: SearchQueryTypes.OR,
1264
          list: getAllCombinations(
1265
            someOfQ.min,
1266
            (query as SearchListQuery).list
1267
          ),
1268
        } as ORSearchQuery);
1269
    }
1270
    return query;
916,888✔
1271
  }
1272

1273
  /**
1274
   * Assigning IDs to search queries. It is a help / simplification to typeorm,
1275
   * so less parameters are needed to pass down to SQL.
1276
   * Witch SOME_OF query the number of WHERE constrains have O(N!) complexity
1277
   */
1278
  private assignQueryIDs(
1279
    queryIN: SearchQueryDTO,
1280
    id = {value: 1}
368✔
1281
  ): SearchQueryDTO {
1282
    // It is possible that one SQL query contains multiple searchQueries
1283
    // (like: where (<searchQuery1> AND (<searchQuery2>))
1284
    // lets make params unique across multiple queries
1285
    if (id.value === 1) {
484✔
1286
      this.queryIdBase++;
402✔
1287
      if (this.queryIdBase > 10000) {
402!
1288
        this.queryIdBase = 0;
×
1289
      }
1290
    }
1291
    if ((queryIN as SearchListQuery).list) {
484✔
1292
      (queryIN as SearchListQuery).list.forEach((q) =>
34✔
1293
        this.assignQueryIDs(q, id)
116✔
1294
      );
1295
      return queryIN;
34✔
1296
    }
1297
    (queryIN as SearchQueryDTOWithID).queryId =
450✔
1298
      this.queryIdBase + '_' + id.value;
1299
    id.value++;
450✔
1300
    return queryIN;
450✔
1301
  }
1302

1303

1304
  public hasDirectoryQuery(query: SearchQueryDTO): boolean {
1305
    switch (query.type) {
35!
1306
      case SearchQueryTypes.AND:
1307
      case SearchQueryTypes.OR:
1308
      case SearchQueryTypes.SOME_OF:
1309
        return (query as SearchListQuery).list.some(q => this.hasDirectoryQuery(q));
×
1310
      case SearchQueryTypes.any_text:
1311
      case SearchQueryTypes.directory:
1312
        return true;
15✔
1313
    }
1314
    return false;
20✔
1315
  }
1316

1317
  /**
1318
   * Returns only those parts of a query tree that only contains directory-related search queries
1319
   */
1320
  private filterDirectoryQuery(query: SearchQueryDTO): SearchQueryDTO {
1321
    switch (query.type) {
21!
1322
      case SearchQueryTypes.AND:
1323
        const andRet = {
×
1324
          type: SearchQueryTypes.AND,
1325
          list: (query as SearchListQuery).list.map((q) =>
1326
            this.filterDirectoryQuery(q)
×
1327
          ),
1328
        } as ANDSearchQuery;
1329
        // if any of the queries contain non dir query thw whole and query is a non dir query
1330
        if (andRet.list.indexOf(null) !== -1) {
×
1331
          return null;
×
1332
        }
1333
        return andRet;
×
1334

1335
      case SearchQueryTypes.OR:
1336
        const orRet = {
×
1337
          type: SearchQueryTypes.OR,
1338
          list: (query as SearchListQuery).list
1339
            .map((q) => this.filterDirectoryQuery(q))
×
1340
            .filter((q) => q !== null),
×
1341
        } as ORSearchQuery;
1342
        if (orRet.list.length === 0) {
×
1343
          return null;
×
1344
        }
1345
        return orRet;
×
1346

1347
      case SearchQueryTypes.any_text:
1348
      case SearchQueryTypes.directory:
1349
        return query;
21✔
1350

1351
      case SearchQueryTypes.SOME_OF:
1352
        throw new Error('"Some of" queries should have been already flattened');
×
1353
    }
1354
    // of none of the above, its not a directory search
1355
    return null;
×
1356
  }
1357

1358
  private async getGPSData(query: SearchQueryDTO): Promise<SearchQueryDTO> {
1359
    if ((query as ANDSearchQuery | ORSearchQuery).list) {
74,454✔
1360
      for (
21,366✔
1361
        let i = 0;
21,366✔
1362
        i < (query as ANDSearchQuery | ORSearchQuery).list.length;
1363
        ++i
1364
      ) {
1365
        (query as ANDSearchQuery | ORSearchQuery).list[i] =
74,086✔
1366
          await this.getGPSData(
1367
            (query as ANDSearchQuery | ORSearchQuery).list[i]
1368
          );
1369
      }
1370
    }
1371
    if (
74,454✔
1372
      query.type === SearchQueryTypes.distance &&
74,464✔
1373
      (query as DistanceSearch).from.text
1374
    ) {
1375
      (query as DistanceSearch).from.GPSData =
2✔
1376
        await ObjectManagers.getInstance().LocationManager.getGPSData(
1377
          (query as DistanceSearch).from.text
1378
        );
1379
    }
1380
    return query;
74,454✔
1381
  }
1382

1383
  private encapsulateAutoComplete(
1384
    values: string[],
1385
    type: SearchQueryTypes
1386
  ): Array<AutoCompleteItem> {
1387
    const res: AutoCompleteItem[] = [];
158✔
1388
    values.forEach((value): void => {
158✔
1389
      res.push(new AutoCompleteItem(value, type));
132✔
1390
    });
1391
    return res;
158✔
1392
  }
1393
}
1394

1395
export interface SearchQueryDTOWithID extends SearchQueryDTO {
1396
  queryId: string;
1397
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc