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

bpatrik / pigallery2 / 17528940173

07 Sep 2025 01:06PM UTC coverage: 65.007% (-0.01%) from 65.017%
17528940173

push

github

bpatrik
Fix session manager tests #1015

1254 of 2192 branches covered (57.21%)

Branch coverage included in aggregate %.

4648 of 6887 relevant lines covered (67.49%)

4425.53 hits per line

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

82.2
/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)) {
365!
409
      return query;
×
410
    }
411
    if (sortings.findIndex(s => s.method == SortByTypes.Random) !== -1 && sortings.length > 1) {
665!
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) {
365✔
415
      switch (sort.method) {
665!
416
        case SortByTypes.Date:
417
          if (Config.Gallery.ignoreTimestampOffset === true) {
253✔
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');
204!
421
          }
422
          break;
253✔
423
        case SortByTypes.Rating:
424
          query.addOrderBy('media.metadata.rating', sort.ascending ? 'ASC' : 'DESC');
345!
425
          break;
345✔
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;
365✔
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,
482
    directoryOnly = false
171✔
483
  ): Promise<Brackets> {
484
    const query = await this.prepareQuery(queryIN);
171✔
485
    return this.buildWhereQuery(query, directoryOnly);
171✔
486
  }
487

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

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

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

536
        // TODO: properly handle latitude / longitude boundaries
537
        const trimRange = (value: number, min: number, max: number): number => {
10✔
538
          return Math.min(Math.max(value, min), max);
40✔
539
        };
540

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

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

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

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

638
          return q;
4✔
639
        });
640

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

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

666
          }
667

668
          return q;
4✔
669
        });
670

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

784
          return q;
4✔
785
        });
786

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

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

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

808
          return q;
6✔
809
        });
810

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

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

830
        return new Brackets((q): unknown => {
34✔
831
          // Fixed frequency
832
          if ((tq.frequency === DatePatternFrequency.years_ago ||
34✔
833
            tq.frequency === DatePatternFrequency.months_ago ||
834
            tq.frequency === DatePatternFrequency.weeks_ago ||
835
            tq.frequency === DatePatternFrequency.days_ago)) {
836

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

844
            switch (tq.frequency) {
12!
845
              case DatePatternFrequency.days_ago:
846
                to.setUTCDate(to.getUTCDate() - tq.agoNumber);
4✔
847
                break;
4✔
848
              case DatePatternFrequency.weeks_ago:
849
                to.setUTCDate(to.getUTCDate() - tq.agoNumber * 7);
×
850
                break;
×
851

852
              case DatePatternFrequency.months_ago:
853
                to.setTime(Utils.addMonthToDate(to, -1 * tq.agoNumber).getTime());
8✔
854
                break;
8✔
855

856
              case DatePatternFrequency.years_ago:
857
                to.setUTCFullYear(to.getUTCFullYear() - tq.agoNumber);
×
858
                break;
×
859
            }
860
            const from = new Date(to);
12✔
861
            from.setUTCDate(from.getUTCDate() - tq.daysLength);
12✔
862

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

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

897
          } else {
898
            // recurring
899

900
            const textParam: { [key: string]: unknown } = {};
22✔
901
            textParam['diff' + queryId] = tq.daysLength;
22✔
902
            const addWhere = (duration: string, crossesDateBoundary: boolean) => {
22✔
903

904
              const relationEql = tq.negate ? '!=' : '=';
16✔
905

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

912

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

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

996

997
          }
998

999
          return q;
28✔
1000
        });
1001
      }
1002

1003
      case SearchQueryTypes.SOME_OF:
1004
        throw new Error('Some of not supported');
×
1005
    }
1006

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

1024
      const LIKE = (query as TextSearch).negate ? 'NOT LIKE' : 'LIKE';
53,082✔
1025
      // if the expression is negated, we use AND instead of OR as nowhere should that match
1026
      const whereFN = (query as TextSearch).negate ? 'andWhere' : 'orWhere';
53,082✔
1027
      const whereFNRev = (query as TextSearch).negate ? 'orWhere' : 'andWhere';
53,082✔
1028

1029
      const textParam: { [key: string]: unknown } = {};
53,082✔
1030
      textParam['text' + queryId] = createMatchString(
53,082✔
1031
        (query as TextSearch).text
1032
      );
1033

1034
      if (
53,082✔
1035
        query.type === SearchQueryTypes.any_text ||
106,032✔
1036
        query.type === SearchQueryTypes.directory
1037
      ) {
1038
        const dirPathStr = (query as TextSearch).text.replace(
160✔
1039
          new RegExp('\\\\', 'g'),
1040
          '/'
1041
        );
1042

1043
        textParam['fullPath' + queryId] = createMatchString(dirPathStr);
160✔
1044
        q[whereFN](
160✔
1045
          `directory.path ${LIKE} :fullPath${queryId} COLLATE ` + SQL_COLLATE,
1046
          textParam
1047
        );
1048

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

1073
      if (
53,082✔
1074
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,170✔
1075
        query.type === SearchQueryTypes.file_name
1076
      ) {
1077
        q[whereFN](
52,854✔
1078
          `media.name ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1079
          textParam
1080
        );
1081
      }
1082

1083
      if (
53,082✔
1084
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,170✔
1085
        query.type === SearchQueryTypes.caption
1086
      ) {
1087
        q[whereFN](
144✔
1088
          `media.metadata.caption ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1089
          textParam
1090
        );
1091
      }
1092

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

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

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

1163
      if (
53,082✔
1164
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,170✔
1165
        query.type === SearchQueryTypes.person
1166
      ) {
1167
        matchArrayField('media.metadata.persons');
202✔
1168
      }
1169

1170
      if (
53,082✔
1171
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,170✔
1172
        query.type === SearchQueryTypes.keyword
1173
      ) {
1174
        matchArrayField('media.metadata.keywords');
218✔
1175
      }
1176
      return q;
53,082✔
1177
    });
1178
  }
1179

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

1194
        if (someOfQ.min === 1) {
30✔
1195
          return this.flattenSameOfQueries({
6✔
1196
            type: SearchQueryTypes.OR,
1197
            list: (someOfQ as SearchListQuery).list,
1198
          } as ORSearchQuery);
1199
        }
1200

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

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

1252
          if (ret.length === 0) {
128,296✔
1253
            return null;
43,900✔
1254
          }
1255
          return ret;
84,396✔
1256
        };
1257

1258
        return this.flattenSameOfQueries({
22✔
1259
          type: SearchQueryTypes.OR,
1260
          list: getAllCombinations(
1261
            someOfQ.min,
1262
            (query as SearchListQuery).list
1263
          ),
1264
        } as ORSearchQuery);
1265
    }
1266
    return query;
916,871✔
1267
  }
1268

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

1299

1300
  public hasDirectoryQuery(query: SearchQueryDTO): boolean {
1301
    switch (query.type) {
33!
1302
      case SearchQueryTypes.AND:
1303
      case SearchQueryTypes.OR:
1304
      case SearchQueryTypes.SOME_OF:
1305
        return (query as SearchListQuery).list.some(q => this.hasDirectoryQuery(q));
×
1306
      case SearchQueryTypes.any_text:
1307
      case SearchQueryTypes.directory:
1308
        return true;
13✔
1309
    }
1310
    return false;
20✔
1311
  }
1312

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

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

1343
      case SearchQueryTypes.any_text:
1344
      case SearchQueryTypes.directory:
1345
        return query;
6✔
1346

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

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

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

1391
export interface SearchQueryDTOWithID extends SearchQueryDTO {
1392
  queryId: string;
1393
}
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