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

bpatrik / pigallery2 / 20490077344

24 Dec 2025 04:25PM UTC coverage: 68.255% (+0.2%) from 68.02%
20490077344

Pull #1106

github

web-flow
Merge 494bf5444 into 22640d71e
Pull Request #1106: Search improvements

1477 of 2426 branches covered (60.88%)

Branch coverage included in aggregate %.

91 of 95 new or added lines in 3 files covered. (95.79%)

2 existing lines in 2 files now uncovered.

5343 of 7566 relevant lines covered (70.62%)

4263.13 hits per line

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

84.66
/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
  DateSearch,
16
  DistanceSearch,
17
  NegatableSearchQuery,
18
  OrientationSearch,
19
  ORSearchQuery,
20
  RangeSearch,
21
  SearchListQuery,
22
  SearchQueryDTO,
23
  SearchQueryTypes,
24
  SomeOfSearchQuery,
25
  TextSearch,
26
  TextSearchQueryMatchTypes,
27
} from '../../../common/entities/SearchQueryDTO';
28
import {GalleryManager} from './GalleryManager';
1✔
29
import {ObjectManagers} from '../ObjectManagers';
1✔
30
import {DatabaseType} from '../../../common/config/private/PrivateConfig';
1✔
31
import {Utils} from '../../../common/Utils';
1✔
32
import {FileEntity} from './enitites/FileEntity';
1✔
33
import {SQL_COLLATE} from './enitites/EntityUtils';
1✔
34
import {GroupSortByTypes, SortByTypes, SortingMethod} from '../../../common/entities/SortingMethods';
1✔
35
import {SessionContext} from '../SessionContext';
36

37
export class SearchManager {
1✔
38
  private DIRECTORY_SELECT = [
321✔
39
    'directory.id',
40
    'directory.name',
41
    'directory.path',
42
  ];
43
  // makes all search query params unique, so typeorm won't mix them
44
  private queryIdBase = 0;
321✔
45

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

87
    return query;
350✔
88
  }
89

90
  private static autoCompleteItemsUnique(
91
    array: Array<AutoCompleteItem>
92
  ): Array<AutoCompleteItem> {
93
    const a = array.concat();
48✔
94
    for (let i = 0; i < a.length; ++i) {
48✔
95
      for (let j = i + 1; j < a.length; ++j) {
84✔
96
        if (a[i].equals(a[j])) {
110✔
97
          a.splice(j--, 1);
20✔
98
        }
99
      }
100
    }
101

102
    return a;
48✔
103
  }
104

105
  async autocomplete(
106
    session: SessionContext,
107
    value: string,
108
    type: SearchQueryTypes
109
  ): Promise<AutoCompleteItem[]> {
110
    const connection = await SQLConnection.getConnection();
48✔
111

112
    const photoRepository = connection.getRepository(PhotoEntity);
48✔
113
    const mediaRepository = connection.getRepository(MediaEntity);
48✔
114
    const personRepository = connection.getRepository(PersonEntry);
48✔
115
    const directoryRepository = connection.getRepository(DirectoryEntity);
48✔
116

117
    const partialResult: AutoCompleteItem[][] = [];
48✔
118

119
    if (
48✔
120
      type === SearchQueryTypes.any_text ||
72✔
121
      type === SearchQueryTypes.keyword
122
    ) {
123
      const acList: AutoCompleteItem[] = [];
28✔
124
      const q = photoRepository
28✔
125
        .createQueryBuilder('media')
126
        .select('DISTINCT(media.metadata.keywords)')
127
        .where('media.metadata.keywords LIKE :valueKW COLLATE ' + SQL_COLLATE, {
128
          valueKW: '%' + value + '%',
129
        });
130

131
      if (session.projectionQuery) {
28✔
132
        if (session.hasDirectoryProjection) {
6✔
133
          q.leftJoin('media.directory', 'directory');
2✔
134
        }
135
        q.andWhere(session.projectionQuery);
6✔
136
      }
137

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

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

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

215
      if (session.projectionQuery) {
24✔
216
        if (session.hasDirectoryProjection) {
4!
217
          q.leftJoin('media.directory', 'directory');
×
218
        }
219
        q.andWhere(session.projectionQuery);
4✔
220
      }
221

222
      q.groupBy(
24✔
223
        'media.metadata.positionData.country, media.metadata.positionData.state, media.metadata.positionData.city'
224
      )
225
        .limit(Config.Search.AutoComplete.ItemsPerCategory.position);
226
      (
24✔
227

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

251
    if (
48✔
252
      type === SearchQueryTypes.any_text ||
72✔
253
      type === SearchQueryTypes.file_name
254
    ) {
255
      const q = mediaRepository
28✔
256
        .createQueryBuilder('media')
257
        .select('DISTINCT(media.name)')
258
        .where('media.name LIKE :value COLLATE ' + SQL_COLLATE, {
259
          value: '%' + value + '%',
260
        });
261

262

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

282
    if (
48✔
283
      type === SearchQueryTypes.any_text ||
72✔
284
      type === SearchQueryTypes.caption
285
    ) {
286
      const q = photoRepository
24✔
287
        .createQueryBuilder('media')
288
        .select('DISTINCT(media.metadata.caption) as caption')
289
        .where(
290
          'media.metadata.caption LIKE :value COLLATE ' + SQL_COLLATE,
291
          {value: '%' + value + '%'}
292
        );
293

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

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

342
    const result: AutoCompleteItem[] = [];
48✔
343

344
    while (result.length < Config.Search.AutoComplete.ItemsPerCategory.maxItems) {
48✔
345
      let adding = false;
132✔
346
      for (let i = 0; i < partialResult.length; ++i) {
132✔
347
        if (partialResult[i].length <= 0) {
502✔
348
          continue;
398✔
349
        }
350
        result.push(partialResult[i].shift()); // first elements are more important
104✔
351
        adding = true;
104✔
352
      }
353
      if (!adding) {
132✔
354
        break;
44✔
355
      }
356
    }
357

358

359
    return SearchManager.autoCompleteItemsUnique(result);
48✔
360
  }
361

362
  async search(session: SessionContext, queryIN: SearchQueryDTO): Promise<SearchResultDTO> {
363
    const query = await this.prepareQuery(queryIN);
182✔
364
    const connection = await SQLConnection.getConnection();
182✔
365

366
    const result: SearchResultDTO = {
182✔
367
      searchQuery: queryIN,
368
      directories: [],
369
      media: [],
370
      metaFile: [],
371
      resultOverflow: false,
372
    };
373

374
    const q = connection
182✔
375
      .getRepository(MediaEntity)
376
      .createQueryBuilder('media')
377
      .select(['media', ...this.DIRECTORY_SELECT])
378
      .where(this.buildWhereQuery(query))
379
      .leftJoin('media.directory', 'directory')
380
      .limit(Config.Search.maxMediaResult + 1);
381

382
    if (session.projectionQuery) {
182✔
383
      q.andWhere(session.projectionQuery);
6✔
384
    }
385

386
    result.media = await q.getMany();
182✔
387

388
    if (result.media.length > Config.Search.maxMediaResult) {
182!
389
      result.resultOverflow = true;
×
390
    }
391

392

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

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

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

447
    return result;
182✔
448
  }
449

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

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

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

466
  }
467

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

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

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

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

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

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

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

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

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

618
      case SearchQueryTypes.rating:
619
      case SearchQueryTypes.date:
620
      case SearchQueryTypes.person_count:
621
      case SearchQueryTypes.resolution:
622
        if (directoryOnly) {
42!
623
          throw new Error('not supported in directoryOnly mode');
×
624
        }
625
        if (typeof (query as RangeSearch).min === 'undefined' && typeof (query as RangeSearch).max === 'undefined') {
42!
NEW
626
          throw new Error(
×
627
            'Invalid search query: Date Query should contain min or max value'
628
          );
629
        }
630

631
        let field = '';
42✔
632
        let timeOffset = '';
42✔
633
        let min = (query as RangeSearch).min;
42✔
634
        let max = (query as RangeSearch).max;
42✔
635

636
        switch (query.type) {
42✔
637
          case SearchQueryTypes.date:
638
            timeOffset = Config.Gallery.ignoreTimestampOffset === true ? ' + (coalesce(media.metadata.creationDateOffset,0) * 60000)' : '';
8!
639
            field = 'media.metadata.creationDate';
8✔
640
            break;
8✔
641

642
          case SearchQueryTypes.rating:
643
            field = 'media.metadata.rating';
10✔
644
            break;
10✔
645

646
          case SearchQueryTypes.person_count:
647
            field = 'media.metadata.personsLength';
14✔
648
            break;
14✔
649

650
          case SearchQueryTypes.resolution:
651
            field = 'media.metadata.size.width * media.metadata.size.height';
10✔
652
            if (min) {
10✔
653
              min *= 1000 * 1000;
4✔
654
            }
655
            if (max) {
10✔
656
              max *= 1000 * 1000;
4✔
657
            }
658
            break;
10✔
659
        }
660

661

662
        return new Brackets((q): unknown => {
42✔
663

664

665
          const textParam: { [key: string]: unknown } = {};
42✔
666
          if (min === max) {
42!
NEW
667
            textParam['eql' + queryId] = min;
×
UNCOV
668
            q.where(
×
669
              `${field} ${timeOffset} = :eql${queryId}`,
670
              textParam
671
            );
NEW
672
            return q;
×
673
          }
674
          const minRelation = (query as NegatableSearchQuery).negate ? '<' : '>=';
42✔
675
          const maxRelation = (query as NegatableSearchQuery).negate ? '>' : '<=';
42✔
676

677
          if (typeof min !== 'undefined') {
42✔
678
            textParam['min' + queryId] = min;
18✔
679
            q.where(
18✔
680
              `${field} ${timeOffset} ${minRelation} :min${queryId}`,
681
              textParam
682
            );
683
          }
684
          if (typeof max !== 'undefined') {
42✔
685
            textParam['max' + queryId] = max;
24✔
686
            q.andWhere(
24✔
687
              `${field} ${timeOffset} ${maxRelation} :max${queryId}`,
688
              textParam
689
            );
690
          }
691

692
          return q;
42✔
693
        });
694

695
      case SearchQueryTypes.orientation:
696
        if (directoryOnly) {
4!
697
          throw new Error('not supported in directoryOnly mode');
×
698
        }
699
        return new Brackets((q): unknown => {
4✔
700
          if ((query as OrientationSearch).landscape) {
4✔
701
            q.where('media.metadata.size.width >= media.metadata.size.height');
2✔
702
          } else {
703
            q.where('media.metadata.size.width <= media.metadata.size.height');
2✔
704
          }
705
          return q;
4✔
706
        });
707

708
      case SearchQueryTypes.date_pattern: {
709
        if (directoryOnly) {
34!
710
          throw new Error('not supported in directoryOnly mode');
×
711
        }
712
        const tq = query as DatePatternSearch;
34✔
713

714
        return new Brackets((q): unknown => {
34✔
715
          // Fixed frequency
716
          if ((tq.frequency === DatePatternFrequency.years_ago ||
34✔
717
            tq.frequency === DatePatternFrequency.months_ago ||
718
            tq.frequency === DatePatternFrequency.weeks_ago ||
719
            tq.frequency === DatePatternFrequency.days_ago)) {
720

721
            if (isNaN(tq.agoNumber)) {
12!
722
              throw new Error('ago number is missing on date pattern search query with frequency: ' + DatePatternFrequency[tq.frequency] + ', ago number: ' + tq.agoNumber);
×
723
            }
724
            const to = new Date();
12✔
725
            to.setHours(0, 0, 0, 0);
12✔
726
            to.setUTCDate(to.getUTCDate() + 1);
12✔
727

728
            switch (tq.frequency) {
12!
729
              case DatePatternFrequency.days_ago:
730
                to.setUTCDate(to.getUTCDate() - tq.agoNumber);
4✔
731
                break;
4✔
732
              case DatePatternFrequency.weeks_ago:
733
                to.setUTCDate(to.getUTCDate() - tq.agoNumber * 7);
×
734
                break;
×
735

736
              case DatePatternFrequency.months_ago:
737
                to.setTime(Utils.addMonthToDate(to, -1 * tq.agoNumber).getTime());
8✔
738
                break;
8✔
739

740
              case DatePatternFrequency.years_ago:
741
                to.setUTCFullYear(to.getUTCFullYear() - tq.agoNumber);
×
742
                break;
×
743
            }
744
            const from = new Date(to);
12✔
745
            from.setUTCDate(from.getUTCDate() - tq.daysLength);
12✔
746

747
            const textParam: { [key: string]: unknown } = {};
12✔
748
            textParam['to' + queryId] = to.getTime();
12✔
749
            textParam['from' + queryId] = from.getTime();
12✔
750
            if (tq.negate) {
12✔
751
              if (Config.Gallery.ignoreTimestampOffset === true) {
6!
752
                q.where(
×
753
                  `(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) >= :to${queryId}`,
754
                  textParam
755
                ).orWhere(`(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) < :from${queryId}`,
756
                  textParam);
757
              } else {
758
                q.where(
6✔
759
                  `media.metadata.creationDate >= :to${queryId}`,
760
                  textParam
761
                ).orWhere(`media.metadata.creationDate < :from${queryId}`,
762
                  textParam);
763

764
              }
765
            } else {
766
              if (Config.Gallery.ignoreTimestampOffset === true) {
6!
767
                q.where(
×
768
                  `(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) < :to${queryId}`,
769
                  textParam
770
                ).andWhere(`(media.metadata.creationDate + (coalesce(media.metadata.creationDateOffset,0) * 60000)) >= :from${queryId}`,
771
                  textParam);
772
              } else {
773
                q.where(
6✔
774
                  `media.metadata.creationDate < :to${queryId}`,
775
                  textParam
776
                ).andWhere(`media.metadata.creationDate >= :from${queryId}`,
777
                  textParam);
778
              }
779
            }
780

781
          } else {
782
            // recurring
783

784
            const textParam: { [key: string]: unknown } = {};
22✔
785
            textParam['diff' + queryId] = tq.daysLength;
22✔
786
            const addWhere = (duration: string, crossesDateBoundary: boolean) => {
22✔
787

788
              const relationEql = tq.negate ? '!=' : '=';
16✔
789

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

796

797
              if (Config.Database.type === DatabaseType.sqlite) {
16✔
798
                if (tq.daysLength == 0) {
8✔
799
                  if (Config.Gallery.ignoreTimestampOffset === true) {
2!
800
                    q.where(
×
801
                      `CAST(strftime('${duration}',(media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, 'unixepoch') AS INTEGER) ${relationEql} CAST(strftime('${duration}','now') AS INTEGER)`
802
                    );
803
                  } else {
804
                    q.where(
2✔
805
                      `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationEql} CAST(strftime('${duration}','now') AS INTEGER)`
806
                    );
807
                  }
808
                } else {
809
                  if (Config.Gallery.ignoreTimestampOffset === true) {
6!
810
                    q.where(
×
811
                      `CAST(strftime('${duration}',(media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, 'unixepoch') AS INTEGER) ${relationTop} CAST(strftime('${duration}','now') AS INTEGER)`
812
                    )[whereFN](`CAST(strftime('${duration}',(media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, 'unixepoch') AS INTEGER) ${relationBottom} CAST(strftime('${duration}','now','-:diff${queryId} day') AS INTEGER)`,
813
                      textParam);
814
                  } else {
815
                    q.where(
6✔
816
                      `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationTop} CAST(strftime('${duration}','now') AS INTEGER)`
817
                    )[whereFN](`CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationBottom} CAST(strftime('${duration}','now','-:diff${queryId} day') AS INTEGER)`,
818
                      textParam);
819
                  }
820
                }
821
              } else {
822
                if (tq.daysLength == 0) {
8✔
823
                  if (Config.Gallery.ignoreTimestampOffset === true) {
2!
824
                    q.where(
×
825
                      `CAST(FROM_UNIXTIME((media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, '${duration}') AS SIGNED) ${relationEql} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
826
                    );
827
                  } else {
828
                    q.where(
2✔
829
                      `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationEql} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
830
                    );
831
                  }
832
                } else {
833
                  if (Config.Gallery.ignoreTimestampOffset === true) {
6!
834
                    q.where(
×
835
                      `CAST(FROM_UNIXTIME((media.metadataCreationDate + (media.metadataCreationDateOffset * 60000))/1000, '${duration}') AS SIGNED) ${relationTop} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
836
                    )[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)`,
837
                      textParam);
838
                  } else {
839
                    q.where(
6✔
840
                      `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationTop} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)`
841
                    )[whereFN](`CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationBottom} CAST(DATE_FORMAT((DATE_ADD(curdate(), INTERVAL -:diff${queryId} DAY)),'${duration}') AS SIGNED)`,
842
                      textParam);
843
                  }
844
                }
845
              }
846
            };
847
            switch (tq.frequency) {
22!
848
              case DatePatternFrequency.every_year:
849
                const d = new Date();
20✔
850
                if (tq.daysLength >= (Utils.isDateFromLeapYear(d) ? 366 : 365)) { // trivial result includes all photos
20!
851
                  if (tq.negate) {
4✔
852
                    q.andWhere('FALSE');
2✔
853
                  }
854
                  return q;
4✔
855
                }
856

857
                const dayOfYear = Utils.getDayOfYear(d);
16✔
858
                addWhere('%m%d', dayOfYear - tq.daysLength < 0);
16✔
859
                break;
16✔
860
              case DatePatternFrequency.every_month:
861
                if (tq.daysLength >= 31) { // trivial result includes all photos
2✔
862
                  if (tq.negate) {
2!
863
                    q.andWhere('FALSE');
×
864
                  }
865
                  return q;
2✔
866
                }
867
                addWhere('%d', (new Date()).getUTCDate() - tq.daysLength < 0);
×
868
                break;
×
869
              case DatePatternFrequency.every_week:
870
                if (tq.daysLength >= 7) { // trivial result includes all photos
×
871
                  if (tq.negate) {
×
872
                    q.andWhere('FALSE');
×
873
                  }
874
                  return q;
×
875
                }
876
                addWhere('%w', (new Date()).getUTCDay() - tq.daysLength < 0);
×
877
                break;
×
878
            }
879

880

881
          }
882

883
          return q;
28✔
884
        });
885
      }
886

887
      case SearchQueryTypes.SOME_OF:
888
        throw new Error('Some of not supported');
×
889
    }
890

891
    return new Brackets((q: WhereExpression) => {
53,056✔
892
      const createMatchString = (str: string): string => {
53,262✔
893
        if (
53,666✔
894
          (query as TextSearch).matchType ===
895
          TextSearchQueryMatchTypes.exact_match
896
        ) {
897
          return str;
170✔
898
        }
899
        // MySQL uses C escape syntax in strings, details:
900
        // https://stackoverflow.com/questions/14926386/how-to-search-for-slash-in-mysql-and-why-escaping-not-required-for-wher
901
        if (Config.Database.type === DatabaseType.mysql) {
53,496✔
902
          /// this reqExp replaces the "\\" to "\\\\\"
903
          return '%' + str.replace(new RegExp('\\\\', 'g'), '\\\\') + '%';
26,757✔
904
        }
905
        return `%${str}%`;
26,739✔
906
      };
907

908
      const LIKE = (query as TextSearch).negate ? 'NOT LIKE' : 'LIKE';
53,262✔
909
      // if the expression is negated, we use AND instead of OR as nowhere should that match
910
      const whereFN = (query as TextSearch).negate ? 'andWhere' : 'orWhere';
53,262✔
911
      const whereFNRev = (query as TextSearch).negate ? 'orWhere' : 'andWhere';
53,262✔
912

913
      const textParam: { [key: string]: unknown } = {};
53,262✔
914
      textParam['text' + queryId] = createMatchString(
53,262✔
915
        (query as TextSearch).value
916
      );
917

918
      if (
53,262✔
919
        query.type === SearchQueryTypes.any_text ||
106,372✔
920
        query.type === SearchQueryTypes.directory
921
      ) {
922
        const dirPathStr = (query as TextSearch).value.replace(
200✔
923
          new RegExp('\\\\', 'g'),
924
          '/'
925
        );
926
        const alias = aliases['directory'] ?? 'directory';
200✔
927
        textParam['fullPath' + queryId] = createMatchString(dirPathStr);
200✔
928
        q[whereFN](
200✔
929
          `${alias}.path ${LIKE} :fullPath${queryId} COLLATE ` + SQL_COLLATE,
930
          textParam
931
        );
932

933
        const directoryPath = GalleryManager.parseRelativeDirPath(dirPathStr);
200✔
934
        q[whereFN](
200✔
935
          new Brackets((dq): unknown => {
936
            textParam['dirName' + queryId] = createMatchString(
200✔
937
              directoryPath.name
938
            );
939
            dq[whereFNRev](
200✔
940
              `${alias}.name ${LIKE} :dirName${queryId} COLLATE ${SQL_COLLATE}`,
941
              textParam
942
            );
943
            if (dirPathStr.includes('/')) {
200✔
944
              textParam['parentName' + queryId] = createMatchString(
4✔
945
                directoryPath.parent
946
              );
947
              dq[whereFNRev](
4✔
948
                `${alias}.path ${LIKE} :parentName${queryId} COLLATE ${SQL_COLLATE}`,
949
                textParam
950
              );
951
            }
952
            return dq;
200✔
953
          })
954
        );
955
      }
956

957
      if (
53,262✔
958
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
959
        query.type === SearchQueryTypes.file_name
960
      ) {
961
        q[whereFN](
52,968✔
962
          `media.name ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
963
          textParam
964
        );
965
      }
966

967
      if (
53,262✔
968
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
969
        query.type === SearchQueryTypes.caption
970
      ) {
971
        q[whereFN](
164✔
972
          `media.metadata.caption ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
973
          textParam
974
        );
975
      }
976

977
      if (
53,262✔
978
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
979
        query.type === SearchQueryTypes.position
980
      ) {
981
        q[whereFN](
154✔
982
          `media.metadata.positionData.country ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
983
          textParam
984
        )[whereFN](
985
          `media.metadata.positionData.state ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
986
          textParam
987
        )[whereFN](
988
          `media.metadata.positionData.city ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
989
          textParam
990
        );
991
      }
992

993
      // Matching for array type fields
994
      const matchArrayField = (fieldName: string): void => {
53,262✔
995
        q[whereFN](
506✔
996
          new Brackets((qbr): void => {
997
            if (
506✔
998
              (query as TextSearch).matchType !==
999
              TextSearchQueryMatchTypes.exact_match
1000
            ) {
1001
              qbr[whereFN](
410✔
1002
                `${fieldName} ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1003
                textParam
1004
              );
1005
            } else {
1006
              qbr[whereFN](
96✔
1007
                new Brackets((qb): void => {
1008
                  textParam['CtextC' + queryId] = `%,${
96✔
1009
                    (query as TextSearch).value
1010
                  },%`;
1011
                  textParam['Ctext' + queryId] = `%,${
96✔
1012
                    (query as TextSearch).value
1013
                  }`;
1014
                  textParam['textC' + queryId] = `${
96✔
1015
                    (query as TextSearch).value
1016
                  },%`;
1017
                  textParam['text_exact' + queryId] = `${
96✔
1018
                    (query as TextSearch).value
1019
                  }`;
1020

1021
                  qb[whereFN](
96✔
1022
                    `${fieldName} ${LIKE} :CtextC${queryId} COLLATE ${SQL_COLLATE}`,
1023
                    textParam
1024
                  );
1025
                  qb[whereFN](
96✔
1026
                    `${fieldName} ${LIKE} :Ctext${queryId} COLLATE ${SQL_COLLATE}`,
1027
                    textParam
1028
                  );
1029
                  qb[whereFN](
96✔
1030
                    `${fieldName} ${LIKE} :textC${queryId} COLLATE ${SQL_COLLATE}`,
1031
                    textParam
1032
                  );
1033
                  qb[whereFN](
96✔
1034
                    `${fieldName} ${LIKE} :text_exact${queryId} COLLATE ${SQL_COLLATE}`,
1035
                    textParam
1036
                  );
1037
                })
1038
              );
1039
            }
1040
            if ((query as TextSearch).negate) {
506✔
1041
              qbr.orWhere(`${fieldName} IS NULL`);
12✔
1042
            }
1043
          })
1044
        );
1045
      };
1046

1047
      if (
53,262✔
1048
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
1049
        query.type === SearchQueryTypes.person
1050
      ) {
1051
        matchArrayField('media.metadata.persons');
256✔
1052
      }
1053

1054
      if (
53,262✔
1055
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
1056
        query.type === SearchQueryTypes.keyword
1057
      ) {
1058
        matchArrayField('media.metadata.keywords');
250✔
1059
      }
1060
      return q;
53,262✔
1061
    });
1062
  }
1063

1064
  public hasDirectoryQuery(query: SearchQueryDTO): boolean {
1065
    switch (query.type) {
53!
1066
      case SearchQueryTypes.AND:
1067
      case SearchQueryTypes.OR:
1068
      case SearchQueryTypes.SOME_OF:
1069
        return (query as SearchListQuery).list.some(q => this.hasDirectoryQuery(q));
×
1070
      case SearchQueryTypes.any_text:
1071
      case SearchQueryTypes.directory:
1072
        return true;
15✔
1073
    }
1074
    return false;
38✔
1075
  }
1076

1077
  protected flattenSameOfQueries(query: SearchQueryDTO): SearchQueryDTO {
1078
    switch (query.type) {
1,447,838✔
1079
      case SearchQueryTypes.AND:
1080
      case SearchQueryTypes.OR:
1081
        return {
530,866✔
1082
          type: query.type,
1083
          list: ((query as SearchListQuery).list || []).map(
530,866!
1084
            (q): SearchQueryDTO => this.flattenSameOfQueries(q)
1,447,396✔
1085
          ),
1086
        } as SearchListQuery;
1087
      case SearchQueryTypes.SOME_OF:
1088
        const someOfQ = query as SomeOfSearchQuery;
32✔
1089
        someOfQ.min = someOfQ.min || 1;
32✔
1090

1091
        if (someOfQ.min === 1) {
32✔
1092
          return this.flattenSameOfQueries({
6✔
1093
            type: SearchQueryTypes.OR,
1094
            list: (someOfQ as SearchListQuery).list,
1095
          } as ORSearchQuery);
1096
        }
1097

1098
        if (someOfQ.min === ((query as SearchListQuery).list || []).length) {
26!
1099
          return this.flattenSameOfQueries({
2✔
1100
            type: SearchQueryTypes.AND,
1101
            list: (someOfQ as SearchListQuery).list,
1102
          } as ANDSearchQuery);
1103
        }
1104

1105
        const getAllCombinations = (
24✔
1106
          num: number,
1107
          arr: SearchQueryDTO[],
1108
          start = 0
24✔
1109
        ): SearchQueryDTO[] => {
1110
          if (num <= 0 || num > arr.length || start >= arr.length) {
435,932✔
1111
            return null;
84,400✔
1112
          }
1113
          if (num <= 1) {
351,532✔
1114
            return arr.slice(start);
193,134✔
1115
          }
1116
          if (num === arr.length - start) {
158,398✔
1117
            return [
30,096✔
1118
              {
1119
                type: SearchQueryTypes.AND,
1120
                list: arr.slice(start),
1121
              } as ANDSearchQuery,
1122
            ];
1123
          }
1124
          const ret: ANDSearchQuery[] = [];
128,302✔
1125
          for (let i = start; i < arr.length; ++i) {
128,302✔
1126
            const subRes = getAllCombinations(num - 1, arr, i + 1);
435,908✔
1127
            if (subRes === null) {
435,908✔
1128
              break;
128,302✔
1129
            }
1130
            const and: ANDSearchQuery = {
307,606✔
1131
              type: SearchQueryTypes.AND,
1132
              list: [arr[i]],
1133
            };
1134
            if (subRes.length === 1) {
307,606✔
1135
              if (subRes[0].type === SearchQueryTypes.AND) {
84,400✔
1136
                and.list.push(...(subRes[0] as ANDSearchQuery).list);
30,096✔
1137
              } else {
1138
                and.list.push(subRes[0]);
54,304✔
1139
              }
1140
            } else {
1141
              and.list.push({
223,206✔
1142
                type: SearchQueryTypes.OR,
1143
                list: subRes,
1144
              } as ORSearchQuery);
1145
            }
1146
            ret.push(and);
307,606✔
1147
          }
1148

1149
          if (ret.length === 0) {
128,302✔
1150
            return null;
43,902✔
1151
          }
1152
          return ret;
84,400✔
1153
        };
1154

1155
        return this.flattenSameOfQueries({
24✔
1156
          type: SearchQueryTypes.OR,
1157
          list: getAllCombinations(
1158
            someOfQ.min,
1159
            (query as SearchListQuery).list
1160
          ),
1161
        } as ORSearchQuery);
1162
    }
1163
    return query;
916,940✔
1164
  }
1165

1166
  /**
1167
   * Assigning IDs to search queries. It is a help / simplification to typeorm,
1168
   * so less parameters are needed to pass down to SQL.
1169
   * Witch SOME_OF query the number of WHERE constrains have O(N!) complexity
1170
   */
1171
  private assignQueryIDs(
1172
    queryIN: SearchQueryDTO,
1173
    id = {value: 1}
392✔
1174
  ): SearchQueryDTO {
1175
    // It is possible that one SQL query contains multiple searchQueries
1176
    // (like: where (<searchQuery1> AND (<searchQuery2>))
1177
    // lets make params unique across multiple queries
1178
    if (id.value === 1) {
522✔
1179
      this.queryIdBase++;
428✔
1180
      if (this.queryIdBase > 10000) {
428!
1181
        this.queryIdBase = 0;
×
1182
      }
1183
    }
1184
    if ((queryIN as SearchListQuery).list) {
522✔
1185
      (queryIN as SearchListQuery).list.forEach((q) =>
36✔
1186
        this.assignQueryIDs(q, id)
130✔
1187
      );
1188
      return queryIN;
36✔
1189
    }
1190
    (queryIN as SearchQueryDTOWithID).queryId =
486✔
1191
      this.queryIdBase + '_' + id.value;
1192
    id.value++;
486✔
1193
    return queryIN;
486✔
1194
  }
1195

1196
  /**
1197
   * Returns only those parts of a query tree that only contains directory-related search queries
1198
   */
1199
  private filterDirectoryQuery(query: SearchQueryDTO): SearchQueryDTO {
1200
    switch (query.type) {
21!
1201
      case SearchQueryTypes.AND:
1202
        const andRet = {
×
1203
          type: SearchQueryTypes.AND,
1204
          list: (query as SearchListQuery).list.map((q) =>
1205
            this.filterDirectoryQuery(q)
×
1206
          ),
1207
        } as ANDSearchQuery;
1208
        // if any of the queries contain non dir query thw whole and query is a non dir query
1209
        if (andRet.list.indexOf(null) !== -1) {
×
1210
          return null;
×
1211
        }
1212
        return andRet;
×
1213

1214
      case SearchQueryTypes.OR:
1215
        const orRet = {
×
1216
          type: SearchQueryTypes.OR,
1217
          list: (query as SearchListQuery).list
1218
            .map((q) => this.filterDirectoryQuery(q))
×
1219
            .filter((q) => q !== null),
×
1220
        } as ORSearchQuery;
1221
        if (orRet.list.length === 0) {
×
1222
          return null;
×
1223
        }
1224
        return orRet;
×
1225

1226
      case SearchQueryTypes.any_text:
1227
      case SearchQueryTypes.directory:
1228
        return query;
21✔
1229

1230
      case SearchQueryTypes.SOME_OF:
1231
        throw new Error('"Some of" queries should have been already flattened');
×
1232
    }
1233
    // of none of the above, its not a directory search
1234
    return null;
×
1235
  }
1236

1237
  private async getGPSData(query: SearchQueryDTO): Promise<SearchQueryDTO> {
1238
    if ((query as ANDSearchQuery | ORSearchQuery).list) {
74,524✔
1239
      for (
21,384✔
1240
        let i = 0;
21,384✔
1241
        i < (query as ANDSearchQuery | ORSearchQuery).list.length;
1242
        ++i
1243
      ) {
1244
        (query as ANDSearchQuery | ORSearchQuery).list[i] =
74,132✔
1245
          await this.getGPSData(
1246
            (query as ANDSearchQuery | ORSearchQuery).list[i]
1247
          );
1248
      }
1249
    }
1250
    if (
74,524✔
1251
      query.type === SearchQueryTypes.distance &&
74,534✔
1252
      (query as DistanceSearch).from.value
1253
    ) {
1254
      (query as DistanceSearch).from.GPSData =
2✔
1255
        await ObjectManagers.getInstance().LocationManager.getGPSData(
1256
          (query as DistanceSearch).from.value
1257
        );
1258
    }
1259
    return query;
74,524✔
1260
  }
1261

1262
  private encapsulateAutoComplete(
1263
    values: string[],
1264
    type: SearchQueryTypes
1265
  ): Array<AutoCompleteItem> {
1266
    const res: AutoCompleteItem[] = [];
168✔
1267
    values.forEach((value): void => {
168✔
1268
      res.push(new AutoCompleteItem(value, type));
148✔
1269
    });
1270
    return res;
168✔
1271
  }
1272
}
1273

1274
export interface SearchQueryDTOWithID extends SearchQueryDTO {
1275
  queryId: string;
1276
}
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