*/ class PreviewMapper extends QBMapper { private const string TABLE_NAME = 'previews'; private const string LOCATION_TABLE_NAME = 'preview_locations'; private const string VERSION_TABLE_NAME = 'preview_versions'; public const MAX_CHUNK_SIZE = 1000; // Columns selected by joinLocation() that do not belong to the previews table private const array JOINED_COLUMN_ALIASES = [ 'version' => 'v', 'bucket_name' => 'l', 'object_store_name' => 'l', ]; public function __construct( IDBConnection $db, private readonly IMimeTypeLoader $mimeTypeLoader, private readonly ISnowflakeGenerator $snowflake, ) { parent::__construct($db, self::TABLE_NAME, Preview::class); } #[\Override] protected function mapRowToEntity(array $row): Entity { $row['mimetype'] = $this->mimeTypeLoader->getMimetypeById((int)$row['mimetype_id']); $row['source_mimetype'] = $this->mimeTypeLoader->getMimetypeById((int)$row['source_mimetype_id']); return parent::mapRowToEntity($row); } #[Override] public function insert(Entity $entity): Entity { /** @var Preview $preview */ $preview = $entity; $preview->setMimetypeId($this->mimeTypeLoader->getId($preview->getMimeType())); $preview->setSourceMimetypeId($this->mimeTypeLoader->getId($preview->getSourceMimeType())); if ($preview->getVersion() !== null && $preview->getVersion() !== '') { $qb = $this->db->getQueryBuilder(); $qb->insert(self::VERSION_TABLE_NAME) ->values([ 'id' => $qb->createNamedParameter($preview->getId()), 'version' => $qb->createNamedParameter($preview->getVersion(), IQueryBuilder::PARAM_STR), 'file_id' => $qb->createNamedParameter($preview->getFileId()), ]) ->executeStatement(); $entity->setVersionId((string)$preview->getId()); } return parent::insert($preview); } #[Override] public function update(Entity $entity): Entity { /** @var Preview $preview */ $preview = $entity; $preview->setMimetypeId($this->mimeTypeLoader->getId($preview->getMimeType())); $preview->setSourceMimetypeId($this->mimeTypeLoader->getId($preview->getSourceMimeType())); return parent::update($preview); } #[Override] public function delete(Entity $entity): Entity { /** @var Preview $preview */ $preview = $entity; $versionId = $preview->getVersionId(); if ($versionId !== null && $versionId !== '' && $versionId !== '-1') { $qb = $this->db->getQueryBuilder(); $qb->delete(self::VERSION_TABLE_NAME) ->where($qb->expr()->eq( 'id', $qb->createNamedParameter($versionId), )) ->executeStatement(); } return parent::delete($entity); } /** * @return \Generator * @throws Exception */ public function getAvailablePreviewsForFile(int $fileId): \Generator { $selectQb = $this->db->getQueryBuilder(); $this->joinLocation($selectQb) ->where($selectQb->expr()->eq('p.file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); yield from $this->yieldEntities($selectQb); } /** * @param int[] $fileIds * @return array * @throws Exception */ public function getAvailablePreviews(array $fileIds): array { $selectQb = $this->db->getQueryBuilder(); $this->joinLocation($selectQb) ->where( $selectQb->expr()->in('p.file_id', $selectQb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)), ); $previews = array_fill_keys($fileIds, []); foreach ($this->yieldEntities($selectQb) as $preview) { $previews[$preview->getFileId()][] = $preview; } return $previews; } /** * @return \Generator */ public function getByFileId(int $fileId): \Generator { $selectQb = $this->db->getQueryBuilder(); $this->joinLocation($selectQb) ->where($selectQb->expr()->eq('p.file_id', $selectQb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); yield from $this->yieldEntities($selectQb); } /** * @param int[] $previewIds */ public function deleteByIds(array $previewIds): void { $qb = $this->db->getQueryBuilder(); $qb->delete(self::TABLE_NAME) ->where($qb->expr()->andX( $qb->expr()->in('id', $qb->createNamedParameter($previewIds, IQueryBuilder::PARAM_INT_ARRAY)) ))->executeStatement(); } protected function joinLocation(IQueryBuilder $qb): IQueryBuilder { return $qb->select('p.*', 'l.bucket_name', 'l.object_store_name', 'v.version') ->from(self::TABLE_NAME, 'p') ->leftJoin('p', self::LOCATION_TABLE_NAME, 'l', $qb->expr()->eq( 'p.location_id', 'l.id' )) ->leftJoin('p', self::VERSION_TABLE_NAME, 'v', $qb->expr()->eq( 'p.version_id', 'v.id' )); } /** * Get the location id corresponding to the $bucket and $objectStore. Create one * if not existing yet. * * @throws Exception */ public function getLocationId(string $bucket, string $objectStore): string { $qb = $this->db->getQueryBuilder(); $result = $qb->select('id') ->from(self::LOCATION_TABLE_NAME) ->where($qb->expr()->eq('bucket_name', $qb->createNamedParameter($bucket))) ->andWhere($qb->expr()->eq('object_store_name', $qb->createNamedParameter($objectStore))) ->executeQuery(); $data = $result->fetchOne(); if ($data) { return (string)$data; } else { try { $id = $this->snowflake->nextId(); $qb->insert(self::LOCATION_TABLE_NAME) ->values([ 'id' => $qb->createNamedParameter($id), 'bucket_name' => $qb->createNamedParameter($bucket), 'object_store_name' => $qb->createNamedParameter($objectStore), ])->executeStatement(); return $id; } catch (Exception $e) { if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { // Fetch again as there seems to be another entry added meanwhile $result = $qb->select('id') ->from(self::LOCATION_TABLE_NAME) ->where($qb->expr()->eq('bucket_name', $qb->createNamedParameter($bucket))) ->andWhere($qb->expr()->eq('object_store_name', $qb->createNamedParameter($objectStore))) ->executeQuery(); $data = $result->fetchOne(); if ($data) { return (string)$data; } } throw $e; } } } public function deleteAll(): void { $delete = $this->db->getQueryBuilder(); $delete->delete($this->getTableName()); } /** * @return \Generator */ public function getPreviews(string $lastId, int $limit = self::MAX_CHUNK_SIZE, ?int $maxAgeDays = null): \Generator { $qb = $this->db->getQueryBuilder(); $this->joinLocation($qb) ->where($qb->expr()->gt('p.id', $qb->createNamedParameter($lastId))) ->orderBy('p.id', 'ASC') ->setMaxResults($limit); if ($maxAgeDays !== null) { $qb->andWhere($qb->expr()->lt( 'p.mtime', $qb->createNamedParameter( (new DateTimeImmutable())->sub(new DateInterval('P' . $maxAgeDays . 'D'))->getTimestamp(), IQueryBuilder::PARAM_INT, ), )); } return $this->yieldEntities($qb); } /** * @param string[] $mimeTypes * @return \Generator */ public function getPreviewsForMimeTypes(array $mimeTypes): \Generator { $qb = $this->db->getQueryBuilder(); $this->joinLocation($qb) ->where($qb->expr()->orX( ...array_map(function (string $mimeType) use ($qb): string { return $qb->expr()->eq('source_mimetype_id', $qb->createNamedParameter($this->mimeTypeLoader->getId($mimeType), IQueryBuilder::PARAM_INT)); }, $mimeTypes) )); return $this->yieldEntities($qb); } public function getPreviewForSpecification(array $parameters): ?Preview { $qb = $this->db->getQueryBuilder(); $this->joinLocation($qb); foreach ($parameters as $key => $value) { // The previews table is joined with preview_versions, which shares // the file_id column name, so plain column names have to be aliased. $column = str_contains($key, '.') ? $key : (self::JOINED_COLUMN_ALIASES[$key] ?? 'p') . '.' . $key; // An untyped false binds as an empty string, which PostgreSQL // rejects for a boolean column. $type = match (true) { is_bool($value) => IQueryBuilder::PARAM_BOOL, is_int($value) => IQueryBuilder::PARAM_INT, default => IQueryBuilder::PARAM_STR, }; $qb->andWhere($qb->expr()->eq($column, $qb->createNamedParameter($value, $type))); } try { return $this->findEntity($qb); } catch (DoesNotExistException) { return null; } } }