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

bpatrik / pigallery2 / 20577878665

29 Dec 2025 04:42PM UTC coverage: 68.254% (+0.007%) from 68.247%
20577878665

push

github

bpatrik
Fixing faces tests

1481 of 2432 branches covered (60.9%)

Branch coverage included in aggregate %.

5356 of 7585 relevant lines covered (70.61%)

4266.46 hits per line

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

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

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

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

86
    return query;
350✔
87
  }
88

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

101
    return a;
48✔
102
  }
103

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

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

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

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

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

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

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

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

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

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

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

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

261

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

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

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

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

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

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

357

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

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

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

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

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

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

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

391

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

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

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

446
    return result;
182✔
447
  }
448

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

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

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

465
  }
466

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

660

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

663

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

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

691
          return q;
42✔
692
        });
693

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

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

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

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

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

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

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

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

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

780
          } else {
781
            // recurring
782

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

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

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

795

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

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

879

880
          }
881

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

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

890

891
    if(!TextSearchQueryTypes.includes(query.type)){
53,056!
892
        throw new Error(
×
893
          `Invalid search query: Unknown query type: ${SearchQueryTypes[query.type]}(type: ${query.type})`
894
        );
895
    }
896

897
    if (typeof (query as TextSearch).value === 'undefined') {
53,056!
898
      throw new Error(
×
899
        `Invalid search query: ${SearchQueryTypes[query.type]}(type: ${query.type}) query should contain 'value' property. Query got: ${JSON.stringify(query)}`
900
      );
901
    }
902

903
    return new Brackets((q: WhereExpression) => {
53,056✔
904
      const createMatchString = (str: string): string => {
53,262✔
905
        if (
53,666✔
906
          (query as TextSearch).matchType ===
907
          TextSearchQueryMatchTypes.exact_match
908
        ) {
909
          return str;
170✔
910
        }
911
        // MySQL uses C escape syntax in strings, details:
912
        // https://stackoverflow.com/questions/14926386/how-to-search-for-slash-in-mysql-and-why-escaping-not-required-for-wher
913
        if (Config.Database.type === DatabaseType.mysql) {
53,496✔
914
          /// this reqExp replaces the "\\" to "\\\\\"
915
          return '%' + str.replace(new RegExp('\\\\', 'g'), '\\\\') + '%';
26,757✔
916
        }
917
        return `%${str}%`;
26,739✔
918
      };
919

920
      const LIKE = (query as TextSearch).negate ? 'NOT LIKE' : 'LIKE';
53,262✔
921
      // if the expression is negated, we use AND instead of OR as nowhere should that match
922
      const whereFN = (query as TextSearch).negate ? 'andWhere' : 'orWhere';
53,262✔
923
      const whereFNRev = (query as TextSearch).negate ? 'orWhere' : 'andWhere';
53,262✔
924

925
      const textParam: { [key: string]: unknown } = {};
53,262✔
926
      textParam['text' + queryId] = createMatchString(
53,262✔
927
        (query as TextSearch).value
928
      );
929

930
      if (
53,262✔
931
        query.type === SearchQueryTypes.any_text ||
106,372✔
932
        query.type === SearchQueryTypes.directory
933
      ) {
934
        const dirPathStr = (query as TextSearch).value.replace(
200✔
935
          new RegExp('\\\\', 'g'),
936
          '/'
937
        );
938
        const alias = aliases['directory'] ?? 'directory';
200✔
939
        textParam['fullPath' + queryId] = createMatchString(dirPathStr);
200✔
940
        q[whereFN](
200✔
941
          `${alias}.path ${LIKE} :fullPath${queryId} COLLATE ` + SQL_COLLATE,
942
          textParam
943
        );
944

945
        const directoryPath = GalleryManager.parseRelativeDirPath(dirPathStr);
200✔
946
        q[whereFN](
200✔
947
          new Brackets((dq): unknown => {
948
            textParam['dirName' + queryId] = createMatchString(
200✔
949
              directoryPath.name
950
            );
951
            dq[whereFNRev](
200✔
952
              `${alias}.name ${LIKE} :dirName${queryId} COLLATE ${SQL_COLLATE}`,
953
              textParam
954
            );
955
            if (dirPathStr.includes('/')) {
200✔
956
              textParam['parentName' + queryId] = createMatchString(
4✔
957
                directoryPath.parent
958
              );
959
              dq[whereFNRev](
4✔
960
                `${alias}.path ${LIKE} :parentName${queryId} COLLATE ${SQL_COLLATE}`,
961
                textParam
962
              );
963
            }
964
            return dq;
200✔
965
          })
966
        );
967
      }
968

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

979
      if (
53,262✔
980
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
981
        query.type === SearchQueryTypes.caption
982
      ) {
983
        q[whereFN](
164✔
984
          `media.metadata.caption ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
985
          textParam
986
        );
987
      }
988

989
      if (
53,262✔
990
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
991
        query.type === SearchQueryTypes.position
992
      ) {
993
        q[whereFN](
154✔
994
          `media.metadata.positionData.country ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
995
          textParam
996
        )[whereFN](
997
          `media.metadata.positionData.state ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
998
          textParam
999
        )[whereFN](
1000
          `media.metadata.positionData.city ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1001
          textParam
1002
        );
1003
      }
1004

1005
      // Matching for array type fields
1006
      const matchArrayField = (fieldName: string): void => {
53,262✔
1007
        q[whereFN](
506✔
1008
          new Brackets((qbr): void => {
1009
            if (
506✔
1010
              (query as TextSearch).matchType !==
1011
              TextSearchQueryMatchTypes.exact_match
1012
            ) {
1013
              qbr[whereFN](
410✔
1014
                `${fieldName} ${LIKE} :text${queryId} COLLATE ${SQL_COLLATE}`,
1015
                textParam
1016
              );
1017
            } else {
1018
              qbr[whereFN](
96✔
1019
                new Brackets((qb): void => {
1020
                  textParam['CtextC' + queryId] = `%,${
96✔
1021
                    (query as TextSearch).value
1022
                  },%`;
1023
                  textParam['Ctext' + queryId] = `%,${
96✔
1024
                    (query as TextSearch).value
1025
                  }`;
1026
                  textParam['textC' + queryId] = `${
96✔
1027
                    (query as TextSearch).value
1028
                  },%`;
1029
                  textParam['text_exact' + queryId] = `${
96✔
1030
                    (query as TextSearch).value
1031
                  }`;
1032

1033
                  qb[whereFN](
96✔
1034
                    `${fieldName} ${LIKE} :CtextC${queryId} COLLATE ${SQL_COLLATE}`,
1035
                    textParam
1036
                  );
1037
                  qb[whereFN](
96✔
1038
                    `${fieldName} ${LIKE} :Ctext${queryId} COLLATE ${SQL_COLLATE}`,
1039
                    textParam
1040
                  );
1041
                  qb[whereFN](
96✔
1042
                    `${fieldName} ${LIKE} :textC${queryId} COLLATE ${SQL_COLLATE}`,
1043
                    textParam
1044
                  );
1045
                  qb[whereFN](
96✔
1046
                    `${fieldName} ${LIKE} :text_exact${queryId} COLLATE ${SQL_COLLATE}`,
1047
                    textParam
1048
                  );
1049
                })
1050
              );
1051
            }
1052
            if ((query as TextSearch).negate) {
506✔
1053
              qbr.orWhere(`${fieldName} IS NULL`);
12✔
1054
            }
1055
          })
1056
        );
1057
      };
1058

1059
      if (
53,262✔
1060
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
1061
        query.type === SearchQueryTypes.person
1062
      ) {
1063
        matchArrayField('media.metadata.persons');
256✔
1064
      }
1065

1066
      if (
53,262✔
1067
        (query.type === SearchQueryTypes.any_text && !directoryOnly) ||
106,530✔
1068
        query.type === SearchQueryTypes.keyword
1069
      ) {
1070
        matchArrayField('media.metadata.keywords');
250✔
1071
      }
1072
      return q;
53,262✔
1073
    });
1074
  }
1075

1076
  public hasDirectoryQuery(query: SearchQueryDTO): boolean {
1077
    switch (query.type) {
53!
1078
      case SearchQueryTypes.AND:
1079
      case SearchQueryTypes.OR:
1080
      case SearchQueryTypes.SOME_OF:
1081
        return (query as SearchListQuery).list.some(q => this.hasDirectoryQuery(q));
×
1082
      case SearchQueryTypes.any_text:
1083
      case SearchQueryTypes.directory:
1084
        return true;
15✔
1085
    }
1086
    return false;
38✔
1087
  }
1088

1089
  protected flattenSameOfQueries(query: SearchQueryDTO): SearchQueryDTO {
1090
    switch (query.type) {
1,447,838✔
1091
      case SearchQueryTypes.AND:
1092
      case SearchQueryTypes.OR:
1093
        return {
530,866✔
1094
          type: query.type,
1095
          list: ((query as SearchListQuery).list || []).map(
530,866!
1096
            (q): SearchQueryDTO => this.flattenSameOfQueries(q)
1,447,396✔
1097
          ),
1098
        } as SearchListQuery;
1099
      case SearchQueryTypes.SOME_OF:
1100
        const someOfQ = query as SomeOfSearchQuery;
32✔
1101
        someOfQ.min = someOfQ.min || 1;
32✔
1102

1103
        if (someOfQ.min === 1) {
32✔
1104
          return this.flattenSameOfQueries({
6✔
1105
            type: SearchQueryTypes.OR,
1106
            list: (someOfQ as SearchListQuery).list,
1107
          } as ORSearchQuery);
1108
        }
1109

1110
        if (someOfQ.min === ((query as SearchListQuery).list || []).length) {
26!
1111
          return this.flattenSameOfQueries({
2✔
1112
            type: SearchQueryTypes.AND,
1113
            list: (someOfQ as SearchListQuery).list,
1114
          } as ANDSearchQuery);
1115
        }
1116

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

1161
          if (ret.length === 0) {
128,302✔
1162
            return null;
43,902✔
1163
          }
1164
          return ret;
84,400✔
1165
        };
1166

1167
        return this.flattenSameOfQueries({
24✔
1168
          type: SearchQueryTypes.OR,
1169
          list: getAllCombinations(
1170
            someOfQ.min,
1171
            (query as SearchListQuery).list
1172
          ),
1173
        } as ORSearchQuery);
1174
    }
1175
    return query;
916,940✔
1176
  }
1177

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

1208
  /**
1209
   * Returns only those parts of a query tree that only contains directory-related search queries
1210
   */
1211
  private filterDirectoryQuery(query: SearchQueryDTO): SearchQueryDTO {
1212
    switch (query.type) {
21!
1213
      case SearchQueryTypes.AND:
1214
        const andRet = {
×
1215
          type: SearchQueryTypes.AND,
1216
          list: (query as SearchListQuery).list.map((q) =>
1217
            this.filterDirectoryQuery(q)
×
1218
          ),
1219
        } as ANDSearchQuery;
1220
        // if any of the queries contain non dir query thw whole and query is a non dir query
1221
        if (andRet.list.indexOf(null) !== -1) {
×
1222
          return null;
×
1223
        }
1224
        return andRet;
×
1225

1226
      case SearchQueryTypes.OR:
1227
        const orRet = {
×
1228
          type: SearchQueryTypes.OR,
1229
          list: (query as SearchListQuery).list
1230
            .map((q) => this.filterDirectoryQuery(q))
×
1231
            .filter((q) => q !== null),
×
1232
        } as ORSearchQuery;
1233
        if (orRet.list.length === 0) {
×
1234
          return null;
×
1235
        }
1236
        return orRet;
×
1237

1238
      case SearchQueryTypes.any_text:
1239
      case SearchQueryTypes.directory:
1240
        return query;
21✔
1241

1242
      case SearchQueryTypes.SOME_OF:
1243
        throw new Error('"Some of" queries should have been already flattened');
×
1244
    }
1245
    // of none of the above, its not a directory search
1246
    return null;
×
1247
  }
1248

1249
  private async getGPSData(query: SearchQueryDTO): Promise<SearchQueryDTO> {
1250
    if ((query as ANDSearchQuery | ORSearchQuery).list) {
74,524✔
1251
      for (
21,384✔
1252
        let i = 0;
21,384✔
1253
        i < (query as ANDSearchQuery | ORSearchQuery).list.length;
1254
        ++i
1255
      ) {
1256
        (query as ANDSearchQuery | ORSearchQuery).list[i] =
74,132✔
1257
          await this.getGPSData(
1258
            (query as ANDSearchQuery | ORSearchQuery).list[i]
1259
          );
1260
      }
1261
    }
1262
    if (
74,524✔
1263
      query.type === SearchQueryTypes.distance &&
74,534✔
1264
      (query as DistanceSearch).from.value
1265
    ) {
1266
      (query as DistanceSearch).from.GPSData =
2✔
1267
        await ObjectManagers.getInstance().LocationManager.getGPSData(
1268
          (query as DistanceSearch).from.value
1269
        );
1270
    }
1271
    return query;
74,524✔
1272
  }
1273

1274
  private encapsulateAutoComplete(
1275
    values: string[],
1276
    type: SearchQueryTypes
1277
  ): Array<AutoCompleteItem> {
1278
    const res: AutoCompleteItem[] = [];
168✔
1279
    values.forEach((value): void => {
168✔
1280
      res.push(new AutoCompleteItem(value, type));
148✔
1281
    });
1282
    return res;
168✔
1283
  }
1284
}
1285

1286
export interface SearchQueryDTOWithID extends SearchQueryDTO {
1287
  queryId: string;
1288
}
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