split search opts from scope

This commit is contained in:
timonrieger
2026-07-25 16:11:42 +02:00
parent 547634efb9
commit 9b40ef069c
8 changed files with 407 additions and 239 deletions
+216 -92
View File
@@ -449,14 +449,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"."fileCreatedAt" desc,
"asset"."id" desc
limit
$2
$4
offset
$3
$5
-- SearchRepository.searchMetadataV3 (empty)
select
@@ -492,14 +496,18 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
true
(
"asset"."visibility" != $1
or "asset"."ownerId" = $2
)
and true
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$1
$3
offset
$2
$4
-- SearchRepository.searchMetadataV3 (or-exif-only)
select
@@ -536,14 +544,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
$4
$6
-- SearchRepository.searchMetadataV3 (string-eq-null)
select
@@ -580,14 +592,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
$3
$5
-- SearchRepository.searchMetadataV3 (string-pattern-like)
select
@@ -624,14 +640,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
$4
$6
-- SearchRepository.searchMetadataV3 (string-pattern-notLike)
select
@@ -668,14 +688,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
$4
$6
-- SearchRepository.searchMetadataV3 (string-pattern-startsWith)
select
@@ -712,14 +736,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
$4
$6
-- SearchRepository.searchMetadataV3 (string-similarity-ocr)
select
@@ -756,21 +784,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
$4
$6
-- SearchRepository.searchMetadataV3 (ids-any)
select
@@ -807,21 +839,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
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($2::uuid[])
and "album_asset"."albumId" = any ($4::uuid[])
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$4
$6
-- SearchRepository.searchMetadataV3 (ids-all)
select
@@ -858,6 +894,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"
@@ -866,20 +906,20 @@ where
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and "asset_face"."personId" = any ($3::uuid[])
and "asset_face"."isVisible" = $4
and "asset_face"."personId" = any ($5::uuid[])
group by
"asset_face"."assetId"
having
count(distinct "asset_face"."personId") = $4
count(distinct "asset_face"."personId") = $6
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$5
$7
offset
$6
$8
-- SearchRepository.searchMetadataV3 (ids-all-single)
select
@@ -916,21 +956,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
"album_asset"
where
"album_asset"."assetId" = "asset"."id"
and "album_asset"."albumId" = any ($2::uuid[])
and "album_asset"."albumId" = any ($4::uuid[])
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$4
$6
-- SearchRepository.searchMetadataV3 (ids-none)
select
@@ -967,6 +1011,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
@@ -974,15 +1022,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
$4
$6
-- SearchRepository.searchMetadataV3 (ids-tags-all)
select
@@ -1019,6 +1067,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"
@@ -1027,19 +1079,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
$5
$7
-- SearchRepository.searchMetadataV3 (has-albums-false)
select
@@ -1076,6 +1128,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
@@ -1087,9 +1143,9 @@ order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$2
$4
offset
$3
$5
-- SearchRepository.searchMetadataV3 (is-encoded)
select
@@ -1126,21 +1182,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
$4
$6
-- SearchRepository.searchMetadataV3 (number-range)
select
@@ -1178,16 +1238,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
$5
$7
-- SearchRepository.searchMetadataV3 (date-eq)
select
@@ -1224,14 +1288,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
$4
$6
-- SearchRepository.searchMetadataV3 (date-range)
select
@@ -1269,16 +1337,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
$5
$7
-- SearchRepository.searchMetadataV3 (order-fileSize-noExif)
select
@@ -1315,14 +1387,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
$3
$5
-- SearchRepository.searchMetadataV3 (order-rating-withExif)
select
@@ -1360,14 +1436,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
$3
$5
-- SearchRepository.searchMetadataV3 (or-branches)
select
@@ -1405,7 +1485,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
@@ -1413,17 +1497,17 @@ where
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3
and "asset_face"."personId" = any ($4::uuid[])
and "asset_face"."isVisible" = $5
and "asset_face"."personId" = any ($6::uuid[])
)
)
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$5
$7
offset
$6
$8
-- SearchRepository.searchMetadataV3 (or-with-top-level)
select
@@ -1461,17 +1545,21 @@ 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
and (
"asset"."isFavorite" = $4
"asset"."isFavorite" = $6
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[])
)
)
)
@@ -1479,9 +1567,9 @@ order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$6
$8
offset
$7
$9
-- SearchRepository.searchMetadataV3 (cursor-offset)
select
@@ -1518,14 +1606,18 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and "asset"."isFavorite" = $2
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and "asset"."isFavorite" = $4
order by
"asset"."fileCreatedAt" desc,
"asset"."id" desc
limit
$3
$5
offset
$4
$6
-- SearchRepository.searchRandomV3 (baseline)
select
@@ -1562,11 +1654,15 @@ 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
random()
limit
$2
$4
-- SearchRepository.searchRandomV3 (with-filter)
select
@@ -1603,11 +1699,15 @@ from
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."ownerId" = any ($1::uuid[])
and "asset"."isFavorite" = $2
and (
"asset"."visibility" != $2
or "asset"."ownerId" = $3
)
and "asset"."isFavorite" = $4
order by
random()
limit
$3
$5
-- SearchRepository.searchSmartV3 (baseline)
begin
@@ -1648,14 +1748,18 @@ from
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 <=> $2,
smart_search.embedding <=> $4,
"asset"."id" asc
limit
$3
$5
offset
$4
$6
commit
-- SearchRepository.searchSmartV3 (with-filter)
@@ -1698,16 +1802,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
smart_search.embedding <=> $4,
smart_search.embedding <=> $6,
"asset"."id" asc
limit
$5
$7
offset
$6
$8
commit
-- SearchRepository.searchSmartV3 (cursor-offset)
@@ -1749,14 +1857,18 @@ from
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 <=> $2,
smart_search.embedding <=> $4,
"asset"."id" asc
limit
$3
$5
offset
$4
$6
commit
-- SearchRepository.searchStatisticsV3 (baseline)
@@ -1767,6 +1879,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)
@@ -1778,9 +1894,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)
@@ -1792,7 +1912,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
+21 -9
View File
@@ -135,10 +135,13 @@ export type AssetSearchOptions = Omit<BaseAssetSearchOptions, 'visibility'> &
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
export interface AssetSearchScope {
userIds?: string[];
lockedOwnerId: string;
}
export interface AssetSearchBuilderV3Options {
filter?: SearchFilter;
/** Server-derived ownership scope. Never client-controlled. */
userIds?: string[];
withExif?: boolean;
withFaces?: boolean;
withPeople?: boolean;
@@ -528,8 +531,12 @@ export class SearchRepository {
// TODO(v4): drop the V3 suffix once the legacy methods are removed
@GenerateSql(...searchMetadataV3Examples)
async searchMetadataV3(pagination: AssetSearchPaginationV3Options, options: AssetSearchBuilderV3Options) {
const items = await withSearchOrder(searchAssetBuilder(this.db, options), options.order)
async searchMetadataV3(
pagination: AssetSearchPaginationV3Options,
options: AssetSearchBuilderV3Options,
scope: AssetSearchScope,
) {
const items = await withSearchOrder(searchAssetBuilder(this.db, options, scope), options.order)
.select(columns.searchAsset)
.limit(pagination.size + 1)
.offset(pagination.offset ?? 0)
@@ -539,8 +546,12 @@ export class SearchRepository {
// TODO(v4): drop the V3 suffix once the legacy methods are removed
@GenerateSql(...searchRandomV3Examples)
searchRandomV3(size: number, options: Omit<AssetSearchBuilderV3Options, 'order'>): Promise<MapAsset[]> {
return searchAssetBuilder(this.db, options)
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)
@@ -552,10 +563,11 @@ export class SearchRepository {
searchSmartV3(
pagination: AssetSearchPaginationV3Options,
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)
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}`)
@@ -569,8 +581,8 @@ export class SearchRepository {
// 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();
}
@@ -246,6 +246,7 @@ describe(SearchService.name, () => {
expect(mocks.search.searchSmartV3).toHaveBeenCalledWith(
{ size: 100, offset: 0 },
expect.objectContaining({ embedding: '[1, 2, 3]' }),
expect.objectContaining({ lockedOwnerId: expect.any(String) }),
);
});
+68 -67
View File
@@ -22,6 +22,7 @@ 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';
@@ -31,7 +32,6 @@ import {
applyLockedVisibilityPolicy,
collectFilterIds,
hasTopLevelPositiveIdsConstraint,
isLockedOnlyFilter,
} from 'src/utils/search-filter';
@Injectable()
@@ -235,6 +235,71 @@ 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(
{ size, 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),
]);
const { offset } = decodeSearchCursor(dto.cursor);
const size = dto.size;
const { hasNextPage, items } = await this.searchRepository.searchSmartV3(
{ size, offset },
{ filter, withExif: dto.withExif, embedding },
scope,
);
return this.mapResponse(items, { auth }, { nextCursor: hasNextPage ? encodeSearchCursor(offset + size) : null });
}
private async resolveSearchScopeV3(
auth: AuthDto,
dto: { filter?: SearchFilter },
): Promise<{ filter: SearchFilter; scope: AssetSearchScope }> {
const filter = dto.filter ?? {};
const effectiveFilter = applyLockedVisibilityPolicy(auth, filter);
@@ -247,75 +312,11 @@ export class SearchService extends BaseService {
const albumIds = collectFilterIds(filter, 'albumIds');
const [userIds] = await Promise.all([
hasTopLevelAlbums ? undefined : this.getUserIdsToSearchV3(auth, filter),
hasTopLevelAlbums ? undefined : this.getUserIdsToSearch(auth),
albumIds.length > 0 ? this.requireAccess({ auth, ids: albumIds, permission: Permission.AlbumRead }) : undefined,
]);
const { offset } = decodeSearchCursor(dto.cursor);
const size = dto.size;
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, {
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;
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);
return { filter: effectiveFilter, scope: { userIds, lockedOwnerId: auth.user.id } };
}
private async resolveEmbedding(
+78 -41
View File
@@ -38,7 +38,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';
@@ -762,7 +766,7 @@ 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 ?? {};
return (
@@ -772,8 +776,9 @@ 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(!!scope.userIds && scope.userIds.length > 0, (qb) => qb.where('asset.ownerId', '=', anyUuid(scope.userIds!)))
.where((eb) =>
eb.or([eb('asset.visibility', '!=', AssetVisibility.Locked), eb('asset.ownerId', '=', scope.lockedOwnerId)]),
)
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
@@ -809,86 +814,121 @@ 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: [{ size: 100 }, {}, scopeExample] },
{ name: 'empty', params: [{ size: 100 }, {}, { lockedOwnerId: DummyValue.UUID }] },
{
name: 'or-exif-only',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { or: [{ city: { eq: DummyValue.STRING } }] } }],
params: [
{ size: 100 },
{
filter: { or: [{ city: { eq: DummyValue.STRING } }] },
},
scopeExample,
],
},
{
name: 'string-eq-null',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { city: { eq: null } } }],
params: [{ size: 100 }, { filter: { city: { eq: null } } }, scopeExample],
},
{
name: 'string-pattern-like',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { like: DummyValue.STRING } } }],
params: [
{ size: 100 },
{
filter: { description: { like: DummyValue.STRING } },
},
scopeExample,
],
},
{
name: 'string-pattern-notLike',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { description: { notLike: DummyValue.STRING } } }],
params: [
{ size: 100 },
{
filter: { description: { notLike: DummyValue.STRING } },
},
scopeExample,
],
},
{
name: 'string-pattern-startsWith',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { originalFileName: { startsWith: DummyValue.STRING } } },
{
filter: { originalFileName: { startsWith: DummyValue.STRING } },
},
scopeExample,
],
},
{
name: 'string-similarity-ocr',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { ocr: { matches: DummyValue.STRING } } }],
params: [{ size: 100 }, { filter: { ocr: { matches: DummyValue.STRING } } }, scopeExample],
},
{
name: 'ids-any',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { albumIds: { any: [DummyValue.UUID] } } }],
params: [{ size: 100 }, { filter: { albumIds: { any: [DummyValue.UUID] } } }, scopeExample],
},
{
name: 'ids-all',
params: [
{ size: 100 },
{ userIds: [DummyValue.UUID], filter: { personIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } } },
{
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: [{ size: 100 }, { filter: { albumIds: { all: [DummyValue.UUID] } } }, scopeExample],
},
{
name: 'ids-none',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { tagIds: { none: [DummyValue.UUID] } } }],
params: [{ size: 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] } } },
{
filter: { tagIds: { all: [DummyValue.UUID, DummyValue.UUID_1] } },
},
scopeExample,
],
},
{
name: 'has-albums-false',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { hasAlbums: { eq: false } } }],
params: [{ size: 100 }, { filter: { hasAlbums: { eq: false } } }, scopeExample],
},
{
name: 'is-encoded',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { isEncoded: { eq: true } } }],
params: [{ size: 100 }, { filter: { isEncoded: { eq: true } } }, scopeExample],
},
{
name: 'number-range',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { fileSizeInBytes: { gte: 100, lte: 1000 } } }],
params: [
{ size: 100 },
{
filter: { fileSizeInBytes: { gte: 100, lte: 1000 } },
},
scopeExample,
],
},
{
name: 'date-eq',
params: [{ size: 100 }, { userIds: [DummyValue.UUID], filter: { takenAt: { eq: DummyValue.DATE } } }],
params: [{ size: 100 }, { filter: { takenAt: { eq: DummyValue.DATE } } }, scopeExample],
},
{
name: 'date-range',
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
},
scopeExample,
],
},
{
@@ -896,10 +936,10 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
order: { field: SearchOrderField.FileSizeInBytes, direction: AssetOrder.Desc },
withExif: false,
},
scopeExample,
],
},
{
@@ -907,10 +947,10 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
order: { field: SearchOrderField.Rating, direction: AssetOrder.Asc },
withExif: true,
},
scopeExample,
],
},
{
@@ -918,11 +958,11 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
filter: {
or: [{ isFavorite: { eq: true } }, { personIds: { any: [DummyValue.UUID] } }],
},
},
scopeExample,
],
},
{
@@ -930,76 +970,73 @@ export const searchMetadataV3Examples: GenerateSqlQueries[] = [
params: [
{ size: 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: [
{ size: 100, offset: 100 },
{ userIds: [DummyValue.UUID], filter: { isFavorite: { eq: true } } },
],
params: [{ size: 100, offset: 100 }, { filter: { isFavorite: { eq: true } } }, scopeExample],
},
];
export const searchRandomV3Examples: GenerateSqlQueries[] = [
{ name: 'baseline', params: [100, { userIds: [DummyValue.UUID] }] },
{ name: 'baseline', params: [100, {}, scopeExample] },
{
name: 'with-filter',
params: [100, { userIds: [DummyValue.UUID], filter: { isFavorite: { eq: true } } }],
params: [100, { filter: { isFavorite: { eq: true } } }, scopeExample],
},
];
export const searchSmartV3Examples: GenerateSqlQueries[] = [
{ name: 'baseline', params: [{ size: 100 }, { userIds: [DummyValue.UUID], embedding: DummyValue.VECTOR }] },
{
name: 'baseline',
params: [{ size: 100 }, { embedding: DummyValue.VECTOR }, scopeExample],
},
{
name: 'with-filter',
params: [
{ size: 100 },
{
userIds: [DummyValue.UUID],
embedding: DummyValue.VECTOR,
filter: { takenAt: { gte: DummyValue.DATE, lt: DummyValue.DATE } },
},
scopeExample,
],
},
{
name: 'cursor-offset',
params: [
{ size: 100, offset: 100 },
{ userIds: [DummyValue.UUID], embedding: DummyValue.VECTOR },
],
params: [{ size: 100, offset: 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,
],
},
];
-20
View File
@@ -5,7 +5,6 @@ import {
applyLockedVisibilityPolicy,
collectFilterIds,
hasTopLevelPositiveIdsConstraint,
isLockedOnlyFilter,
} from 'src/utils/search-filter';
import { AuthFactory } from 'test/factories/auth.factory';
import { describe, expect, it } from 'vitest';
@@ -67,25 +66,6 @@ describe(applyLockedVisibilityPolicy.name, () => {
});
});
describe(isLockedOnlyFilter.name, () => {
it('should detect provably locked-only filters', () => {
expect(isLockedOnlyFilter({ visibility: { eq: AssetVisibility.Locked } })).toBe(true);
expect(isLockedOnlyFilter({ visibility: { in: [AssetVisibility.Locked] } })).toBe(true);
expect(
isLockedOnlyFilter({
visibility: { notIn: [AssetVisibility.Timeline, AssetVisibility.Archive, AssetVisibility.Hidden] },
}),
).toBe(true);
});
it('should not match other filters', () => {
expect(isLockedOnlyFilter({ visibility: { ne: AssetVisibility.Locked } })).toBe(false);
expect(isLockedOnlyFilter({ visibility: { in: [AssetVisibility.Locked, AssetVisibility.Timeline] } })).toBe(false);
expect(isLockedOnlyFilter({ or: [{ visibility: { eq: AssetVisibility.Locked } }] })).toBe(false);
expect(isLockedOnlyFilter({})).toBe(false);
});
});
describe(collectFilterIds.name, () => {
it('should union and dedupe ids across operators and branches', () => {
const [albumA, albumB, albumC] = [
-7
View File
@@ -18,9 +18,6 @@ const canMatch = <T>(condition: EnumCondition<T>, value: T): boolean =>
(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.
*/
@@ -54,10 +51,6 @@ 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>();
@@ -331,6 +331,25 @@ describe(SearchService.name, () => {
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();
@@ -385,13 +404,14 @@ describe(SearchService.name, () => {
assetIds.push(asset.id);
}
const options = { filter: {}, userIds: [user.id], embedding: unitVector(0) };
const firstPage = await searchRepository.searchSmartV3({ size: 2 }, options);
const options = { filter: {}, embedding: unitVector(0) };
const scope = { userIds: [user.id], lockedOwnerId: user.id };
const firstPage = await searchRepository.searchSmartV3({ size: 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({ size: 2, offset: 2 }, options);
const secondPage = await searchRepository.searchSmartV3({ size: 2, offset: 2 }, options, scope);
expect(secondPage.items.length).toBe(1);
expect(secondPage.hasNextPage).toBe(false);
});