mirror of
https://github.com/immich-app/immich
synced 2026-08-29 13:15:45 +00:00
feat(server): new search API (#30179)
This commit is contained in:
parent
25c60ef99e
commit
8b3d6b320b
18 changed files with 5039 additions and 384 deletions
|
|
@ -4,7 +4,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart';
|
|||
import 'package:immich_mobile/infrastructure/repositories/search_api.repository.dart';
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart' hide AssetVisibility;
|
||||
import 'package:openapi/api.dart' hide AssetVisibility, SearchFilter;
|
||||
|
||||
class SearchService {
|
||||
final _log = Logger("SearchService");
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:immich_mobile/data/server/api_repository.dart';
|
|||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' hide AssetVisibility;
|
||||
import 'package:immich_mobile/models/search/search_filter.model.dart';
|
||||
import 'package:immich_mobile/utils/option.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:openapi/api.dart' hide SearchFilter;
|
||||
|
||||
class SearchApiRepository extends ApiRepository {
|
||||
final SearchApi _api;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ final Map<String, Map<String, Object?>> openApiPatches = {
|
|||
'SyncUserV1': {'profileChangedAt': _now, 'hasProfileImage': false},
|
||||
'SyncAssetV1': {'isEdited': false},
|
||||
'ServerFeaturesDto': {'ocr': false, 'realtimeTranscoding': false},
|
||||
'SearchAssetResponseDto': {'nextCursor': null},
|
||||
'MemoriesResponse': {'duration': 5, 'sidebarWeb': false},
|
||||
'WorkflowResponseDto': {'logging': false},
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2154,6 +2154,168 @@ export type SearchExploreResponseDto = {
|
|||
fieldName: string;
|
||||
items: SearchExploreItem[];
|
||||
};
|
||||
export type IdsFilter = {
|
||||
all?: string[];
|
||||
"any"?: string[];
|
||||
none?: string[];
|
||||
};
|
||||
export type StringFilter = {
|
||||
eq?: string;
|
||||
"in"?: string[];
|
||||
ne?: string;
|
||||
notIn?: string[];
|
||||
};
|
||||
export type StringFilterNullable = {
|
||||
eq?: string | null;
|
||||
"in"?: string[];
|
||||
ne?: string | null;
|
||||
notIn?: string[];
|
||||
};
|
||||
export type DateFilter = {
|
||||
eq?: string;
|
||||
gt?: string;
|
||||
gte?: string;
|
||||
lt?: string;
|
||||
lte?: string;
|
||||
ne?: string;
|
||||
};
|
||||
export type StringPatternFilter = {
|
||||
endsWith?: string;
|
||||
eq?: string | null;
|
||||
"in"?: string[];
|
||||
like?: string;
|
||||
ne?: string | null;
|
||||
notIn?: string[];
|
||||
notLike?: string;
|
||||
startsWith?: string;
|
||||
};
|
||||
export type NumberFilter = {
|
||||
eq?: number;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
"in"?: number[];
|
||||
lt?: number;
|
||||
lte?: number;
|
||||
ne?: number;
|
||||
notIn?: number[];
|
||||
};
|
||||
export type BoolFilter = {
|
||||
eq: boolean;
|
||||
};
|
||||
export type IdFilter = {
|
||||
eq?: string;
|
||||
ne?: string;
|
||||
};
|
||||
export type IdFilterNullable = {
|
||||
eq?: string | null;
|
||||
ne?: string | null;
|
||||
};
|
||||
export type StringSimilarityFilter = {
|
||||
matches: string;
|
||||
};
|
||||
export type NumberFilterNullable = {
|
||||
eq?: number | null;
|
||||
gt?: number;
|
||||
gte?: number;
|
||||
"in"?: number[];
|
||||
lt?: number;
|
||||
lte?: number;
|
||||
ne?: number | null;
|
||||
notIn?: number[];
|
||||
};
|
||||
export type DateFilterNullable = {
|
||||
eq?: string | null;
|
||||
gt?: string;
|
||||
gte?: string;
|
||||
lt?: string;
|
||||
lte?: string;
|
||||
ne?: string | null;
|
||||
};
|
||||
export type EnumFilterAssetType = {
|
||||
eq?: AssetTypeEnum;
|
||||
"in"?: AssetTypeEnum[];
|
||||
ne?: AssetTypeEnum;
|
||||
notIn?: AssetTypeEnum[];
|
||||
};
|
||||
export type EnumFilterAssetVisibility = {
|
||||
eq?: AssetVisibility;
|
||||
"in"?: AssetVisibility[];
|
||||
ne?: AssetVisibility;
|
||||
notIn?: AssetVisibility[];
|
||||
};
|
||||
export type SearchFilterBranch = {
|
||||
albumIds?: IdsFilter;
|
||||
checksum?: StringFilter;
|
||||
city?: StringFilterNullable;
|
||||
country?: StringFilterNullable;
|
||||
createdAt?: DateFilter;
|
||||
description?: StringPatternFilter;
|
||||
encodedVideoPath?: StringFilter;
|
||||
fileSizeInBytes?: NumberFilter;
|
||||
hasAlbums?: BoolFilter;
|
||||
hasPeople?: BoolFilter;
|
||||
hasTags?: BoolFilter;
|
||||
id?: IdFilter;
|
||||
isEncoded?: BoolFilter;
|
||||
isFavorite?: BoolFilter;
|
||||
isMotion?: BoolFilter;
|
||||
isOffline?: BoolFilter;
|
||||
lensModel?: StringFilterNullable;
|
||||
libraryId?: IdFilterNullable;
|
||||
make?: StringFilterNullable;
|
||||
model?: StringFilterNullable;
|
||||
ocr?: StringSimilarityFilter;
|
||||
originalFileName?: StringPatternFilter;
|
||||
originalPath?: StringPatternFilter;
|
||||
personIds?: IdsFilter;
|
||||
rating?: NumberFilterNullable;
|
||||
state?: StringFilterNullable;
|
||||
tagIds?: IdsFilter;
|
||||
takenAt?: DateFilter;
|
||||
trashedAt?: DateFilterNullable;
|
||||
"type"?: EnumFilterAssetType;
|
||||
updatedAt?: DateFilter;
|
||||
visibility?: EnumFilterAssetVisibility;
|
||||
};
|
||||
export type SearchFilter = {
|
||||
albumIds?: IdsFilter;
|
||||
checksum?: StringFilter;
|
||||
city?: StringFilterNullable;
|
||||
country?: StringFilterNullable;
|
||||
createdAt?: DateFilter;
|
||||
description?: StringPatternFilter;
|
||||
encodedVideoPath?: StringFilter;
|
||||
fileSizeInBytes?: NumberFilter;
|
||||
hasAlbums?: BoolFilter;
|
||||
hasPeople?: BoolFilter;
|
||||
hasTags?: BoolFilter;
|
||||
id?: IdFilter;
|
||||
isEncoded?: BoolFilter;
|
||||
isFavorite?: BoolFilter;
|
||||
isMotion?: BoolFilter;
|
||||
isOffline?: BoolFilter;
|
||||
lensModel?: StringFilterNullable;
|
||||
libraryId?: IdFilterNullable;
|
||||
make?: StringFilterNullable;
|
||||
model?: StringFilterNullable;
|
||||
ocr?: StringSimilarityFilter;
|
||||
or?: SearchFilterBranch[];
|
||||
originalFileName?: StringPatternFilter;
|
||||
originalPath?: StringPatternFilter;
|
||||
personIds?: IdsFilter;
|
||||
rating?: NumberFilterNullable;
|
||||
state?: StringFilterNullable;
|
||||
tagIds?: IdsFilter;
|
||||
takenAt?: DateFilter;
|
||||
trashedAt?: DateFilterNullable;
|
||||
"type"?: EnumFilterAssetType;
|
||||
updatedAt?: DateFilter;
|
||||
visibility?: EnumFilterAssetVisibility;
|
||||
};
|
||||
export type SearchOrder = {
|
||||
direction?: AssetOrder;
|
||||
field?: SearchOrderField;
|
||||
};
|
||||
export type MetadataSearchDto = {
|
||||
/** Filter by album IDs */
|
||||
albumIds?: string[];
|
||||
|
|
@ -2167,10 +2329,13 @@ export type MetadataSearchDto = {
|
|||
createdAfter?: string;
|
||||
/** Filter by creation date (before) */
|
||||
createdBefore?: string;
|
||||
/** Cursor for the next page of results */
|
||||
cursor?: string;
|
||||
/** Filter by description text */
|
||||
description?: string;
|
||||
/** Filter by encoded video file path */
|
||||
encodedVideoPath?: string;
|
||||
filter?: SearchFilter;
|
||||
/** Filter by asset ID */
|
||||
id?: string;
|
||||
/** Filter by encoded status */
|
||||
|
|
@ -2195,6 +2360,7 @@ export type MetadataSearchDto = {
|
|||
ocr?: string;
|
||||
/** Sort order */
|
||||
order?: AssetOrder;
|
||||
orderBy?: SearchOrder;
|
||||
/** Filter by original file name */
|
||||
originalFileName?: string;
|
||||
/** Filter by original file path */
|
||||
|
|
@ -2262,6 +2428,8 @@ export type SearchAssetResponseDto = {
|
|||
count: number;
|
||||
facets: SearchFacetResponseDto[];
|
||||
items: AssetResponseDto[];
|
||||
/** Cursor for the next page of results */
|
||||
nextCursor: string | null;
|
||||
/** Next page token */
|
||||
nextPage: string | null;
|
||||
/** Total number of matching assets */
|
||||
|
|
@ -2294,6 +2462,7 @@ export type RandomSearchDto = {
|
|||
createdAfter?: string;
|
||||
/** Filter by creation date (before) */
|
||||
createdBefore?: string;
|
||||
filter?: SearchFilter;
|
||||
/** Filter by encoded status */
|
||||
isEncoded?: boolean;
|
||||
/** Filter by favorite status */
|
||||
|
|
@ -2358,6 +2527,7 @@ export type SmartSearchDto = {
|
|||
createdAfter?: string;
|
||||
/** Filter by creation date (before) */
|
||||
createdBefore?: string;
|
||||
filter?: SearchFilter;
|
||||
/** Filter by encoded status */
|
||||
isEncoded?: boolean;
|
||||
/** Filter by favorite status */
|
||||
|
|
@ -2428,6 +2598,7 @@ export type StatisticsSearchDto = {
|
|||
createdBefore?: string;
|
||||
/** Filter by description text */
|
||||
description?: string;
|
||||
filter?: SearchFilter;
|
||||
/** Filter by encoded status */
|
||||
isEncoded?: boolean;
|
||||
/** Filter by favorite status */
|
||||
|
|
@ -8095,6 +8266,12 @@ export enum JobName {
|
|||
IntegrityDeleteReportType = "IntegrityDeleteReportType",
|
||||
IntegrityDeleteReports = "IntegrityDeleteReports"
|
||||
}
|
||||
export enum SearchOrderField {
|
||||
FileCreatedAt = "fileCreatedAt",
|
||||
LocalDateTime = "localDateTime",
|
||||
FileSizeInBytes = "fileSizeInBytes",
|
||||
Rating = "rating"
|
||||
}
|
||||
export enum SearchSuggestionType {
|
||||
Country = "country",
|
||||
State = "state",
|
||||
|
|
|
|||
|
|
@ -115,6 +115,46 @@ describe(SearchController.name, () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should reject a deprecated field combined with a new structure field', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ filter: {}, city: 'Oslo' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['city'], message: 'Deprecated field city cannot be combined with filter' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an unknown key in the filter', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ filter: { previewPath: { eq: 'preview.webp' } } });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['filter'], message: 'Unrecognized key: "previewPath"' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a nested or', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ filter: { or: [{ or: [{ city: { eq: 'Oslo' } }] }] } });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['filter', 'or', 0], message: 'Unrecognized key: "or"' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an empty or branch', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/search/metadata')
|
||||
.send({ filter: { or: [{}] } });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['filter', 'or', 0], message: 'At least one filter condition is required' }]),
|
||||
);
|
||||
});
|
||||
|
||||
describe('POST /search/random', () => {
|
||||
it('should reject if withStacked is not a boolean', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
|
|
|
|||
|
|
@ -69,7 +69,11 @@ export class SearchController {
|
|||
@Endpoint({
|
||||
summary: 'Search large assets',
|
||||
description: 'Search for assets that are considered large based on specified criteria.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
history: new HistoryBuilder()
|
||||
.added('v1')
|
||||
.beta('v1')
|
||||
.stable('v2')
|
||||
.deprecated('v3.2.0', { replacementId: 'searchAssets' }),
|
||||
})
|
||||
searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise<AssetResponseDto[]> {
|
||||
return this.service.searchLargeAssets(auth, dto);
|
||||
|
|
|
|||
|
|
@ -14,32 +14,40 @@ import {
|
|||
import { isoDatetimeToDate, nonEmptyPartial, stringToBool } from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
const ADDED_V3_2 = new HistoryBuilder().added('v3.2.0').getExtensions();
|
||||
|
||||
// fields deprecated in favor of the structured filter tree
|
||||
const DEPRECATED_FLAT_FIELD = {
|
||||
...new HistoryBuilder().added('v1').stable('v2').deprecated('v3.2.0').getExtensions(),
|
||||
deprecated: true,
|
||||
};
|
||||
|
||||
const BaseSearchSchema = z.object({
|
||||
libraryId: z.uuidv4().nullish().describe('Library ID to filter by'),
|
||||
type: AssetTypeSchema.optional(),
|
||||
isEncoded: z.boolean().optional().describe('Filter by encoded status'),
|
||||
isFavorite: z.boolean().optional().describe('Filter by favorite status'),
|
||||
isMotion: z.boolean().optional().describe('Filter by motion photo status'),
|
||||
isOffline: z.boolean().optional().describe('Filter by offline status'),
|
||||
visibility: AssetVisibilitySchema.optional(),
|
||||
createdBefore: isoDatetimeToDate.optional().describe('Filter by creation date (before)'),
|
||||
createdAfter: isoDatetimeToDate.optional().describe('Filter by creation date (after)'),
|
||||
updatedBefore: isoDatetimeToDate.optional().describe('Filter by update date (before)'),
|
||||
updatedAfter: isoDatetimeToDate.optional().describe('Filter by update date (after)'),
|
||||
trashedBefore: isoDatetimeToDate.optional().describe('Filter by trash date (before)'),
|
||||
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)'),
|
||||
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)'),
|
||||
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)'),
|
||||
city: z.string().nullable().optional().describe('Filter by city name'),
|
||||
state: z.string().nullable().optional().describe('Filter by state/province name'),
|
||||
country: z.string().nullable().optional().describe('Filter by country name'),
|
||||
make: z.string().nullable().optional().describe('Filter by camera make'),
|
||||
model: z.string().nullable().optional().describe('Filter by camera model'),
|
||||
lensModel: z.string().nullable().optional().describe('Filter by lens model'),
|
||||
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album'),
|
||||
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs'),
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
|
||||
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
|
||||
libraryId: z.uuidv4().nullish().describe('Library ID to filter by').meta(DEPRECATED_FLAT_FIELD),
|
||||
type: AssetTypeSchema.optional().meta(DEPRECATED_FLAT_FIELD),
|
||||
isEncoded: z.boolean().optional().describe('Filter by encoded status').meta(DEPRECATED_FLAT_FIELD),
|
||||
isFavorite: z.boolean().optional().describe('Filter by favorite status').meta(DEPRECATED_FLAT_FIELD),
|
||||
isMotion: z.boolean().optional().describe('Filter by motion photo status').meta(DEPRECATED_FLAT_FIELD),
|
||||
isOffline: z.boolean().optional().describe('Filter by offline status').meta(DEPRECATED_FLAT_FIELD),
|
||||
visibility: AssetVisibilitySchema.optional().meta(DEPRECATED_FLAT_FIELD),
|
||||
createdBefore: isoDatetimeToDate.optional().describe('Filter by creation date (before)').meta(DEPRECATED_FLAT_FIELD),
|
||||
createdAfter: isoDatetimeToDate.optional().describe('Filter by creation date (after)').meta(DEPRECATED_FLAT_FIELD),
|
||||
updatedBefore: isoDatetimeToDate.optional().describe('Filter by update date (before)').meta(DEPRECATED_FLAT_FIELD),
|
||||
updatedAfter: isoDatetimeToDate.optional().describe('Filter by update date (after)').meta(DEPRECATED_FLAT_FIELD),
|
||||
trashedBefore: isoDatetimeToDate.optional().describe('Filter by trash date (before)').meta(DEPRECATED_FLAT_FIELD),
|
||||
trashedAfter: isoDatetimeToDate.optional().describe('Filter by trash date (after)').meta(DEPRECATED_FLAT_FIELD),
|
||||
takenBefore: isoDatetimeToDate.optional().describe('Filter by taken date (before)').meta(DEPRECATED_FLAT_FIELD),
|
||||
takenAfter: isoDatetimeToDate.optional().describe('Filter by taken date (after)').meta(DEPRECATED_FLAT_FIELD),
|
||||
city: z.string().nullable().optional().describe('Filter by city name').meta(DEPRECATED_FLAT_FIELD),
|
||||
state: z.string().nullable().optional().describe('Filter by state/province name').meta(DEPRECATED_FLAT_FIELD),
|
||||
country: z.string().nullable().optional().describe('Filter by country name').meta(DEPRECATED_FLAT_FIELD),
|
||||
make: z.string().nullable().optional().describe('Filter by camera make').meta(DEPRECATED_FLAT_FIELD),
|
||||
model: z.string().nullable().optional().describe('Filter by camera model').meta(DEPRECATED_FLAT_FIELD),
|
||||
lensModel: z.string().nullable().optional().describe('Filter by lens model').meta(DEPRECATED_FLAT_FIELD),
|
||||
isNotInAlbum: z.boolean().optional().describe('Filter assets not in any album').meta(DEPRECATED_FLAT_FIELD),
|
||||
personIds: z.array(z.uuidv4()).optional().describe('Filter by person IDs').meta(DEPRECATED_FLAT_FIELD),
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs').meta(DEPRECATED_FLAT_FIELD),
|
||||
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs').meta(DEPRECATED_FLAT_FIELD),
|
||||
rating: z
|
||||
.int()
|
||||
.min(1)
|
||||
|
|
@ -52,51 +60,24 @@ const BaseSearchSchema = z.object({
|
|||
.stable('v2')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.')
|
||||
.updated('v3', 'Using -1 as a rating is no longer valid.')
|
||||
.deprecated('v3.2.0')
|
||||
.getExtensions(),
|
||||
deprecated: true,
|
||||
}),
|
||||
ocr: z.string().optional().describe('Filter by OCR text content'),
|
||||
ocr: z.string().optional().describe('Filter by OCR text content').meta(DEPRECATED_FLAT_FIELD),
|
||||
});
|
||||
|
||||
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
|
||||
withDeleted: z.boolean().optional().describe('Include deleted assets'),
|
||||
withDeleted: z.boolean().optional().describe('Include deleted assets').meta(DEPRECATED_FLAT_FIELD),
|
||||
withExif: z.boolean().optional().describe('Include EXIF data in response'),
|
||||
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
|
||||
size: z.int().min(1).max(1000).default(250).describe('Number of results to return'),
|
||||
});
|
||||
|
||||
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
withStacked: z.boolean().optional().describe('Include stacked assets'),
|
||||
withPeople: z.boolean().optional().describe('Include people data in response'),
|
||||
}).meta({ id: 'RandomSearchDto' });
|
||||
|
||||
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
|
||||
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
|
||||
size: z.coerce.number().int().min(1).max(1000).default(250).describe('Number of results to return'),
|
||||
}).meta({ id: 'LargeAssetSearchDto' });
|
||||
|
||||
const MetadataSearchSchema = RandomSearchSchema.extend({
|
||||
id: z.uuidv4().optional().describe('Filter by asset ID'),
|
||||
description: z.string().trim().optional().describe('Filter by description text'),
|
||||
checksum: z.string().optional().describe('Filter by file checksum'),
|
||||
originalFileName: z.string().trim().optional().describe('Filter by original file name'),
|
||||
originalPath: z.string().optional().describe('Filter by original file path'),
|
||||
previewPath: z.string().optional().describe('Filter by preview file path'),
|
||||
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
|
||||
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
|
||||
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
|
||||
page: z.int().min(1).optional().describe('Page number'),
|
||||
}).meta({ id: 'MetadataSearchDto' });
|
||||
|
||||
const StatisticsSearchSchema = BaseSearchSchema.extend({
|
||||
description: z.string().trim().optional().describe('Filter by description text'),
|
||||
}).meta({ id: 'StatisticsSearchDto' });
|
||||
|
||||
const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
query: z.string().trim().optional().describe('Natural language search query'),
|
||||
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
|
||||
language: z.string().optional().describe('Search language code'),
|
||||
page: z.int().min(1).optional().describe('Page number'),
|
||||
}).meta({ id: 'SmartSearchDto' });
|
||||
|
||||
const SearchPlacesSchema = z
|
||||
.object({
|
||||
name: z.string().describe('Place name to search for'),
|
||||
|
|
@ -263,47 +244,56 @@ export const SearchOrderSchema = z
|
|||
})
|
||||
.meta({ id: 'SearchOrder' });
|
||||
|
||||
const searchFilterBranchShape = {
|
||||
id: IdFilterSchema,
|
||||
libraryId: IdFilterNullableSchema,
|
||||
type: EnumFilterAssetTypeSchema,
|
||||
visibility: EnumFilterAssetVisibilitySchema,
|
||||
isFavorite: BoolFilterSchema,
|
||||
isMotion: BoolFilterSchema,
|
||||
isOffline: BoolFilterSchema,
|
||||
isEncoded: BoolFilterSchema,
|
||||
hasAlbums: BoolFilterSchema,
|
||||
hasPeople: BoolFilterSchema,
|
||||
hasTags: BoolFilterSchema,
|
||||
city: StringFilterNullableSchema,
|
||||
state: StringFilterNullableSchema,
|
||||
country: StringFilterNullableSchema,
|
||||
make: StringFilterNullableSchema,
|
||||
model: StringFilterNullableSchema,
|
||||
lensModel: StringFilterNullableSchema,
|
||||
description: StringPatternFilterSchema,
|
||||
originalFileName: StringPatternFilterSchema,
|
||||
originalPath: StringPatternFilterSchema,
|
||||
ocr: StringSimilarityFilterSchema,
|
||||
rating: NumberFilterNullableSchema,
|
||||
fileSizeInBytes: NumberFilterSchema,
|
||||
takenAt: DateFilterSchema,
|
||||
createdAt: DateFilterSchema,
|
||||
updatedAt: DateFilterSchema,
|
||||
trashedAt: DateFilterNullableSchema,
|
||||
personIds: IdsFilterSchema,
|
||||
tagIds: IdsFilterSchema,
|
||||
albumIds: IdsFilterSchema,
|
||||
checksum: StringFilterSchema,
|
||||
encodedVideoPath: StringFilterSchema,
|
||||
};
|
||||
|
||||
const SearchFilterBranchSchema = z
|
||||
.object({
|
||||
id: IdFilterSchema,
|
||||
libraryId: IdFilterNullableSchema,
|
||||
type: EnumFilterAssetTypeSchema,
|
||||
visibility: EnumFilterAssetVisibilitySchema,
|
||||
isFavorite: BoolFilterSchema,
|
||||
isMotion: BoolFilterSchema,
|
||||
isOffline: BoolFilterSchema,
|
||||
isEncoded: BoolFilterSchema,
|
||||
hasAlbums: BoolFilterSchema,
|
||||
hasPeople: BoolFilterSchema,
|
||||
hasTags: BoolFilterSchema,
|
||||
city: StringFilterNullableSchema,
|
||||
state: StringFilterNullableSchema,
|
||||
country: StringFilterNullableSchema,
|
||||
make: StringFilterNullableSchema,
|
||||
model: StringFilterNullableSchema,
|
||||
lensModel: StringFilterNullableSchema,
|
||||
description: StringPatternFilterSchema,
|
||||
originalFileName: StringPatternFilterSchema,
|
||||
originalPath: StringPatternFilterSchema,
|
||||
ocr: StringSimilarityFilterSchema,
|
||||
rating: NumberFilterNullableSchema,
|
||||
fileSizeInBytes: NumberFilterSchema,
|
||||
takenAt: DateFilterSchema,
|
||||
createdAt: DateFilterSchema,
|
||||
updatedAt: DateFilterSchema,
|
||||
trashedAt: DateFilterNullableSchema,
|
||||
personIds: IdsFilterSchema,
|
||||
tagIds: IdsFilterSchema,
|
||||
albumIds: IdsFilterSchema,
|
||||
checksum: StringFilterSchema,
|
||||
encodedVideoPath: StringFilterSchema,
|
||||
})
|
||||
.strictObject(searchFilterBranchShape)
|
||||
.partial()
|
||||
.refine((branch) => Object.values(branch).some((value) => value !== undefined), {
|
||||
message: 'At least one filter condition is required',
|
||||
})
|
||||
.meta({ id: 'SearchFilterBranch' });
|
||||
|
||||
export const SearchFilterSchema = SearchFilterBranchSchema.extend({
|
||||
or: z.array(SearchFilterBranchSchema).min(1).optional(),
|
||||
}).meta({ id: 'SearchFilter' });
|
||||
export const SearchFilterSchema = z
|
||||
.strictObject(searchFilterBranchShape)
|
||||
.partial()
|
||||
.extend({
|
||||
or: z.array(SearchFilterBranchSchema).min(1).optional(),
|
||||
})
|
||||
.meta({ id: 'SearchFilter' });
|
||||
|
||||
export type IdFilter = z.infer<typeof IdFilterSchema>;
|
||||
export type IdFilterNullable = z.infer<typeof IdFilterNullableSchema>;
|
||||
|
|
@ -319,6 +309,95 @@ export type SearchOrder = z.infer<typeof SearchOrderSchema>;
|
|||
export type SearchFilter = z.infer<typeof SearchFilterSchema>;
|
||||
export type SearchFilterBranch = z.infer<typeof SearchFilterBranchSchema>;
|
||||
|
||||
const NEW_SHAPE_FIELDS = ['filter', 'orderBy', 'cursor'] as const;
|
||||
|
||||
export const isNewShapeRequest = (dto: Partial<Record<(typeof NEW_SHAPE_FIELDS)[number], unknown>>): boolean =>
|
||||
NEW_SHAPE_FIELDS.some((field) => dto[field] !== undefined);
|
||||
|
||||
/** Whether every asset the branch can match is provably inside an (access-checked) album */
|
||||
export const isAlbumConfined = (branch: SearchFilterBranch): boolean =>
|
||||
branch.albumIds?.any !== undefined || branch.albumIds?.all !== undefined;
|
||||
|
||||
/** Whether every result of the whole filter is album-confined */
|
||||
export const isFullyAlbumConfined = (filter: SearchFilter): boolean =>
|
||||
isAlbumConfined(filter) || (!!filter.or?.length && filter.or.every((branch) => isAlbumConfined(branch)));
|
||||
|
||||
/**
|
||||
* The structured shape and the deprecated flat search fields are mutually exclusive
|
||||
* TODO(v4): remove together with the deprecated flat fields.
|
||||
*/
|
||||
const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) => {
|
||||
const deprecatedFields = Object.keys(schema.shape).filter(
|
||||
(field) => (schema.shape[field] as z.ZodType).meta()?.deprecated,
|
||||
);
|
||||
|
||||
return schema.superRefine((dto, ctx) => {
|
||||
const values = dto as Record<string, unknown>;
|
||||
const newShapeFields = NEW_SHAPE_FIELDS.filter((field) => values[field] !== undefined);
|
||||
if (newShapeFields.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const field of deprecatedFields) {
|
||||
if (values[field] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: [field],
|
||||
message: `Deprecated field ${field} cannot be combined with ${newShapeFields.join('/')}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const filterField = SearchFilterSchema.optional().meta(ADDED_V3_2);
|
||||
const cursorField = z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_2);
|
||||
|
||||
const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({
|
||||
withStacked: z.boolean().optional().describe('Include stacked assets'),
|
||||
withPeople: z.boolean().optional().describe('Include people data in response'),
|
||||
filter: filterField,
|
||||
});
|
||||
|
||||
const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' });
|
||||
|
||||
const MetadataSearchSchema = withShapeExclusivity(
|
||||
RandomSearchBaseSchema.extend({
|
||||
id: z.uuidv4().optional().describe('Filter by asset ID').meta(DEPRECATED_FLAT_FIELD),
|
||||
description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD),
|
||||
checksum: z.string().optional().describe('Filter by file checksum').meta(DEPRECATED_FLAT_FIELD),
|
||||
originalFileName: z.string().trim().optional().describe('Filter by original file name').meta(DEPRECATED_FLAT_FIELD),
|
||||
originalPath: z.string().optional().describe('Filter by original file path').meta(DEPRECATED_FLAT_FIELD),
|
||||
previewPath: z.string().optional().describe('Filter by preview file path').meta(DEPRECATED_FLAT_FIELD),
|
||||
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path').meta(DEPRECATED_FLAT_FIELD),
|
||||
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path').meta(DEPRECATED_FLAT_FIELD),
|
||||
order: AssetOrderSchema.optional().describe('Sort order').meta(DEPRECATED_FLAT_FIELD),
|
||||
page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD),
|
||||
orderBy: SearchOrderSchema.optional().meta(ADDED_V3_2),
|
||||
cursor: cursorField,
|
||||
}),
|
||||
).meta({ id: 'MetadataSearchDto' });
|
||||
|
||||
const StatisticsSearchSchema = withShapeExclusivity(
|
||||
BaseSearchSchema.extend({
|
||||
description: z.string().trim().optional().describe('Filter by description text').meta(DEPRECATED_FLAT_FIELD),
|
||||
filter: filterField,
|
||||
}),
|
||||
).meta({ id: 'StatisticsSearchDto' });
|
||||
|
||||
const SmartSearchSchema = withShapeExclusivity(
|
||||
BaseSearchWithResultsSchema.extend({
|
||||
size: z.int().min(1).max(1000).default(100).describe('Number of results to return'),
|
||||
query: z.string().trim().optional().describe('Natural language search query'),
|
||||
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
|
||||
language: z.string().optional().describe('Search language code'),
|
||||
page: z.int().min(1).optional().describe('Page number').meta(DEPRECATED_FLAT_FIELD),
|
||||
filter: filterField,
|
||||
}),
|
||||
).meta({ id: 'SmartSearchDto' });
|
||||
|
||||
export class RandomSearchDto extends createZodDto(RandomSearchSchema) {}
|
||||
export class LargeAssetSearchDto extends createZodDto(LargeAssetSearchSchema) {}
|
||||
export class MetadataSearchDto extends createZodDto(MetadataSearchSchema) {}
|
||||
|
|
@ -372,7 +451,8 @@ const SearchAssetResponseSchema = z
|
|||
count: z.int().min(0).describe('Number of assets in this page'),
|
||||
items: z.array(AssetResponseSchema),
|
||||
facets: z.array(SearchFacetResponseSchema),
|
||||
nextPage: z.string().nullable().describe('Next page token'),
|
||||
nextPage: z.string().nullable().describe('Next page token').meta(DEPRECATED_FLAT_FIELD),
|
||||
nextCursor: z.string().nullable().describe('Cursor for the next page of results').meta(ADDED_V3_2),
|
||||
})
|
||||
.meta({ id: 'SearchAssetResponseDto' });
|
||||
|
||||
|
|
|
|||
|
|
@ -455,14 +455,20 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and true
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$2
|
||||
$4
|
||||
offset
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (empty)
|
||||
-- SearchRepository.searchMetadataV3 (or-mixed-scope)
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."updateId",
|
||||
|
|
@ -496,12 +502,31 @@ from
|
|||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
true
|
||||
(
|
||||
"asset"."visibility" != $1
|
||||
or "asset"."ownerId" = $2
|
||||
)
|
||||
and (
|
||||
exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "album_asset"."albumId" = any ($3::uuid[])
|
||||
)
|
||||
or (
|
||||
"asset_exif"."city" = $4
|
||||
and "asset"."ownerId" = any ($5::uuid[])
|
||||
)
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$1
|
||||
$6
|
||||
offset
|
||||
$7
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (or-exif-only)
|
||||
select
|
||||
|
|
@ -538,12 +563,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and "asset_exif"."city" = $2
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and "asset_exif"."city" = $4
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-eq-null)
|
||||
select
|
||||
|
|
@ -580,12 +611,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and "asset_exif"."city" is null
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$2
|
||||
$4
|
||||
offset
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-pattern-like)
|
||||
select
|
||||
|
|
@ -622,12 +659,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($2) || '%')
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and f_unaccent ("asset_exif"."description") ilike ('%' || f_unaccent ($4) || '%')
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-pattern-notLike)
|
||||
select
|
||||
|
|
@ -664,12 +707,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($2) || '%')
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and f_unaccent ("asset_exif"."description") not ilike ('%' || f_unaccent ($4) || '%')
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-pattern-startsWith)
|
||||
select
|
||||
|
|
@ -706,12 +755,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($2) || '%')
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and f_unaccent ("asset"."originalFileName") ilike (f_unaccent ($4) || '%')
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (string-similarity-ocr)
|
||||
select
|
||||
|
|
@ -748,19 +803,25 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"ocr_search"
|
||||
where
|
||||
"ocr_search"."assetId" = "asset"."id"
|
||||
and f_unaccent (ocr_search.text) %>> f_unaccent ($2)
|
||||
and f_unaccent (ocr_search.text) %>> f_unaccent ($4)
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-any)
|
||||
select
|
||||
|
|
@ -796,20 +857,25 @@ from
|
|||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
(
|
||||
"asset"."visibility" != $1
|
||||
or "asset"."ownerId" = $2
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "album_asset"."albumId" = any ($2::uuid[])
|
||||
and "album_asset"."albumId" = any ($3::uuid[])
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$4
|
||||
offset
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-all)
|
||||
select
|
||||
|
|
@ -846,6 +912,10 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
"asset_face"."assetId"
|
||||
|
|
@ -854,18 +924,20 @@ where
|
|||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $2
|
||||
and "asset_face"."personGroupId" = any ($3::uuid[])
|
||||
and "asset_face"."isVisible" = $4
|
||||
and "asset_face"."personGroupId" = any ($5::uuid[])
|
||||
group by
|
||||
"asset_face"."assetId"
|
||||
having
|
||||
count(distinct "asset_face"."personGroupId") = $4
|
||||
count(distinct "asset_face"."personGroupId") = $6
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$5
|
||||
$7
|
||||
offset
|
||||
$8
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-all-single)
|
||||
select
|
||||
|
|
@ -901,20 +973,25 @@ from
|
|||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
(
|
||||
"asset"."visibility" != $1
|
||||
or "asset"."ownerId" = $2
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "album_asset"."albumId" = any ($2::uuid[])
|
||||
and "album_asset"."albumId" = any ($3::uuid[])
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$4
|
||||
offset
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-none)
|
||||
select
|
||||
|
|
@ -951,6 +1028,10 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
|
|
@ -958,13 +1039,15 @@ where
|
|||
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
|
||||
where
|
||||
"tag_asset"."assetId" = "asset"."id"
|
||||
and "tag_closure"."id_ancestor" = any ($2::uuid[])
|
||||
and "tag_closure"."id_ancestor" = any ($4::uuid[])
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (ids-tags-all)
|
||||
select
|
||||
|
|
@ -1001,6 +1084,10 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
"tag_asset"."assetId"
|
||||
|
|
@ -1009,17 +1096,19 @@ where
|
|||
inner join "tag_closure" on "tag_asset"."tagId" = "tag_closure"."id_descendant"
|
||||
where
|
||||
"tag_asset"."assetId" = "asset"."id"
|
||||
and "tag_closure"."id_ancestor" = any ($2::uuid[])
|
||||
and "tag_closure"."id_ancestor" = any ($4::uuid[])
|
||||
group by
|
||||
"tag_asset"."assetId"
|
||||
having
|
||||
count(distinct "tag_closure"."id_ancestor") = $3
|
||||
count(distinct "tag_closure"."id_ancestor") = $5
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$4
|
||||
$6
|
||||
offset
|
||||
$7
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (has-albums-false)
|
||||
select
|
||||
|
|
@ -1056,6 +1145,10 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
|
|
@ -1067,7 +1160,9 @@ order by
|
|||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$2
|
||||
$4
|
||||
offset
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (is-encoded)
|
||||
select
|
||||
|
|
@ -1104,19 +1199,25 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $2
|
||||
and "asset_file"."type" = $4
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (number-range)
|
||||
select
|
||||
|
|
@ -1154,14 +1255,20 @@ from
|
|||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset_exif"."fileSizeInByte" <= $2
|
||||
and "asset_exif"."fileSizeInByte" >= $3
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and (
|
||||
"asset_exif"."fileSizeInByte" <= $4
|
||||
and "asset_exif"."fileSizeInByte" >= $5
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$4
|
||||
$6
|
||||
offset
|
||||
$7
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (date-eq)
|
||||
select
|
||||
|
|
@ -1198,12 +1305,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and "asset"."fileCreatedAt" = $2
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and "asset"."fileCreatedAt" = $4
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$3
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (date-range)
|
||||
select
|
||||
|
|
@ -1241,14 +1354,20 @@ from
|
|||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."fileCreatedAt" < $2
|
||||
and "asset"."fileCreatedAt" >= $3
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and (
|
||||
"asset"."fileCreatedAt" < $4
|
||||
and "asset"."fileCreatedAt" >= $5
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$4
|
||||
$6
|
||||
offset
|
||||
$7
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (order-fileSize-noExif)
|
||||
select
|
||||
|
|
@ -1285,12 +1404,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and true
|
||||
order by
|
||||
"asset_exif"."fileSizeInByte" desc nulls last,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$2
|
||||
$4
|
||||
offset
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (order-rating-withExif)
|
||||
select
|
||||
|
|
@ -1328,12 +1453,18 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and true
|
||||
order by
|
||||
"asset_exif"."rating" asc nulls last,
|
||||
"asset"."id" asc
|
||||
limit
|
||||
$2
|
||||
$4
|
||||
offset
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (or-branches)
|
||||
select
|
||||
|
|
@ -1371,7 +1502,11 @@ from
|
|||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."isFavorite" = $2
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and (
|
||||
"asset"."isFavorite" = $4
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
|
|
@ -1379,15 +1514,17 @@ where
|
|||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $3
|
||||
and "asset_face"."personGroupId" = any ($4::uuid[])
|
||||
and "asset_face"."isVisible" = $5
|
||||
and "asset_face"."personGroupId" = any ($6::uuid[])
|
||||
)
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$5
|
||||
$7
|
||||
offset
|
||||
$8
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (or-with-top-level)
|
||||
select
|
||||
|
|
@ -1423,19 +1560,25 @@ from
|
|||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
(
|
||||
"asset"."visibility" != $1
|
||||
or "asset"."ownerId" = $2
|
||||
)
|
||||
and (
|
||||
"asset"."fileCreatedAt" < $2
|
||||
and "asset"."fileCreatedAt" >= $3
|
||||
"asset"."fileCreatedAt" < $3
|
||||
and "asset"."fileCreatedAt" >= $4
|
||||
and (
|
||||
"asset"."isFavorite" = $4
|
||||
(
|
||||
"asset"."isFavorite" = $5
|
||||
and "asset"."ownerId" = any ($6::uuid[])
|
||||
)
|
||||
or exists (
|
||||
select
|
||||
from
|
||||
"album_asset"
|
||||
where
|
||||
"album_asset"."assetId" = "asset"."id"
|
||||
and "album_asset"."albumId" = any ($5::uuid[])
|
||||
and "album_asset"."albumId" = any ($7::uuid[])
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
@ -1443,8 +1586,310 @@ order by
|
|||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$8
|
||||
offset
|
||||
$9
|
||||
|
||||
-- SearchRepository.searchMetadataV3 (cursor-offset)
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."updateId",
|
||||
"asset"."createdAt",
|
||||
"asset"."updatedAt",
|
||||
"asset"."deletedAt",
|
||||
"asset"."status",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."duplicateId",
|
||||
"asset"."duration",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."isExternal",
|
||||
"asset"."isFavorite",
|
||||
"asset"."isOffline",
|
||||
"asset"."isEdited",
|
||||
"asset"."visibility",
|
||||
"asset"."libraryId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."localDateTime",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."stackId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and "asset"."isFavorite" = $4
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc,
|
||||
"asset"."id" desc
|
||||
limit
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
|
||||
-- SearchRepository.searchRandomV3 (baseline)
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."updateId",
|
||||
"asset"."createdAt",
|
||||
"asset"."updatedAt",
|
||||
"asset"."deletedAt",
|
||||
"asset"."status",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."duplicateId",
|
||||
"asset"."duration",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."isExternal",
|
||||
"asset"."isFavorite",
|
||||
"asset"."isOffline",
|
||||
"asset"."isEdited",
|
||||
"asset"."visibility",
|
||||
"asset"."libraryId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."localDateTime",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."stackId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and true
|
||||
order by
|
||||
random()
|
||||
limit
|
||||
$4
|
||||
|
||||
-- SearchRepository.searchRandomV3 (with-filter)
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."updateId",
|
||||
"asset"."createdAt",
|
||||
"asset"."updatedAt",
|
||||
"asset"."deletedAt",
|
||||
"asset"."status",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."duplicateId",
|
||||
"asset"."duration",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."isExternal",
|
||||
"asset"."isFavorite",
|
||||
"asset"."isOffline",
|
||||
"asset"."isEdited",
|
||||
"asset"."visibility",
|
||||
"asset"."libraryId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."localDateTime",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."stackId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and "asset"."isFavorite" = $4
|
||||
order by
|
||||
random()
|
||||
limit
|
||||
$5
|
||||
|
||||
-- SearchRepository.searchSmartV3 (baseline)
|
||||
begin
|
||||
set
|
||||
local vchordrq.probes = 1
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."updateId",
|
||||
"asset"."createdAt",
|
||||
"asset"."updatedAt",
|
||||
"asset"."deletedAt",
|
||||
"asset"."status",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."duplicateId",
|
||||
"asset"."duration",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."isExternal",
|
||||
"asset"."isFavorite",
|
||||
"asset"."isOffline",
|
||||
"asset"."isEdited",
|
||||
"asset"."visibility",
|
||||
"asset"."libraryId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."localDateTime",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."stackId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and true
|
||||
order by
|
||||
smart_search.embedding <=> $4,
|
||||
"asset"."id" asc
|
||||
limit
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
commit
|
||||
|
||||
-- SearchRepository.searchSmartV3 (with-filter)
|
||||
begin
|
||||
set
|
||||
local vchordrq.probes = 1
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."updateId",
|
||||
"asset"."createdAt",
|
||||
"asset"."updatedAt",
|
||||
"asset"."deletedAt",
|
||||
"asset"."status",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."duplicateId",
|
||||
"asset"."duration",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."isExternal",
|
||||
"asset"."isFavorite",
|
||||
"asset"."isOffline",
|
||||
"asset"."isEdited",
|
||||
"asset"."visibility",
|
||||
"asset"."libraryId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."localDateTime",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."stackId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and (
|
||||
"asset"."fileCreatedAt" < $4
|
||||
and "asset"."fileCreatedAt" >= $5
|
||||
)
|
||||
order by
|
||||
smart_search.embedding <=> $6,
|
||||
"asset"."id" asc
|
||||
limit
|
||||
$7
|
||||
offset
|
||||
$8
|
||||
commit
|
||||
|
||||
-- SearchRepository.searchSmartV3 (cursor-offset)
|
||||
begin
|
||||
set
|
||||
local vchordrq.probes = 1
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."updateId",
|
||||
"asset"."createdAt",
|
||||
"asset"."updatedAt",
|
||||
"asset"."deletedAt",
|
||||
"asset"."status",
|
||||
"asset"."checksum",
|
||||
"asset"."checksumAlgorithm",
|
||||
"asset"."duplicateId",
|
||||
"asset"."duration",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset"."fileModifiedAt",
|
||||
"asset"."isExternal",
|
||||
"asset"."isFavorite",
|
||||
"asset"."isOffline",
|
||||
"asset"."isEdited",
|
||||
"asset"."visibility",
|
||||
"asset"."libraryId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."localDateTime",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."stackId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
inner join "smart_search" on "asset"."id" = "smart_search"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and true
|
||||
order by
|
||||
smart_search.embedding <=> $4,
|
||||
"asset"."id" asc
|
||||
limit
|
||||
$5
|
||||
offset
|
||||
$6
|
||||
commit
|
||||
|
||||
-- SearchRepository.searchStatisticsV3 (baseline)
|
||||
select
|
||||
count(*) as "total"
|
||||
|
|
@ -1453,6 +1898,10 @@ from
|
|||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and true
|
||||
|
||||
-- SearchRepository.searchStatisticsV3 (with-filter)
|
||||
|
|
@ -1464,9 +1913,13 @@ from
|
|||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset_exif"."fileSizeInByte" >= $2
|
||||
and "asset"."fileCreatedAt" < $3
|
||||
and "asset"."fileCreatedAt" >= $4
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and (
|
||||
"asset_exif"."fileSizeInByte" >= $4
|
||||
and "asset"."fileCreatedAt" < $5
|
||||
and "asset"."fileCreatedAt" >= $6
|
||||
)
|
||||
|
||||
-- SearchRepository.searchStatisticsV3 (with-or)
|
||||
|
|
@ -1478,7 +1931,11 @@ from
|
|||
where
|
||||
"asset"."ownerId" = any ($1::uuid[])
|
||||
and (
|
||||
"asset"."isFavorite" = $2
|
||||
"asset"."visibility" != $2
|
||||
or "asset"."ownerId" = $3
|
||||
)
|
||||
and (
|
||||
"asset"."isFavorite" = $4
|
||||
or not exists (
|
||||
select
|
||||
from
|
||||
|
|
|
|||
|
|
@ -14,11 +14,13 @@ import {
|
|||
searchAssetBuilder,
|
||||
searchAssetBuilderLegacy,
|
||||
searchMetadataV3Examples,
|
||||
searchRandomV3Examples,
|
||||
searchSmartV3Examples,
|
||||
searchStatisticsV3Examples,
|
||||
withExifInner,
|
||||
withSearchOrder,
|
||||
} from 'src/utils/database';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||
import z from 'zod';
|
||||
|
||||
export interface SearchAssetIdOptions {
|
||||
|
|
@ -135,12 +137,15 @@ export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
|
|||
|
||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||
|
||||
export interface AssetSearchBuilderV3Options {
|
||||
filter?: SearchFilter;
|
||||
/** Server-derived ownership scope. Never client-controlled. */
|
||||
userIds?: string[];
|
||||
export interface AssetSearchScope {
|
||||
userIds: string[];
|
||||
lockedOwnerId: string;
|
||||
/** whose version of the people to select, required when selecting faces or people */
|
||||
viewingUserId?: string;
|
||||
}
|
||||
|
||||
export interface AssetSearchBuilderV3Options {
|
||||
filter?: SearchFilter;
|
||||
withExif?: boolean;
|
||||
withFaces?: boolean;
|
||||
withPeople?: boolean;
|
||||
|
|
@ -148,10 +153,6 @@ export interface AssetSearchBuilderV3Options {
|
|||
order?: SearchOrder;
|
||||
}
|
||||
|
||||
export interface AssetSearchPaginationV3Options {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type SmartSearchOptions = SearchDateOptions &
|
||||
SearchEmbeddingOptions &
|
||||
SearchExifOptions &
|
||||
|
|
@ -211,6 +212,7 @@ export interface GetCameraLensModelsOptions {
|
|||
export class SearchRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
// TODO(v4): remove with the deprecated flat-field search API
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{ page: 1, size: 100 },
|
||||
|
|
@ -236,6 +238,7 @@ export class SearchRepository {
|
|||
return paginationHelper(items, pagination.size);
|
||||
}
|
||||
|
||||
// TODO(v4): remove with the deprecated flat-field search API
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{
|
||||
|
|
@ -252,6 +255,7 @@ export class SearchRepository {
|
|||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
// TODO(v4): remove with the deprecated flat-field search API
|
||||
@GenerateSql({
|
||||
params: [
|
||||
100,
|
||||
|
|
@ -272,6 +276,7 @@ export class SearchRepository {
|
|||
.execute();
|
||||
}
|
||||
|
||||
// TODO(v4): remove with the deprecated flat-field search API
|
||||
@GenerateSql({
|
||||
params: [
|
||||
100,
|
||||
|
|
@ -295,6 +300,7 @@ export class SearchRepository {
|
|||
.execute();
|
||||
}
|
||||
|
||||
// TODO(v4): remove with the deprecated flat-field search API
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{ page: 1, size: 200 },
|
||||
|
|
@ -530,20 +536,56 @@ export class SearchRepository {
|
|||
return res.map((row) => row.lensModel!);
|
||||
}
|
||||
|
||||
// TODO(v4): drop the V3 suffix once the legacy methods are removed
|
||||
@GenerateSql(...searchMetadataV3Examples)
|
||||
searchMetadataV3(
|
||||
pagination: AssetSearchPaginationV3Options,
|
||||
options: AssetSearchBuilderV3Options,
|
||||
): Promise<MapAsset[]> {
|
||||
return withSearchOrder(searchAssetBuilder(this.db, options), options.order)
|
||||
async searchMetadataV3(pagination: PaginationOptions, options: AssetSearchBuilderV3Options, scope: AssetSearchScope) {
|
||||
const items = await withSearchOrder(searchAssetBuilder(this.db, options, scope), options.order)
|
||||
.select(columns.searchAsset)
|
||||
.limit(pagination.size)
|
||||
.limit(pagination.take + 1)
|
||||
.offset(pagination.skip ?? 0)
|
||||
.execute();
|
||||
return paginationHelper(items, pagination.take);
|
||||
}
|
||||
|
||||
// TODO(v4): drop the V3 suffix once the legacy methods are removed
|
||||
@GenerateSql(...searchRandomV3Examples)
|
||||
searchRandomV3(
|
||||
size: number,
|
||||
options: Omit<AssetSearchBuilderV3Options, 'order'>,
|
||||
scope: AssetSearchScope,
|
||||
): Promise<MapAsset[]> {
|
||||
return searchAssetBuilder(this.db, options, scope)
|
||||
.select(columns.searchAsset)
|
||||
.orderBy(sql`random()`)
|
||||
.limit(size)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// TODO(v4): drop the V3 suffix once the legacy methods are removed
|
||||
@GenerateSql(...searchSmartV3Examples)
|
||||
searchSmartV3(
|
||||
pagination: PaginationOptions,
|
||||
options: Omit<AssetSearchBuilderV3Options, 'order'> & { embedding: string },
|
||||
scope: AssetSearchScope,
|
||||
) {
|
||||
return this.db.transaction().execute(async (trx) => {
|
||||
await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Clip])}`.execute(trx);
|
||||
const items = await searchAssetBuilder(trx, options, scope)
|
||||
.select(columns.searchAsset)
|
||||
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
|
||||
.orderBy('asset.id', 'asc')
|
||||
.limit(pagination.take + 1)
|
||||
.offset(pagination.skip ?? 0)
|
||||
.execute();
|
||||
return paginationHelper(items, pagination.take);
|
||||
});
|
||||
}
|
||||
|
||||
// TODO(v4): drop the V3 suffix once the legacy methods are removed
|
||||
@GenerateSql(...searchStatisticsV3Examples)
|
||||
searchStatisticsV3(options: AssetSearchBuilderV3Options) {
|
||||
return searchAssetBuilder(this.db, options)
|
||||
searchStatisticsV3(options: AssetSearchBuilderV3Options, scope: AssetSearchScope) {
|
||||
return searchAssetBuilder(this.db, options, scope)
|
||||
.select((qb) => qb.fn.countAll<number>().as('total'))
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset } from 'test/mappers';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { beforeEach, vitest } from 'vitest';
|
||||
|
||||
|
|
@ -215,6 +217,84 @@ describe(SearchService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('new shape routing', () => {
|
||||
it('should route a filter request to the V3 search and a flat request to the legacy search', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.search.searchMetadataV3.mockResolvedValue({ hasNextPage: false, items: [] });
|
||||
await sut.searchMetadata(auth, { size: 250, filter: {} });
|
||||
expect(mocks.search.searchMetadataV3).toHaveBeenCalled();
|
||||
expect(mocks.search.searchMetadata).not.toHaveBeenCalled();
|
||||
|
||||
mocks.search.searchMetadata.mockResolvedValue({ hasNextPage: false, items: [] });
|
||||
await sut.searchMetadata(auth, { size: 250, city: 'Oslo' });
|
||||
expect(mocks.search.searchMetadata).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should route statistics, random, and smart filter requests to their V3 search', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.search.searchStatisticsV3.mockResolvedValue({ total: 0 });
|
||||
await expect(sut.searchStatistics(auth, { filter: {} })).resolves.toEqual({ total: 0 });
|
||||
|
||||
mocks.search.searchRandomV3.mockResolvedValue([]);
|
||||
await expect(sut.searchRandom(auth, { size: 250, filter: {} })).resolves.toEqual([]);
|
||||
|
||||
mocks.search.searchSmartV3.mockResolvedValue({ hasNextPage: false, items: [] });
|
||||
mocks.machineLearning.encodeText.mockResolvedValue('[1, 2, 3]');
|
||||
await sut.searchSmart(auth, { size: 100, filter: {}, query: 'test' });
|
||||
expect(mocks.search.searchSmartV3).toHaveBeenCalledWith(
|
||||
{ take: 100 },
|
||||
expect.objectContaining({ embedding: '[1, 2, 3]' }),
|
||||
expect.objectContaining({ lockedOwnerId: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an invalid cursor', async () => {
|
||||
await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, cursor: '???' })).rejects.toThrowError(
|
||||
new BadRequestException('Invalid cursor'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject an unelevated session whose filter could match locked assets', async () => {
|
||||
const filter = { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } };
|
||||
await expect(sut.searchMetadata(AuthFactory.create(), { size: 250, filter })).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a shared link whose filter is not confined to albums everywhere', async () => {
|
||||
const auth = AuthFactory.from().sharedLink().build();
|
||||
const albumId = newUuid();
|
||||
|
||||
await expect(sut.searchMetadata(auth, { size: 250, filter: {} })).rejects.toThrowError(
|
||||
new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.searchMetadata(auth, {
|
||||
size: 250,
|
||||
filter: { or: [{ albumIds: { any: [albumId] } }, { city: { eq: 'Oslo' } }] },
|
||||
}),
|
||||
).rejects.toThrowError(
|
||||
new BadRequestException('Shared link access is only allowed in combination with an albumIds filter'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow a shared link when every branch is confined to a covered album', async () => {
|
||||
const auth = AuthFactory.from().sharedLink().build();
|
||||
const albumId = newUuid();
|
||||
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.search.searchMetadataV3.mockResolvedValue({ hasNextPage: false, items: [] });
|
||||
|
||||
await expect(
|
||||
sut.searchMetadata(auth, { size: 250, filter: { or: [{ albumIds: { any: [albumId] } }] } }),
|
||||
).resolves.toBeDefined();
|
||||
expect(mocks.search.searchMetadataV3).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSmart', () => {
|
||||
beforeEach(() => {
|
||||
mocks.search.searchSmart.mockResolvedValue({ hasNextPage: false, items: [] });
|
||||
|
|
@ -226,7 +306,7 @@ describe(SearchService.name, () => {
|
|||
machineLearning: { enabled: false },
|
||||
});
|
||||
|
||||
await expect(sut.searchSmart(authStub.user1, { query: 'test' })).rejects.toThrowError(
|
||||
await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError(
|
||||
new BadRequestException('Smart search is not enabled'),
|
||||
);
|
||||
});
|
||||
|
|
@ -236,13 +316,13 @@ describe(SearchService.name, () => {
|
|||
machineLearning: { clip: { enabled: false } },
|
||||
});
|
||||
|
||||
await expect(sut.searchSmart(authStub.user1, { query: 'test' })).rejects.toThrowError(
|
||||
await expect(sut.searchSmart(authStub.user1, { size: 100, query: 'test' })).rejects.toThrowError(
|
||||
new BadRequestException('Smart search is not enabled'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test' });
|
||||
await sut.searchSmart(authStub.user1, { size: 100, query: 'test' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
'test',
|
||||
|
|
@ -252,6 +332,7 @@ describe(SearchService.name, () => {
|
|||
{ page: 1, size: 100 },
|
||||
{
|
||||
query: 'test',
|
||||
size: 100,
|
||||
embedding: '[1, 2, 3]',
|
||||
userIds: [authStub.user1.user.id],
|
||||
viewingUserId: authStub.user1.user.id,
|
||||
|
|
@ -278,7 +359,7 @@ describe(SearchService.name, () => {
|
|||
machineLearning: { clip: { modelName: 'ViT-B-16-SigLIP__webli' } },
|
||||
});
|
||||
|
||||
await sut.searchSmart(authStub.user1, { query: 'test' });
|
||||
await sut.searchSmart(authStub.user1, { size: 100, query: 'test' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
'test',
|
||||
|
|
@ -287,7 +368,7 @@ describe(SearchService.name, () => {
|
|||
});
|
||||
|
||||
it('should use language specified in request', async () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' });
|
||||
await sut.searchSmart(authStub.user1, { size: 100, query: 'test', language: 'de' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
'test',
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||
import { LRUMap } from 'mnemonist';
|
||||
import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SystemConfig } from 'src/dtos/config.dto';
|
||||
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import {
|
||||
isFullyAlbumConfined,
|
||||
isNewShapeRequest,
|
||||
LargeAssetSearchDto,
|
||||
mapPlaces,
|
||||
MetadataSearchDto,
|
||||
PlacesResponseDto,
|
||||
RandomSearchDto,
|
||||
SearchFilter,
|
||||
SearchPeopleDto,
|
||||
SearchPlacesDto,
|
||||
SearchResponseDto,
|
||||
|
|
@ -19,10 +23,13 @@ import {
|
|||
StatisticsSearchDto,
|
||||
} from 'src/dtos/search.dto';
|
||||
import { AssetOrder, AssetVisibility, Permission } from 'src/enum';
|
||||
import { AssetSearchScope } from 'src/repositories/search.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||
import { isSmartSearchEnabled } from 'src/utils/misc';
|
||||
import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor';
|
||||
import { applyLockedVisibilityPolicy, collectFilterIds } from 'src/utils/search-filter';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService extends BaseService {
|
||||
|
|
@ -65,6 +72,10 @@ export class SearchService extends BaseService {
|
|||
}
|
||||
|
||||
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||
if (isNewShapeRequest(dto)) {
|
||||
return this.searchMetadataV3(auth, dto);
|
||||
}
|
||||
|
||||
if (dto.visibility === AssetVisibility.Locked) {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
|
@ -86,7 +97,7 @@ export class SearchService extends BaseService {
|
|||
}
|
||||
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 250;
|
||||
const size = dto.size;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
|
||||
{ page, size },
|
||||
{
|
||||
|
|
@ -99,10 +110,14 @@ export class SearchService extends BaseService {
|
|||
},
|
||||
);
|
||||
|
||||
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
|
||||
return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null });
|
||||
}
|
||||
|
||||
async searchStatistics(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
|
||||
if (isNewShapeRequest(dto)) {
|
||||
return this.searchStatisticsV3(auth, dto);
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
if (dto.visibility === AssetVisibility.Locked) {
|
||||
requireElevatedPermission(auth);
|
||||
|
|
@ -117,12 +132,16 @@ export class SearchService extends BaseService {
|
|||
}
|
||||
|
||||
async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
|
||||
if (isNewShapeRequest(dto)) {
|
||||
return this.searchRandomV3(auth, dto);
|
||||
}
|
||||
|
||||
if (dto.visibility === AssetVisibility.Locked) {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const items = await this.searchRepository.searchRandom(dto.size || 250, {
|
||||
const items = await this.searchRepository.searchRandom(dto.size, {
|
||||
...dto,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
|
|
@ -137,7 +156,7 @@ export class SearchService extends BaseService {
|
|||
}
|
||||
|
||||
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
|
||||
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, {
|
||||
const items = await this.searchRepository.searchLargeAssets(dto.size, {
|
||||
...dto,
|
||||
visibility: dto.visibility ?? (auth.session?.hasElevatedPermission ? undefined : 'not-locked'),
|
||||
userIds,
|
||||
|
|
@ -147,6 +166,10 @@ export class SearchService extends BaseService {
|
|||
}
|
||||
|
||||
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
|
||||
if (isNewShapeRequest(dto)) {
|
||||
return this.searchSmartV3(auth, dto);
|
||||
}
|
||||
|
||||
if (dto.visibility === AssetVisibility.Locked) {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
|
@ -157,30 +180,9 @@ export class SearchService extends BaseService {
|
|||
}
|
||||
|
||||
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
|
||||
let embedding;
|
||||
if (dto.query) {
|
||||
const key = machineLearning.clip.modelName + dto.query + dto.language;
|
||||
embedding = this.embeddingCache.get(key);
|
||||
if (!embedding) {
|
||||
embedding = await this.machineLearningRepository.encodeText(dto.query, {
|
||||
modelName: machineLearning.clip.modelName,
|
||||
language: dto.language,
|
||||
});
|
||||
this.embeddingCache.set(key, embedding);
|
||||
}
|
||||
} else if (dto.queryAssetId) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] });
|
||||
const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId);
|
||||
const assetEmbedding = getEmbeddingResponse?.embedding;
|
||||
if (!assetEmbedding) {
|
||||
throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`);
|
||||
}
|
||||
embedding = assetEmbedding;
|
||||
} else {
|
||||
throw new BadRequestException('Either `query` or `queryAssetId` must be set');
|
||||
}
|
||||
const embedding = await this.resolveEmbedding(auth, dto, machineLearning);
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 100;
|
||||
const size = dto.size;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||
{ page, size },
|
||||
{
|
||||
|
|
@ -192,7 +194,7 @@ export class SearchService extends BaseService {
|
|||
},
|
||||
);
|
||||
|
||||
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
|
||||
return this.mapResponse(items, { auth }, { nextPage: hasNextPage ? (page + 1).toString() : null });
|
||||
}
|
||||
|
||||
async getAssetsByCity(auth: AuthDto): Promise<AssetResponseDto[]> {
|
||||
|
|
@ -236,6 +238,121 @@ export class SearchService extends BaseService {
|
|||
}
|
||||
}
|
||||
|
||||
private async searchMetadataV3(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||
const { filter, scope } = await this.resolveSearchScopeV3(auth, dto);
|
||||
|
||||
const { offset } = decodeSearchCursor(dto.cursor);
|
||||
const size = dto.size;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadataV3(
|
||||
{ take: size, skip: offset },
|
||||
{
|
||||
filter,
|
||||
withExif: dto.withExif,
|
||||
withPeople: dto.withPeople,
|
||||
withStacked: dto.withStacked,
|
||||
order: dto.orderBy,
|
||||
},
|
||||
scope,
|
||||
);
|
||||
|
||||
return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null });
|
||||
}
|
||||
|
||||
private async searchStatisticsV3(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
|
||||
const { filter, scope } = await this.resolveSearchScopeV3(auth, dto);
|
||||
return this.searchRepository.searchStatisticsV3({ filter }, scope);
|
||||
}
|
||||
|
||||
private async searchRandomV3(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
|
||||
const { filter, scope } = await this.resolveSearchScopeV3(auth, dto);
|
||||
const items = await this.searchRepository.searchRandomV3(
|
||||
dto.size,
|
||||
{
|
||||
filter,
|
||||
withExif: dto.withExif,
|
||||
withPeople: dto.withPeople,
|
||||
withStacked: dto.withStacked,
|
||||
},
|
||||
scope,
|
||||
);
|
||||
return items.map((item) => mapAsset(item, { auth }));
|
||||
}
|
||||
|
||||
private async searchSmartV3(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
|
||||
const { machineLearning } = await this.getConfig({ withCache: false });
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
throw new BadRequestException('Smart search is not enabled');
|
||||
}
|
||||
|
||||
const [{ filter, scope }, embedding] = await Promise.all([
|
||||
this.resolveSearchScopeV3(auth, dto),
|
||||
this.resolveEmbedding(auth, dto, machineLearning),
|
||||
]);
|
||||
|
||||
// no cursor until a rank-aware pagination strategy for smart search is decided
|
||||
const { items } = await this.searchRepository.searchSmartV3(
|
||||
{ take: dto.size },
|
||||
{ filter, withExif: dto.withExif, embedding },
|
||||
scope,
|
||||
);
|
||||
|
||||
return this.mapResponse(items, { auth });
|
||||
}
|
||||
|
||||
private async resolveSearchScopeV3(
|
||||
auth: AuthDto,
|
||||
dto: { filter?: SearchFilter },
|
||||
): Promise<{ filter: SearchFilter; scope: AssetSearchScope }> {
|
||||
const filter = dto.filter ?? {};
|
||||
const effectiveFilter = applyLockedVisibilityPolicy(auth, filter);
|
||||
|
||||
const fullyConfined = isFullyAlbumConfined(filter);
|
||||
// a shared link visitor does not have a universe, so there every branch must be confined
|
||||
if (auth.sharedLink && !fullyConfined) {
|
||||
throw new BadRequestException('Shared link access is only allowed in combination with an albumIds filter');
|
||||
}
|
||||
|
||||
const albumIds = collectFilterIds(filter, 'albumIds');
|
||||
const [userIds] = await Promise.all([
|
||||
// a fully confined filter searches albums only, so the unused universe can skip the partner lookup
|
||||
fullyConfined ? [auth.user.id] : this.getUserIdsToSearch(auth),
|
||||
albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined,
|
||||
]);
|
||||
|
||||
return { filter: effectiveFilter, scope: { userIds, lockedOwnerId: auth.user.id, viewingUserId: auth.user.id } };
|
||||
}
|
||||
|
||||
private async resolveEmbedding(
|
||||
auth: AuthDto,
|
||||
dto: SmartSearchDto,
|
||||
machineLearning: SystemConfig['machineLearning'],
|
||||
): Promise<string> {
|
||||
if (dto.query) {
|
||||
const key = machineLearning.clip.modelName + dto.query + dto.language;
|
||||
let embedding = this.embeddingCache.get(key);
|
||||
if (!embedding) {
|
||||
embedding = await this.machineLearningRepository.encodeText(dto.query, {
|
||||
modelName: machineLearning.clip.modelName,
|
||||
language: dto.language,
|
||||
});
|
||||
this.embeddingCache.set(key, embedding);
|
||||
}
|
||||
return embedding;
|
||||
}
|
||||
|
||||
if (dto.queryAssetId) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] });
|
||||
const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId);
|
||||
const assetEmbedding = getEmbeddingResponse?.embedding;
|
||||
if (!assetEmbedding) {
|
||||
throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`);
|
||||
}
|
||||
return assetEmbedding;
|
||||
}
|
||||
|
||||
throw new BadRequestException('Either `query` or `queryAssetId` must be set');
|
||||
}
|
||||
|
||||
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
|
||||
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
|
||||
if (visibility === AssetVisibility.Locked) {
|
||||
|
|
@ -249,7 +366,11 @@ export class SearchService extends BaseService {
|
|||
return [auth.user.id, ...partnerIds];
|
||||
}
|
||||
|
||||
private mapResponse(assets: MapAsset[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto {
|
||||
private mapResponse(
|
||||
assets: MapAsset[],
|
||||
options: AssetMapOptions,
|
||||
page: { nextPage?: string | null; nextCursor?: string | null } = {},
|
||||
): SearchResponseDto {
|
||||
return {
|
||||
albums: { total: 0, count: 0, items: [], facets: [] },
|
||||
assets: {
|
||||
|
|
@ -257,7 +378,8 @@ export class SearchService extends BaseService {
|
|||
count: assets.length,
|
||||
items: assets.map((asset) => mapAsset(asset, options)),
|
||||
facets: [],
|
||||
nextPage,
|
||||
nextPage: page.nextPage ?? null,
|
||||
nextCursor: page.nextCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
|||
import {
|
||||
DEFAULT_SEARCH_ORDER,
|
||||
IdsFilter,
|
||||
isAlbumConfined,
|
||||
SearchFilterBranch,
|
||||
SearchOrder,
|
||||
StringFilter,
|
||||
|
|
@ -38,7 +39,11 @@ import {
|
|||
ExifOrientation,
|
||||
SearchOrderField,
|
||||
} from 'src/enum';
|
||||
import { AssetSearchBuilderOptions, AssetSearchBuilderV3Options } from 'src/repositories/search.repository';
|
||||
import {
|
||||
AssetSearchBuilderOptions,
|
||||
AssetSearchBuilderV3Options,
|
||||
AssetSearchScope,
|
||||
} from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AudioStreamInfo, VectorExtension, VideoFormat, VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
|
|
@ -787,8 +792,16 @@ function branchPredicates(eb: AssetExpressionBuilder, branch: SearchFilterBranch
|
|||
|
||||
// ordering is deliberately left to the caller so aggregate-only consumers (counts, stats)
|
||||
// can compose the same filters without stripping an order by
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options) {
|
||||
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderV3Options, scope: AssetSearchScope) {
|
||||
const filter = options.filter ?? {};
|
||||
const branches = filter.or ?? [];
|
||||
const ownershipPredicate = (eb: AssetExpressionBuilder) => eb('asset.ownerId', '=', anyUuid(scope.userIds));
|
||||
// search universe: own+partner assets unless album-confined, which searches the albums instead;
|
||||
// ownership lands nowhere (top level confined), per unconfined branch, or hoisted globally
|
||||
const topConfined = isAlbumConfined(filter);
|
||||
const anyBranchConfined = branches.some((branch) => isAlbumConfined(branch));
|
||||
const scopePerBranch = !topConfined && anyBranchConfined;
|
||||
const scopeGlobally = !topConfined && !anyBranchConfined;
|
||||
|
||||
return (
|
||||
kysely
|
||||
|
|
@ -797,17 +810,27 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
|||
// postgres eliminates the left join when no exif column is referenced, so unused joins are free
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.$if(!!options.withExif, (qb) => qb.select(selectExifInfo))
|
||||
.$if(!!options.userIds && options.userIds.length > 0, (qb) =>
|
||||
qb.where('asset.ownerId', '=', anyUuid(options.userIds!)),
|
||||
.$if(scopeGlobally, (qb) => qb.where(ownershipPredicate))
|
||||
.where((eb) =>
|
||||
eb.or([eb('asset.visibility', '!=', AssetVisibility.Locked), eb('asset.ownerId', '=', scope.lockedOwnerId)]),
|
||||
)
|
||||
.$if(!!(options.withFaces || options.withPeople), (qb) =>
|
||||
qb.select(withFacesAndPeople({ viewingUserId: options.viewingUserId! })),
|
||||
qb.select(withFacesAndPeople({ viewingUserId: scope.viewingUserId! })),
|
||||
)
|
||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||
.where((eb) => {
|
||||
const predicates = branchPredicates(eb, filter);
|
||||
if (filter.or && filter.or.length > 0) {
|
||||
predicates.push(eb.or(filter.or.map((branch) => eb.and(branchPredicates(eb, branch)))));
|
||||
if (branches.length > 0) {
|
||||
predicates.push(
|
||||
eb.or(
|
||||
branches.map((branch) =>
|
||||
eb.and([
|
||||
...branchPredicates(eb, branch),
|
||||
...(scopePerBranch && !isAlbumConfined(branch) ? [ownershipPredicate(eb)] : []),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return predicates.length > 0 ? eb.and(predicates) : eb.lit(true);
|
||||
})
|
||||
|
|
@ -836,160 +859,238 @@ export function withSearchOrder(qb: ReturnType<typeof searchAssetBuilder>, order
|
|||
);
|
||||
}
|
||||
|
||||
const scopeExample: AssetSearchScope = { userIds: [DummyValue.UUID], lockedOwnerId: DummyValue.UUID };
|
||||
|
||||
export const searchMetadataV3Examples: GenerateSqlQueries[] = [
|
||||
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID] }] },
|
||||
{ name: 'empty', params: [{ size: 100 }, {}] },
|
||||
{ name: 'baseline', params: [{ take: 100 }, {}, scopeExample] },
|
||||
{
|
||||
name: 'or-mixed-scope',
|
||||
params: [
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { or: [{ albumIds: { any: [DummyValue.UUID] } }, { city: { eq: DummyValue.STRING } }] },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-exif-only',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }],
|
||||
params: [
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { or: [{ city: { eq: DummyValue.STRING } }] },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'string-eq-null',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
|
||||
params: [{ take: 100 }, { filter: { city: { eq: null } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-like',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }],
|
||||
params: [
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { description: { like: DummyValue.STRING } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-notLike',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }],
|
||||
params: [
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { description: { notLike: DummyValue.STRING } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'string-pattern-startsWith',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } },
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { originalFileName: { startsWith: DummyValue.STRING } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'string-similarity-ocr',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }],
|
||||
params: [{ take: 100 }, { filter: { ocr: { matches: DummyValue.STRING } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'ids-any',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }],
|
||||
params: [{ take: 100 }, { filter: { albumIds: { any: [DummyValue.UUID] } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'ids-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'ids-all-single',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { all: [DummyValue.UUID] } } }],
|
||||
params: [{ take: 100 }, { filter: { albumIds: { all: [DummyValue.UUID] } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'ids-none',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }],
|
||||
params: [{ take: 100 }, { filter: { tagIds: { none: [DummyValue.UUID] } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'ids-tags-all',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ userIds: [DummyValue.UUID], filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'has-albums-false',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }],
|
||||
params: [{ take: 100 }, { filter: { hasAlbums: { eq: false } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'is-encoded',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }],
|
||||
params: [{ take: 100 }, { filter: { isEncoded: { eq: true } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'number-range',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }],
|
||||
params: [
|
||||
{ take: 100 },
|
||||
{
|
||||
filter: { fileSizeInBytes: { gte: 100, lte: 1000 } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'date-eq',
|
||||
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }],
|
||||
params: [{ take: 100 }, { filter: { takenAt: { eq: DummyValue.DATE } } }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'date-range',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ take: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-fileSize-noExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ take: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc },
|
||||
withExif: false,
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'order-rating-withExif',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ take: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc },
|
||||
withExif: true,
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-branches',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ take: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'or-with-top-level',
|
||||
params: [
|
||||
{ size: 100 },
|
||||
{ take: 100 },
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
or: [{ isFavorite: { eq: true } }, { albumIds: { any: [DummyValue.UUID] } }],
|
||||
},
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cursor-offset',
|
||||
params: [{ take: 100, skip: 100 }, { filter: { isFavorite: { eq: true } } }, scopeExample],
|
||||
},
|
||||
];
|
||||
|
||||
export const searchRandomV3Examples: GenerateSqlQueries[] = [
|
||||
{ name: 'baseline', params: [100, {}, scopeExample] },
|
||||
{
|
||||
name: 'with-filter',
|
||||
params: [100, { filter: { isFavorite: { eq: true } } }, scopeExample],
|
||||
},
|
||||
];
|
||||
|
||||
export const searchSmartV3Examples: GenerateSqlQueries[] = [
|
||||
{
|
||||
name: 'baseline',
|
||||
params: [{ take: 100 }, { embedding: DummyValue.VECTOR }, scopeExample],
|
||||
},
|
||||
{
|
||||
name: 'with-filter',
|
||||
params: [
|
||||
{ take: 100 },
|
||||
{
|
||||
embedding: DummyValue.VECTOR,
|
||||
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'cursor-offset',
|
||||
params: [{ take: 100, skip: 100 }, { embedding: DummyValue.VECTOR }, scopeExample],
|
||||
},
|
||||
];
|
||||
|
||||
export const searchStatisticsV3Examples: GenerateSqlQueries[] = [
|
||||
{ name: 'baseline', params: [{ userIds: [DummyValue.UUID] }] },
|
||||
{ name: 'baseline', params: [{}, scopeExample] },
|
||||
{
|
||||
name: 'with-filter',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE },
|
||||
fileSizeInBytes: { gte: 100 },
|
||||
},
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'with-or',
|
||||
params: [
|
||||
{
|
||||
userIds: [DummyValue.UUID],
|
||||
filter: {
|
||||
or: [{ isFavorite: { eq: true } }, { hasAlbums: { eq: false } }],
|
||||
},
|
||||
},
|
||||
scopeExample,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
38
server/src/utils/search-cursor.spec.ts
Normal file
38
server/src/utils/search-cursor.spec.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import { decodeSearchCursor, encodeSearchCursor } from 'src/utils/search-cursor';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('encodeSearchCursor', () => {
|
||||
it('should produce an opaque base64url string', () => {
|
||||
const cursor = encodeSearchCursor(250);
|
||||
expect(cursor).toMatch(/^[\w-]+$/);
|
||||
});
|
||||
|
||||
it('should round-trip an offset', () => {
|
||||
for (const offset of [0, 1, 250, 1_000_000]) {
|
||||
expect(decodeSearchCursor(encodeSearchCursor(offset))).toEqual({ offset });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeSearchCursor', () => {
|
||||
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
|
||||
it('should treat a missing cursor as the first page', () => {
|
||||
expect(decodeSearchCursor(undefined)).toEqual({ offset: 0 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty string', ''],
|
||||
['garbage that is not base64url', '!!!not-base64!!!'],
|
||||
['base64 of non-JSON', Buffer.from('not json').toString('base64url')],
|
||||
['JSON without an offset', encode({})],
|
||||
['JSON with a non-object payload', encode(42)],
|
||||
['null payload', encode(null)],
|
||||
['negative offset', encode({ offset: -1 })],
|
||||
['fractional offset', encode({ offset: 1.5 })],
|
||||
['string offset', encode({ offset: '5' })],
|
||||
])('should reject %s', (_, cursor) => {
|
||||
expect(() => decodeSearchCursor(cursor)).toThrowError(new BadRequestException('Invalid cursor'));
|
||||
});
|
||||
});
|
||||
21
server/src/utils/search-cursor.ts
Normal file
21
server/src/utils/search-cursor.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { BadRequestException } from '@nestjs/common';
|
||||
import z from 'zod';
|
||||
|
||||
const SearchCursorPayloadSchema = z.object({
|
||||
offset: z.int().min(0),
|
||||
});
|
||||
|
||||
export const encodeSearchCursor = (offset: number): string =>
|
||||
Buffer.from(JSON.stringify({ offset } satisfies z.infer<typeof SearchCursorPayloadSchema>)).toString('base64url');
|
||||
|
||||
export const decodeSearchCursor = (cursor?: string): { offset: number } => {
|
||||
if (cursor === undefined) {
|
||||
return { offset: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
return SearchCursorPayloadSchema.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')));
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid cursor');
|
||||
}
|
||||
};
|
||||
116
server/src/utils/search-filter.spec.ts
Normal file
116
server/src/utils/search-filter.spec.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { isAlbumConfined, isFullyAlbumConfined, SearchFilter } from 'src/dtos/search.dto';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { applyLockedVisibilityPolicy, collectFilterIds } from 'src/utils/search-filter';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build();
|
||||
const unelevatedAuth = () => AuthFactory.from().session().build();
|
||||
|
||||
describe(applyLockedVisibilityPolicy.name, () => {
|
||||
it('should let an elevated session query locked assets', () => {
|
||||
const filter = { visibility: { eq: AssetVisibility.Locked } };
|
||||
expect(applyLockedVisibilityPolicy(elevatedAuth(), filter)).toBe(filter);
|
||||
});
|
||||
|
||||
it('should reject an unelevated session when any operator permits locked', () => {
|
||||
for (const visibility of [
|
||||
{ eq: AssetVisibility.Locked },
|
||||
{ ne: AssetVisibility.Timeline },
|
||||
{ in: [AssetVisibility.Locked, AssetVisibility.Timeline] },
|
||||
{ notIn: [AssetVisibility.Timeline] },
|
||||
]) {
|
||||
expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), { visibility })).toThrow(UnauthorizedException);
|
||||
}
|
||||
});
|
||||
|
||||
it('should keep a filter whose top-level visibility excludes locked', () => {
|
||||
for (const visibility of [
|
||||
{ eq: AssetVisibility.Timeline },
|
||||
{ ne: AssetVisibility.Locked },
|
||||
{ in: [AssetVisibility.Timeline, AssetVisibility.Archive] },
|
||||
{ notIn: [AssetVisibility.Locked] },
|
||||
]) {
|
||||
const filter = { visibility };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter);
|
||||
}
|
||||
});
|
||||
|
||||
it('should let a safe top-level visibility decide over could-match branches', () => {
|
||||
const filter = { visibility: { ne: AssetVisibility.Locked }, or: [{ visibility: { eq: AssetVisibility.Locked } }] };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toBe(filter);
|
||||
});
|
||||
|
||||
it('should reject a branch that permits locked when the top level has no visibility', () => {
|
||||
const filter = {
|
||||
or: [{ city: { eq: 'Oslo' } }, { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } }],
|
||||
};
|
||||
expect(() => applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should otherwise inject visibility != locked without mutating the input', () => {
|
||||
const filter = { city: { eq: 'Oslo' }, visibility: undefined };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), filter)).toEqual({
|
||||
city: { eq: 'Oslo' },
|
||||
visibility: { ne: AssetVisibility.Locked },
|
||||
});
|
||||
expect(filter.visibility).toBeUndefined();
|
||||
|
||||
const branched = { or: [{ isFavorite: { eq: true } }, { visibility: { eq: AssetVisibility.Timeline } }] };
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: AssetVisibility.Locked });
|
||||
});
|
||||
});
|
||||
|
||||
describe(collectFilterIds.name, () => {
|
||||
it('should union and dedupe ids across operators and branches', () => {
|
||||
const [albumA, albumB, albumC] = [
|
||||
'00000000-0000-4000-8000-00000000000a',
|
||||
'00000000-0000-4000-8000-00000000000b',
|
||||
'00000000-0000-4000-8000-00000000000c',
|
||||
];
|
||||
const filter: SearchFilter = {
|
||||
albumIds: { any: [albumA], none: [albumB] },
|
||||
or: [{ albumIds: { all: [albumC, albumA] } }, { city: { eq: 'Oslo' } }],
|
||||
};
|
||||
expect(collectFilterIds(filter, 'albumIds').toSorted()).toEqual([albumA, albumB, albumC]);
|
||||
expect(collectFilterIds({}, 'albumIds')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should only collect ids of the requested field', () => {
|
||||
const albumId = '00000000-0000-4000-8000-00000000000a';
|
||||
const personId = '00000000-0000-4000-8000-00000000000b';
|
||||
const filter = { albumIds: { any: [albumId] }, personIds: { any: [personId] } };
|
||||
expect(collectFilterIds(filter, 'personIds')).toEqual([personId]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isAlbumConfined.name, () => {
|
||||
it('should require a positive albumIds constraint', () => {
|
||||
const albumId = '00000000-0000-4000-8000-00000000000a';
|
||||
expect(isAlbumConfined({ albumIds: { any: [albumId] } })).toBe(true);
|
||||
expect(isAlbumConfined({ albumIds: { all: [albumId] } })).toBe(true);
|
||||
expect(isAlbumConfined({ albumIds: { none: [albumId] } })).toBe(false);
|
||||
expect(isAlbumConfined({ city: { eq: 'Oslo' } })).toBe(false);
|
||||
expect(isAlbumConfined({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isFullyAlbumConfined.name, () => {
|
||||
const albumId = '00000000-0000-4000-8000-00000000000a';
|
||||
|
||||
it('should be confined by the top level or by every branch', () => {
|
||||
expect(isFullyAlbumConfined({ albumIds: { any: [albumId] } })).toBe(true);
|
||||
expect(isFullyAlbumConfined({ albumIds: { any: [albumId] }, or: [{ city: { eq: 'Oslo' } }] })).toBe(true);
|
||||
expect(isFullyAlbumConfined({ or: [{ albumIds: { any: [albumId] } }, { albumIds: { all: [albumId] } }] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not be confined when any result can escape the albums', () => {
|
||||
expect(isFullyAlbumConfined({})).toBe(false);
|
||||
expect(isFullyAlbumConfined({ albumIds: { none: [albumId] } })).toBe(false);
|
||||
expect(isFullyAlbumConfined({ or: [{ albumIds: { any: [albumId] } }, { city: { eq: 'Oslo' } }] })).toBe(false);
|
||||
expect(isFullyAlbumConfined({ or: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
73
server/src/utils/search-filter.ts
Normal file
73
server/src/utils/search-filter.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SearchFilter, SearchFilterBranch } from 'src/dtos/search.dto';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
|
||||
type EnumField = 'type' | 'visibility';
|
||||
type EnumOperator = keyof NonNullable<SearchFilterBranch[EnumField]>;
|
||||
type EnumOperandMap<T> = { eq: T; ne: T; in: T[]; notIn: T[] };
|
||||
type EnumCondition<T> = { [K in EnumOperator]?: EnumOperandMap<T>[K] };
|
||||
type IdsFilterField = 'albumIds' | 'personIds' | 'tagIds';
|
||||
|
||||
const filterBranches = (filter: SearchFilter): SearchFilterBranch[] => [filter, ...(filter.or ?? [])];
|
||||
|
||||
/** Whether a row with `value` can satisfy the condition. A missing operator allows any value. */
|
||||
const canMatch = <T>(condition: EnumCondition<T>, value: T): boolean => {
|
||||
const { eq, ne, in: anyOf, notIn, ...unhandled } = condition;
|
||||
// fails to compile when EnumFilter gains an operator this check does not consider
|
||||
void (unhandled satisfies Record<string, never>);
|
||||
|
||||
return (
|
||||
(eq === undefined || eq === value) &&
|
||||
(ne === undefined || ne !== value) &&
|
||||
(anyOf === undefined || anyOf.includes(value)) &&
|
||||
(notIn === undefined || !notIn.includes(value))
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The conditions that decide which `field` values the filter can return. A top-level condition decides alone, otherwise each branch itself.
|
||||
*/
|
||||
const decidingConditions = <F extends EnumField>(filter: SearchFilter, field: F) => {
|
||||
if (filter[field] !== undefined) {
|
||||
return [filter[field]];
|
||||
}
|
||||
|
||||
return filterBranches(filter)
|
||||
.map((branch) => branch[field])
|
||||
.filter((condition) => condition !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps locked assets out of search results unless the session is elevated: a filter that asks for
|
||||
* them is rejected with 401, and any other filter gets `visibility != locked` ANDed in.
|
||||
*/
|
||||
export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter): SearchFilter => {
|
||||
if (auth.session?.hasElevatedPermission) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
if (decidingConditions(filter, 'visibility').some((condition) => canMatch(condition, AssetVisibility.Locked))) {
|
||||
requireElevatedPermission(auth);
|
||||
}
|
||||
|
||||
if (filter.visibility !== undefined) {
|
||||
return filter;
|
||||
}
|
||||
|
||||
return { ...filter, visibility: { ne: AssetVisibility.Locked } };
|
||||
};
|
||||
|
||||
export const collectFilterIds = (filter: SearchFilter, field: IdsFilterField): string[] => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const branch of filterBranches(filter)) {
|
||||
for (const operator of ['any', 'all', 'none'] as const) {
|
||||
for (const id of branch[field]?.[operator] ?? []) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { Kysely } from 'kysely';
|
||||
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||
import { AlbumUserRole, AssetVisibility } from 'src/enum';
|
||||
import { AlbumUserRole, AssetOrder, AssetVisibility, SearchOrderField } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
|
|
@ -16,6 +16,8 @@ import { getKyselyDB } from 'test/utils';
|
|||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const unitVector = (index: number) => JSON.stringify(Array.from({ length: 512 }, (_, i) => (i === index ? 1 : 0)));
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(SearchService, {
|
||||
database: db || defaultDatabase,
|
||||
|
|
@ -56,7 +58,7 @@ describe(SearchService.name, () => {
|
|||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
await expect(sut.searchLargeAssets(auth, {})).resolves.toEqual([
|
||||
await expect(sut.searchLargeAssets(auth, { size: 250 })).resolves.toEqual([
|
||||
expect.objectContaining({ id: assets[2].id }),
|
||||
expect.objectContaining({ id: assets[0].id }),
|
||||
expect.objectContaining({ id: assets[1].id }),
|
||||
|
|
@ -120,7 +122,7 @@ describe(SearchService.name, () => {
|
|||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const response = await sut.searchMetadata(auth, { withStacked: false });
|
||||
const response = await sut.searchMetadata(auth, { size: 250, withStacked: false });
|
||||
|
||||
expect(response.assets.items.length).toBe(1);
|
||||
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
||||
|
|
@ -135,7 +137,7 @@ describe(SearchService.name, () => {
|
|||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const response = await sut.searchMetadata(auth, { withStacked: false });
|
||||
const response = await sut.searchMetadata(auth, { size: 250, withStacked: false });
|
||||
|
||||
expect(response.assets.items.length).toBe(0);
|
||||
});
|
||||
|
|
@ -148,7 +150,7 @@ describe(SearchService.name, () => {
|
|||
|
||||
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
|
||||
|
||||
const response = await sut.searchMetadata(auth, { withStacked: false });
|
||||
const response = await sut.searchMetadata(auth, { size: 250, withStacked: false });
|
||||
|
||||
expect(response.assets.items.length).toBe(1);
|
||||
});
|
||||
|
|
@ -168,7 +170,7 @@ describe(SearchService.name, () => {
|
|||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const response = await sut.searchMetadata(auth, { albumIds: [album.id] });
|
||||
const response = await sut.searchMetadata(auth, { size: 250, albumIds: [album.id] });
|
||||
|
||||
expect(response.assets.items.length).toBe(1);
|
||||
});
|
||||
|
|
@ -186,7 +188,7 @@ describe(SearchService.name, () => {
|
|||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
await expect(sut.searchMetadata(auth, { albumIds: [album.id] })).rejects.toThrow(
|
||||
await expect(sut.searchMetadata(auth, { size: 250, albumIds: [album.id] })).rejects.toThrow(
|
||||
'Not found or no album.read access',
|
||||
);
|
||||
});
|
||||
|
|
@ -222,9 +224,229 @@ describe(SearchService.name, () => {
|
|||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const response = await sut.searchRandom(auth, {});
|
||||
const response = await sut.searchRandom(auth, { size: 250 });
|
||||
|
||||
expect(response.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('new search shape', () => {
|
||||
it('should filter by an exif field and return a cursor-less single page', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, city: 'Oslo' });
|
||||
const { asset: other } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: other.id, city: 'Bergen' });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, { size: 250, filter: { city: { eq: 'Oslo' } } });
|
||||
|
||||
expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
|
||||
expect(response.assets.nextPage).toBeNull();
|
||||
expect(response.assets.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
it('should combine OR branches', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset: oslo } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: oslo.id, city: 'Oslo' });
|
||||
const { asset: favorite } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
|
||||
await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, {
|
||||
size: 250,
|
||||
filter: { or: [{ city: { eq: 'Oslo' } }, { isFavorite: { eq: true } }] },
|
||||
});
|
||||
|
||||
expect(response.assets.items.map(({ id }) => id).toSorted()).toEqual([oslo.id, favorite.id].toSorted());
|
||||
});
|
||||
|
||||
it('should scope a top-level album constraint to the album', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, { size: 250, filter: { albumIds: { any: [album.id] } } });
|
||||
|
||||
expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
|
||||
});
|
||||
|
||||
it('should scope an album-constrained branch to the album', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: otherUser.id });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, {
|
||||
size: 250,
|
||||
filter: { or: [{ albumIds: { any: [album.id] } }] },
|
||||
});
|
||||
|
||||
expect(response.assets.items).toEqual([expect.objectContaining({ id: asset.id })]);
|
||||
});
|
||||
|
||||
it('should keep the ownership scope for branches without an album constraint', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { asset: ownOslo } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: ownOslo.id, city: 'Oslo' });
|
||||
const { asset: foreignOslo } = await ctx.newAsset({ ownerId: otherUser.id });
|
||||
await ctx.newExif({ assetId: foreignOslo.id, city: 'Oslo' });
|
||||
const { asset: albumAsset } = await ctx.newAsset({ ownerId: otherUser.id });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: albumAsset.id });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, {
|
||||
size: 250,
|
||||
filter: { or: [{ albumIds: { any: [album.id] } }, { city: { eq: 'Oslo' } }] },
|
||||
});
|
||||
|
||||
// the album branch searches the album, the city branch stays confined to the caller's own assets
|
||||
expect(response.assets.items.map(({ id }) => id).toSorted()).toEqual([ownOslo.id, albumAsset.id].toSorted());
|
||||
});
|
||||
|
||||
it('should reject an inaccessible album anywhere in the filter', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: otherUser } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: otherUser.id });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
await expect(
|
||||
sut.searchMetadata(auth, { size: 250, filter: { or: [{ albumIds: { none: [album.id] } }] } }),
|
||||
).rejects.toThrow('Not found or no album.read access');
|
||||
});
|
||||
|
||||
it('should return locked assets only to an elevated session that asks for them', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset: timeline } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { asset: locked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
||||
|
||||
const unelevated = await sut.searchMetadata(factory.auth({ user: { id: user.id } }), { size: 250, filter: {} });
|
||||
expect(unelevated.assets.items).toEqual([expect.objectContaining({ id: timeline.id })]);
|
||||
|
||||
const elevatedAuth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
|
||||
const elevated = await sut.searchMetadata(elevatedAuth, {
|
||||
size: 250,
|
||||
filter: { visibility: { eq: AssetVisibility.Locked } },
|
||||
});
|
||||
expect(elevated.assets.items).toEqual([expect.objectContaining({ id: locked.id })]);
|
||||
});
|
||||
|
||||
it('should exclude partner assets from a locked-only search', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: partner } = await ctx.newUser();
|
||||
await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id });
|
||||
const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
||||
await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
|
||||
const response = await sut.searchMetadata(auth, {
|
||||
size: 250,
|
||||
filter: { visibility: { eq: AssetVisibility.Locked } },
|
||||
});
|
||||
|
||||
expect(response.assets.items).toEqual([expect.objectContaining({ id: ownLocked.id })]);
|
||||
});
|
||||
|
||||
it('should never return partner locked assets, even for locked-matching mixed filters', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { user: partner } = await ctx.newUser();
|
||||
await ctx.newPartner({ sharedById: partner.id, sharedWithId: user.id });
|
||||
const { asset: ownLocked } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
||||
const { asset: partnerTimeline } = await ctx.newAsset({ ownerId: partner.id });
|
||||
await ctx.newAsset({ ownerId: partner.id, visibility: AssetVisibility.Locked });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id }, session: { hasElevatedPermission: true } });
|
||||
const response = await sut.searchMetadata(auth, {
|
||||
size: 250,
|
||||
filter: { visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } },
|
||||
});
|
||||
|
||||
const ids = response.assets.items.map(({ id }) => id);
|
||||
expect(ids.toSorted()).toEqual([ownLocked.id, partnerTimeline.id].toSorted());
|
||||
});
|
||||
|
||||
it('should paginate with an opaque cursor', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await ctx.newAsset({ ownerId: user.id });
|
||||
}
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
|
||||
const firstPage = await sut.searchMetadata(auth, { filter: {}, size: 2 });
|
||||
expect(firstPage.assets.items.length).toBe(2);
|
||||
expect(firstPage.assets.nextPage).toBeNull();
|
||||
expect(firstPage.assets.nextCursor).toEqual(expect.any(String));
|
||||
|
||||
const secondPage = await sut.searchMetadata(auth, { cursor: firstPage.assets.nextCursor!, size: 2 });
|
||||
expect(secondPage.assets.items.length).toBe(1);
|
||||
expect(secondPage.assets.nextCursor).toBeNull();
|
||||
|
||||
const ids = [...firstPage.assets.items, ...secondPage.assets.items].map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
});
|
||||
|
||||
it('should order by fileSizeInBytes', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const sizes = [12_334, 599, 123_456];
|
||||
const assetIds: string[] = [];
|
||||
for (const fileSizeInByte of sizes) {
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, fileSizeInByte });
|
||||
assetIds.push(asset.id);
|
||||
}
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const response = await sut.searchMetadata(auth, {
|
||||
size: 250,
|
||||
orderBy: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Asc },
|
||||
});
|
||||
|
||||
expect(response.assets.items.map(({ id }) => id)).toEqual([assetIds[1], assetIds[0], assetIds[2]]);
|
||||
});
|
||||
|
||||
it('should order smart search results by embedding distance with cursor offsets', async () => {
|
||||
const { ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const searchRepository = ctx.get(SearchRepository);
|
||||
|
||||
const assetIds: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await searchRepository.upsert(asset.id, unitVector(i));
|
||||
assetIds.push(asset.id);
|
||||
}
|
||||
|
||||
const options = { filter: {}, embedding: unitVector(0) };
|
||||
const scope = { userIds: [user.id], lockedOwnerId: user.id };
|
||||
const firstPage = await searchRepository.searchSmartV3({ take: 2 }, options, scope);
|
||||
expect(firstPage.items.length).toBe(2);
|
||||
expect(firstPage.items[0].id).toBe(assetIds[0]);
|
||||
expect(firstPage.hasNextPage).toBe(true);
|
||||
|
||||
const secondPage = await searchRepository.searchSmartV3({ take: 2, skip: 2 }, options, scope);
|
||||
expect(secondPage.items.length).toBe(1);
|
||||
expect(secondPage.hasNextPage).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue