diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 39ff6ab4af..1805969beb 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -53,9 +53,4 @@ export interface IMetadataRepository { readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; - getCountries(userIds: string[]): Promise>; - getStates(userIds: string[], country?: string): Promise>; - getCities(userIds: string[], country?: string, state?: string): Promise>; - getCameraMakes(userIds: string[], model?: string): Promise>; - getCameraModels(userIds: string[], make?: string): Promise>; } diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index 0226e3663c..323aaf1d00 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -181,4 +181,9 @@ export interface ISearchRepository { deleteAllSearchEmbeddings(): Promise; getDimensionSize(): Promise; setDimensionSize(dimSize: number): Promise; + getCountries(userIds: string[]): Promise>; + getStates(userIds: string[], country?: string): Promise>; + getCities(userIds: string[], country?: string, state?: string): Promise>; + getCameraMakes(userIds: string[], model?: string): Promise>; + getCameraModels(userIds: string[], make?: string): Promise>; } diff --git a/server/src/queries/metadata.repository.sql b/server/src/queries/metadata.repository.sql deleted file mode 100644 index 2125274320..0000000000 --- a/server/src/queries/metadata.repository.sql +++ /dev/null @@ -1,56 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- MetadataRepository.getCountries -SELECT DISTINCT - ON ("exif"."country") "exif"."country" AS "country" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" IN ($1) - --- MetadataRepository.getStates -SELECT DISTINCT - ON ("exif"."state") "exif"."state" AS "state" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" IN ($1) - AND "exif"."country" = $2 - --- MetadataRepository.getCities -SELECT DISTINCT - ON ("exif"."city") "exif"."city" AS "city" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" IN ($1) - AND "exif"."country" = $2 - AND "exif"."state" = $3 - --- MetadataRepository.getCameraMakes -SELECT DISTINCT - ON ("exif"."make") "exif"."make" AS "make" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" IN ($1) - AND "exif"."model" = $2 - --- MetadataRepository.getCameraModels -SELECT DISTINCT - ON ("exif"."model") "exif"."model" AS "model" -FROM - "exif" "exif" - LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" - AND ("asset"."deletedAt" IS NULL) -WHERE - "asset"."ownerId" IN ($1) - AND "exif"."make" = $2 diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index dd2e3ae75c..6658a47835 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -401,3 +401,58 @@ FROM INNER JOIN cte ON asset.id = cte."assetId" ORDER BY exif.city + +-- SearchRepository.getCountries +SELECT DISTINCT + ON ("exif"."country") "exif"."country" AS "country" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + +-- SearchRepository.getStates +SELECT DISTINCT + ON ("exif"."state") "exif"."state" AS "state" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."country" = $2 + +-- SearchRepository.getCities +SELECT DISTINCT + ON ("exif"."city") "exif"."city" AS "city" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."country" = $2 + AND "exif"."state" = $3 + +-- SearchRepository.getCameraMakes +SELECT DISTINCT + ON ("exif"."make") "exif"."make" AS "make" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."model" = $2 + +-- SearchRepository.getCameraModels +SELECT DISTINCT + ON ("exif"."model") "exif"."model" AS "model" +FROM + "exif" "exif" + LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" + AND ("asset"."deletedAt" IS NULL) +WHERE + "asset"."ownerId" IN ($1) + AND "exif"."make" = $2 diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index f5933915ce..b02e071b1a 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored'; import geotz from 'geo-tz'; -import { DummyValue, GenerateSql } from 'src/decorators'; import { ExifEntity } from 'src/entities/exif.entity'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; @@ -54,91 +53,4 @@ export class MetadataRepository implements IMetadataRepository { this.logger.warn(`Error writing exif data (${path}): ${error}`); } } - - @GenerateSql({ params: [[DummyValue.UUID]] }) - async getCountries(userIds: string[]): Promise { - const results = await this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.country', 'country') - .distinctOn(['exif.country']) - .getRawMany<{ country: string }>(); - - return results.map(({ country }) => country).filter((item) => item !== ''); - } - - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getStates(userIds: string[], country: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.state', 'state') - .distinctOn(['exif.state']); - - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - const result = await query.getRawMany<{ state: string }>(); - - return result.map(({ state }) => state).filter((item) => item !== ''); - } - - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) - async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.city', 'city') - .distinctOn(['exif.city']); - - if (country) { - query.andWhere('exif.country = :country', { country }); - } - - if (state) { - query.andWhere('exif.state = :state', { state }); - } - - const results = await query.getRawMany<{ city: string }>(); - - return results.map(({ city }) => city).filter((item) => item !== ''); - } - - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraMakes(userIds: string[], model: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.make', 'make') - .distinctOn(['exif.make']); - - if (model) { - query.andWhere('exif.model = :model', { model }); - } - - const results = await query.getRawMany<{ make: string }>(); - return results.map(({ make }) => make).filter((item) => item !== ''); - } - - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) - async getCameraModels(userIds: string[], make: string | undefined): Promise { - const query = this.exifRepository - .createQueryBuilder('exif') - .leftJoin('exif.asset', 'asset') - .where('asset.ownerId IN (:...userIds )', { userIds }) - .select('exif.model', 'model') - .distinctOn(['exif.model']); - - if (make) { - query.andWhere('exif.make = :make', { make }); - } - - const results = await query.getRawMany<{ model: string }>(); - return results.map(({ model }) => model).filter((item) => item !== ''); - } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 40f87ddf24..999e9063ef 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -4,6 +4,7 @@ import { getVectorExtension } from 'src/database.config'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; +import { ExifEntity } from 'src/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; @@ -35,6 +36,7 @@ export class SearchRepository implements ISearchRepository { constructor( @InjectRepository(SmartInfoEntity) private repository: Repository, @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository, @InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository, @@ -322,6 +324,93 @@ export class SearchRepository implements ISearchRepository { return this.smartSearchRepository.clear(); } + @GenerateSql({ params: [[DummyValue.UUID]] }) + async getCountries(userIds: string[]): Promise { + const results = await this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.country', 'country') + .distinctOn(['exif.country']) + .getRawMany<{ country: string }>(); + + return results.map(({ country }) => country).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getStates(userIds: string[], country: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.state', 'state') + .distinctOn(['exif.state']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + const result = await query.getRawMany<{ state: string }>(); + + return result.map(({ state }) => state).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] }) + async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.city', 'city') + .distinctOn(['exif.city']); + + if (country) { + query.andWhere('exif.country = :country', { country }); + } + + if (state) { + query.andWhere('exif.state = :state', { state }); + } + + const results = await query.getRawMany<{ city: string }>(); + + return results.map(({ city }) => city).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraMakes(userIds: string[], model: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.make', 'make') + .distinctOn(['exif.make']); + + if (model) { + query.andWhere('exif.model = :model', { model }); + } + + const results = await query.getRawMany<{ make: string }>(); + return results.map(({ make }) => make).filter((item) => item !== ''); + } + + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] }) + async getCameraModels(userIds: string[], make: string | undefined): Promise { + const query = this.exifRepository + .createQueryBuilder('exif') + .leftJoin('exif.asset', 'asset') + .where('asset.ownerId IN (:...userIds )', { userIds }) + .select('exif.model', 'model') + .distinctOn(['exif.model']); + + if (make) { + query.andWhere('exif.make = :make', { make }); + } + + const results = await query.getRawMany<{ model: string }>(); + return results.map(({ model }) => model).filter((item) => item !== ''); + } + private getRuntimeConfig(numResults?: number): string { if (getVectorExtension() === DatabaseExtension.VECTOR) { return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index ded087b8b5..c30de68e05 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -3,7 +3,6 @@ import { SearchSuggestionType } from 'src/dtos/search.dto'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; @@ -15,7 +14,6 @@ import { personStub } from 'test/fixtures/person.stub'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; -import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock'; import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock'; @@ -32,7 +30,6 @@ describe(SearchService.name, () => { let personMock: Mocked; let searchMock: Mocked; let partnerMock: Mocked; - let metadataMock: Mocked; let loggerMock: Mocked; beforeEach(() => { @@ -42,19 +39,9 @@ describe(SearchService.name, () => { personMock = newPersonRepositoryMock(); searchMock = newSearchRepositoryMock(); partnerMock = newPartnerRepositoryMock(); - metadataMock = newMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new SearchService( - systemMock, - machineMock, - personMock, - searchMock, - assetMock, - partnerMock, - metadataMock, - loggerMock, - ); + sut = new SearchService(systemMock, machineMock, personMock, searchMock, assetMock, partnerMock, loggerMock); }); it('should work', () => { @@ -99,19 +86,19 @@ describe(SearchService.name, () => { describe('getSearchSuggestions', () => { it('should return search suggestions (including null)', async () => { - metadataMock.getCountries.mockResolvedValue(['USA', null]); + searchMock.getCountries.mockResolvedValue(['USA', null]); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA', null]); - expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); it('should return search suggestions (without null)', async () => { - metadataMock.getCountries.mockResolvedValue(['USA', null]); + searchMock.getCountries.mockResolvedValue(['USA', null]); await expect( sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }), ).resolves.toEqual(['USA']); - expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); + expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]); }); }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 4c86d4ad75..73ace233d0 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -19,7 +19,6 @@ import { AssetOrder } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; -import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface'; @@ -38,7 +37,6 @@ export class SearchService { @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, - @Inject(IMetadataRepository) private metadataRepository: IMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(SearchService.name); @@ -129,19 +127,19 @@ export class SearchService { private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) { switch (dto.type) { case SearchSuggestionType.COUNTRY: { - return this.metadataRepository.getCountries(userIds); + return this.searchRepository.getCountries(userIds); } case SearchSuggestionType.STATE: { - return this.metadataRepository.getStates(userIds, dto.country); + return this.searchRepository.getStates(userIds, dto.country); } case SearchSuggestionType.CITY: { - return this.metadataRepository.getCities(userIds, dto.country, dto.state); + return this.searchRepository.getCities(userIds, dto.country, dto.state); } case SearchSuggestionType.CAMERA_MAKE: { - return this.metadataRepository.getCameraMakes(userIds, dto.model); + return this.searchRepository.getCameraMakes(userIds, dto.model); } case SearchSuggestionType.CAMERA_MODEL: { - return this.metadataRepository.getCameraModels(userIds, dto.make); + return this.searchRepository.getCameraModels(userIds, dto.make); } default: { return []; diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 5dbfb3d453..60c5644b36 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -7,10 +7,5 @@ export const newMetadataRepositoryMock = (): Mocked => { readTags: vitest.fn(), writeTags: vitest.fn(), extractBinaryTag: vitest.fn(), - getCameraMakes: vitest.fn(), - getCameraModels: vitest.fn(), - getCities: vitest.fn(), - getCountries: vitest.fn(), - getStates: vitest.fn(), }; }; diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index fd244c6f5c..5426316b65 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -13,5 +13,10 @@ export const newSearchRepositoryMock = (): Mocked => { deleteAllSearchEmbeddings: vitest.fn(), getDimensionSize: vitest.fn(), setDimensionSize: vitest.fn(), + getCameraMakes: vitest.fn(), + getCameraModels: vitest.fn(), + getCities: vitest.fn(), + getCountries: vitest.fn(), + getStates: vitest.fn(), }; };