import { AssetSearchBuilderOptions, Paginated, PaginationOptions } from '@app/domain'; import _ from 'lodash'; import { Between, FindManyOptions, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, ObjectLiteral, Repository, SelectQueryBuilder, } from 'typeorm'; import { PaginatedBuilderOptions, PaginationMode, PaginationResult, chunks, setUnion } from '../domain/domain.util'; import { AssetEntity } from './entities'; import { DATABASE_PARAMETER_CHUNK_SIZE } from './infra.util'; /** * Allows optional values unlike the regular Between and uses MoreThanOrEqual * or LessThanOrEqual when only one parameter is specified. */ export function OptionalBetween(from?: T, to?: T) { if (from && to) { return Between(from, to); } else if (from) { return MoreThanOrEqual(from); } else if (to) { return LessThanOrEqual(to); } } export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => { const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options; return Number.isInteger(value) && value >= min && value <= max; }; function paginationHelper(items: Entity[], take: number): PaginationResult { const hasNextPage = items.length > take; items.splice(take); return { items, hasNextPage }; } export async function paginate( repository: Repository, { take, skip }: PaginationOptions, searchOptions?: FindManyOptions, ): Paginated { const items = await repository.find( _.omitBy( { ...searchOptions, // Take one more item to check if there's a next page take: take + 1, skip, }, _.isUndefined, ), ); return paginationHelper(items, take); } export async function paginatedBuilder( qb: SelectQueryBuilder, { take, skip, mode }: PaginatedBuilderOptions, ): Paginated { if (mode === PaginationMode.LIMIT_OFFSET) { qb.limit(take + 1).offset(skip); } else { qb.take(take + 1).skip(skip); } const items = await qb.getMany(); return paginationHelper(items, take); } export const asVector = (embedding: number[], quote = false) => quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`; /** * Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection, * to overcome the maximum number of parameters allowed by the database driver. * * @param options.paramIndex The index of the function parameter to chunk. Defaults to 0. * @param options.flatten Whether to flatten the results. Defaults to false. */ export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator { return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { const originalMethod = descriptor.value; const parameterIndex = options.paramIndex ?? 0; descriptor.value = async function (...arguments_: any[]) { const argument = arguments_[parameterIndex]; // Early return if argument length is less than or equal to the chunk size. if ( (Array.isArray(argument) && argument.length <= DATABASE_PARAMETER_CHUNK_SIZE) || (argument instanceof Set && argument.size <= DATABASE_PARAMETER_CHUNK_SIZE) ) { return await originalMethod.apply(this, arguments_); } return Promise.all( chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => { await Reflect.apply(originalMethod, this, [ ...arguments_.slice(0, parameterIndex), chunk, ...arguments_.slice(parameterIndex + 1), ]); }), ).then((results) => (options.mergeFn ? options.mergeFn(results) : results)); }; }; } export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator { return Chunked({ ...options, mergeFn: _.flatten }); } export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator { return Chunked({ ...options, mergeFn: setUnion }); } export function searchAssetBuilder( builder: SelectQueryBuilder, options: AssetSearchBuilderOptions, ): SelectQueryBuilder { builder.andWhere( _.omitBy( { createdAt: OptionalBetween(options.createdAfter, options.createdBefore), updatedAt: OptionalBetween(options.updatedAfter, options.updatedBefore), deletedAt: OptionalBetween(options.trashedAfter, options.trashedBefore), fileCreatedAt: OptionalBetween(options.takenAfter, options.takenBefore), }, _.isUndefined, ), ); const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined); const hasExifQuery = Object.keys(exifInfo).length > 0; if (options.withExif && !hasExifQuery) { builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); } if (hasExifQuery) { options.withExif ? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo') : builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); builder.andWhere({ exifInfo }); } const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']); builder.andWhere(_.omitBy(id, _.isUndefined)); if (options.userIds) { builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds }); } const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'resizePath', 'webpPath']); builder.andWhere(_.omitBy(path, _.isUndefined)); if (options.originalPath) { builder.andWhere(`f_unaccent(${builder.alias}.originalPath) ILIKE f_unaccent(:originalPath)`, { originalPath: `%${options.originalPath}%`, }); } const status = _.pick(options, ['isExternal', 'isFavorite', 'isOffline', 'isReadOnly', 'isVisible', 'type']); const { isArchived, isEncoded, isMotion, withArchived, isNotInAlbum, withFaces, withPeople, withSmartInfo, personIds, withExif, withStacked, trashedAfter, trashedBefore, } = options; builder.andWhere( _.omitBy( { ...status, isArchived: isArchived ?? (withArchived ? undefined : false), encodedVideoPath: isEncoded ? Not(IsNull()) : undefined, livePhotoVideoId: isMotion ? Not(IsNull()) : undefined, }, _.isUndefined, ), ); if (isNotInAlbum) { builder .leftJoin(`${builder.alias}.albums`, 'albums') .andWhere('albums.id IS NULL') .andWhere(`${builder.alias}.isVisible = true`); } if (withFaces || withPeople) { builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces'); } if (withPeople) { builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); } if (withSmartInfo) { builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo'); } if (personIds && personIds.length > 0) { builder .leftJoin(`${builder.alias}.faces`, 'faces') .andWhere('faces.personId IN (:...personIds)', { personIds }) .addGroupBy(`${builder.alias}.id`) .having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length }); if (withExif) { builder.addGroupBy('exifInfo.assetId'); } } if (withStacked) { builder.leftJoinAndSelect(`${builder.alias}.stack`, 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); } const withDeleted = options.withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined); if (withDeleted) { builder.withDeleted(); } return builder; }