mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-02 18:59:15 -05:00 
			
		
		
		
	perf(server): optimize getByIds query (#7918)
				
					
				
			* clean up usage * i'm not updating all these tests * update tests * add indices * add indices to entities remove index from person entity add to face entity fix * simplify query * update sql * missing await * remove synchronize false
This commit is contained in:
		
							parent
							
								
									d67cc00e4e
								
							
						
					
					
						commit
						ee8e8a0c0f
					
				@ -164,7 +164,7 @@ describe(DownloadService.name, () => {
 | 
				
			|||||||
      const assetIds = ['asset-1', 'asset-2'];
 | 
					      const assetIds = ['asset-1', 'asset-2'];
 | 
				
			||||||
      await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse);
 | 
					      await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2']);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2'], { exifInfo: true });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should return a list of archives (albumId)', async () => {
 | 
					    it('should return a list of archives (albumId)', async () => {
 | 
				
			||||||
@ -228,10 +228,10 @@ describe(DownloadService.name, () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds));
 | 
					      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds));
 | 
				
			||||||
      when(assetMock.getByIds)
 | 
					      when(assetMock.getByIds)
 | 
				
			||||||
        .calledWith([assetStub.livePhotoStillAsset.id])
 | 
					        .calledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true })
 | 
				
			||||||
        .mockResolvedValue([assetStub.livePhotoStillAsset]);
 | 
					        .mockResolvedValue([assetStub.livePhotoStillAsset]);
 | 
				
			||||||
      when(assetMock.getByIds)
 | 
					      when(assetMock.getByIds)
 | 
				
			||||||
        .calledWith([assetStub.livePhotoMotionAsset.id])
 | 
					        .calledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true })
 | 
				
			||||||
        .mockResolvedValue([assetStub.livePhotoMotionAsset]);
 | 
					        .mockResolvedValue([assetStub.livePhotoMotionAsset]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({
 | 
					      await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({
 | 
				
			||||||
 | 
				
			|||||||
@ -50,7 +50,7 @@ export class DownloadService {
 | 
				
			|||||||
      // motion part of live photos
 | 
					      // motion part of live photos
 | 
				
			||||||
      const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter<string>((id): id is string => !!id);
 | 
					      const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter<string>((id): id is string => !!id);
 | 
				
			||||||
      if (motionIds.length > 0) {
 | 
					      if (motionIds.length > 0) {
 | 
				
			||||||
        assets.push(...(await this.assetRepository.getByIds(motionIds)));
 | 
					        assets.push(...(await this.assetRepository.getByIds(motionIds, { exifInfo: true })));
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      for (const asset of assets) {
 | 
					      for (const asset of assets) {
 | 
				
			||||||
@ -114,7 +114,7 @@ export class DownloadService {
 | 
				
			|||||||
    if (dto.assetIds) {
 | 
					    if (dto.assetIds) {
 | 
				
			||||||
      const assetIds = dto.assetIds;
 | 
					      const assetIds = dto.assetIds;
 | 
				
			||||||
      await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds);
 | 
					      await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds);
 | 
				
			||||||
      const assets = await this.assetRepository.getByIds(assetIds);
 | 
					      const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true });
 | 
				
			||||||
      return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets }));
 | 
					      return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets }));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -330,8 +330,6 @@ describe(JobService.name, () => {
 | 
				
			|||||||
          } else {
 | 
					          } else {
 | 
				
			||||||
            assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
 | 
					            assetMock.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          assetMock.getByIds.mockResolvedValue([]);
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        await sut.init(makeMockHandlers(true));
 | 
					        await sut.init(makeMockHandlers(true));
 | 
				
			||||||
 | 
				
			|||||||
@ -214,7 +214,7 @@ export class JobService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      case JobName.METADATA_EXTRACTION: {
 | 
					      case JobName.METADATA_EXTRACTION: {
 | 
				
			||||||
        if (item.data.source === 'sidecar-write') {
 | 
					        if (item.data.source === 'sidecar-write') {
 | 
				
			||||||
          const [asset] = await this.assetRepository.getByIds([item.data.id]);
 | 
					          const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
 | 
				
			||||||
          if (asset) {
 | 
					          if (asset) {
 | 
				
			||||||
            this.communicationRepository.send(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset));
 | 
					            this.communicationRepository.send(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset));
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
@ -272,7 +272,7 @@ export class JobService {
 | 
				
			|||||||
          break;
 | 
					          break;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const [asset] = await this.assetRepository.getByIds([item.data.id]);
 | 
					        const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients
 | 
					        // Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients
 | 
				
			||||||
        if (asset && asset.isVisible) {
 | 
					        if (asset && asset.isVisible) {
 | 
				
			||||||
 | 
				
			|||||||
@ -165,7 +165,7 @@ export class MediaService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async handleGenerateJpegThumbnail({ id }: IEntityJob) {
 | 
					  async handleGenerateJpegThumbnail({ id }: IEntityJob) {
 | 
				
			||||||
    const [asset] = await this.assetRepository.getByIds([id]);
 | 
					    const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
 | 
				
			||||||
    if (!asset) {
 | 
					    if (!asset) {
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -215,7 +215,7 @@ export class MediaService {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async handleGenerateWebpThumbnail({ id }: IEntityJob) {
 | 
					  async handleGenerateWebpThumbnail({ id }: IEntityJob) {
 | 
				
			||||||
    const [asset] = await this.assetRepository.getByIds([id]);
 | 
					    const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
 | 
				
			||||||
    if (!asset) {
 | 
					    if (!asset) {
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -114,7 +114,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
  describe('handleLivePhotoLinking', () => {
 | 
					  describe('handleLivePhotoLinking', () => {
 | 
				
			||||||
    it('should handle an asset that could not be found', async () => {
 | 
					    it('should handle an asset that could not be found', async () => {
 | 
				
			||||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false);
 | 
					      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false);
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
				
			||||||
      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
					      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
				
			||||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
					      expect(assetMock.save).not.toHaveBeenCalled();
 | 
				
			||||||
      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
					      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
				
			||||||
@ -124,7 +124,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
      assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]);
 | 
					      assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false);
 | 
					      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false);
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
				
			||||||
      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
					      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
				
			||||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
					      expect(assetMock.save).not.toHaveBeenCalled();
 | 
				
			||||||
      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
					      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
				
			||||||
@ -134,7 +134,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
      assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]);
 | 
					      assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(true);
 | 
					      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(true);
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
				
			||||||
      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
					      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
				
			||||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
					      expect(assetMock.save).not.toHaveBeenCalled();
 | 
				
			||||||
      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
					      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
				
			||||||
@ -149,7 +149,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
      ]);
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(true);
 | 
					      await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(true);
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true });
 | 
				
			||||||
      expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({
 | 
					      expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({
 | 
				
			||||||
        livePhotoCID: assetStub.livePhotoStillAsset.id,
 | 
					        livePhotoCID: assetStub.livePhotoStillAsset.id,
 | 
				
			||||||
        ownerId: assetStub.livePhotoMotionAsset.ownerId,
 | 
					        ownerId: assetStub.livePhotoMotionAsset.ownerId,
 | 
				
			||||||
@ -170,7 +170,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
      assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
					      assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
 | 
					      await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true });
 | 
				
			||||||
      expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({
 | 
					      expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({
 | 
				
			||||||
        livePhotoCID: assetStub.livePhotoMotionAsset.id,
 | 
					        livePhotoCID: assetStub.livePhotoMotionAsset.id,
 | 
				
			||||||
        ownerId: assetStub.livePhotoStillAsset.ownerId,
 | 
					        ownerId: assetStub.livePhotoStillAsset.ownerId,
 | 
				
			||||||
 | 
				
			|||||||
@ -153,7 +153,7 @@ export class MetadataService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  async handleLivePhotoLinking(job: IEntityJob) {
 | 
					  async handleLivePhotoLinking(job: IEntityJob) {
 | 
				
			||||||
    const { id } = job;
 | 
					    const { id } = job;
 | 
				
			||||||
    const [asset] = await this.assetRepository.getByIds([id]);
 | 
					    const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
 | 
				
			||||||
    if (!asset?.exifInfo) {
 | 
					    if (!asset?.exifInfo) {
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -121,6 +121,7 @@ export interface IAssetRepository {
 | 
				
			|||||||
    relations?: FindOptionsRelations<AssetEntity>,
 | 
					    relations?: FindOptionsRelations<AssetEntity>,
 | 
				
			||||||
    select?: FindOptionsSelect<AssetEntity>,
 | 
					    select?: FindOptionsSelect<AssetEntity>,
 | 
				
			||||||
  ): Promise<AssetEntity[]>;
 | 
					  ): Promise<AssetEntity[]>;
 | 
				
			||||||
 | 
					  getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
 | 
				
			||||||
  getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
 | 
					  getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
 | 
				
			||||||
  getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
 | 
					  getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
 | 
				
			||||||
  getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
 | 
					  getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
 | 
				
			||||||
 | 
				
			|||||||
@ -76,7 +76,7 @@ describe(SearchService.name, () => {
 | 
				
			|||||||
        fieldName: 'smartInfo.tags',
 | 
					        fieldName: 'smartInfo.tags',
 | 
				
			||||||
        items: [{ value: 'train', data: assetStub.imageFrom2015.id }],
 | 
					        items: [{ value: 'train', data: assetStub.imageFrom2015.id }],
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      assetMock.getByIds.mockResolvedValueOnce([assetStub.image, assetStub.imageFrom2015]);
 | 
					      assetMock.getByIdsWithAllRelations.mockResolvedValueOnce([assetStub.image, assetStub.imageFrom2015]);
 | 
				
			||||||
      const expectedResponse = [
 | 
					      const expectedResponse = [
 | 
				
			||||||
        { fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] },
 | 
					        { fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] },
 | 
				
			||||||
        { fieldName: 'smartInfo.tags', items: [{ value: 'train', data: mapAsset(assetStub.imageFrom2015) }] },
 | 
					        { fieldName: 'smartInfo.tags', items: [{ value: 'train', data: mapAsset(assetStub.imageFrom2015) }] },
 | 
				
			||||||
 | 
				
			|||||||
@ -60,7 +60,7 @@ export class SearchService {
 | 
				
			|||||||
      this.assetRepository.getAssetIdByTag(auth.user.id, options),
 | 
					      this.assetRepository.getAssetIdByTag(auth.user.id, options),
 | 
				
			||||||
    ]);
 | 
					    ]);
 | 
				
			||||||
    const assetIds = new Set<string>(results.flatMap((field) => field.items.map((item) => item.data)));
 | 
					    const assetIds = new Set<string>(results.flatMap((field) => field.items.map((item) => item.data)));
 | 
				
			||||||
    const assets = await this.assetRepository.getByIds([...assetIds]);
 | 
					    const assets = await this.assetRepository.getByIdsWithAllRelations([...assetIds]);
 | 
				
			||||||
    const assetMap = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, mapAsset(asset)]));
 | 
					    const assetMap = new Map<string, AssetResponseDto>(assets.map((asset) => [asset.id, mapAsset(asset)]));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return results.map(({ fieldName, items }) => ({
 | 
					    return results.map(({ fieldName, items }) => ({
 | 
				
			||||||
 | 
				
			|||||||
@ -76,6 +76,10 @@ export class SmartInfoService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const [asset] = await this.assetRepository.getByIds([id]);
 | 
					    const [asset] = await this.assetRepository.getByIds([id]);
 | 
				
			||||||
 | 
					    if (!asset) {
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!asset.resizePath) {
 | 
					    if (!asset.resizePath) {
 | 
				
			||||||
      return false;
 | 
					      return false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -101,11 +101,11 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
        .mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
					        .mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(assetMock.getByIds)
 | 
					      when(assetMock.getByIds)
 | 
				
			||||||
        .calledWith([assetStub.livePhotoStillAsset.id])
 | 
					        .calledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true })
 | 
				
			||||||
        .mockResolvedValue([assetStub.livePhotoStillAsset]);
 | 
					        .mockResolvedValue([assetStub.livePhotoStillAsset]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(assetMock.getByIds)
 | 
					      when(assetMock.getByIds)
 | 
				
			||||||
        .calledWith([assetStub.livePhotoMotionAsset.id])
 | 
					        .calledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true })
 | 
				
			||||||
        .mockResolvedValue([assetStub.livePhotoMotionAsset]);
 | 
					        .mockResolvedValue([assetStub.livePhotoMotionAsset]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(moveMock.create)
 | 
					      when(moveMock.create)
 | 
				
			||||||
@ -140,8 +140,8 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
 | 
					      await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true });
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true });
 | 
				
			||||||
      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
 | 
					      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
 | 
				
			||||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
					      expect(assetMock.save).toHaveBeenCalledWith({
 | 
				
			||||||
        id: assetStub.livePhotoStillAsset.id,
 | 
					        id: assetStub.livePhotoStillAsset.id,
 | 
				
			||||||
@ -172,7 +172,9 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
					        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
				
			||||||
        .mockResolvedValue(assetStub.image);
 | 
					        .mockResolvedValue(assetStub.image);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
 | 
					      when(assetMock.getByIds)
 | 
				
			||||||
 | 
					        .calledWith([assetStub.image.id], { exifInfo: true })
 | 
				
			||||||
 | 
					        .mockResolvedValue([assetStub.image]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(moveMock.update)
 | 
					      when(moveMock.update)
 | 
				
			||||||
        .calledWith({
 | 
					        .calledWith({
 | 
				
			||||||
@ -190,7 +192,7 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
					      await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
				
			||||||
      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
 | 
					      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
 | 
				
			||||||
      expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
 | 
					      expect(storageMock.rename).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
 | 
				
			||||||
      expect(moveMock.update).toHaveBeenCalledWith({
 | 
					      expect(moveMock.update).toHaveBeenCalledWith({
 | 
				
			||||||
@ -227,7 +229,9 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
					        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
				
			||||||
        .mockResolvedValue(assetStub.image);
 | 
					        .mockResolvedValue(assetStub.image);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
 | 
					      when(assetMock.getByIds)
 | 
				
			||||||
 | 
					        .calledWith([assetStub.image.id], { exifInfo: true })
 | 
				
			||||||
 | 
					        .mockResolvedValue([assetStub.image]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(moveMock.update)
 | 
					      when(moveMock.update)
 | 
				
			||||||
        .calledWith({
 | 
					        .calledWith({
 | 
				
			||||||
@ -245,7 +249,7 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
					      await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
				
			||||||
      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
 | 
					      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
 | 
				
			||||||
      expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
 | 
					      expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
 | 
				
			||||||
      expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath);
 | 
					      expect(storageMock.rename).toHaveBeenCalledWith(previousFailedNewPath, newPath);
 | 
				
			||||||
@ -275,7 +279,9 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
					        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
				
			||||||
        .mockResolvedValue(assetStub.image);
 | 
					        .mockResolvedValue(assetStub.image);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
 | 
					      when(assetMock.getByIds)
 | 
				
			||||||
 | 
					        .calledWith([assetStub.image.id], { exifInfo: true })
 | 
				
			||||||
 | 
					        .mockResolvedValue([assetStub.image]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      when(moveMock.create)
 | 
					      when(moveMock.create)
 | 
				
			||||||
        .calledWith({
 | 
					        .calledWith({
 | 
				
			||||||
@ -294,7 +300,7 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
					      await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
					      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
				
			||||||
      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1);
 | 
					      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1);
 | 
				
			||||||
      expect(storageMock.stat).toHaveBeenCalledWith(newPath);
 | 
					      expect(storageMock.stat).toHaveBeenCalledWith(newPath);
 | 
				
			||||||
      expect(moveMock.create).toHaveBeenCalledWith({
 | 
					      expect(moveMock.create).toHaveBeenCalledWith({
 | 
				
			||||||
@ -340,7 +346,9 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
          .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
					          .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
				
			||||||
          .mockResolvedValue(assetStub.image);
 | 
					          .mockResolvedValue(assetStub.image);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        when(assetMock.getByIds).calledWith([assetStub.image.id]).mockResolvedValue([assetStub.image]);
 | 
					        when(assetMock.getByIds)
 | 
				
			||||||
 | 
					          .calledWith([assetStub.image.id], { exifInfo: true })
 | 
				
			||||||
 | 
					          .mockResolvedValue([assetStub.image]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        when(moveMock.update)
 | 
					        when(moveMock.update)
 | 
				
			||||||
          .calledWith({
 | 
					          .calledWith({
 | 
				
			||||||
@ -358,7 +366,7 @@ describe(StorageTemplateService.name, () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
					        await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
					        expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
				
			||||||
        expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
 | 
					        expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
 | 
				
			||||||
        expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
 | 
					        expect(storageMock.stat).toHaveBeenCalledWith(previousFailedNewPath);
 | 
				
			||||||
        expect(storageMock.rename).not.toHaveBeenCalled();
 | 
					        expect(storageMock.rename).not.toHaveBeenCalled();
 | 
				
			||||||
 | 
				
			|||||||
@ -92,7 +92,10 @@ export class StorageTemplateService {
 | 
				
			|||||||
      return true;
 | 
					      return true;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const [asset] = await this.assetRepository.getByIds([id]);
 | 
					    const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
 | 
				
			||||||
 | 
					    if (!asset) {
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const user = await this.userRepository.get(asset.ownerId, {});
 | 
					    const user = await this.userRepository.get(asset.ownerId, {});
 | 
				
			||||||
    const storageLabel = user?.storageLabel || null;
 | 
					    const storageLabel = user?.storageLabel || null;
 | 
				
			||||||
@ -101,7 +104,10 @@ export class StorageTemplateService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    // move motion part of live photo
 | 
					    // move motion part of live photo
 | 
				
			||||||
    if (asset.livePhotoVideoId) {
 | 
					    if (asset.livePhotoVideoId) {
 | 
				
			||||||
      const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
 | 
					      const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId], { exifInfo: true });
 | 
				
			||||||
 | 
					      if (!livePhotoVideo) {
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
 | 
					      const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
 | 
				
			||||||
      await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
 | 
					      await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -3,6 +3,7 @@ import { AssetEntity } from './asset.entity';
 | 
				
			|||||||
import { PersonEntity } from './person.entity';
 | 
					import { PersonEntity } from './person.entity';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Entity('asset_faces', { synchronize: false })
 | 
					@Entity('asset_faces', { synchronize: false })
 | 
				
			||||||
 | 
					@Index('IDX_asset_faces_assetId_personId', ['assetId', 'personId'])
 | 
				
			||||||
@Index(['personId', 'assetId'])
 | 
					@Index(['personId', 'assetId'])
 | 
				
			||||||
export class AssetFaceEntity {
 | 
					export class AssetFaceEntity {
 | 
				
			||||||
  @PrimaryGeneratedColumn('uuid')
 | 
					  @PrimaryGeneratedColumn('uuid')
 | 
				
			||||||
 | 
				
			|||||||
@ -35,6 +35,7 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum';
 | 
				
			|||||||
@Index('IDX_day_of_month', { synchronize: false })
 | 
					@Index('IDX_day_of_month', { synchronize: false })
 | 
				
			||||||
@Index('IDX_month', { synchronize: false })
 | 
					@Index('IDX_month', { synchronize: false })
 | 
				
			||||||
@Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId'])
 | 
					@Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId'])
 | 
				
			||||||
 | 
					@Index('IDX_asset_id_stackId', ['id', 'stackId'])
 | 
				
			||||||
@Index('idx_originalFileName_trigram', { synchronize: false })
 | 
					@Index('idx_originalFileName_trigram', { synchronize: false })
 | 
				
			||||||
// For all assets, each originalpath must be unique per user and library
 | 
					// For all assets, each originalpath must be unique per user and library
 | 
				
			||||||
export class AssetEntity {
 | 
					export class AssetEntity {
 | 
				
			||||||
@ -145,7 +146,7 @@ export class AssetEntity {
 | 
				
			|||||||
  smartSearch?: SmartSearchEntity;
 | 
					  smartSearch?: SmartSearchEntity;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
 | 
					  @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
 | 
				
			||||||
  @JoinTable({ name: 'tag_asset' })
 | 
					  @JoinTable({ name: 'tag_asset', synchronize: false })
 | 
				
			||||||
  tags!: TagEntity[];
 | 
					  tags!: TagEntity[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
 | 
					  @ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					import { MigrationInterface, QueryRunner } from "typeorm";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class AddAssetRelationIndices1710293990203 implements MigrationInterface {
 | 
				
			||||||
 | 
					    public async up(queryRunner: QueryRunner): Promise<void> {
 | 
				
			||||||
 | 
					        await queryRunner.query(`CREATE INDEX "IDX_asset_id_stackId" on assets ("id", "stackId")`);
 | 
				
			||||||
 | 
					        await queryRunner.query(`CREATE INDEX "IDX_tag_asset_assetsId_tagsId" on tag_asset ("assetsId", "tagsId")`);
 | 
				
			||||||
 | 
					        await queryRunner.query(`CREATE INDEX "IDX_asset_faces_assetId_personId" on asset_faces ("assetId", "personId")`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async down(queryRunner: QueryRunner): Promise<void> {
 | 
				
			||||||
 | 
					        await queryRunner.query(`DROP INDEX "IDX_asset_id_stackId" on assets ("id", "stackId")`);
 | 
				
			||||||
 | 
					        await queryRunner.query(`DROP INDEX "IDX_tag_asset_assetsId_tagsId" on tag_asset ("assetsId", "tagsId")`);
 | 
				
			||||||
 | 
					        await queryRunner.query(`DROP INDEX "IDX_asset_faces_assetId_personId" on asset_faces ("assetId", "personId")`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -137,8 +137,20 @@ export class AssetRepository implements IAssetRepository {
 | 
				
			|||||||
    relations?: FindOptionsRelations<AssetEntity>,
 | 
					    relations?: FindOptionsRelations<AssetEntity>,
 | 
				
			||||||
    select?: FindOptionsSelect<AssetEntity>,
 | 
					    select?: FindOptionsSelect<AssetEntity>,
 | 
				
			||||||
  ): Promise<AssetEntity[]> {
 | 
					  ): Promise<AssetEntity[]> {
 | 
				
			||||||
    if (!relations) {
 | 
					    return this.repository.find({
 | 
				
			||||||
      relations = {
 | 
					      where: { id: In(ids) },
 | 
				
			||||||
 | 
					      relations,
 | 
				
			||||||
 | 
					      select,
 | 
				
			||||||
 | 
					      withDeleted: true,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @GenerateSql({ params: [[DummyValue.UUID]] })
 | 
				
			||||||
 | 
					  @ChunkedArray()
 | 
				
			||||||
 | 
					  getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]> {
 | 
				
			||||||
 | 
					    return this.repository.find({
 | 
				
			||||||
 | 
					      where: { id: In(ids) },
 | 
				
			||||||
 | 
					      relations: {
 | 
				
			||||||
        exifInfo: true,
 | 
					        exifInfo: true,
 | 
				
			||||||
        smartInfo: true,
 | 
					        smartInfo: true,
 | 
				
			||||||
        tags: true,
 | 
					        tags: true,
 | 
				
			||||||
@ -148,13 +160,7 @@ export class AssetRepository implements IAssetRepository {
 | 
				
			|||||||
        stack: {
 | 
					        stack: {
 | 
				
			||||||
          assets: true,
 | 
					          assets: true,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      };
 | 
					      },
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return this.repository.find({
 | 
					 | 
				
			||||||
      where: { id: In(ids) },
 | 
					 | 
				
			||||||
      relations,
 | 
					 | 
				
			||||||
      select,
 | 
					 | 
				
			||||||
      withDeleted: true,
 | 
					      withDeleted: true,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
@ -160,6 +160,42 @@ ORDER BY
 | 
				
			|||||||
  "entity"."localDateTime" DESC
 | 
					  "entity"."localDateTime" DESC
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- AssetRepository.getByIds
 | 
					-- AssetRepository.getByIds
 | 
				
			||||||
 | 
					SELECT
 | 
				
			||||||
 | 
					  "AssetEntity"."id" AS "AssetEntity_id",
 | 
				
			||||||
 | 
					  "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
 | 
				
			||||||
 | 
					  "AssetEntity"."ownerId" AS "AssetEntity_ownerId",
 | 
				
			||||||
 | 
					  "AssetEntity"."libraryId" AS "AssetEntity_libraryId",
 | 
				
			||||||
 | 
					  "AssetEntity"."deviceId" AS "AssetEntity_deviceId",
 | 
				
			||||||
 | 
					  "AssetEntity"."type" AS "AssetEntity_type",
 | 
				
			||||||
 | 
					  "AssetEntity"."originalPath" AS "AssetEntity_originalPath",
 | 
				
			||||||
 | 
					  "AssetEntity"."resizePath" AS "AssetEntity_resizePath",
 | 
				
			||||||
 | 
					  "AssetEntity"."webpPath" AS "AssetEntity_webpPath",
 | 
				
			||||||
 | 
					  "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
 | 
				
			||||||
 | 
					  "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
 | 
				
			||||||
 | 
					  "AssetEntity"."createdAt" AS "AssetEntity_createdAt",
 | 
				
			||||||
 | 
					  "AssetEntity"."updatedAt" AS "AssetEntity_updatedAt",
 | 
				
			||||||
 | 
					  "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt",
 | 
				
			||||||
 | 
					  "AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt",
 | 
				
			||||||
 | 
					  "AssetEntity"."localDateTime" AS "AssetEntity_localDateTime",
 | 
				
			||||||
 | 
					  "AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt",
 | 
				
			||||||
 | 
					  "AssetEntity"."isFavorite" AS "AssetEntity_isFavorite",
 | 
				
			||||||
 | 
					  "AssetEntity"."isArchived" AS "AssetEntity_isArchived",
 | 
				
			||||||
 | 
					  "AssetEntity"."isExternal" AS "AssetEntity_isExternal",
 | 
				
			||||||
 | 
					  "AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly",
 | 
				
			||||||
 | 
					  "AssetEntity"."isOffline" AS "AssetEntity_isOffline",
 | 
				
			||||||
 | 
					  "AssetEntity"."checksum" AS "AssetEntity_checksum",
 | 
				
			||||||
 | 
					  "AssetEntity"."duration" AS "AssetEntity_duration",
 | 
				
			||||||
 | 
					  "AssetEntity"."isVisible" AS "AssetEntity_isVisible",
 | 
				
			||||||
 | 
					  "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
 | 
				
			||||||
 | 
					  "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
 | 
				
			||||||
 | 
					  "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
 | 
				
			||||||
 | 
					  "AssetEntity"."stackId" AS "AssetEntity_stackId"
 | 
				
			||||||
 | 
					FROM
 | 
				
			||||||
 | 
					  "assets" "AssetEntity"
 | 
				
			||||||
 | 
					WHERE
 | 
				
			||||||
 | 
					  (("AssetEntity"."id" IN ($1)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- AssetRepository.getByIdsWithAllRelations
 | 
				
			||||||
SELECT
 | 
					SELECT
 | 
				
			||||||
  "AssetEntity"."id" AS "AssetEntity_id",
 | 
					  "AssetEntity"."id" AS "AssetEntity_id",
 | 
				
			||||||
  "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
 | 
					  "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
 | 
				
			||||||
 | 
				
			|||||||
@ -8,6 +8,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
 | 
				
			|||||||
    getByDate: jest.fn(),
 | 
					    getByDate: jest.fn(),
 | 
				
			||||||
    getByDayOfYear: jest.fn(),
 | 
					    getByDayOfYear: jest.fn(),
 | 
				
			||||||
    getByIds: jest.fn().mockResolvedValue([]),
 | 
					    getByIds: jest.fn().mockResolvedValue([]),
 | 
				
			||||||
 | 
					    getByIdsWithAllRelations: jest.fn().mockResolvedValue([]),
 | 
				
			||||||
    getByAlbumId: jest.fn(),
 | 
					    getByAlbumId: jest.fn(),
 | 
				
			||||||
    getByUserId: jest.fn(),
 | 
					    getByUserId: jest.fn(),
 | 
				
			||||||
    getById: jest.fn(),
 | 
					    getById: jest.fn(),
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user