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

bpatrik / pigallery2 / 17595229310

09 Sep 2025 08:47PM UTC coverage: 65.569% (+0.5%) from 65.073%
17595229310

push

github

bpatrik
Fix tests #1015

1289 of 2225 branches covered (57.93%)

Branch coverage included in aggregate %.

4742 of 6973 relevant lines covered (68.01%)

4378.93 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/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 = [
261✔
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;
261✔
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) {
62✔
57
        if (a[i].equals(a[j])) {
76✔
58
          a.splice(j--, 1);
20✔
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
      // make sure all persons have up-to-date cache
124
      await ObjectManagers.getInstance().PersonManager.getAll(session);
24✔
125
      partialResult.push(
24✔
126
        this.encapsulateAutoComplete(
127
          (
128
            await personRepository
129
              .createQueryBuilder('person')
130
              .select('DISTINCT(person.name), cache.count')
131
              .leftJoin('person.cache', 'cache', 'cache.projectionKey = :pk', {pk: session.user.projectionKey})
132
              .where('person.name LIKE :text COLLATE ' + SQL_COLLATE, {
133
                text: '%' + text + '%',
134
              })
135
              .andWhere('cache.count > 0 AND cache.valid = 1')
136
              .limit(
137
                Config.Search.AutoComplete.ItemsPerCategory.person
138
              )
139
              .orderBy('cache.count', 'DESC')
140
              .getRawMany()
141
          ).map((r) => r.name),
14✔
142
          SearchQueryTypes.person
143
        )
144
      );
145
    }
146

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

176
      if (session.projectionQuery) {
24✔
177
        if (session.hasDirectoryProjection) {
4!
178
          q.leftJoin('media.directory', 'directory');
×
179
        }
180
        q.andWhere(session.projectionQuery);
4✔
181
      }
182

183
      q.groupBy(
24✔
184
        'media.metadata.positionData.country, media.metadata.positionData.state, media.metadata.positionData.city'
185
      )
186
        .limit(Config.Search.AutoComplete.ItemsPerCategory.position);
187
      (
24✔
188

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

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

223

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

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

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

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

303
    const result: AutoCompleteItem[] = [];
38✔
304

305
    while (result.length < Config.Search.AutoComplete.ItemsPerCategory.maxItems) {
38✔
306
      let adding = false;
100✔
307
      for (let i = 0; i < partialResult.length; ++i) {
100✔
308
        if (partialResult[i].length <= 0) {
470✔
309
          continue;
388✔
310
        }
311
        result.push(partialResult[i].shift()); // first elements are more important
82✔
312
        adding = true;
82✔
313
      }
314
      if (!adding) {
100✔
315
        break;
34✔
316
      }
317
    }
318

319

320
    return SearchManager.autoCompleteItemsUnique(result);
38✔
321
  }
322

323
  async search(session: SessionContext, queryIN: SearchQueryDTO): Promise<SearchResultDTO> {
324
    const query = await this.prepareQuery(queryIN);
180✔
325
    const connection = await SQLConnection.getConnection();
180✔
326

327
    const result: SearchResultDTO = {
180✔
328
      searchQuery: queryIN,
329
      directories: [],
330
      media: [],
331
      metaFile: [],
332
      resultOverflow: false,
333
    };
334

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

343
    if (session.projectionQuery) {
180✔
344
      q.andWhere(session.projectionQuery);
6✔
345
    }
346

347
    result.media = await q.getMany();
180✔
348

349
    if (result.media.length > Config.Search.maxMediaResult) {
180!
350
      result.resultOverflow = true;
×
351
    }
352

353

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

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

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

404
    return result;
180✔
405
  }
406

407

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

449
    return query;
379✔
450
  }
451

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

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

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

468
  }
469

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

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

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

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

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

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

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

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

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

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

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

646
          return q;
4✔
647
        });
648

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

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

674
          }
675

676
          return q;
4✔
677
        });
678

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

792
          return q;
4✔
793
        });
794

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

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

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

816
          return q;
6✔
817
        });
818

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

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

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

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

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

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

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

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

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

905
          } else {
906
            // recurring
907

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

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

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

920

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

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

1004

1005
          }
1006

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1202
        if (someOfQ.min === 1) {
30✔
1203
          return this.flattenSameOfQueries({
6✔
1204
            type: SearchQueryTypes.OR,
1205
            list: (someOfQ as SearchListQuery).list,
1206
          } as ORSearchQuery);
1207
        }
1208

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

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

1260
          if (ret.length === 0) {
128,296✔
1261
            return null;
43,900✔
1262
          }
1263
          return ret;
84,396✔
1264
        };
1265

1266
        return this.flattenSameOfQueries({
22✔
1267
          type: SearchQueryTypes.OR,
1268
          list: getAllCombinations(
1269
            someOfQ.min,
1270
            (query as SearchListQuery).list
1271
          ),
1272
        } as ORSearchQuery);
1273
    }
1274
    return query;
916,894✔
1275
  }
1276

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

1307

1308
  public hasDirectoryQuery(query: SearchQueryDTO): boolean {
1309
    switch (query.type) {
41!
1310
      case SearchQueryTypes.AND:
1311
      case SearchQueryTypes.OR:
1312
      case SearchQueryTypes.SOME_OF:
1313
        return (query as SearchListQuery).list.some(q => this.hasDirectoryQuery(q));
×
1314
      case SearchQueryTypes.any_text:
1315
      case SearchQueryTypes.directory:
1316
        return true;
15✔
1317
    }
1318
    return false;
26✔
1319
  }
1320

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

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

1351
      case SearchQueryTypes.any_text:
1352
      case SearchQueryTypes.directory:
1353
        return query;
21✔
1354

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

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

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

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