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

bpatrik / pigallery2 / 17685697493

12 Sep 2025 08:48PM UTC coverage: 65.828% (+1.7%) from 64.102%
17685697493

push

github

web-flow
Merge pull request #1030 from bpatrik/db-projection

Implement projected (scoped/filtered) gallery. Fixes #1015

1305 of 2243 branches covered (58.18%)

Branch coverage included in aggregate %.

706 of 873 new or added lines in 54 files covered. (80.87%)

16 existing lines in 10 files now uncovered.

4794 of 7022 relevant lines covered (68.27%)

4355.01 hits per line

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

82.41
/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/person/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 = [
285✔
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;
285✔
50

51
  public static setSorting<T>(
52
    query: SelectQueryBuilder<T>,
53
    sortings: SortingMethod[]
54
  ): SelectQueryBuilder<T> {
55
    if (!sortings || !Array.isArray(sortings)) {
443!
NEW
56
      return query;
×
57
    }
58
    if (sortings.findIndex(s => s.method == SortByTypes.Random) !== -1 && sortings.length > 1) {
896!
NEW
59
      throw new Error('Error during applying sorting: Can\' randomize and also sort the result. Bad input:' + sortings.map(s => GroupSortByTypes[s.method]).join(', '));
×
60
    }
61
    for (const sort of sortings) {
443✔
62
      switch (sort.method) {
896!
63
        case SortByTypes.Date:
64
          if (Config.Gallery.ignoreTimestampOffset === true) {
336✔
65
            query.addOrderBy('media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)', sort.ascending ? 'ASC' : 'DESC');
119!
66
          } else {
67
            query.addOrderBy('media.metadata.creationDate', sort.ascending ? 'ASC' : 'DESC');
217!
68
          }
69
          break;
336✔
70
        case SortByTypes.Rating:
71
          query.addOrderBy('media.metadata.rating', sort.ascending ? 'ASC' : 'DESC');
423!
72
          break;
423✔
73
        case SortByTypes.Name:
74
          query.addOrderBy('media.name', sort.ascending ? 'ASC' : 'DESC');
12!
75
          break;
12✔
76
        case SortByTypes.PersonCount:
77
          query.addOrderBy('media.metadata.personsLength', sort.ascending ? 'ASC' : 'DESC');
119!
78
          break;
119✔
79
        case SortByTypes.FileSize:
NEW
80
          query.addOrderBy('media.metadata.fileSize', sort.ascending ? 'ASC' : 'DESC');
×
NEW
81
          break;
×
82
        case SortByTypes.Random:
83
          if (Config.Database.type === DatabaseType.mysql) {
6✔
84
            query.groupBy('RAND(), media.id');
3✔
85
          } else {
86
            query.groupBy('RANDOM()');
3✔
87
          }
88
          break;
6✔
89
      }
90
    }
91

92
    return query;
443✔
93
  }
94

95
  private static autoCompleteItemsUnique(
96
    array: Array<AutoCompleteItem>
97
  ): Array<AutoCompleteItem> {
98
    const a = array.concat();
48✔
99
    for (let i = 0; i < a.length; ++i) {
48✔
100
      for (let j = i + 1; j < a.length; ++j) {
82✔
101
        if (a[i].equals(a[j])) {
104✔
102
          a.splice(j--, 1);
20✔
103
        }
104
      }
105
    }
106

107
    return a;
48✔
108
  }
109

110
  async autocomplete(
111
    session: SessionContext,
112
    text: string,
113
    type: SearchQueryTypes
114
  ): Promise<AutoCompleteItem[]> {
115
    const connection = await SQLConnection.getConnection();
48✔
116

117
    const photoRepository = connection.getRepository(PhotoEntity);
48✔
118
    const mediaRepository = connection.getRepository(MediaEntity);
48✔
119
    const personRepository = connection.getRepository(PersonEntry);
48✔
120
    const directoryRepository = connection.getRepository(DirectoryEntity);
48✔
121

122
    const partialResult: AutoCompleteItem[][] = [];
48✔
123

124
    if (
48✔
125
      type === SearchQueryTypes.any_text ||
72✔
126
      type === SearchQueryTypes.keyword
127
    ) {
128
      const acList: AutoCompleteItem[] = [];
28✔
129
      const q = photoRepository
28✔
130
        .createQueryBuilder('media')
131
        .select('DISTINCT(media.metadata.keywords)')
132
        .where('media.metadata.keywords LIKE :textKW COLLATE ' + SQL_COLLATE, {
133
          textKW: '%' + text + '%',
134
        });
135

136
      if (session.projectionQuery) {
28✔
137
        if (session.hasDirectoryProjection) {
6✔
138
          q.leftJoin('media.directory', 'directory');
2✔
139
        }
140
        q.andWhere(session.projectionQuery);
6✔
141
      }
142

143
      q.limit(Config.Search.AutoComplete.ItemsPerCategory.keyword);
28✔
144
      (await q.getRawMany())
28✔
145
        .map(
146
          (r): Array<string> =>
147
            (r.metadataKeywords as string).split(',') as Array<string>
42✔
148
        )
149
        .forEach((keywords): void => {
150
          acList.push(
42✔
151
            ...this.encapsulateAutoComplete(
152
              keywords.filter(
153
                (k): boolean =>
154
                  k.toLowerCase().indexOf(text.toLowerCase()) !== -1
150✔
155
              ),
156
              SearchQueryTypes.keyword
157
            )
158
          );
159
        });
160
      partialResult.push(acList);
28✔
161
    }
162

163
    if (
48✔
164
      type === SearchQueryTypes.any_text ||
72✔
165
      type === SearchQueryTypes.person
166
    ) {
167
      // make sure all persons have up-to-date cache
168
      await ObjectManagers.getInstance().PersonManager.getAll(session);
34✔
169
      partialResult.push(
34✔
170
        this.encapsulateAutoComplete(
171
          (
172
            await personRepository
173
              .createQueryBuilder('person')
174
              .select('DISTINCT(person.name), cache.count')
175
              .leftJoin('person.cache', 'cache', 'cache.projectionKey = :pk', {pk: session.user.projectionKey})
176
              .where('person.name LIKE :text COLLATE ' + SQL_COLLATE, {
177
                text: '%' + text + '%',
178
              })
179
              .andWhere('cache.count > 0 AND cache.valid = 1')
180
              .limit(
181
                Config.Search.AutoComplete.ItemsPerCategory.person
182
              )
183
              .orderBy('cache.count', 'DESC')
184
              .getRawMany()
185
          ).map((r) => r.name),
34✔
186
          SearchQueryTypes.person
187
        )
188
      );
189
    }
190

191
    if (
48✔
192
      type === SearchQueryTypes.any_text ||
96✔
193
      type === SearchQueryTypes.position ||
194
      type === SearchQueryTypes.distance
195
    ) {
196
      const acList: AutoCompleteItem[] = [];
24✔
197
      const q = photoRepository
24✔
198
        .createQueryBuilder('media')
199
        .select(
200
          'media.metadata.positionData.country as country, ' +
201
          'media.metadata.positionData.state as state, media.metadata.positionData.city as city'
202
        );
203
      const b = new Brackets((q) => {
24✔
204
        q.where(
24✔
205
          'media.metadata.positionData.country LIKE :text COLLATE ' +
206
          SQL_COLLATE,
207
          {text: '%' + text + '%'}
208
        ).orWhere(
209
          'media.metadata.positionData.state LIKE :text COLLATE ' +
210
          SQL_COLLATE,
211
          {text: '%' + text + '%'}
212
        ).orWhere(
213
          'media.metadata.positionData.city LIKE :text COLLATE ' +
214
          SQL_COLLATE,
215
          {text: '%' + text + '%'}
216
        );
217
      });
218
      q.where(b);
24✔
219

220
      if (session.projectionQuery) {
24✔
221
        if (session.hasDirectoryProjection) {
4!
NEW
222
          q.leftJoin('media.directory', 'directory');
×
223
        }
224
        q.andWhere(session.projectionQuery);
4✔
225
      }
226

227
      q.groupBy(
24✔
228
        'media.metadata.positionData.country, media.metadata.positionData.state, media.metadata.positionData.city'
229
      )
230
        .limit(Config.Search.AutoComplete.ItemsPerCategory.position);
231
      (
24✔
232

233
        await q.getRawMany()
234
      )
235
        .filter((pm): boolean => !!pm)
10✔
236
        .map(
237
          (pm): Array<string> =>
238
            [pm.city || '', pm.country || '', pm.state || ''] as Array<string>
10!
239
        )
240
        .forEach((positions): void => {
241
          acList.push(
10✔
242
            ...this.encapsulateAutoComplete(
243
              positions.filter(
244
                (p): boolean =>
245
                  p.toLowerCase().indexOf(text.toLowerCase()) !== -1
30✔
246
              ),
247
              type === SearchQueryTypes.distance
10!
248
                ? type
249
                : SearchQueryTypes.position
250
            )
251
          );
252
        });
253
      partialResult.push(acList);
24✔
254
    }
255

256
    if (
48✔
257
      type === SearchQueryTypes.any_text ||
72✔
258
      type === SearchQueryTypes.file_name
259
    ) {
260
      const q = mediaRepository
28✔
261
        .createQueryBuilder('media')
262
        .select('DISTINCT(media.name)')
263
        .where('media.name LIKE :text COLLATE ' + SQL_COLLATE, {
264
          text: '%' + text + '%',
265
        });
266

267

268
      if (session.projectionQuery) {
28✔
269
        if (session.hasDirectoryProjection) {
6!
NEW
270
          q.leftJoin('media.directory', 'directory');
×
271
        }
272
        q.andWhere(session.projectionQuery);
6✔
273
      }
274
      q.limit(
28✔
275
        Config.Search.AutoComplete.ItemsPerCategory.fileName
276
      );
277
      partialResult.push(
28✔
278
        this.encapsulateAutoComplete(
279
          (
280
            await q.getRawMany()
281
          ).map((r) => r.name),
18✔
282
          SearchQueryTypes.file_name
283
        )
284
      );
285
    }
286

287
    if (
48✔
288
      type === SearchQueryTypes.any_text ||
72✔
289
      type === SearchQueryTypes.caption
290
    ) {
291
      const q = photoRepository
24✔
292
        .createQueryBuilder('media')
293
        .select('DISTINCT(media.metadata.caption) as caption')
294
        .where(
295
          'media.metadata.caption LIKE :text COLLATE ' + SQL_COLLATE,
296
          {text: '%' + text + '%'}
297
        );
298

299
      if (session.projectionQuery) {
24✔
300
        if (session.hasDirectoryProjection) {
4!
NEW
301
          q.leftJoin('media.directory', 'directory');
×
302
        }
303
        q.andWhere(session.projectionQuery);
4✔
304
      }
305
      q.limit(
24✔
306
        Config.Search.AutoComplete.ItemsPerCategory.caption
307
      );
308
      partialResult.push(
24✔
309
        this.encapsulateAutoComplete(
310
          (
311
            await q.getRawMany()
312
          ).map((r) => r.caption),
6✔
313
          SearchQueryTypes.caption
314
        )
315
      );
316
    }
317

318
    if (
48✔
319
      type === SearchQueryTypes.any_text ||
72✔
320
      type === SearchQueryTypes.directory
321
    ) {
322
      const dirs = await directoryRepository
30✔
323
        .createQueryBuilder('directory')
324
        .leftJoinAndSelect('directory.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: session.user.projectionKey})
325
        .where('directory.name LIKE :text COLLATE ' + SQL_COLLATE, {
326
          text: '%' + text + '%',
327
        })
328
        .andWhere('(cache.recursiveMediaCount > 0 OR cache.id is NULL)')
329
        .limit(
330
          Config.Search.AutoComplete.ItemsPerCategory.directory
331
        )
332
        .getMany();
333
      // fill cache as we need it for this autocomplete search
334
      for (const dir of dirs) {
30✔
335
        if (!dir.cache?.valid) {
12✔
336
          dir.cache = await ObjectManagers.getInstance().ProjectedCacheManager.setAndGetCacheForDirectory(connection, session, dir);
12✔
337
        }
338
      }
339
      partialResult.push(
30✔
340
        this.encapsulateAutoComplete(
341
          dirs.filter(d => d.cache.valid && d.cache.recursiveMediaCount > 0).map((r) => r.name),
12✔
342
          SearchQueryTypes.directory
343
        )
344
      );
345
    }
346

347
    const result: AutoCompleteItem[] = [];
48✔
348

349
    while (result.length < Config.Search.AutoComplete.ItemsPerCategory.maxItems) {
48✔
350
      let adding = false;
130✔
351
      for (let i = 0; i < partialResult.length; ++i) {
130✔
352
        if (partialResult[i].length <= 0) {
500✔
353
          continue;
398✔
354
        }
355
        result.push(partialResult[i].shift()); // first elements are more important
102✔
356
        adding = true;
102✔
357
      }
358
      if (!adding) {
130✔
359
        break;
44✔
360
      }
361
    }
362

363

364
    return SearchManager.autoCompleteItemsUnique(result);
48✔
365
  }
366

367
  async search(session: SessionContext, queryIN: SearchQueryDTO): Promise<SearchResultDTO> {
368
    const query = await this.prepareQuery(queryIN);
180✔
369
    const connection = await SQLConnection.getConnection();
180✔
370

371
    const result: SearchResultDTO = {
180✔
372
      searchQuery: queryIN,
373
      directories: [],
374
      media: [],
375
      metaFile: [],
376
      resultOverflow: false,
377
    };
378

379
    const q = connection
180✔
380
      .getRepository(MediaEntity)
381
      .createQueryBuilder('media')
382
      .select(['media', ...this.DIRECTORY_SELECT])
383
      .where(this.buildWhereQuery(query))
384
      .leftJoin('media.directory', 'directory')
385
      .limit(Config.Search.maxMediaResult + 1);
386

387
    if (session.projectionQuery) {
180✔
388
      q.andWhere(session.projectionQuery);
6✔
389
    }
390

391
    result.media = await q.getMany();
180✔
392

393
    if (result.media.length > Config.Search.maxMediaResult) {
180!
394
      result.resultOverflow = true;
×
395
    }
396

397

398
    if (Config.Search.listMetafiles === true) {
180✔
399
      const dIds = Array.from(new Set(result.media.map(m => (m.directory as unknown as { id: number }).id)));
6✔
400
      result.metaFile = await connection
2✔
401
        .getRepository(FileEntity)
402
        .createQueryBuilder('file')
403
        .select(['file', ...this.DIRECTORY_SELECT])
404
        .where(`file.directoryId IN(${dIds})`)
405
        .leftJoin('file.directory', 'directory')
406
        .getMany();
407
    }
408

409
    if (Config.Search.listDirectories === true) {
180✔
410
      const dirQuery = this.filterDirectoryQuery(query);
6✔
411
      if (dirQuery !== null) {
6✔
412
        result.directories = await connection
6✔
413
          .getRepository(DirectoryEntity)
414
          .createQueryBuilder('directory')
415
          .where(this.buildWhereQuery(dirQuery, true))
416
          .leftJoin('directory.cache', 'cache', 'cache.projectionKey = :pk AND cache.valid = 1', {pk: session.user.projectionKey})
417
          .leftJoin('cache.cover', 'cover')
418
          .leftJoin('cover.directory', 'coverDirectory')
419
          .limit(Config.Search.maxDirectoryResult + 1)
420
          .select([
421
            'directory',
422
            'cache.oldestMedia',
423
            'cache.youngestMedia',
424
            'cache.mediaCount',
425
            'cache.recursiveMediaCount',
426
            'cover.name',
427
            'coverDirectory.name',
428
            'coverDirectory.path',
429
          ])
430
          .getMany();
431

432
        // setting covers
433
        if (result.directories) {
6✔
434
          for (const item of result.directories) {
6✔
435
            await ObjectManagers.getInstance().GalleryManager.fillCacheForSubDir(connection, session, item as DirectoryEntity);
10✔
436
          }
437
        }
438
        // do not show empty directories in search results
439
        result.directories = result.directories.filter(d => d.cache.recursiveMediaCount > 0);
10✔
440
        if (
6!
441
          result.directories.length > Config.Search.maxDirectoryResult
442
        ) {
443
          result.resultOverflow = true;
×
444
        }
445
      }
446
    }
447

448
    return result;
180✔
449
  }
450

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

460
    if (session.projectionQuery) {
6✔
461
      sqlQuery.andWhere(session.projectionQuery);
2✔
462
    }
463
    SearchManager.setSorting(sqlQuery, sortings);
6✔
464

465
    return sqlQuery.limit(take).getMany();
6✔
466

467
  }
468

469
  public async getCount(session: SessionContext, query: SearchQueryDTO): Promise<number> {
470
    const connection = await SQLConnection.getConnection();
6✔
471

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

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

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

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

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

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

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

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

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

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

645
          return q;
4✔
646
        });
647

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

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

673
          }
674

675
          return q;
4✔
676
        });
677

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

791
          return q;
4✔
792
        });
793

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

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

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

815
          return q;
6✔
816
        });
817

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

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

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

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

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

859
              case DatePatternFrequency.months_ago:
860
                to.setTime(Utils.addMonthToDate(to, -1 * tq.agoNumber).getTime());
8✔
861
                break;
8✔
862

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

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

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

904
          } else {
905
            // recurring
906

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

911
              const relationEql = tq.negate ? '!=' : '=';
16✔
912

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

919

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

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

1003

1004
          }
1005

1006
          return q;
28✔
1007
        });
1008
      }
1009

1010
      case SearchQueryTypes.SOME_OF:
1011
        throw new Error('Some of not supported');
×
1012
    }
1013

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

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

1036
      const textParam: { [key: string]: unknown } = {};
53,276✔
1037
      textParam['text' + queryId] = createMatchString(
53,276✔
1038
        (query as TextSearch).text
1039
      );
1040

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

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

1080
      if (
53,276✔
1081
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,558✔
1082
        query.type === SearchQueryTypes.file_name
1083
      ) {
1084
        q[whereFN](
52,992✔
1085
          `media.name ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1086
          textParam
1087
        );
1088
      }
1089

1090
      if (
53,276✔
1091
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,558✔
1092
        query.type === SearchQueryTypes.caption
1093
      ) {
1094
        q[whereFN](
188✔
1095
          `media.metadata.caption ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1096
          textParam
1097
        );
1098
      }
1099

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

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

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

1170
      if (
53,276✔
1171
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,558✔
1172
        query.type === SearchQueryTypes.person
1173
      ) {
1174
        matchArrayField('media.metadata.persons');
250✔
1175
      }
1176

1177
      if (
53,276✔
1178
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,558✔
1179
        query.type === SearchQueryTypes.keyword
1180
      ) {
1181
        matchArrayField('media.metadata.keywords');
282✔
1182
      }
1183
      return q;
53,276✔
1184
    });
1185
  }
1186

1187
  public hasDirectoryQuery(query: SearchQueryDTO): boolean {
1188
    switch (query.type) {
53!
1189
      case SearchQueryTypes.AND:
1190
      case SearchQueryTypes.OR:
1191
      case SearchQueryTypes.SOME_OF:
NEW
1192
        return (query as SearchListQuery).list.some(q => this.hasDirectoryQuery(q));
×
1193
      case SearchQueryTypes.any_text:
1194
      case SearchQueryTypes.directory:
1195
        return true;
15✔
1196
    }
1197
    return false;
38✔
1198
  }
1199

1200
  protected flattenSameOfQueries(query: SearchQueryDTO): SearchQueryDTO {
1201
    switch (query.type) {
1,447,812✔
1202
      case SearchQueryTypes.AND:
1203
      case SearchQueryTypes.OR:
1204
        return {
530,848✔
1205
          type: query.type,
1206
          list: ((query as SearchListQuery).list || []).map(
530,848!
1207
            (q): SearchQueryDTO => this.flattenSameOfQueries(q)
1,447,350✔
1208
          ),
1209
        } as SearchListQuery;
1210
      case SearchQueryTypes.SOME_OF:
1211
        const someOfQ = query as SomeOfSearchQuery;
30✔
1212
        someOfQ.min = someOfQ.min || 1;
30✔
1213

1214
        if (someOfQ.min === 1) {
30✔
1215
          return this.flattenSameOfQueries({
6✔
1216
            type: SearchQueryTypes.OR,
1217
            list: (someOfQ as SearchListQuery).list,
1218
          } as ORSearchQuery);
1219
        }
1220

1221
        if (someOfQ.min === ((query as SearchListQuery).list || []).length) {
24!
1222
          return this.flattenSameOfQueries({
2✔
1223
            type: SearchQueryTypes.AND,
1224
            list: (someOfQ as SearchListQuery).list,
1225
          } as ANDSearchQuery);
1226
        }
1227

1228
        const getAllCombinations = (
22✔
1229
          num: number,
1230
          arr: SearchQueryDTO[],
1231
          start = 0
22✔
1232
        ): SearchQueryDTO[] => {
1233
          if (num <= 0 || num > arr.length || start >= arr.length) {
435,914✔
1234
            return null;
84,396✔
1235
          }
1236
          if (num <= 1) {
351,518✔
1237
            return arr.slice(start);
193,128✔
1238
          }
1239
          if (num === arr.length - start) {
158,390✔
1240
            return [
30,094✔
1241
              {
1242
                type: SearchQueryTypes.AND,
1243
                list: arr.slice(start),
1244
              } as ANDSearchQuery,
1245
            ];
1246
          }
1247
          const ret: ANDSearchQuery[] = [];
128,296✔
1248
          for (let i = start; i < arr.length; ++i) {
128,296✔
1249
            const subRes = getAllCombinations(num - 1, arr, i + 1);
435,892✔
1250
            if (subRes === null) {
435,892✔
1251
              break;
128,296✔
1252
            }
1253
            const and: ANDSearchQuery = {
307,596✔
1254
              type: SearchQueryTypes.AND,
1255
              list: [arr[i]],
1256
            };
1257
            if (subRes.length === 1) {
307,596✔
1258
              if (subRes[0].type === SearchQueryTypes.AND) {
84,396✔
1259
                and.list.push(...(subRes[0] as ANDSearchQuery).list);
30,094✔
1260
              } else {
1261
                and.list.push(subRes[0]);
54,302✔
1262
              }
1263
            } else {
1264
              and.list.push({
223,200✔
1265
                type: SearchQueryTypes.OR,
1266
                list: subRes,
1267
              } as ORSearchQuery);
1268
            }
1269
            ret.push(and);
307,596✔
1270
          }
1271

1272
          if (ret.length === 0) {
128,296✔
1273
            return null;
43,900✔
1274
          }
1275
          return ret;
84,396✔
1276
        };
1277

1278
        return this.flattenSameOfQueries({
22✔
1279
          type: SearchQueryTypes.OR,
1280
          list: getAllCombinations(
1281
            someOfQ.min,
1282
            (query as SearchListQuery).list
1283
          ),
1284
        } as ORSearchQuery);
1285
    }
1286
    return query;
916,934✔
1287
  }
1288

1289
  /**
1290
   * Assigning IDs to search queries. It is a help / simplification to typeorm,
1291
   * so less parameters are needed to pass down to SQL.
1292
   * Witch SOME_OF query the number of WHERE constrains have O(N!) complexity
1293
   */
1294
  private assignQueryIDs(
1295
    queryIN: SearchQueryDTO,
1296
    id = {value: 1}
414✔
1297
  ): SearchQueryDTO {
1298
    // It is possible that one SQL query contains multiple searchQueries
1299
    // (like: where (<searchQuery1> AND (<searchQuery2>))
1300
    // lets make params unique across multiple queries
1301
    if (id.value === 1) {
530✔
1302
      this.queryIdBase++;
448✔
1303
      if (this.queryIdBase > 10000) {
448!
1304
        this.queryIdBase = 0;
×
1305
      }
1306
    }
1307
    if ((queryIN as SearchListQuery).list) {
530✔
1308
      (queryIN as SearchListQuery).list.forEach((q) =>
34✔
1309
        this.assignQueryIDs(q, id)
116✔
1310
      );
1311
      return queryIN;
34✔
1312
    }
1313
    (queryIN as SearchQueryDTOWithID).queryId =
496✔
1314
      this.queryIdBase + '_' + id.value;
1315
    id.value++;
496✔
1316
    return queryIN;
496✔
1317
  }
1318

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

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

1349
      case SearchQueryTypes.any_text:
1350
      case SearchQueryTypes.directory:
1351
        return query;
21✔
1352

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

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

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

1397
export interface SearchQueryDTOWithID extends SearchQueryDTO {
1398
  queryId: string;
1399
}
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