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

bpatrik / pigallery2 / 17685697493

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

push

github

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

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

1305 of 2243 branches covered (58.18%)

Branch coverage included in aggregate %.

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

16 existing lines in 10 files now uncovered.

4794 of 7022 relevant lines covered (68.27%)

4355.01 hits per line

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

91.3
/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';
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();
219✔
29
  }
30

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

38
  public async getAllProjections(): Promise<string[]> {
39
    const connection = await SQLConnection.getConnection();
219✔
40
    const activeKeys = new Set<string>();
219✔
41

42
    // Always include default projection key
43
    activeKeys.add(SessionManager.NO_PROJECTION_KEY);
219✔
44

45
    // Users
46
    const users = await connection.getRepository(UserEntity).find();
219✔
47
    for (const user of users) {
219✔
48
      const ctx = await ObjectManagers.getInstance().SessionManager.buildContext(user);
269✔
49
      if (ctx?.user?.projectionKey) {
269✔
50
        activeKeys.add(ctx.user.projectionKey);
269✔
51
      }
52
    }
53

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

65
    return Array.from(activeKeys);
219✔
66
  }
67

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

77
    await connection.getRepository(ProjectedDirectoryCacheEntity)
219✔
78
      .createQueryBuilder()
79
      .delete()
80
      .where('projectionKey NOT IN (:...keys)', {keys})
81
      .execute();
82

83
    await connection.getRepository(ProjectedAlbumCacheEntity)
219✔
84
      .createQueryBuilder()
85
      .delete()
86
      .where('projectionKey NOT IN (:...keys)', {keys})
87
      .execute();
88

89
    await connection.getRepository(ProjectedPersonCacheEntity)
219✔
90
      .createQueryBuilder()
91
      .delete()
92
      .where('projectionKey NOT IN (:...keys)', {keys})
93
      .execute();
94
  }
95

96
  public async setAndGetCacheForDirectory(connection: Connection, session: SessionContext, dir: {
97
    id: number,
98
    name: string,
99
    path: string
100
  }): Promise<ProjectedDirectoryCacheEntity> {
101
    // Compute aggregates under the current projection (if any)
102
    const mediaRepo = connection.getRepository(MediaEntity);
340✔
103
    const baseQb = mediaRepo
340✔
104
      .createQueryBuilder('media')
105
      .innerJoin('media.directory', 'directory')
106
      .where('directory.id = :dir', {dir: dir.id});
107

108
    if (session.projectionQuery) {
340✔
109
      baseQb.andWhere(session.projectionQuery);
32✔
110
    }
111

112
    const agg = await baseQb
340✔
113
      .select([
114
        'COUNT(*) as mediaCount',
115
        'MIN(media.metadata.creationDate) as oldest',
116
        'MAX(media.metadata.creationDate) as youngest',
117
      ])
118
      .getRawOne();
119

120
    const mediaCount: number = agg?.mediaCount != null ? parseInt(agg.mediaCount as any, 10) : 0;
340!
121
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
340✔
122
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
340✔
123

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

141
    if (session.projectionQuery) {
340✔
142
      recQb.andWhere(session.projectionQuery);
32✔
143
    }
144
    const aggRec = await recQb.select(['COUNT(*) as cnt']).getRawOne();
340✔
145
    const recursiveMediaCount = aggRec?.cnt != null ? parseInt(aggRec.cnt as any, 10) : 0;
340!
146

147

148
    // Compute cover respecting projection
149
    const coverMedia = await ObjectManagers.getInstance().CoverManager.getCoverForDirectory(session, dir);
340✔
150

151
    const cacheRepo = connection.getRepository(ProjectedDirectoryCacheEntity);
340✔
152

153
    // Find existing cache row by (projectionKey, directory)
154
    const projectionKey = session?.user?.projectionKey;
340✔
155

156
    let row = await cacheRepo
340✔
157
      .createQueryBuilder('pdc')
158
      .leftJoin('pdc.directory', 'd')
159
      .where('pdc.projectionKey = :pk AND d.id = :dir', {pk: projectionKey, dir: dir.id})
160
      .getOne();
161

162
    if (!row) {
340✔
163
      row = new ProjectedDirectoryCacheEntity();
208✔
164
      row.projectionKey = projectionKey;
208✔
165
      // Avoid fetching the full directory graph; assign relation by id only
166
      row.directory = {id: dir.id} as any;
208✔
167
    }
168

169
    row.mediaCount = mediaCount || 0;
340✔
170
    row.recursiveMediaCount = recursiveMediaCount || 0;
340✔
171
    row.oldestMedia = oldestMedia ?? null;
340✔
172
    row.youngestMedia = youngestMedia ?? null;
340✔
173
    row.cover = coverMedia as any;
340✔
174
    row.valid = true;
340✔
175

176
    const ret = await cacheRepo.save(row);
340✔
177
    // we would not select these either
178
    delete ret.projectionKey;
340✔
179
    delete ret.directory;
340✔
180
    delete ret.id;
340✔
181
    if (ret.cover) {
340✔
182
      delete ret.cover.id;
311✔
183
    }
184
    return ret;
340✔
185
  }
186

187
  public async setAndGetCacheForAlbum(connection: Connection, session: SessionContext, album: {
188
    id: number,
189
    searchQuery: any
190
  }): Promise<ProjectedAlbumCacheEntity> {
191

192

193
    const cacheRepo = connection.getRepository(ProjectedAlbumCacheEntity);
34✔
194

195
    // Find existing cache row by (projectionKey, album)
196
    const projectionKey = session?.user?.projectionKey;
34✔
197

198
    let row = await cacheRepo
34✔
199
      .createQueryBuilder('pac')
200
      .leftJoin('pac.album', 'a')
201
      .where('pac.projectionKey = :pk AND a.id = :albumId', {pk: projectionKey, albumId: album.id})
202
      .getOne();
203

204
    if (row && row.valid === true) {
34!
NEW
205
      return row;
×
206
    }
207

208
    // Compute aggregates under the current projection (if any)
209
    const mediaRepo = connection.getRepository(MediaEntity);
34✔
210

211
    // Build base query from album's search query
212
    const baseQb = mediaRepo
34✔
213
      .createQueryBuilder('media')
214
      .leftJoin('media.directory', 'directory');
215

216
    // Apply album search query constraints
217
    const albumWhereQuery = await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(
34✔
218
      album.searchQuery
219
    );
220
    baseQb.andWhere(albumWhereQuery);
34✔
221

222
    // Apply projection constraints if any
223
    if (session.projectionQuery) {
34✔
224
      baseQb.andWhere(session.projectionQuery);
14✔
225
    }
226

227
    const agg = await baseQb
34✔
228
      .select([
229
        'COUNT(*) as itemCount',
230
        'MIN(media.metadata.creationDate) as oldest',
231
        'MAX(media.metadata.creationDate) as youngest',
232
      ])
233
      .getRawOne();
234

235
    const itemCount: number = agg?.itemCount != null ? parseInt(agg.itemCount as any, 10) : 0;
34!
236
    const oldestMedia: number = agg?.oldest != null ? parseInt(agg.oldest as any, 10) : null;
34✔
237
    const youngestMedia: number = agg?.youngest != null ? parseInt(agg.youngest as any, 10) : null;
34✔
238

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

242

243
    if (!row) {
34✔
244
      row = new ProjectedAlbumCacheEntity();
32✔
245
      row.projectionKey = projectionKey;
32✔
246
      // Avoid fetching the full album graph; assign relation by id only
247
      row.album = {id: album.id} as any;
32✔
248
    }
249

250
    row.itemCount = itemCount || 0;
34✔
251
    row.oldestMedia = oldestMedia ?? null;
34✔
252
    row.youngestMedia = youngestMedia ?? null;
34✔
253
    row.cover = coverMedia as any;
34✔
254
    row.valid = true;
34✔
255

256
    const ret = await cacheRepo.save(row);
34✔
257
    // we would not select these either
258
    delete ret.projectionKey;
34✔
259
    delete ret.album;
34✔
260
    delete ret.id;
34✔
261
    if (ret.cover) {
34✔
262
      delete ret.cover.id;
16✔
263
    }
264
    return ret;
34✔
265
  }
266

267
  @ExtensionDecorator(e => e.gallery.ProjectedCacheManager.invalidateDirectoryCache)
100✔
268
  protected async invalidateDirectoryCache(dir: ParentDirectoryDTO) {
1✔
269
    const connection = await SQLConnection.getConnection();
100✔
270
    const dirRepo = connection.getRepository(DirectoryEntity);
100✔
271

272
    // Collect directory paths from target to root
273
    const paths: { path: string; name: string }[] = [];
100✔
274
    let fullPath = DiskManager.normalizeDirPath(path.join(dir.path, dir.name));
100✔
275
    const root = DiskManager.pathFromRelativeDirName('.');
100✔
276

277
    // Build path-name pairs for current directory and all parents
278
    while (fullPath !== root) {
100✔
279
      const name = DiskManager.dirName(fullPath);
110✔
280
      const parentPath = DiskManager.pathFromRelativeDirName(fullPath);
110✔
281
      paths.push({path: parentPath, name});
110✔
282
      fullPath = parentPath;
110✔
283
    }
284

285
    // Add root directory
286
    paths.push({path: DiskManager.pathFromRelativeDirName(root), name: DiskManager.dirName(root)});
100✔
287

288
    if (paths.length === 0) {
100!
NEW
289
      return;
×
290
    }
291

292
    // Build query for all directories in one shot
293
    const qb = dirRepo.createQueryBuilder('d');
100✔
294
    paths.forEach((p, i) => {
100✔
295
      qb.orWhere(new Brackets(q => {
210✔
296
        q.where(`d.path = :path${i}`, {[`path${i}`]: p.path})
210✔
297
          .andWhere(`d.name = :name${i}`, {[`name${i}`]: p.name});
298
      }));
299
    });
300

301
    // Find matching directories and invalidate their cache entries
302
    const entities = await qb.getMany();
100✔
303
    if (entities.length === 0) {
100!
NEW
304
      return;
×
305
    }
306

307
    // Invalidate all related cache entries in one operation
308
    await connection.getRepository(ProjectedDirectoryCacheEntity)
100✔
309
      .createQueryBuilder()
310
      .update()
311
      .set({valid: false})
312
      .where('directoryId IN (:...dirIds)', {dirIds: entities.map(e => e.id)})
112✔
313
      .execute();
314
  }
315

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