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

bpatrik / pigallery2 / 19797917245

30 Nov 2025 11:01AM UTC coverage: 68.883% (+0.01%) from 68.872%
19797917245

push

github

bpatrik
fixing tests

1452 of 2359 branches covered (61.55%)

Branch coverage included in aggregate %.

5273 of 7404 relevant lines covered (71.22%)

4169.08 hits per line

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

91.58
/src/backend/model/database/ProjectedCacheManager.ts
1
import {Brackets, Connection} from 'typeorm';
1✔
2
import {SQLConnection} from './SQLConnection';
1✔
3
import {ProjectedDirectoryCacheEntity} from './enitites/ProjectedDirectoryCacheEntity';
1✔
4
import {ProjectedAlbumCacheEntity} from './enitites/album/ProjectedAlbumCacheEntity';
1✔
5
import {DirectoryEntity} from './enitites/DirectoryEntity';
1✔
6
import {ObjectManagers} from '../ObjectManagers';
1✔
7
import {UserEntity} from './enitites/UserEntity';
1✔
8
import {SharingEntity} from './enitites/SharingEntity';
1✔
9
import {IObjectManager} from './IObjectManager';
10
import {ParentDirectoryDTO} from '../../../common/entities/DirectoryDTO';
11
import {DiskManager} from '../fileaccess/DiskManager';
1✔
12
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
1✔
13
import {Logger} from '../../Logger';
1✔
14
import {SessionManager} from './SessionManager';
1✔
15
import * as path from 'path';
1✔
16
import {SessionContext} from '../SessionContext';
1✔
17
import {MediaEntity} from './enitites/MediaEntity';
1✔
18
import {Config} from '../../../common/config/private/Config';
1✔
19
import {DatabaseType} from '../../../common/config/private/PrivateConfig';
1✔
20
import {ProjectedPersonCacheEntity} from './enitites/person/ProjectedPersonCacheEntity';
1✔
21

22
const LOG_TAG = '[ProjectedCacheManager]';
1✔
23

24
export class ProjectedCacheManager implements IObjectManager {
1✔
25

26
  async init(): Promise<void> {
27
    // Cleanup at startup to avoid stale growth
28
    await this.cleanupNonExistingProjections();
255✔
29
  }
30

31
  public async onNewDataVersion(changedDir?: ParentDirectoryDTO): Promise<void> {
32
    if (!changedDir) {
108!
33
      return;
×
34
    }
35
    await this.invalidateDirectoryCache(changedDir);
108✔
36
  }
37

38
  /**
39
   * Includes all possible projection keys, including the default one and sharing keys too.
40
   */
41
  public async getAllProjections(): Promise<string[]> {
42
    const connection = await SQLConnection.getConnection();
255✔
43
    const activeKeys = new Set<string>();
255✔
44

45
    // Always include default projection key
46
    activeKeys.add(SessionManager.NO_PROJECTION_KEY);
255✔
47

48
    // Users
49
    const users = await connection.getRepository(UserEntity).find();
255✔
50
    for (const user of users) {
255✔
51
      const ctx = await ObjectManagers.getInstance().SessionManager.buildContext(user);
145✔
52
      if (ctx?.user?.projectionKey) {
145✔
53
        activeKeys.add(ctx.user.projectionKey);
145✔
54
      }
55
    }
56

57
    // Sharings
58
    const shares = await connection.getRepository(SharingEntity)
255✔
59
      .createQueryBuilder('share')
60
      .leftJoinAndSelect('share.creator', 'creator')
61
      .getMany();
62
    for (const s of shares) {
255✔
63
      const q = ObjectManagers.getInstance().SessionManager.buildAllowListForSharing(s);
×
64
      const key = ObjectManagers.getInstance().SessionManager.createProjectionKey(q);
×
65
      activeKeys.add(key);
×
66
    }
67

68
    return Array.from(activeKeys);
255✔
69
  }
70

71
  public async cleanupNonExistingProjections(): Promise<void> {
72
    const connection = await SQLConnection.getConnection();
255✔
73
    const keys = await this.getAllProjections();
255✔
74
    Logger.debug(LOG_TAG, 'Cleanup non existing projections, known number of keys: ' + keys.length);
255✔
75
    if (keys.length === 0) {
255!
76
      // No known projections; nothing to prune safely
77
      return;
×
78
    }
79

80
    await connection.getRepository(ProjectedDirectoryCacheEntity)
255✔
81
      .createQueryBuilder()
82
      .delete()
83
      .where('projectionKey NOT IN (:...keys)', {keys})
84
      .execute();
85

86
    await connection.getRepository(ProjectedAlbumCacheEntity)
255✔
87
      .createQueryBuilder()
88
      .delete()
89
      .where('projectionKey NOT IN (:...keys)', {keys})
90
      .execute();
91

92
    await connection.getRepository(ProjectedPersonCacheEntity)
255✔
93
      .createQueryBuilder()
94
      .delete()
95
      .where('projectionKey NOT IN (:...keys)', {keys})
96
      .execute();
97
  }
98

99

100
  @ExtensionDecorator(e => e.gallery.ProjectedCacheManager.getCacheForDirectory)
270✔
101
  public async getCacheForDirectory(connection: Connection, session: SessionContext, dir: {
1✔
102
    id: number,
103
    name: string,
104
    path: string
105
  }): Promise<ProjectedDirectoryCacheEntity> {
106
    // Compute aggregates under the current projection (if any)
107
    const mediaRepo = connection.getRepository(MediaEntity);
270✔
108
    const baseQb = mediaRepo
270✔
109
      .createQueryBuilder('media')
110
      .innerJoin('media.directory', 'directory')
111
      .where('directory.id = :dir', {dir: dir.id});
112

113
    if (session.projectionQuery) {
270✔
114
      baseQb.andWhere(session.projectionQuery);
26✔
115
    }
116

117
    const agg = await baseQb
270✔
118
      .select([
119
        'COUNT(*) as mediaCount',
120
        'MIN(media.metadata.creationDate) as oldest',
121
        'MAX(media.metadata.creationDate) as youngest',
122
      ])
123
      .getRawOne();
124

125
    const mediaCount: number = agg?.mediaCount != null ? parseInt(agg.mediaCount as any, 10) : 0;
270!
126
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
270✔
127
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
270✔
128

129
    // Compute recursive media count under projection (includes children) using single SQL query
130
    const recQb = mediaRepo
270✔
131
      .createQueryBuilder('media')
132
      .innerJoin('media.directory', 'directory')
133
      .where(
134
        new Brackets(q => {
135
          q.where('directory.id = :dir', {dir: dir.id});
270✔
136
          if (Config.Database.type === DatabaseType.mysql) {
270✔
137
            q.orWhere('directory.path like :path || \'%\'', {path: DiskManager.pathFromParent(dir)});
135✔
138
          } else {
139
            q.orWhere('directory.path GLOB :path', {
135✔
140
              path: DiskManager.pathFromParent(dir).replaceAll('[', '[[]') + '*',
141
            });
142
          }
143
        })
144
      );
145

146
    if (session.projectionQuery) {
270✔
147
      recQb.andWhere(session.projectionQuery);
26✔
148
    }
149
    const aggRec = await recQb.select(['COUNT(*) as cnt']).getRawOne();
270✔
150
    const recursiveMediaCount = aggRec?.cnt != null ? parseInt(aggRec.cnt as any, 10) : 0;
270!
151

152

153
    // Compute cover respecting projection
154
    const coverMedia = await ObjectManagers.getInstance().CoverManager.getCoverForDirectory(session, dir);
270✔
155

156
    const cacheRepo = connection.getRepository(ProjectedDirectoryCacheEntity);
270✔
157

158
    // Find existing cache row by (projectionKey, directory)
159
    const projectionKey = session?.user?.projectionKey;
270✔
160

161
    let row = await cacheRepo
270✔
162
      .createQueryBuilder('pdc')
163
      .leftJoin('pdc.directory', 'd')
164
      .where('pdc.projectionKey = :pk AND d.id = :dir', {pk: projectionKey, dir: dir.id})
165
      .getOne();
166

167
    if (!row) {
270✔
168
      row = new ProjectedDirectoryCacheEntity();
230✔
169
      row.projectionKey = projectionKey;
230✔
170
      // Avoid fetching the full directory graph; assign relation by id only
171
      row.directory = {id: dir.id} as any;
230✔
172
    }
173

174
    row.mediaCount = mediaCount || 0;
270✔
175
    row.recursiveMediaCount = recursiveMediaCount || 0;
270✔
176
    row.oldestMedia = oldestMedia ?? null;
270✔
177
    row.youngestMedia = youngestMedia ?? null;
270✔
178
    row.cover = coverMedia as any;
270✔
179
    row.valid = true;
270✔
180

181
    return row;
270✔
182
  }
183

184
  public async setAndGetCacheForDirectory(connection: Connection, session: SessionContext, dir: {
185
    id: number,
186
    name: string,
187
    path: string
188
  }): Promise<ProjectedDirectoryCacheEntity> {
189
    const cacheRepo = connection.getRepository(ProjectedDirectoryCacheEntity);
270✔
190
    const row = await this.getCacheForDirectory(connection, session, dir);
270✔
191
    const ret = await cacheRepo.save(row);
270✔
192
    // we would not select these either
193
    delete ret.projectionKey;
270✔
194
    delete ret.directory;
270✔
195
    delete ret.id;
270✔
196
    if (ret.cover) {
270✔
197
      delete ret.cover.id;
241✔
198
    }
199
    return ret;
270✔
200
  }
201

202
  public async setAndGetCacheForAlbum(connection: Connection, session: SessionContext, album: {
203
    id: number,
204
    searchQuery: any
205
  }): Promise<ProjectedAlbumCacheEntity> {
206

207

208
    const cacheRepo = connection.getRepository(ProjectedAlbumCacheEntity);
34✔
209

210
    // Find existing cache row by (projectionKey, album)
211
    const projectionKey = session?.user?.projectionKey;
34✔
212

213
    let row = await cacheRepo
34✔
214
      .createQueryBuilder('pac')
215
      .leftJoin('pac.album', 'a')
216
      .where('pac.projectionKey = :pk AND a.id = :albumId', {pk: projectionKey, albumId: album.id})
217
      .getOne();
218

219
    if (row && row.valid === true) {
34!
220
      return row;
×
221
    }
222

223
    // Compute aggregates under the current projection (if any)
224
    const mediaRepo = connection.getRepository(MediaEntity);
34✔
225

226
    // Build base query from album's search query
227
    const baseQb = mediaRepo
34✔
228
      .createQueryBuilder('media')
229
      .leftJoin('media.directory', 'directory');
230

231
    // Apply album search query constraints
232
    const albumWhereQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(
34✔
233
      album.searchQuery
234
    );
235
    baseQb.andWhere(albumWhereQuery);
34✔
236

237
    // Apply projection constraints if any
238
    if (session.projectionQuery) {
34✔
239
      baseQb.andWhere(session.projectionQuery);
14✔
240
    }
241

242
    const agg = await baseQb
34✔
243
      .select([
244
        'COUNT(*) as itemCount',
245
        'MIN(media.metadata.creationDate) as oldest',
246
        'MAX(media.metadata.creationDate) as youngest',
247
      ])
248
      .getRawOne();
249

250
    const itemCount: number = agg?.itemCount != null ? parseInt(agg.itemCount as any, 10) : 0;
34!
251
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
34✔
252
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
34✔
253

254
    // Compute cover respecting projection
255
    const coverMedia = await ObjectManagers.getInstance().CoverManager.getCoverForAlbum(session, album as any);
34✔
256

257

258
    if (!row) {
34✔
259
      row = new ProjectedAlbumCacheEntity();
32✔
260
      row.projectionKey = projectionKey;
32✔
261
      // Avoid fetching the full album graph; assign relation by id only
262
      row.album = {id: album.id} as any;
32✔
263
    }
264

265
    row.itemCount = itemCount || 0;
34✔
266
    row.oldestMedia = oldestMedia ?? null;
34✔
267
    row.youngestMedia = youngestMedia ?? null;
34✔
268
    row.cover = coverMedia as any;
34✔
269
    row.valid = true;
34✔
270

271
    const ret = await cacheRepo.save(row);
34✔
272
    // we would not select these either
273
    delete ret.projectionKey;
34✔
274
    delete ret.album;
34✔
275
    delete ret.id;
34✔
276
    if (ret.cover) {
34✔
277
      delete ret.cover.id;
16✔
278
    }
279
    return ret;
34✔
280
  }
281

282
  @ExtensionDecorator(e => e.gallery.ProjectedCacheManager.invalidateDirectoryCache)
110✔
283
  protected async invalidateDirectoryCache(dir: ParentDirectoryDTO) {
1✔
284
    const connection = await SQLConnection.getConnection();
110✔
285
    const dirRepo = connection.getRepository(DirectoryEntity);
110✔
286

287
    // Collect directory paths from target to root
288
    const paths: { path: string; name: string }[] = [];
110✔
289
    let fullPath = DiskManager.normalizeDirPath(path.join(dir.path, dir.name));
110✔
290
    const root = DiskManager.pathFromRelativeDirName('.');
110✔
291

292
    // Build path-name pairs for current directory and all parents
293
    while (fullPath !== root) {
110✔
294
      const name = DiskManager.dirName(fullPath);
120✔
295
      const parentPath = DiskManager.pathFromRelativeDirName(fullPath);
120✔
296
      paths.push({path: parentPath, name});
120✔
297
      fullPath = parentPath;
120✔
298
    }
299

300
    // Add root directory
301
    paths.push({path: DiskManager.pathFromRelativeDirName(root), name: DiskManager.dirName(root)});
110✔
302

303
    if (paths.length === 0) {
110!
304
      return;
×
305
    }
306

307
    // Build query for all directories in one shot
308
    const qb = dirRepo.createQueryBuilder('d');
110✔
309
    paths.forEach((p, i) => {
110✔
310
      qb.orWhere(new Brackets(q => {
230✔
311
        q.where(`d.path = :path${i}`, {[`path${i}`]: p.path})
230✔
312
          .andWhere(`d.name = :name${i}`, {[`name${i}`]: p.name});
313
      }));
314
    });
315

316
    // Find matching directories and invalidate their cache entries
317
    const entities = await qb.getMany();
110✔
318
    if (entities.length === 0) {
110!
319
      return;
×
320
    }
321

322
    // Invalidate all related cache entries in one operation
323
    await connection.getRepository(ProjectedDirectoryCacheEntity)
110✔
324
      .createQueryBuilder()
325
      .update()
326
      .set({valid: false})
327
      .where('directoryId IN (:...dirIds)', {dirIds: entities.map(e => e.id)})
122✔
328
      .execute();
329
  }
330

331
}
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