mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 22:51:21 -07:00
services and simplify
This commit is contained in:
@@ -300,32 +300,29 @@ 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);
|
||||
|
||||
/**
|
||||
* The structured shape and the deprecated flat search fields are
|
||||
* mutually exclusive; carrier fields combine with either.
|
||||
* 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) =>
|
||||
schema.superRefine((dto, ctx) => {
|
||||
const NEW_SHAPE_FIELDS = ['filter', 'orderBy', 'cursor'];
|
||||
const CARRIER_FIELDS = new Set([
|
||||
'size',
|
||||
'withExif',
|
||||
'withStacked',
|
||||
'withPeople',
|
||||
'query',
|
||||
'queryAssetId',
|
||||
'language',
|
||||
]);
|
||||
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, value] of Object.entries(values)) {
|
||||
if (value === undefined || CARRIER_FIELDS.has(field) || NEW_SHAPE_FIELDS.includes(field)) {
|
||||
for (const field of deprecatedFields) {
|
||||
if (values[field] === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -336,11 +333,15 @@ const withShapeExclusivity = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const filterField = SearchFilterSchema.optional().meta(ADDED_V3_1);
|
||||
const cursorField = z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1);
|
||||
|
||||
const RandomSearchBaseSchema = BaseSearchWithResultsSchema.extend({
|
||||
withStacked: z.boolean().optional().describe('Include stacked assets'),
|
||||
withPeople: z.boolean().optional().describe('Include people data in response'),
|
||||
filter: SearchFilterSchema.optional().meta(ADDED_V3_1),
|
||||
filter: filterField,
|
||||
});
|
||||
|
||||
const RandomSearchSchema = withShapeExclusivity(RandomSearchBaseSchema).meta({ id: 'RandomSearchDto' });
|
||||
@@ -358,14 +359,14 @@ const MetadataSearchSchema = withShapeExclusivity(
|
||||
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_1),
|
||||
cursor: z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1),
|
||||
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: SearchFilterSchema.optional().meta(ADDED_V3_1),
|
||||
filter: filterField,
|
||||
}),
|
||||
).meta({ id: 'StatisticsSearchDto' });
|
||||
|
||||
@@ -375,8 +376,8 @@ const SmartSearchSchema = withShapeExclusivity(
|
||||
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: SearchFilterSchema.optional().meta(ADDED_V3_1),
|
||||
cursor: z.string().min(1).optional().describe('Cursor for the next page of results').meta(ADDED_V3_1),
|
||||
filter: filterField,
|
||||
cursor: cursorField,
|
||||
}),
|
||||
).meta({ id: 'SmartSearchDto' });
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { LRUMap } from 'mnemonist';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import {
|
||||
isNewShapeRequest,
|
||||
LargeAssetSearchDto,
|
||||
mapPlaces,
|
||||
MetadataSearchDto,
|
||||
PlacesResponseDto,
|
||||
RandomSearchDto,
|
||||
SearchFilter,
|
||||
SearchPeopleDto,
|
||||
SearchPlacesDto,
|
||||
SearchResponseDto,
|
||||
@@ -23,6 +26,13 @@ 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,
|
||||
hasTopLevelPositiveIdsConstraint,
|
||||
isLockedOnlyFilter,
|
||||
} from 'src/utils/search-filter';
|
||||
|
||||
@Injectable()
|
||||
export class SearchService extends BaseService {
|
||||
@@ -63,6 +73,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);
|
||||
}
|
||||
@@ -96,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);
|
||||
@@ -113,6 +131,10 @@ 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);
|
||||
}
|
||||
@@ -141,6 +163,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);
|
||||
}
|
||||
@@ -151,28 +177,7 @@ 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 { hasNextPage, items } = await this.searchRepository.searchSmart(
|
||||
@@ -185,7 +190,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[]> {
|
||||
@@ -229,6 +234,121 @@ export class SearchService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async searchMetadataV3(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
|
||||
const filter = dto.filter ?? {};
|
||||
const effectiveFilter = applyLockedVisibilityPolicy(auth, filter);
|
||||
|
||||
// only a top-level any/all constrains every result to accessible albums; anything else keeps
|
||||
// the ownership scope so an OR branch cannot widen the search to other users' assets
|
||||
const hasTopLevelAlbums = hasTopLevelPositiveIdsConstraint(filter, 'albumIds');
|
||||
if (auth.sharedLink && !hasTopLevelAlbums) {
|
||||
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([
|
||||
hasTopLevelAlbums ? undefined : this.getUserIdsToSearchV3(auth, filter),
|
||||
albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined,
|
||||
]);
|
||||
|
||||
const { offset } = decodeSearchCursor(dto.cursor);
|
||||
const size = dto.size || 250;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchMetadataV3(
|
||||
{ size, offset },
|
||||
{
|
||||
filter: effectiveFilter,
|
||||
userIds,
|
||||
withExif: dto.withExif,
|
||||
withPeople: dto.withPeople,
|
||||
withStacked: dto.withStacked,
|
||||
order: dto.orderBy,
|
||||
},
|
||||
);
|
||||
|
||||
return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null });
|
||||
}
|
||||
|
||||
private async searchStatisticsV3(auth: AuthDto, dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
|
||||
const { effectiveFilter, userIds } = await this.resolveSearchScopeV3(auth, dto);
|
||||
return this.searchRepository.searchStatisticsV3({ filter: effectiveFilter, userIds });
|
||||
}
|
||||
|
||||
private async searchRandomV3(auth: AuthDto, dto: RandomSearchDto): Promise<AssetResponseDto[]> {
|
||||
const { effectiveFilter, userIds } = await this.resolveSearchScopeV3(auth, dto);
|
||||
const items = await this.searchRepository.searchRandomV3(dto.size || 250, {
|
||||
filter: effectiveFilter,
|
||||
userIds,
|
||||
withExif: dto.withExif,
|
||||
withPeople: dto.withPeople,
|
||||
withStacked: dto.withStacked,
|
||||
});
|
||||
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 [{ effectiveFilter, userIds }, embedding] = await Promise.all([
|
||||
this.resolveSearchScopeV3(auth, dto),
|
||||
this.resolveEmbedding(auth, dto, machineLearning),
|
||||
]);
|
||||
|
||||
const { offset } = decodeSearchCursor(dto.cursor);
|
||||
const size = dto.size || 100;
|
||||
const { hasNextPage, items } = await this.searchRepository.searchSmartV3(
|
||||
{ size, offset },
|
||||
{ filter: effectiveFilter, userIds, withExif: dto.withExif, embedding },
|
||||
);
|
||||
|
||||
return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null });
|
||||
}
|
||||
|
||||
private async resolveSearchScopeV3(auth: AuthDto, dto: { filter?: SearchFilter }) {
|
||||
const filter = dto.filter ?? {};
|
||||
return {
|
||||
effectiveFilter: applyLockedVisibilityPolicy(auth, filter),
|
||||
userIds: await this.getUserIdsToSearchV3(auth, filter),
|
||||
};
|
||||
}
|
||||
|
||||
private getUserIdsToSearchV3(auth: AuthDto, filter: SearchFilter): Promise<string[]> {
|
||||
return this.getUserIdsToSearch(auth, isLockedOnlyFilter(filter) ? AssetVisibility.Locked : undefined);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -244,9 +364,8 @@ export class SearchService extends BaseService {
|
||||
|
||||
private mapResponse(
|
||||
assets: MapAsset[],
|
||||
nextPage: string | null,
|
||||
options: AssetMapOptions,
|
||||
nextCursor: string | null = null,
|
||||
page: { nextPage?: string | null; nextCursor?: string | null } = {},
|
||||
): SearchResponseDto {
|
||||
return {
|
||||
albums: { total: 0, count: 0, items: [], facets: [] },
|
||||
@@ -255,8 +374,8 @@ export class SearchService extends BaseService {
|
||||
count: assets.length,
|
||||
items: assets.map((asset) => mapAsset(asset, options)),
|
||||
facets: [],
|
||||
nextPage,
|
||||
nextCursor,
|
||||
nextPage: page.nextPage ?? null,
|
||||
nextCursor: page.nextCursor ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ describe('encodeSearchCursor', () => {
|
||||
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!!!'],
|
||||
|
||||
@@ -5,12 +5,14 @@ const SearchCursorPayloadSchema = z.object({
|
||||
o: z.int().min(0),
|
||||
});
|
||||
|
||||
type SearchCursorPayload = z.infer<typeof SearchCursorPayloadSchema>;
|
||||
|
||||
export const encodeSearchCursor = (offset: number): string =>
|
||||
Buffer.from(JSON.stringify({ o: offset } satisfies SearchCursorPayload)).toString('base64url');
|
||||
Buffer.from(JSON.stringify({ o: offset } satisfies z.infer<typeof SearchCursorPayloadSchema>)).toString('base64url');
|
||||
|
||||
export const decodeSearchCursor = (cursor?: string): { offset: number } => {
|
||||
if (cursor === undefined) {
|
||||
return { offset: 0 };
|
||||
}
|
||||
|
||||
export const decodeSearchCursor = (cursor: string): { offset: number } => {
|
||||
try {
|
||||
const payload = SearchCursorPayloadSchema.parse(JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')));
|
||||
return { offset: payload.o };
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { SearchFilter } from 'src/dtos/search.dto';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { applyLockedVisibilityPolicy } from 'src/utils/search-filter';
|
||||
import {
|
||||
applyLockedVisibilityPolicy,
|
||||
collectFilterIds,
|
||||
hasTopLevelPositiveIdsConstraint,
|
||||
isLockedOnlyFilter,
|
||||
} from 'src/utils/search-filter';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const { Locked, Timeline, Archive } = AssetVisibility;
|
||||
const { Locked, Timeline, Archive, Hidden } = AssetVisibility;
|
||||
|
||||
const elevatedAuth = () => AuthFactory.from().session({ hasElevatedPermission: true }).build();
|
||||
const unelevatedAuth = () => AuthFactory.from().session().build();
|
||||
@@ -50,3 +56,53 @@ describe(applyLockedVisibilityPolicy.name, () => {
|
||||
expect(applyLockedVisibilityPolicy(unelevatedAuth(), branched).visibility).toEqual({ ne: Locked });
|
||||
});
|
||||
});
|
||||
|
||||
describe(isLockedOnlyFilter.name, () => {
|
||||
it('should detect provably locked-only filters', () => {
|
||||
expect(isLockedOnlyFilter({ visibility: { eq: Locked } })).toBe(true);
|
||||
expect(isLockedOnlyFilter({ visibility: { in: [Locked] } })).toBe(true);
|
||||
expect(isLockedOnlyFilter({ visibility: { notIn: [Timeline, Archive, Hidden] } })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match other filters', () => {
|
||||
expect(isLockedOnlyFilter({ visibility: { ne: Locked } })).toBe(false);
|
||||
expect(isLockedOnlyFilter({ visibility: { in: [Locked, Timeline] } })).toBe(false);
|
||||
expect(isLockedOnlyFilter({ or: [{ visibility: { eq: Locked } }] })).toBe(false);
|
||||
expect(isLockedOnlyFilter({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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(hasTopLevelPositiveIdsConstraint.name, () => {
|
||||
it('should detect only top-level any/all constraints', () => {
|
||||
const albumId = '00000000-0000-4000-8000-00000000000a';
|
||||
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { any: [albumId] } }, 'albumIds')).toBe(true);
|
||||
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { all: [albumId] } }, 'albumIds')).toBe(true);
|
||||
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { none: [albumId] } }, 'albumIds')).toBe(false);
|
||||
expect(hasTopLevelPositiveIdsConstraint({ or: [{ albumIds: { any: [albumId] } }] }, 'albumIds')).toBe(false);
|
||||
expect(hasTopLevelPositiveIdsConstraint({ albumIds: { any: [albumId] } }, 'tagIds')).toBe(false);
|
||||
expect(hasTopLevelPositiveIdsConstraint({}, 'albumIds')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,26 +4,32 @@ import { AssetVisibility } from 'src/enum';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
|
||||
type EnumField = 'type' | 'visibility';
|
||||
type EnumValue = NonNullable<NonNullable<SearchFilterBranch[EnumField]>['eq']>;
|
||||
type EnumCondition = { eq?: EnumValue; ne?: EnumValue; in?: EnumValue[]; notIn?: EnumValue[] };
|
||||
type EnumCondition<T> = { eq?: T; ne?: T; in?: T[]; notIn?: T[] };
|
||||
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 = (condition: EnumCondition, value: EnumValue): boolean =>
|
||||
const canMatch = <T>(condition: EnumCondition<T>, value: T): boolean =>
|
||||
(condition.eq === undefined || condition.eq === value) &&
|
||||
(condition.ne === undefined || condition.ne !== value) &&
|
||||
(condition.in === undefined || condition.in.includes(value)) &&
|
||||
(condition.notIn === undefined || !condition.notIn.includes(value));
|
||||
|
||||
const matchesOnly = <T>(condition: EnumCondition<T>, value: T, values: T[]): boolean =>
|
||||
values.every((other) => other === value || !canMatch(condition, other));
|
||||
|
||||
/**
|
||||
* The conditions that decide which `field` values the filter can return. A top-level condition
|
||||
* decides alone, otherwise each branch itself.
|
||||
* The conditions that decide which `field` values the filter can return. A top-level condition decides alone, otherwise each branch itself.
|
||||
*/
|
||||
const decidingConditions = (filter: SearchFilter, field: EnumField): EnumCondition[] => {
|
||||
const decidingConditions = <F extends EnumField>(filter: SearchFilter, field: F) => {
|
||||
if (filter[field] !== undefined) {
|
||||
return [filter[field]];
|
||||
}
|
||||
|
||||
return (filter.or ?? []).map((branch) => branch[field]).filter((condition) => condition !== undefined);
|
||||
return filterBranches(filter)
|
||||
.map((branch) => branch[field])
|
||||
.filter((condition) => condition !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -45,3 +51,26 @@ export const applyLockedVisibilityPolicy = (auth: AuthDto, filter: SearchFilter)
|
||||
|
||||
return { ...filter, visibility: { ne: AssetVisibility.Locked } };
|
||||
};
|
||||
|
||||
/** Whether the top-level visibility condition limits the filter to locked assets only. */
|
||||
export const isLockedOnlyFilter = ({ visibility }: SearchFilter): boolean =>
|
||||
visibility !== undefined &&
|
||||
matchesOnly(visibility, AssetVisibility.Locked, Object.values(AssetVisibility));
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
/** Whether the top level constrains results to specific `field` ids (`any`/`all`; `none` only excludes). */
|
||||
export const hasTopLevelPositiveIdsConstraint = (filter: SearchFilter, field: IdsFilterField): boolean =>
|
||||
filter[field]?.any !== undefined || filter[field]?.all !== undefined;
|
||||
|
||||
Reference in New Issue
Block a user