1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat(server): search unknown place (#10866)

* Allow submission of null country

* Update searchAssetBuilder to handle nulls

andWhere({country:null}) produces `"exifInfo"."country" = NULL`. We want
`"exifInfo"."country" IS NULL`, so we have to treat NULL as a special
case

* Allow null country in frontend

* Make the query code a bit more straightforward

* Remove unused brackets import

* Remove log message

* Don't change whitespace for no reason

* Fix prettier style issue

* Update search.dto.ts validators per @jrasm91's recommendation

* Update api types

* Combine null country and state into one guard clause

* chore: clean up

* chore: add e2e for null/empty city, state, country search

* refactor: server returns suggestion for null values

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Justin Forseth 2024-08-01 21:27:40 -06:00 committed by GitHub
parent 3afb5b497f
commit d3a5490e71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 366 additions and 154 deletions

View File

@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, getMapMarkers, updateAsset } from '@immich/sdk'; import { AssetMediaResponseDto, LoginResponseDto, deleteAssets, updateAsset } from '@immich/sdk';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
@ -32,9 +32,6 @@ describe('/search', () => {
let assetOneJpg5: AssetMediaResponseDto; let assetOneJpg5: AssetMediaResponseDto;
let assetSprings: AssetMediaResponseDto; let assetSprings: AssetMediaResponseDto;
let assetLast: AssetMediaResponseDto; let assetLast: AssetMediaResponseDto;
let cities: string[];
let states: string[];
let countries: string[];
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
@ -85,7 +82,7 @@ describe('/search', () => {
// note: the coordinates here are not the actual coordinates of the images and are random for most of them // note: the coordinates here are not the actual coordinates of the images and are random for most of them
const coordinates = [ const coordinates = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris { latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 63.0695, longitude: -151.0074 }, // denali { latitude: 35.6895, longitude: 139.691_71 }, // tokyo
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin { latitude: 52.524_37, longitude: 13.410_53 }, // berlin
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore { latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul { latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
@ -101,16 +98,15 @@ describe('/search', () => {
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh { latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge { latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg { latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
]; ];
const updates = assets.map((asset, i) => const updates = coordinates.map((dto, i) =>
updateAsset({ id: asset.id, updateAssetDto: coordinates[i] }, { headers: asBearerAuth(admin.accessToken) }), updateAsset({ id: assets[i].id, updateAssetDto: dto }, { headers: asBearerAuth(admin.accessToken) }),
); );
await Promise.all(updates); await Promise.all(updates);
for (const asset of assets) { for (const [i] of coordinates.entries()) {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id }); await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
} }
[ [
@ -137,12 +133,6 @@ describe('/search', () => {
assetLast = assets.at(-1) as AssetMediaResponseDto; assetLast = assets.at(-1) as AssetMediaResponseDto;
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
const mapMarkers = await getMapMarkers({}, { headers: asBearerAuth(admin.accessToken) });
const nonTrashed = mapMarkers.filter((mark) => mark.id !== assetSilver.id);
cities = [...new Set(nonTrashed.map((mark) => mark.city).filter((entry): entry is string => !!entry))].sort();
states = [...new Set(nonTrashed.map((mark) => mark.state).filter((entry): entry is string => !!entry))].sort();
countries = [...new Set(nonTrashed.map((mark) => mark.country).filter((entry): entry is string => !!entry))].sort();
}, 30_000); }, 30_000);
afterAll(async () => { afterAll(async () => {
@ -321,23 +311,120 @@ describe('/search', () => {
}, },
{ {
should: 'should search by city', should: 'should search by city',
deferred: () => ({ dto: { city: 'Accra' }, assets: [assetHeic] }), deferred: () => ({
dto: {
city: 'Accra',
includeNull: true,
},
assets: [assetHeic],
}),
},
{
should: "should search city ('')",
deferred: () => ({
dto: {
city: '',
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search city (null)',
deferred: () => ({
dto: {
city: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
}, },
{ {
should: 'should search by state', should: 'should search by state',
deferred: () => ({ dto: { state: 'New York' }, assets: [assetDensity] }), deferred: () => ({
dto: {
state: 'New York',
includeNull: true,
},
assets: [assetDensity],
}),
},
{
should: "should search state ('')",
deferred: () => ({
dto: {
state: '',
isVisible: true,
withExif: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
},
{
should: 'should search state (null)',
deferred: () => ({
dto: {
state: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
}),
}, },
{ {
should: 'should search by country', should: 'should search by country',
deferred: () => ({ dto: { country: 'France' }, assets: [assetFalcon] }), deferred: () => ({
dto: {
country: 'France',
includeNull: true,
},
assets: [assetFalcon],
}),
},
{
should: "should search country ('')",
deferred: () => ({
dto: {
country: '',
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
},
{
should: 'should search country (null)',
deferred: () => ({
dto: {
country: null,
isVisible: true,
includeNull: true,
},
assets: [assetLast],
}),
}, },
{ {
should: 'should search by make', should: 'should search by make',
deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }), deferred: () => ({
dto: {
make: 'Canon',
includeNull: true,
},
assets: [assetFalcon, assetDenali],
}),
}, },
{ {
should: 'should search by model', should: 'should search by model',
deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }), deferred: () => ({
dto: {
model: 'Canon EOS 7D',
includeNull: true,
},
assets: [assetDenali],
}),
}, },
{ {
should: 'should allow searching the upload library (libraryId: null)', should: 'should allow searching the upload library (libraryId: null)',
@ -450,32 +537,79 @@ describe('/search', () => {
it('should get suggestions for country', async () => { it('should get suggestions for country', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=country') .get('/search/suggestions?type=country&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(countries); expect(body).toEqual([
'Cuba',
'France',
'Georgia',
'Germany',
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
null,
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for state', async () => { it('should get suggestions for state', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=state') .get('/search/suggestions?type=state&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toHaveLength(states.length); expect(body).toEqual([
expect(body).toEqual(expect.arrayContaining(states)); 'Andalusia',
'Berlin',
'Glarus',
'Greater Accra',
'Havana',
'Île-de-France',
'Marrakesh-Safi',
'Mississippi',
'New York',
'Shanghai',
'St.-Petersburg',
'Tbilisi',
'Tokyo',
'Virginia',
null,
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for city', async () => { it('should get suggestions for city', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=city') .get('/search/suggestions?type=city&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual(cities); expect(body).toEqual([
'Accra',
'Berlin',
'Glarus',
'Havana',
'Marrakesh',
'Montalbán de Córdoba',
'New York City',
'Novena',
'Paris',
'Philadelphia',
'Saint Petersburg',
'Shanghai',
'Stanley',
'Tbilisi',
'Tokyo',
null,
]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for camera make', async () => { it('should get suggestions for camera make', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=camera-make') .get('/search/suggestions?type=camera-make&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([ expect(body).toEqual([
'Apple', 'Apple',
@ -485,13 +619,14 @@ describe('/search', () => {
'PENTAX Corporation', 'PENTAX Corporation',
'samsung', 'samsung',
'SONY', 'SONY',
null,
]); ]);
expect(status).toBe(200); expect(status).toBe(200);
}); });
it('should get suggestions for camera model', async () => { it('should get suggestions for camera model', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/search/suggestions?type=camera-model') .get('/search/suggestions?type=camera-model&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([ expect(body).toEqual([
'Canon EOS 7D', 'Canon EOS 7D',
@ -506,6 +641,7 @@ describe('/search', () => {
'SM-F711N', 'SM-F711N',
'SM-S906U', 'SM-S906U',
'SM-T970', 'SM-T970',
null,
]); ]);
expect(status).toBe(200); expect(status).toBe(200);
}); });

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4727,6 +4727,15 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "includeNull",
"required": false,
"in": "query",
"description": "This property was added in v111.0.0",
"schema": {
"type": "boolean"
}
},
{ {
"name": "make", "name": "make",
"required": false, "required": false,
@ -9378,9 +9387,11 @@
"type": "string" "type": "string"
}, },
"city": { "city": {
"nullable": true,
"type": "string" "type": "string"
}, },
"country": { "country": {
"nullable": true,
"type": "string" "type": "string"
}, },
"createdAfter": { "createdAfter": {
@ -9426,6 +9437,7 @@
"type": "boolean" "type": "boolean"
}, },
"lensModel": { "lensModel": {
"nullable": true,
"type": "string" "type": "string"
}, },
"libraryId": { "libraryId": {
@ -9437,6 +9449,7 @@
"type": "string" "type": "string"
}, },
"model": { "model": {
"nullable": true,
"type": "string" "type": "string"
}, },
"order": { "order": {
@ -9468,6 +9481,7 @@
"type": "number" "type": "number"
}, },
"state": { "state": {
"nullable": true,
"type": "string" "type": "string"
}, },
"takenAfter": { "takenAfter": {
@ -10611,9 +10625,11 @@
"SmartSearchDto": { "SmartSearchDto": {
"properties": { "properties": {
"city": { "city": {
"nullable": true,
"type": "string" "type": "string"
}, },
"country": { "country": {
"nullable": true,
"type": "string" "type": "string"
}, },
"createdAfter": { "createdAfter": {
@ -10649,6 +10665,7 @@
"type": "boolean" "type": "boolean"
}, },
"lensModel": { "lensModel": {
"nullable": true,
"type": "string" "type": "string"
}, },
"libraryId": { "libraryId": {
@ -10660,6 +10677,7 @@
"type": "string" "type": "string"
}, },
"model": { "model": {
"nullable": true,
"type": "string" "type": "string"
}, },
"page": { "page": {
@ -10682,6 +10700,7 @@
"type": "number" "type": "number"
}, },
"state": { "state": {
"nullable": true,
"type": "string" "type": "string"
}, },
"takenAfter": { "takenAfter": {

View File

@ -708,8 +708,8 @@ export type SearchExploreResponseDto = {
}; };
export type MetadataSearchDto = { export type MetadataSearchDto = {
checksum?: string; checksum?: string;
city?: string; city?: string | null;
country?: string; country?: string | null;
createdAfter?: string; createdAfter?: string;
createdBefore?: string; createdBefore?: string;
deviceAssetId?: string; deviceAssetId?: string;
@ -723,10 +723,10 @@ export type MetadataSearchDto = {
isNotInAlbum?: boolean; isNotInAlbum?: boolean;
isOffline?: boolean; isOffline?: boolean;
isVisible?: boolean; isVisible?: boolean;
lensModel?: string; lensModel?: string | null;
libraryId?: string | null; libraryId?: string | null;
make?: string; make?: string;
model?: string; model?: string | null;
order?: AssetOrder; order?: AssetOrder;
originalFileName?: string; originalFileName?: string;
originalPath?: string; originalPath?: string;
@ -734,7 +734,7 @@ export type MetadataSearchDto = {
personIds?: string[]; personIds?: string[];
previewPath?: string; previewPath?: string;
size?: number; size?: number;
state?: string; state?: string | null;
takenAfter?: string; takenAfter?: string;
takenBefore?: string; takenBefore?: string;
thumbnailPath?: string; thumbnailPath?: string;
@ -782,8 +782,8 @@ export type PlacesResponseDto = {
name: string; name: string;
}; };
export type SmartSearchDto = { export type SmartSearchDto = {
city?: string; city?: string | null;
country?: string; country?: string | null;
createdAfter?: string; createdAfter?: string;
createdBefore?: string; createdBefore?: string;
deviceId?: string; deviceId?: string;
@ -794,15 +794,15 @@ export type SmartSearchDto = {
isNotInAlbum?: boolean; isNotInAlbum?: boolean;
isOffline?: boolean; isOffline?: boolean;
isVisible?: boolean; isVisible?: boolean;
lensModel?: string; lensModel?: string | null;
libraryId?: string | null; libraryId?: string | null;
make?: string; make?: string;
model?: string; model?: string | null;
page?: number; page?: number;
personIds?: string[]; personIds?: string[];
query: string; query: string;
size?: number; size?: number;
state?: string; state?: string | null;
takenAfter?: string; takenAfter?: string;
takenBefore?: string; takenBefore?: string;
trashedAfter?: string; trashedAfter?: string;
@ -2418,8 +2418,9 @@ export function searchSmart({ smartSearchDto }: {
body: smartSearchDto body: smartSearchDto
}))); })));
} }
export function getSearchSuggestions({ country, make, model, state, $type }: { export function getSearchSuggestions({ country, includeNull, make, model, state, $type }: {
country?: string; country?: string;
includeNull?: boolean;
make?: string; make?: string;
model?: string; model?: string;
state?: string; state?: string;
@ -2430,6 +2431,7 @@ export function getSearchSuggestions({ country, make, model, state, $type }: {
data: string[]; data: string[];
}>(`/search/suggestions${QS.query(QS.explode({ }>(`/search/suggestions${QS.query(QS.explode({
country, country,
includeNull,
make, make,
model, model,
state, state,

View File

@ -62,6 +62,7 @@ export class SearchController {
@Get('suggestions') @Get('suggestions')
@Authenticated() @Authenticated()
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> { getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
return this.service.getSearchSuggestions(auth, dto); // TODO fix open api generation to indicate that results can be nullable
return this.service.getSearchSuggestions(auth, dto) as Promise<string[]>;
} }
} }

View File

@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder } from 'src/entities/album.entity'; import { AssetOrder } from 'src/entities/album.entity';
@ -75,34 +76,29 @@ class BaseSearchDto {
takenAfter?: Date; takenAfter?: Date;
@IsString() @IsString()
@IsNotEmpty() @Optional({ nullable: true, emptyToNull: true })
@Optional() city?: string | null;
city?: string;
@IsString()
@Optional({ nullable: true, emptyToNull: true })
state?: string | null;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Optional() @Optional({ nullable: true, emptyToNull: true })
state?: string; country?: string | null;
@IsString() @IsString()
@IsNotEmpty() @Optional({ nullable: true, emptyToNull: true })
@Optional()
country?: string;
@IsString()
@IsNotEmpty()
@Optional()
make?: string; make?: string;
@IsString() @IsString()
@IsNotEmpty() @Optional({ nullable: true, emptyToNull: true })
@Optional() model?: string | null;
model?: string;
@IsString() @IsString()
@IsNotEmpty() @Optional({ nullable: true, emptyToNull: true })
@Optional() lensModel?: string | null;
lensModel?: string;
@IsInt() @IsInt()
@Min(1) @Min(1)
@ -242,6 +238,10 @@ export class SearchSuggestionRequestDto {
@IsString() @IsString()
@Optional() @Optional()
model?: string; model?: string;
@ValidateBoolean({ optional: true })
@PropertyLifecycle({ addedAt: 'v111.0.0' })
includeNull?: boolean;
} }
class SearchFacetCountResponseDto { class SearchFacetCountResponseDto {

View File

@ -26,9 +26,9 @@ export interface IMetadataRepository {
readTags(path: string): Promise<ImmichTags | null>; readTags(path: string): Promise<ImmichTags | null>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>; writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>; extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
getCountries(userId: string): Promise<string[]>; getCountries(userId: string): Promise<Array<string | null>>;
getStates(userId: string, country?: string): Promise<string[]>; getStates(userId: string, country?: string): Promise<Array<string | null>>;
getCities(userId: string, country?: string, state?: string): Promise<string[]>; getCities(userId: string, country?: string, state?: string): Promise<Array<string | null>>;
getCameraMakes(userId: string, model?: string): Promise<string[]>; getCameraMakes(userId: string, model?: string): Promise<Array<string | null>>;
getCameraModels(userId: string, make?: string): Promise<string[]>; getCameraModels(userId: string, make?: string): Promise<Array<string | null>>;
} }

View File

@ -95,12 +95,12 @@ export interface SearchPathOptions {
} }
export interface SearchExifOptions { export interface SearchExifOptions {
city?: string; city?: string | null;
country?: string; country?: string | null;
lensModel?: string; lensModel?: string | null;
make?: string; make?: string | null;
model?: string; model?: string | null;
state?: string; state?: string | null;
} }
export interface SearchEmbeddingOptions { export interface SearchEmbeddingOptions {

View File

@ -2,65 +2,55 @@
-- MetadataRepository.getCountries -- MetadataRepository.getCountries
SELECT DISTINCT SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "exif_country", ON ("exif"."country") "exif"."country" AS "country"
"exif"."assetId" AS "exif_assetId"
FROM FROM
"exif" "exif" "exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" = $1
AND "exif"."country" IS NOT NULL
-- MetadataRepository.getStates -- MetadataRepository.getStates
SELECT DISTINCT SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "exif_state", ON ("exif"."state") "exif"."state" AS "state"
"exif"."assetId" AS "exif_assetId"
FROM FROM
"exif" "exif" "exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" = $1
AND "exif"."state" IS NOT NULL
AND "exif"."country" = $2 AND "exif"."country" = $2
-- MetadataRepository.getCities -- MetadataRepository.getCities
SELECT DISTINCT SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "exif_city", ON ("exif"."city") "exif"."city" AS "city"
"exif"."assetId" AS "exif_assetId"
FROM FROM
"exif" "exif" "exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" = $1
AND "exif"."city" IS NOT NULL
AND "exif"."country" = $2 AND "exif"."country" = $2
AND "exif"."state" = $3 AND "exif"."state" = $3
-- MetadataRepository.getCameraMakes -- MetadataRepository.getCameraMakes
SELECT DISTINCT SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "exif_make", ON ("exif"."make") "exif"."make" AS "make"
"exif"."assetId" AS "exif_assetId"
FROM FROM
"exif" "exif" "exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" = $1
AND "exif"."make" IS NOT NULL
AND "exif"."model" = $2 AND "exif"."model" = $2
-- MetadataRepository.getCameraModels -- MetadataRepository.getCameraModels
SELECT DISTINCT SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "exif_model", ON ("exif"."model") "exif"."model" AS "model"
"exif"."assetId" AS "exif_assetId"
FROM FROM
"exif" "exif" "exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId" LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL) AND ("asset"."deletedAt" IS NULL)
WHERE WHERE
"asset"."ownerId" = $1 "asset"."ownerId" = $1
AND "exif"."model" IS NOT NULL
AND "exif"."make" = $2 AND "exif"."make" = $2

View File

@ -57,49 +57,42 @@ export class MetadataRepository implements IMetadataRepository {
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getCountries(userId: string): Promise<string[]> { async getCountries(userId: string): Promise<string[]> {
const entity = await this.exifRepository const results = await this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset') .leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId }) .where('asset.ownerId = :userId', { userId })
.andWhere('exif.country IS NOT NULL') .select('exif.country', 'country')
.select('exif.country')
.distinctOn(['exif.country']) .distinctOn(['exif.country'])
.getMany(); .getRawMany<{ country: string }>();
return entity.map((e) => e.country ?? '').filter((c) => c !== ''); return results.map(({ country }) => country).filter((item) => item !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getStates(userId: string, country: string | undefined): Promise<string[]> { async getStates(userId: string, country: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset') .leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId }) .where('asset.ownerId = :userId', { userId })
.andWhere('exif.state IS NOT NULL') .select('exif.state', 'state')
.select('exif.state')
.distinctOn(['exif.state']); .distinctOn(['exif.state']);
if (country) { if (country) {
query.andWhere('exif.country = :country', { country }); query.andWhere('exif.country = :country', { country });
} }
result = await query.getMany(); const result = await query.getRawMany<{ state: string }>();
return result.map((entity) => entity.state ?? '').filter((s) => s !== ''); return result.map(({ state }) => state).filter((item) => item !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> { async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset') .leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId }) .where('asset.ownerId = :userId', { userId })
.andWhere('exif.city IS NOT NULL') .select('exif.city', 'city')
.select('exif.city')
.distinctOn(['exif.city']); .distinctOn(['exif.city']);
if (country) { if (country) {
@ -110,50 +103,42 @@ export class MetadataRepository implements IMetadataRepository {
query.andWhere('exif.state = :state', { state }); query.andWhere('exif.state = :state', { state });
} }
result = await query.getMany(); const results = await query.getRawMany<{ city: string }>();
return result.map((entity) => entity.city ?? '').filter((c) => c !== ''); return results.map(({ city }) => city).filter((item) => item !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> { async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset') .leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId }) .where('asset.ownerId = :userId', { userId })
.andWhere('exif.make IS NOT NULL') .select('exif.make', 'make')
.select('exif.make')
.distinctOn(['exif.make']); .distinctOn(['exif.make']);
if (model) { if (model) {
query.andWhere('exif.model = :model', { model }); query.andWhere('exif.model = :model', { model });
} }
result = await query.getMany(); const results = await query.getRawMany<{ make: string }>();
return results.map(({ make }) => make).filter((item) => item !== '');
return result.map((entity) => entity.make ?? '').filter((m) => m !== '');
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> { async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository const query = this.exifRepository
.createQueryBuilder('exif') .createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset') .leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId }) .where('asset.ownerId = :userId', { userId })
.andWhere('exif.model IS NOT NULL') .select('exif.model', 'model')
.select('exif.model')
.distinctOn(['exif.model']); .distinctOn(['exif.model']);
if (make) { if (make) {
query.andWhere('exif.make = :make', { make }); query.andWhere('exif.make = :make', { make });
} }
result = await query.getMany(); const results = await query.getRawMany<{ model: string }>();
return results.map(({ model }) => model).filter((item) => item !== '');
return result.map((entity) => entity.model ?? '').filter((m) => m !== '');
} }
} }

View File

@ -1,4 +1,5 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
@ -95,4 +96,22 @@ describe(SearchService.name, () => {
expect(result).toEqual(expectedResponse); expect(result).toEqual(expectedResponse);
}); });
}); });
describe('getSearchSuggestions', () => {
it('should return search suggestions (including null)', async () => {
metadataMock.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);
});
it('should return search suggestions (without null)', async () => {
metadataMock.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);
});
});
}); });

View File

@ -120,22 +120,30 @@ export class SearchService {
return assets.map((asset) => mapAsset(asset)); return assets.map((asset) => mapAsset(asset));
} }
getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> { async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto) {
const results = await this.getSuggestions(auth.user.id, dto);
return results.filter((result) => (dto.includeNull ? true : result !== null));
}
private getSuggestions(userId: string, dto: SearchSuggestionRequestDto) {
switch (dto.type) { switch (dto.type) {
case SearchSuggestionType.COUNTRY: { case SearchSuggestionType.COUNTRY: {
return this.metadataRepository.getCountries(auth.user.id); return this.metadataRepository.getCountries(userId);
} }
case SearchSuggestionType.STATE: { case SearchSuggestionType.STATE: {
return this.metadataRepository.getStates(auth.user.id, dto.country); return this.metadataRepository.getStates(userId, dto.country);
} }
case SearchSuggestionType.CITY: { case SearchSuggestionType.CITY: {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state); return this.metadataRepository.getCities(userId, dto.country, dto.state);
} }
case SearchSuggestionType.CAMERA_MAKE: { case SearchSuggestionType.CAMERA_MAKE: {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model); return this.metadataRepository.getCameraMakes(userId, dto.model);
} }
case SearchSuggestionType.CAMERA_MODEL: { case SearchSuggestionType.CAMERA_MODEL: {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make); return this.metadataRepository.getCameraModels(userId, dto.make);
}
default: {
return [];
} }
} }
} }

View File

@ -48,7 +48,13 @@ export function searchAssetBuilder(
? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo') ? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo')
: builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); : builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
builder.andWhere({ exifInfo }); for (const [key, value] of Object.entries(exifInfo)) {
if (value === null) {
builder.andWhere(`exifInfo.${key} IS NULL`);
} else {
builder.andWhere(`exifInfo.${key} = :${key}`, { [key]: value });
}
}
} }
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']); const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']);

View File

@ -66,6 +66,8 @@ export class UUIDParamDto {
export interface OptionalOptions extends ValidationOptions { export interface OptionalOptions extends ValidationOptions {
nullable?: boolean; nullable?: boolean;
/** convert empty strings to null */
emptyToNull?: boolean;
} }
/** /**
@ -76,12 +78,20 @@ export interface OptionalOptions extends ValidationOptions {
* @see IsOptional exported from `class-validator. * @see IsOptional exported from `class-validator.
*/ */
// https://stackoverflow.com/a/71353929 // https://stackoverflow.com/a/71353929
export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) { export function Optional({ nullable, emptyToNull, ...validationOptions }: OptionalOptions = {}) {
const decorators: PropertyDecorator[] = [];
if (nullable === true) { if (nullable === true) {
return IsOptional(validationOptions); decorators.push(IsOptional(validationOptions));
} else {
decorators.push(ValidateIf((object: any, v: any) => v !== undefined, validationOptions));
} }
return ValidateIf((object: any, v: any) => v !== undefined, validationOptions); if (emptyToNull) {
decorators.push(Transform(({ value }) => (value === '' ? null : value)));
}
return applyDecorators(...decorators);
} }
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean }; type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };

View File

@ -4,9 +4,16 @@
value: string; value: string;
}; };
export function toComboBoxOptions(items: string[]) { export const asComboboxOptions = (values: string[]) =>
return items.map<ComboBoxOption>((item) => ({ label: item, value: item })); values.map((value) => {
} if (value === '') {
return { label: get(t)('unknown'), value: '' };
}
return { label: value, value };
});
export const asSelectedOption = (value?: string) => (value === undefined ? undefined : asComboboxOptions([value])[0]);
</script> </script>
<script lang="ts"> <script lang="ts">
@ -21,6 +28,7 @@
import { generateId } from '$lib/utils/generate-id'; import { generateId } from '$lib/utils/generate-id';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
export let label: string; export let label: string;
export let hideLabel = false; export let hideLabel = false;

View File

@ -6,9 +6,9 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk'; import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let filters: SearchCameraFilter; export let filters: SearchCameraFilter;
@ -22,17 +22,30 @@
$: handlePromiseError(updateModels(makeFilter)); $: handlePromiseError(updateModels(makeFilter));
async function updateMakes(model?: string) { async function updateMakes(model?: string) {
makes = await getSearchSuggestions({ const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraMake, $type: SearchSuggestionType.CameraMake,
model, model,
includeNull: true,
}); });
if (filters.make && !makes.includes(filters.make)) {
filters.make = undefined;
}
makes = results.map((result) => result ?? '');
} }
async function updateModels(make?: string) { async function updateModels(make?: string) {
models = await getSearchSuggestions({ const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.CameraModel, $type: SearchSuggestionType.CameraModel,
make, make,
includeNull: true,
}); });
const models = results.map((result) => result ?? '');
if (filters.model && !models.includes(filters.model)) {
filters.model = undefined;
}
} }
</script> </script>
@ -44,9 +57,9 @@
<Combobox <Combobox
label={$t('make')} label={$t('make')}
on:select={({ detail }) => (filters.make = detail?.value)} on:select={({ detail }) => (filters.make = detail?.value)}
options={toComboBoxOptions(makes)} options={asComboboxOptions(makes)}
placeholder={$t('search_camera_make')} placeholder={$t('search_camera_make')}
selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined} selectedOption={asSelectedOption(makeFilter)}
/> />
</div> </div>
@ -54,9 +67,9 @@
<Combobox <Combobox
label={$t('model')} label={$t('model')}
on:select={({ detail }) => (filters.model = detail?.value)} on:select={({ detail }) => (filters.model = detail?.value)}
options={toComboBoxOptions(models)} options={asComboboxOptions(models)}
placeholder={$t('search_camera_model')} placeholder={$t('search_camera_model')}
selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined} selectedOption={asSelectedOption(modelFilter)}
/> />
</div> </div>
</div> </div>

View File

@ -42,18 +42,23 @@
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined; const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>(); const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
// combobox and all the search components have terrible support for value | null so we use empty string instead.
function withNullAsUndefined<T>(value: T | null) {
return value === null ? undefined : value;
}
let filter: SearchFilter = { let filter: SearchFilter = {
context: 'query' in searchQuery ? searchQuery.query : '', context: 'query' in searchQuery ? searchQuery.query : '',
filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined, filename: 'originalFileName' in searchQuery ? searchQuery.originalFileName : undefined,
personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []), personIds: new Set('personIds' in searchQuery ? searchQuery.personIds : []),
location: { location: {
country: searchQuery.country, country: withNullAsUndefined(searchQuery.country),
state: searchQuery.state, state: withNullAsUndefined(searchQuery.state),
city: searchQuery.city, city: withNullAsUndefined(searchQuery.city),
}, },
camera: { camera: {
make: searchQuery.make, make: withNullAsUndefined(searchQuery.make),
model: searchQuery.model, model: withNullAsUndefined(searchQuery.model),
}, },
date: { date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined, takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,

View File

@ -7,9 +7,9 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk'; import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import Combobox, { toComboBoxOptions } from '../combobox.svelte';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let filters: SearchLocationFilter; export let filters: SearchLocationFilter;
@ -25,33 +25,41 @@
$: handlePromiseError(updateCities(countryFilter, stateFilter)); $: handlePromiseError(updateCities(countryFilter, stateFilter));
async function updateCountries() { async function updateCountries() {
countries = await getSearchSuggestions({ const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.Country, $type: SearchSuggestionType.Country,
includeNull: true,
}); });
countries = results.map((result) => result ?? '');
if (filters.country && !countries.includes(filters.country)) { if (filters.country && !countries.includes(filters.country)) {
filters.country = undefined; filters.country = undefined;
} }
} }
async function updateStates(country?: string) { async function updateStates(country?: string) {
states = await getSearchSuggestions({ const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.State, $type: SearchSuggestionType.State,
country, country,
includeNull: true,
}); });
states = results.map((result) => result ?? '');
if (filters.state && !states.includes(filters.state)) { if (filters.state && !states.includes(filters.state)) {
filters.state = undefined; filters.state = undefined;
} }
} }
async function updateCities(country?: string, state?: string) { async function updateCities(country?: string, state?: string) {
cities = await getSearchSuggestions({ const results: Array<string | null> = await getSearchSuggestions({
$type: SearchSuggestionType.City, $type: SearchSuggestionType.City,
country, country,
state, state,
}); });
cities = results.map((result) => result ?? '');
if (filters.city && !cities.includes(filters.city)) { if (filters.city && !cities.includes(filters.city)) {
filters.city = undefined; filters.city = undefined;
} }
@ -66,9 +74,9 @@
<Combobox <Combobox
label={$t('country')} label={$t('country')}
on:select={({ detail }) => (filters.country = detail?.value)} on:select={({ detail }) => (filters.country = detail?.value)}
options={toComboBoxOptions(countries)} options={asComboboxOptions(countries)}
placeholder={$t('search_country')} placeholder={$t('search_country')}
selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined} selectedOption={asSelectedOption(filters.country)}
/> />
</div> </div>
@ -76,9 +84,9 @@
<Combobox <Combobox
label={$t('state')} label={$t('state')}
on:select={({ detail }) => (filters.state = detail?.value)} on:select={({ detail }) => (filters.state = detail?.value)}
options={toComboBoxOptions(states)} options={asComboboxOptions(states)}
placeholder={$t('search_state')} placeholder={$t('search_state')}
selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined} selectedOption={asSelectedOption(filters.state)}
/> />
</div> </div>
@ -86,9 +94,9 @@
<Combobox <Combobox
label={$t('city')} label={$t('city')}
on:select={({ detail }) => (filters.city = detail?.value)} on:select={({ detail }) => (filters.city = detail?.value)}
options={toComboBoxOptions(cities)} options={asComboboxOptions(cities)}
placeholder={$t('search_city')} placeholder={$t('search_city')}
selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined} selectedOption={asSelectedOption(filters.city)}
/> />
</div> </div>
</div> </div>

View File

@ -259,6 +259,8 @@
{#await getPersonName(value) then personName} {#await getPersonName(value) then personName}
{personName} {personName}
{/await} {/await}
{:else if value === null || value === ''}
{$t('unknown')}
{:else} {:else}
{value} {value}
{/if} {/if}