aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/FilesMetadata
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/FilesMetadata')
-rw-r--r--lib/private/FilesMetadata/FilesMetadataManager.php306
-rw-r--r--lib/private/FilesMetadata/Job/UpdateSingleMetadata.php49
-rw-r--r--lib/private/FilesMetadata/Listener/MetadataDelete.php44
-rw-r--r--lib/private/FilesMetadata/Listener/MetadataUpdate.php47
-rw-r--r--lib/private/FilesMetadata/MetadataQuery.php176
-rw-r--r--lib/private/FilesMetadata/Model/FilesMetadata.php638
-rw-r--r--lib/private/FilesMetadata/Model/MetadataValueWrapper.php428
-rw-r--r--lib/private/FilesMetadata/Service/IndexRequestService.php178
-rw-r--r--lib/private/FilesMetadata/Service/MetadataRequestService.php192
9 files changed, 2058 insertions, 0 deletions
diff --git a/lib/private/FilesMetadata/FilesMetadataManager.php b/lib/private/FilesMetadata/FilesMetadataManager.php
new file mode 100644
index 00000000000..4846799b3d4
--- /dev/null
+++ b/lib/private/FilesMetadata/FilesMetadataManager.php
@@ -0,0 +1,306 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata;
+
+use JsonException;
+use OC\FilesMetadata\Job\UpdateSingleMetadata;
+use OC\FilesMetadata\Listener\MetadataDelete;
+use OC\FilesMetadata\Listener\MetadataUpdate;
+use OC\FilesMetadata\Model\FilesMetadata;
+use OC\FilesMetadata\Service\IndexRequestService;
+use OC\FilesMetadata\Service\MetadataRequestService;
+use OCP\BackgroundJob\IJobList;
+use OCP\DB\Exception;
+use OCP\DB\Exception as DBException;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\EventDispatcher\IEventDispatcher;
+use OCP\Files\Cache\CacheEntryRemovedEvent;
+use OCP\Files\Events\Node\NodeWrittenEvent;
+use OCP\Files\InvalidPathException;
+use OCP\Files\Node;
+use OCP\Files\NotFoundException;
+use OCP\FilesMetadata\Event\MetadataBackgroundEvent;
+use OCP\FilesMetadata\Event\MetadataLiveEvent;
+use OCP\FilesMetadata\Event\MetadataNamedEvent;
+use OCP\FilesMetadata\Exceptions\FilesMetadataException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\IFilesMetadataManager;
+use OCP\FilesMetadata\IMetadataQuery;
+use OCP\FilesMetadata\Model\IFilesMetadata;
+use OCP\FilesMetadata\Model\IMetadataValueWrapper;
+use OCP\IAppConfig;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @inheritDoc
+ * @since 28.0.0
+ */
+class FilesMetadataManager implements IFilesMetadataManager {
+ public const CONFIG_KEY = 'files_metadata';
+ public const MIGRATION_DONE = 'files_metadata_installed';
+ private const JSON_MAXSIZE = 100000;
+
+ private ?IFilesMetadata $all = null;
+
+ public function __construct(
+ private IEventDispatcher $eventDispatcher,
+ private IJobList $jobList,
+ private IAppConfig $appConfig,
+ private LoggerInterface $logger,
+ private MetadataRequestService $metadataRequestService,
+ private IndexRequestService $indexRequestService,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ *
+ * @param Node $node related node
+ * @param int $process type of process
+ *
+ * @return IFilesMetadata
+ * @throws FilesMetadataException if metadata are invalid
+ * @throws InvalidPathException if path to file is not valid
+ * @throws NotFoundException if file cannot be found
+ * @see self::PROCESS_BACKGROUND
+ * @see self::PROCESS_LIVE
+ * @since 28.0.0
+ */
+ public function refreshMetadata(
+ Node $node,
+ int $process = self::PROCESS_LIVE,
+ string $namedEvent = '',
+ ): IFilesMetadata {
+ $storageId = $node->getStorage()->getCache()->getNumericStorageId();
+ try {
+ /** @var FilesMetadata $metadata */
+ $metadata = $this->metadataRequestService->getMetadataFromFileId($node->getId());
+ } catch (FilesMetadataNotFoundException) {
+ $metadata = new FilesMetadata($node->getId());
+ }
+ $metadata->setStorageId($storageId);
+
+ // if $process is LIVE, we enforce LIVE
+ // if $process is NAMED, we go NAMED
+ // else BACKGROUND
+ if ((self::PROCESS_LIVE & $process) !== 0) {
+ $event = new MetadataLiveEvent($node, $metadata);
+ } elseif ((self::PROCESS_NAMED & $process) !== 0) {
+ $event = new MetadataNamedEvent($node, $metadata, $namedEvent);
+ } else {
+ $event = new MetadataBackgroundEvent($node, $metadata);
+ }
+
+ $this->eventDispatcher->dispatchTyped($event);
+ $this->saveMetadata($event->getMetadata());
+
+ // if requested, we add a new job for next cron to refresh metadata out of main thread
+ // if $process was set to LIVE+BACKGROUND, we run background process directly
+ if ($event instanceof MetadataLiveEvent && $event->isRunAsBackgroundJobRequested()) {
+ if ((self::PROCESS_BACKGROUND & $process) !== 0) {
+ return $this->refreshMetadata($node, self::PROCESS_BACKGROUND);
+ }
+
+ $this->jobList->add(UpdateSingleMetadata::class, [$node->getOwner()?->getUID(), $node->getId()]);
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * @param int $fileId file id
+ * @param boolean $generate Generate if metadata does not exists
+ *
+ * @inheritDoc
+ * @return IFilesMetadata
+ * @throws FilesMetadataNotFoundException if not found
+ * @since 28.0.0
+ */
+ public function getMetadata(int $fileId, bool $generate = false): IFilesMetadata {
+ try {
+ return $this->metadataRequestService->getMetadataFromFileId($fileId);
+ } catch (FilesMetadataNotFoundException $ex) {
+ if ($generate) {
+ return new FilesMetadata($fileId);
+ }
+
+ throw $ex;
+ }
+ }
+
+ /**
+ * returns metadata of multiple file ids
+ *
+ * @param int[] $fileIds file ids
+ *
+ * @return array File ID is the array key, files without metadata are not returned in the array
+ * @psalm-return array<int, IFilesMetadata>
+ * @since 28.0.0
+ */
+ public function getMetadataForFiles(array $fileIds): array {
+ return $this->metadataRequestService->getMetadataFromFileIds($fileIds);
+ }
+
+ /**
+ * @param IFilesMetadata $filesMetadata metadata
+ *
+ * @inheritDoc
+ * @throws FilesMetadataException if metadata seems malformed
+ * @since 28.0.0
+ */
+ public function saveMetadata(IFilesMetadata $filesMetadata): void {
+ if ($filesMetadata->getFileId() === 0 || !$filesMetadata->updated()) {
+ return;
+ }
+
+ $json = json_encode($filesMetadata->jsonSerialize());
+ if (strlen($json) > self::JSON_MAXSIZE) {
+ $this->logger->debug('huge metadata content detected: ' . $json);
+ throw new FilesMetadataException('json cannot exceed ' . self::JSON_MAXSIZE . ' characters long; fileId: ' . $filesMetadata->getFileId() . '; size: ' . strlen($json));
+ }
+
+ try {
+ if ($filesMetadata->getSyncToken() === '') {
+ $this->metadataRequestService->store($filesMetadata);
+ } else {
+ $this->metadataRequestService->updateMetadata($filesMetadata);
+ }
+ } catch (DBException $e) {
+ // most of the logged exception are the result of race condition
+ // between 2 simultaneous process trying to create/update metadata
+ $this->logger->warning('issue while saveMetadata', ['exception' => $e, 'metadata' => $filesMetadata]);
+
+ return;
+ }
+
+ // update indexes
+ foreach ($filesMetadata->getIndexes() as $index) {
+ try {
+ $this->indexRequestService->updateIndex($filesMetadata, $index);
+ } catch (DBException $e) {
+ $this->logger->warning('issue while updateIndex', ['exception' => $e]);
+ }
+ }
+
+ // update metadata types list
+ $current = $this->getKnownMetadata();
+ $current->import($filesMetadata->jsonSerialize(true));
+ $this->appConfig->setValueArray('core', self::CONFIG_KEY, $current->jsonSerialize(), lazy: true);
+ }
+
+ /**
+ * @param int $fileId file id
+ *
+ * @inheritDoc
+ * @since 28.0.0
+ */
+ public function deleteMetadata(int $fileId): void {
+ try {
+ $this->metadataRequestService->dropMetadata($fileId);
+ } catch (Exception $e) {
+ $this->logger->warning('issue while deleteMetadata', ['exception' => $e, 'fileId' => $fileId]);
+ }
+
+ try {
+ $this->indexRequestService->dropIndex($fileId);
+ } catch (Exception $e) {
+ $this->logger->warning('issue while deleteMetadata', ['exception' => $e, 'fileId' => $fileId]);
+ }
+ }
+
+ /**
+ * @param IQueryBuilder $qb
+ * @param string $fileTableAlias alias of the table that contains data about files
+ * @param string $fileIdField alias of the field that contains file ids
+ *
+ * @inheritDoc
+ * @return IMetadataQuery
+ * @see IMetadataQuery
+ * @since 28.0.0
+ */
+ public function getMetadataQuery(
+ IQueryBuilder $qb,
+ string $fileTableAlias,
+ string $fileIdField,
+ ): IMetadataQuery {
+ return new MetadataQuery($qb, $this, $fileTableAlias, $fileIdField);
+ }
+
+ /**
+ * @inheritDoc
+ * @return IFilesMetadata
+ * @since 28.0.0
+ */
+ public function getKnownMetadata(): IFilesMetadata {
+ if ($this->all !== null) {
+ return $this->all;
+ }
+ $this->all = new FilesMetadata();
+
+ try {
+ $this->all->import($this->appConfig->getValueArray('core', self::CONFIG_KEY, lazy: true));
+ } catch (JsonException) {
+ $this->logger->warning('issue while reading stored list of metadata. Advised to run ./occ files:scan --all --generate-metadata');
+ }
+
+ return $this->all;
+ }
+
+ /**
+ * @param string $key metadata key
+ * @param string $type metadata type
+ * @param bool $indexed TRUE if metadata can be search
+ * @param int $editPermission remote edit permission via Webdav PROPPATCH
+ *
+ * @inheritDoc
+ * @since 28.0.0
+ * @see IMetadataValueWrapper::TYPE_INT
+ * @see IMetadataValueWrapper::TYPE_FLOAT
+ * @see IMetadataValueWrapper::TYPE_BOOL
+ * @see IMetadataValueWrapper::TYPE_ARRAY
+ * @see IMetadataValueWrapper::TYPE_STRING_LIST
+ * @see IMetadataValueWrapper::TYPE_INT_LIST
+ * @see IMetadataValueWrapper::TYPE_STRING
+ * @see IMetadataValueWrapper::EDIT_FORBIDDEN
+ * @see IMetadataValueWrapper::EDIT_REQ_OWNERSHIP
+ * @see IMetadataValueWrapper::EDIT_REQ_WRITE_PERMISSION
+ * @see IMetadataValueWrapper::EDIT_REQ_READ_PERMISSION
+ */
+ public function initMetadata(
+ string $key,
+ string $type,
+ bool $indexed = false,
+ int $editPermission = IMetadataValueWrapper::EDIT_FORBIDDEN,
+ ): void {
+ $current = $this->getKnownMetadata();
+ try {
+ if ($current->getType($key) === $type
+ && $indexed === $current->isIndex($key)
+ && $editPermission === $current->getEditPermission($key)) {
+ return; // if key exists, with same type and indexed, we do nothing.
+ }
+ } catch (FilesMetadataNotFoundException) {
+ // if value does not exist, we keep on the writing of course
+ }
+
+ $current->import([$key => ['type' => $type, 'indexed' => $indexed, 'editPermission' => $editPermission]]);
+ $this->appConfig->setValueArray('core', self::CONFIG_KEY, $current->jsonSerialize(), lazy: true);
+ $this->all = $current;
+ }
+
+ /**
+ * load listeners
+ *
+ * @param IEventDispatcher $eventDispatcher
+ */
+ public static function loadListeners(IEventDispatcher $eventDispatcher): void {
+ $eventDispatcher->addServiceListener(NodeWrittenEvent::class, MetadataUpdate::class);
+ $eventDispatcher->addServiceListener(CacheEntryRemovedEvent::class, MetadataDelete::class);
+ }
+}
diff --git a/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php b/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php
new file mode 100644
index 00000000000..5746493ddef
--- /dev/null
+++ b/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata\Job;
+
+use OC\FilesMetadata\FilesMetadataManager;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\QueuedJob;
+use OCP\Files\IRootFolder;
+use OCP\FilesMetadata\Event\MetadataLiveEvent;
+use OCP\FilesMetadata\IFilesMetadataManager;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Simple background job, created when requested by an app during the
+ * dispatch of MetadataLiveEvent.
+ * This background job will re-run the event to refresh metadata on a non-live thread.
+ *
+ * @see MetadataLiveEvent::requestBackgroundJob()
+ * @since 28.0.0
+ */
+class UpdateSingleMetadata extends QueuedJob {
+ public function __construct(
+ ITimeFactory $time,
+ private IRootFolder $rootFolder,
+ private FilesMetadataManager $filesMetadataManager,
+ private LoggerInterface $logger,
+ ) {
+ parent::__construct($time);
+ }
+
+ protected function run($argument) {
+ [$userId, $fileId] = $argument;
+
+ try {
+ $node = $this->rootFolder->getUserFolder($userId)->getFirstNodeById($fileId);
+ if ($node) {
+ $this->filesMetadataManager->refreshMetadata($node, IFilesMetadataManager::PROCESS_BACKGROUND);
+ }
+ } catch (\Exception $e) {
+ $this->logger->warning('issue while running UpdateSingleMetadata', ['exception' => $e, 'userId' => $userId, 'fileId' => $fileId]);
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/Listener/MetadataDelete.php b/lib/private/FilesMetadata/Listener/MetadataDelete.php
new file mode 100644
index 00000000000..226bd3bdafa
--- /dev/null
+++ b/lib/private/FilesMetadata/Listener/MetadataDelete.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata\Listener;
+
+use Exception;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Files\Cache\CacheEntryRemovedEvent;
+use OCP\FilesMetadata\IFilesMetadataManager;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Handle file deletion event and remove stored metadata related to the deleted file
+ *
+ * @template-implements IEventListener<CacheEntryRemovedEvent>
+ */
+class MetadataDelete implements IEventListener {
+ public function __construct(
+ private IFilesMetadataManager $filesMetadataManager,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ public function handle(Event $event): void {
+ if (!($event instanceof CacheEntryRemovedEvent)) {
+ return;
+ }
+
+ try {
+ $nodeId = $event->getFileId();
+ if ($nodeId > 0) {
+ $this->filesMetadataManager->deleteMetadata($nodeId);
+ }
+ } catch (Exception $e) {
+ $this->logger->warning('issue while running MetadataDelete', ['exception' => $e]);
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/Listener/MetadataUpdate.php b/lib/private/FilesMetadata/Listener/MetadataUpdate.php
new file mode 100644
index 00000000000..4c5c913c740
--- /dev/null
+++ b/lib/private/FilesMetadata/Listener/MetadataUpdate.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata\Listener;
+
+use Exception;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Files\Events\Node\NodeCreatedEvent;
+use OCP\Files\Events\Node\NodeWrittenEvent;
+use OCP\FilesMetadata\IFilesMetadataManager;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Handle file creation/modification events and initiate a new event related to the created/edited file.
+ * The generated new event is broadcast in order to obtain file related metadata from other apps.
+ * metadata will be stored in database.
+ *
+ * @template-implements IEventListener<NodeCreatedEvent|NodeWrittenEvent>
+ */
+class MetadataUpdate implements IEventListener {
+ public function __construct(
+ private IFilesMetadataManager $filesMetadataManager,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * @param Event $event
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof NodeWrittenEvent)) {
+ return;
+ }
+
+ try {
+ $this->filesMetadataManager->refreshMetadata($event->getNode());
+ } catch (Exception $e) {
+ $this->logger->warning('issue while running MetadataUpdate', ['exception' => $e]);
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/MetadataQuery.php b/lib/private/FilesMetadata/MetadataQuery.php
new file mode 100644
index 00000000000..deae433e2fc
--- /dev/null
+++ b/lib/private/FilesMetadata/MetadataQuery.php
@@ -0,0 +1,176 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata;
+
+use OC\FilesMetadata\Model\FilesMetadata;
+use OC\FilesMetadata\Service\IndexRequestService;
+use OC\FilesMetadata\Service\MetadataRequestService;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataTypeException;
+use OCP\FilesMetadata\IFilesMetadataManager;
+use OCP\FilesMetadata\IMetadataQuery;
+use OCP\FilesMetadata\Model\IFilesMetadata;
+use OCP\FilesMetadata\Model\IMetadataValueWrapper;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @inheritDoc
+ * @since 28.0.0
+ */
+class MetadataQuery implements IMetadataQuery {
+ private array $knownJoinedIndex = [];
+ public function __construct(
+ private IQueryBuilder $queryBuilder,
+ private IFilesMetadata|IFilesMetadataManager $manager,
+ private string $fileTableAlias = 'fc',
+ private string $fileIdField = 'fileid',
+ private string $alias = 'meta',
+ private string $aliasIndexPrefix = 'meta_index',
+ ) {
+ if ($manager instanceof IFilesMetadata) {
+ /**
+ * Since 29, because knownMetadata is stored in lazy appconfig, it seems smarter
+ * to not call getKnownMetadata() at the load of this class as it is only needed
+ * in {@see getMetadataValueField}.
+ *
+ * FIXME: remove support for IFilesMetadata
+ */
+ $logger = \OCP\Server::get(LoggerInterface::class);
+ $logger->debug('It is deprecated to use IFilesMetadata as second parameter when calling MetadataQuery::__construct()');
+ }
+ }
+
+ /**
+ * @inheritDoc
+ * @see self::extractMetadata()
+ * @since 28.0.0
+ */
+ public function retrieveMetadata(): void {
+ $this->queryBuilder->selectAlias($this->alias . '.json', 'meta_json');
+ $this->queryBuilder->selectAlias($this->alias . '.sync_token', 'meta_sync_token');
+ $this->queryBuilder->leftJoin(
+ $this->fileTableAlias, MetadataRequestService::TABLE_METADATA, $this->alias,
+ $this->queryBuilder->expr()->eq($this->fileTableAlias . '.' . $this->fileIdField, $this->alias . '.file_id')
+ );
+ }
+
+ /**
+ * @param array $row result row
+ *
+ * @inheritDoc
+ * @return IFilesMetadata metadata
+ * @see self::retrieveMetadata()
+ * @since 28.0.0
+ */
+ public function extractMetadata(array $row): IFilesMetadata {
+ $fileId = (array_key_exists($this->fileIdField, $row)) ? $row[$this->fileIdField] : 0;
+ $metadata = new FilesMetadata((int)$fileId);
+ try {
+ $metadata->importFromDatabase($row, $this->alias . '_');
+ } catch (FilesMetadataNotFoundException) {
+ // can be ignored as files' metadata are optional and might not exist in database
+ }
+
+ return $metadata;
+ }
+
+ /**
+ * @param string $metadataKey metadata key
+ * @param bool $enforce limit the request only to existing metadata
+ *
+ * @inheritDoc
+ * @since 28.0.0
+ */
+ public function joinIndex(string $metadataKey, bool $enforce = false): string {
+ if (array_key_exists($metadataKey, $this->knownJoinedIndex)) {
+ return $this->knownJoinedIndex[$metadataKey];
+ }
+
+ $aliasIndex = $this->aliasIndexPrefix . '_' . count($this->knownJoinedIndex);
+ $this->knownJoinedIndex[$metadataKey] = $aliasIndex;
+
+ $expr = $this->queryBuilder->expr();
+ $andX = $expr->andX($expr->eq($aliasIndex . '.file_id', $this->fileTableAlias . '.' . $this->fileIdField));
+ $andX->add($expr->eq($this->getMetadataKeyField($metadataKey), $this->queryBuilder->createNamedParameter($metadataKey)));
+
+ if ($enforce) {
+ $this->queryBuilder->innerJoin(
+ $this->fileTableAlias,
+ IndexRequestService::TABLE_METADATA_INDEX,
+ $aliasIndex,
+ $andX
+ );
+ } else {
+ $this->queryBuilder->leftJoin(
+ $this->fileTableAlias,
+ IndexRequestService::TABLE_METADATA_INDEX,
+ $aliasIndex,
+ $andX
+ );
+ }
+
+ return $aliasIndex;
+ }
+
+ /**
+ * @throws FilesMetadataNotFoundException
+ */
+ private function joinedTableAlias(string $metadataKey): string {
+ if (!array_key_exists($metadataKey, $this->knownJoinedIndex)) {
+ throw new FilesMetadataNotFoundException('table related to ' . $metadataKey . ' not initiated, you need to use leftJoin() first.');
+ }
+
+ return $this->knownJoinedIndex[$metadataKey];
+ }
+
+ /**
+ * @inheritDoc
+ *
+ * @param string $metadataKey metadata key
+ *
+ * @return string table field
+ * @throws FilesMetadataNotFoundException
+ * @since 28.0.0
+ */
+ public function getMetadataKeyField(string $metadataKey): string {
+ return $this->joinedTableAlias($metadataKey) . '.meta_key';
+ }
+
+ /**
+ * @inheritDoc
+ *
+ * @param string $metadataKey metadata key
+ *
+ * @return string table field
+ * @throws FilesMetadataNotFoundException if metadataKey is not known
+ * @throws FilesMetadataTypeException is metadataKey is not set as indexed
+ * @since 28.0.0
+ */
+ public function getMetadataValueField(string $metadataKey): string {
+ if ($this->manager instanceof IFilesMetadataManager) {
+ /**
+ * Since 29, because knownMetadata is stored in lazy appconfig, it seems smarter
+ * to not call getKnownMetadata() at the load of this class as it is only needed
+ * in this method.
+ *
+ * FIXME: keep only this line and remove support for previous IFilesMetadata in constructor
+ */
+ $knownMetadata = $this->manager->getKnownMetadata();
+ } else {
+ $knownMetadata = $this->manager;
+ }
+
+ return match ($knownMetadata->getType($metadataKey)) {
+ IMetadataValueWrapper::TYPE_STRING => $this->joinedTableAlias($metadataKey) . '.meta_value_string',
+ IMetadataValueWrapper::TYPE_INT, IMetadataValueWrapper::TYPE_BOOL => $this->joinedTableAlias($metadataKey) . '.meta_value_int',
+ default => throw new FilesMetadataTypeException('metadata is not set as indexed'),
+ };
+ }
+}
diff --git a/lib/private/FilesMetadata/Model/FilesMetadata.php b/lib/private/FilesMetadata/Model/FilesMetadata.php
new file mode 100644
index 00000000000..b66e1fe3711
--- /dev/null
+++ b/lib/private/FilesMetadata/Model/FilesMetadata.php
@@ -0,0 +1,638 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata\Model;
+
+use JsonException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataKeyFormatException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataTypeException;
+use OCP\FilesMetadata\Model\IFilesMetadata;
+use OCP\FilesMetadata\Model\IMetadataValueWrapper;
+
+/**
+ * Model that represent metadata linked to a specific file.
+ *
+ * @inheritDoc
+ * @since 28.0.0
+ */
+class FilesMetadata implements IFilesMetadata {
+ /** @var array<string, MetadataValueWrapper> */
+ private array $metadata = [];
+ private bool $updated = false;
+ private int $lastUpdate = 0;
+ private string $syncToken = '';
+ private ?int $storageId = null;
+
+ public function __construct(
+ private int $fileId = 0,
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ * @return int related file id
+ * @since 28.0.0
+ */
+ public function getFileId(): int {
+ return $this->fileId;
+ }
+
+ public function getStorageId(): ?int {
+ return $this->storageId;
+ }
+
+ /**
+ * Set which storage the file this metadata belongs to.
+ *
+ * This helps with sharded filecache setups to know where to store the metadata
+ *
+ * @param int $storageId
+ * @return void
+ */
+ public function setStorageId(int $storageId): void {
+ $this->storageId = $storageId;
+ }
+
+ /**
+ * @inheritDoc
+ * @return int timestamp
+ * @since 28.0.0
+ */
+ public function lastUpdateTimestamp(): int {
+ return $this->lastUpdate;
+ }
+
+ /**
+ * @inheritDoc
+ * @return string token
+ * @since 28.0.0
+ */
+ public function getSyncToken(): string {
+ return $this->syncToken;
+ }
+
+ /**
+ * @inheritDoc
+ * @return string[] list of keys
+ * @since 28.0.0
+ */
+ public function getKeys(): array {
+ return array_keys($this->metadata);
+ }
+
+ /**
+ * @param string $needle metadata key to search
+ *
+ * @inheritDoc
+ * @return bool TRUE if key exist
+ * @since 28.0.0
+ */
+ public function hasKey(string $needle): bool {
+ return (in_array($needle, $this->getKeys()));
+ }
+
+ /**
+ * @inheritDoc
+ * @return string[] list of indexes
+ * @since 28.0.0
+ */
+ public function getIndexes(): array {
+ $indexes = [];
+ foreach ($this->getKeys() as $key) {
+ if ($this->metadata[$key]->isIndexed()) {
+ $indexes[] = $key;
+ }
+ }
+
+ return $indexes;
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return bool TRUE if key exists and is set as indexed
+ * @since 28.0.0
+ */
+ public function isIndex(string $key): bool {
+ return $this->metadata[$key]?->isIndexed() ?? false;
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return int edit permission
+ * @throws FilesMetadataNotFoundException
+ * @since 28.0.0
+ */
+ public function getEditPermission(string $key): int {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getEditPermission();
+ }
+
+ /**
+ * @param string $key metadata key
+ * @param int $permission edit permission
+ *
+ * @inheritDoc
+ * @throws FilesMetadataNotFoundException
+ * @since 28.0.0
+ */
+ public function setEditPermission(string $key, int $permission): void {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ $this->metadata[$key]->setEditPermission($permission);
+ }
+
+
+ public function getEtag(string $key): string {
+ if (!array_key_exists($key, $this->metadata)) {
+ return '';
+ }
+
+ return $this->metadata[$key]->getEtag();
+ }
+
+ public function setEtag(string $key, string $etag): void {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ $this->metadata[$key]->setEtag($etag);
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return string metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function getString(string $key): string {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getValueString();
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return int metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function getInt(string $key): int {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getValueInt();
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return float metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function getFloat(string $key): float {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getValueFloat();
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return bool metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function getBool(string $key): bool {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getValueBool();
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return array metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function getArray(string $key): array {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getValueArray();
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return string[] metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function getStringList(string $key): array {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getValueStringList();
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return int[] metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function getIntList(string $key): array {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getValueIntList();
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return string value type
+ * @throws FilesMetadataNotFoundException
+ * @see IMetadataValueWrapper::TYPE_STRING
+ * @see IMetadataValueWrapper::TYPE_INT
+ * @see IMetadataValueWrapper::TYPE_FLOAT
+ * @see IMetadataValueWrapper::TYPE_BOOL
+ * @see IMetadataValueWrapper::TYPE_ARRAY
+ * @see IMetadataValueWrapper::TYPE_STRING_LIST
+ * @see IMetadataValueWrapper::TYPE_INT_LIST
+ * @since 28.0.0
+ */
+ public function getType(string $key): string {
+ if (!array_key_exists($key, $this->metadata)) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ return $this->metadata[$key]->getType();
+ }
+
+ /**
+ * @param string $key metadata key
+ * @param string $value metadata value
+ * @param bool $index set TRUE if value must be indexed
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataKeyFormatException
+ * @since 28.0.0
+ */
+ public function setString(string $key, string $value, bool $index = false): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->getString($key) === $value && $index === $this->isIndex($key)) {
+ return $this; // we ignore if value and index have not changed
+ }
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException $e) {
+ // if value does not exist, or type has changed, we keep on the writing
+ }
+
+ $meta = new MetadataValueWrapper(IMetadataValueWrapper::TYPE_STRING);
+ $this->updated = true;
+ $this->metadata[$key] = $meta->setValueString($value)->setIndexed($index);
+
+ return $this;
+ }
+
+ /**
+ * @param string $key metadata key
+ * @param int $value metadata value
+ * @param bool $index set TRUE if value must be indexed
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataKeyFormatException
+ * @since 28.0.0
+ */
+ public function setInt(string $key, int $value, bool $index = false): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->getInt($key) === $value && $index === $this->isIndex($key)) {
+ return $this; // we ignore if value have not changed
+ }
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException $e) {
+ // if value does not exist, or type has changed, we keep on the writing
+ }
+
+ $meta = new MetadataValueWrapper(IMetadataValueWrapper::TYPE_INT);
+ $this->metadata[$key] = $meta->setValueInt($value)->setIndexed($index);
+ $this->updated = true;
+
+ return $this;
+ }
+
+ /**
+ * @param string $key metadata key
+ * @param float $value metadata value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataKeyFormatException
+ * @since 28.0.0
+ */
+ public function setFloat(string $key, float $value, bool $index = false): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->getFloat($key) === $value && $index === $this->isIndex($key)) {
+ return $this; // we ignore if value have not changed
+ }
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException $e) {
+ // if value does not exist, or type has changed, we keep on the writing
+ }
+
+ $meta = new MetadataValueWrapper(IMetadataValueWrapper::TYPE_FLOAT);
+ $this->metadata[$key] = $meta->setValueFloat($value)->setIndexed($index);
+ $this->updated = true;
+
+ return $this;
+ }
+
+
+ /**
+ * @param string $key metadata key
+ * @param bool $value metadata value
+ * @param bool $index set TRUE if value must be indexed
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataKeyFormatException
+ * @since 28.0.0
+ */
+ public function setBool(string $key, bool $value, bool $index = false): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->getBool($key) === $value && $index === $this->isIndex($key)) {
+ return $this; // we ignore if value have not changed
+ }
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException $e) {
+ // if value does not exist, or type has changed, we keep on the writing
+ }
+
+ $meta = new MetadataValueWrapper(IMetadataValueWrapper::TYPE_BOOL);
+ $this->metadata[$key] = $meta->setValueBool($value)->setIndexed($index);
+ $this->updated = true;
+
+ return $this;
+ }
+
+
+ /**
+ * @param string $key metadata key
+ * @param array $value metadata value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataKeyFormatException
+ * @since 28.0.0
+ */
+ public function setArray(string $key, array $value): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->getArray($key) === $value) {
+ return $this; // we ignore if value have not changed
+ }
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException $e) {
+ // if value does not exist, or type has changed, we keep on the writing
+ }
+
+ $meta = new MetadataValueWrapper(IMetadataValueWrapper::TYPE_ARRAY);
+ $this->metadata[$key] = $meta->setValueArray($value);
+ $this->updated = true;
+
+ return $this;
+ }
+
+ /**
+ * @param string $key metadata key
+ * @param string[] $value metadata value
+ * @param bool $index set TRUE if each values from the list must be indexed
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataKeyFormatException
+ * @since 28.0.0
+ */
+ public function setStringList(string $key, array $value, bool $index = false): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->getStringList($key) === $value) {
+ return $this; // we ignore if value have not changed
+ }
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException $e) {
+ // if value does not exist, or type has changed, we keep on the writing
+ }
+
+ $meta = new MetadataValueWrapper(IMetadataValueWrapper::TYPE_STRING_LIST);
+ $this->metadata[$key] = $meta->setValueStringList($value)->setIndexed($index);
+ $this->updated = true;
+
+ return $this;
+ }
+
+ /**
+ * @param string $key metadata key
+ * @param int[] $value metadata value
+ * @param bool $index set TRUE if each values from the list must be indexed
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataKeyFormatException
+ * @since 28.0.0
+ */
+ public function setIntList(string $key, array $value, bool $index = false): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->getIntList($key) === $value) {
+ return $this; // we ignore if value have not changed
+ }
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException $e) {
+ // if value does not exist, or type has changed, we keep on the writing
+ }
+
+ $valueWrapper = new MetadataValueWrapper(IMetadataValueWrapper::TYPE_INT_LIST);
+ $this->metadata[$key] = $valueWrapper->setValueIntList($value)->setIndexed($index);
+ $this->updated = true;
+
+ return $this;
+ }
+
+ /**
+ * @param string $key metadata key
+ *
+ * @inheritDoc
+ * @return self
+ * @since 28.0.0
+ */
+ public function unset(string $key): IFilesMetadata {
+ if (!array_key_exists($key, $this->metadata)) {
+ return $this;
+ }
+
+ unset($this->metadata[$key]);
+ $this->updated = true;
+
+ return $this;
+ }
+
+ /**
+ * @param string $keyPrefix metadata key prefix
+ *
+ * @inheritDoc
+ * @return self
+ * @since 28.0.0
+ */
+ public function removeStartsWith(string $keyPrefix): IFilesMetadata {
+ if ($keyPrefix === '') {
+ return $this;
+ }
+
+ foreach ($this->getKeys() as $key) {
+ if (str_starts_with($key, $keyPrefix)) {
+ $this->unset($key);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $key
+ *
+ * @return void
+ * @throws FilesMetadataKeyFormatException
+ */
+ private function confirmKeyFormat(string $key): void {
+ $acceptedChars = ['-', '_'];
+ if (ctype_alnum(str_replace($acceptedChars, '', $key))) {
+ return;
+ }
+
+ throw new FilesMetadataKeyFormatException('key can only contains alphanumerical characters, and dash (-, _)');
+ }
+
+ /**
+ * @inheritDoc
+ * @return bool TRUE if metadata have been modified
+ * @since 28.0.0
+ */
+ public function updated(): bool {
+ return $this->updated;
+ }
+
+ public function jsonSerialize(bool $emptyValues = false): array {
+ $data = [];
+ foreach ($this->metadata as $metaKey => $metaValueWrapper) {
+ $data[$metaKey] = $metaValueWrapper->jsonSerialize($emptyValues);
+ }
+
+ return $data;
+ }
+
+ /**
+ * @return array<string, string|int|bool|float|string[]|int[]>
+ */
+ public function asArray(): array {
+ $data = [];
+ foreach ($this->metadata as $metaKey => $metaValueWrapper) {
+ try {
+ $data[$metaKey] = $metaValueWrapper->getValueAny();
+ } catch (FilesMetadataNotFoundException $e) {
+ // ignore exception
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * @param array $data
+ *
+ * @inheritDoc
+ * @return IFilesMetadata
+ * @since 28.0.0
+ */
+ public function import(array $data): IFilesMetadata {
+ foreach ($data as $k => $v) {
+ $valueWrapper = new MetadataValueWrapper();
+ $this->metadata[$k] = $valueWrapper->import($v);
+ }
+ $this->updated = false;
+
+ return $this;
+ }
+
+ /**
+ * import data from database to configure this model
+ *
+ * @param array $data
+ * @param string $prefix
+ *
+ * @return IFilesMetadata
+ * @throws FilesMetadataNotFoundException
+ * @since 28.0.0
+ */
+ public function importFromDatabase(array $data, string $prefix = ''): IFilesMetadata {
+ try {
+ $this->syncToken = $data[$prefix . 'sync_token'] ?? '';
+
+ return $this->import(
+ json_decode(
+ $data[$prefix . 'json'] ?? '[]',
+ true,
+ 512,
+ JSON_THROW_ON_ERROR
+ )
+ );
+ } catch (JsonException) {
+ throw new FilesMetadataNotFoundException();
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/Model/MetadataValueWrapper.php b/lib/private/FilesMetadata/Model/MetadataValueWrapper.php
new file mode 100644
index 00000000000..710a8129340
--- /dev/null
+++ b/lib/private/FilesMetadata/Model/MetadataValueWrapper.php
@@ -0,0 +1,428 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata\Model;
+
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataTypeException;
+use OCP\FilesMetadata\Model\IMetadataValueWrapper;
+
+/**
+ * @inheritDoc
+ * @see IFilesMetadata
+ * @since 28.0.0
+ */
+class MetadataValueWrapper implements IMetadataValueWrapper {
+ private string $type;
+ /** @var string|int|float|bool|array|string[]|int[] */
+ private mixed $value = null;
+ private string $etag = '';
+ private bool $indexed = false;
+ private int $editPermission = self::EDIT_FORBIDDEN;
+
+ /**
+ * @param string $type value type
+ *
+ * @inheritDoc
+ * @see self::TYPE_INT
+ * @see self::TYPE_FLOAT
+ * @see self::TYPE_BOOL
+ * @see self::TYPE_ARRAY
+ * @see self::TYPE_STRING_LIST
+ * @see self::TYPE_INT_LIST
+ * @see self::TYPE_STRING
+ * @since 28.0.0
+ */
+ public function __construct(string $type = '') {
+ $this->type = $type;
+ }
+
+ /**
+ * @inheritDoc
+ * @return string value type
+ * @see self::TYPE_INT
+ * @see self::TYPE_FLOAT
+ * @see self::TYPE_BOOL
+ * @see self::TYPE_ARRAY
+ * @see self::TYPE_STRING_LIST
+ * @see self::TYPE_INT_LIST
+ * @see self::TYPE_STRING
+ * @since 28.0.0
+ */
+ public function getType(): string {
+ return $this->type;
+ }
+
+ /**
+ * @param string $type value type
+ *
+ * @inheritDoc
+ * @return bool
+ * @see self::TYPE_INT
+ * @see self::TYPE_FLOAT
+ * @see self::TYPE_BOOL
+ * @see self::TYPE_ARRAY
+ * @see self::TYPE_STRING_LIST
+ * @see self::TYPE_INT_LIST
+ * @see self::TYPE_STRING
+ * @since 28.0.0
+ */
+ public function isType(string $type): bool {
+ return (strtolower($type) === strtolower($this->type));
+ }
+
+ /**
+ * @param string $type value type
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if type cannot be confirmed
+ * @see self::TYPE_INT
+ * @see self::TYPE_BOOL
+ * @see self::TYPE_ARRAY
+ * @see self::TYPE_STRING_LIST
+ * @see self::TYPE_INT_LIST
+ * @see self::TYPE_STRING
+ * @see self::TYPE_FLOAT
+ * @since 28.0.0
+ */
+ public function assertType(string $type): self {
+ if (!$this->isType($type)) {
+ throw new FilesMetadataTypeException('type is \'' . $this->getType() . '\', expecting \'' . $type . '\'');
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param string $value string to be set as value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if wrapper was not set to store a string
+ * @since 28.0.0
+ */
+ public function setValueString(string $value): self {
+ $this->assertType(self::TYPE_STRING);
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param int $value int to be set as value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if wrapper was not set to store an int
+ * @since 28.0.0
+ */
+ public function setValueInt(int $value): self {
+ $this->assertType(self::TYPE_INT);
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param float $value float to be set as value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if wrapper was not set to store a float
+ * @since 28.0.0
+ */
+ public function setValueFloat(float $value): self {
+ $this->assertType(self::TYPE_FLOAT);
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param bool $value bool to be set as value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if wrapper was not set to store a bool
+ * @since 28.0.0
+ */
+ public function setValueBool(bool $value): self {
+ $this->assertType(self::TYPE_BOOL);
+ $this->value = $value;
+
+
+ return $this;
+ }
+
+ /**
+ * @param array $value array to be set as value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if wrapper was not set to store an array
+ * @since 28.0.0
+ */
+ public function setValueArray(array $value): self {
+ $this->assertType(self::TYPE_ARRAY);
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param string[] $value string list to be set as value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if wrapper was not set to store a string list
+ * @since 28.0.0
+ */
+ public function setValueStringList(array $value): self {
+ $this->assertType(self::TYPE_STRING_LIST);
+ // TODO confirm value is an array or string ?
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * @param int[] $value int list to be set as value
+ *
+ * @inheritDoc
+ * @return self
+ * @throws FilesMetadataTypeException if wrapper was not set to store an int list
+ * @since 28.0.0
+ */
+ public function setValueIntList(array $value): self {
+ $this->assertType(self::TYPE_INT_LIST);
+ // TODO confirm value is an array of int ?
+ $this->value = $value;
+
+ return $this;
+ }
+
+
+ /**
+ * @inheritDoc
+ * @return string set value
+ * @throws FilesMetadataTypeException if wrapper was not set to store a string
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueString(): string {
+ $this->assertType(self::TYPE_STRING);
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return (string)$this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return int set value
+ * @throws FilesMetadataTypeException if wrapper was not set to store an int
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueInt(): int {
+ $this->assertType(self::TYPE_INT);
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return (int)$this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return float set value
+ * @throws FilesMetadataTypeException if wrapper was not set to store a float
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueFloat(): float {
+ $this->assertType(self::TYPE_FLOAT);
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return (float)$this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return bool set value
+ * @throws FilesMetadataTypeException if wrapper was not set to store a bool
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueBool(): bool {
+ $this->assertType(self::TYPE_BOOL);
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return (bool)$this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return array set value
+ * @throws FilesMetadataTypeException if wrapper was not set to store an array
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueArray(): array {
+ $this->assertType(self::TYPE_ARRAY);
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return (array)$this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return string[] set value
+ * @throws FilesMetadataTypeException if wrapper was not set to store a string list
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueStringList(): array {
+ $this->assertType(self::TYPE_STRING_LIST);
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return (array)$this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return int[] set value
+ * @throws FilesMetadataTypeException if wrapper was not set to store an int list
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueIntList(): array {
+ $this->assertType(self::TYPE_INT_LIST);
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return (array)$this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return string|int|float|bool|array|string[]|int[] set value
+ * @throws FilesMetadataNotFoundException if value is not set
+ * @since 28.0.0
+ */
+ public function getValueAny(): mixed {
+ if ($this->value === null) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return $this->value;
+ }
+
+ /**
+ * @inheritDoc
+ * @return string stored etag
+ * @since 29.0.0
+ */
+ public function getEtag(): string {
+ return $this->etag;
+ }
+
+ /**
+ * @param string $etag etag value
+ *
+ * @inheritDoc
+ * @return self
+ * @since 29.0.0
+ */
+ public function setEtag(string $etag): self {
+ $this->etag = $etag;
+ return $this;
+ }
+
+ /**
+ * @param bool $indexed TRUE to set the stored value as an indexed value
+ *
+ * @inheritDoc
+ * @return self
+ * @since 28.0.0
+ */
+ public function setIndexed(bool $indexed): self {
+ $this->indexed = $indexed;
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ * @return bool TRUE if value is an indexed value
+ * @since 28.0.0
+ */
+ public function isIndexed(): bool {
+ return $this->indexed;
+ }
+
+ /**
+ * @param int $permission edit permission
+ *
+ * @inheritDoc
+ * @return self
+ * @since 28.0.0
+ */
+ public function setEditPermission(int $permission): self {
+ $this->editPermission = $permission;
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ * @return int edit permission
+ * @since 28.0.0
+ */
+ public function getEditPermission(): int {
+ return $this->editPermission;
+ }
+
+ /**
+ * @param array $data serialized version of the object
+ *
+ * @inheritDoc
+ * @return self
+ * @see jsonSerialize
+ * @since 28.0.0
+ */
+ public function import(array $data): self {
+ $this->value = $data['value'] ?? null;
+ $this->type = $data['type'] ?? '';
+ $this->setEtag($data['etag'] ?? '');
+ $this->setIndexed($data['indexed'] ?? false);
+ $this->setEditPermission($data['editPermission'] ?? self::EDIT_FORBIDDEN);
+ return $this;
+ }
+
+ public function jsonSerialize(bool $emptyValues = false): array {
+ return [
+ 'value' => ($emptyValues) ? null : $this->value,
+ 'type' => $this->getType(),
+ 'etag' => $this->getEtag(),
+ 'indexed' => $this->isIndexed(),
+ 'editPermission' => $this->getEditPermission()
+ ];
+ }
+}
diff --git a/lib/private/FilesMetadata/Service/IndexRequestService.php b/lib/private/FilesMetadata/Service/IndexRequestService.php
new file mode 100644
index 00000000000..91bd9f0b11e
--- /dev/null
+++ b/lib/private/FilesMetadata/Service/IndexRequestService.php
@@ -0,0 +1,178 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata\Service;
+
+use OCP\DB\Exception as DbException;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataTypeException;
+use OCP\FilesMetadata\Model\IFilesMetadata;
+use OCP\FilesMetadata\Model\IMetadataValueWrapper;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+
+/**
+ * manage sql request to the metadata_index table
+ */
+class IndexRequestService {
+ public const TABLE_METADATA_INDEX = 'files_metadata_index';
+
+ public function __construct(
+ private IDBConnection $dbConnection,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ /**
+ * update the index for a specific metadata key
+ *
+ * @param IFilesMetadata $filesMetadata metadata
+ * @param string $key metadata key to update
+ *
+ * @throws DbException
+ */
+ public function updateIndex(IFilesMetadata $filesMetadata, string $key): void {
+ $fileId = $filesMetadata->getFileId();
+ try {
+ $metadataType = $filesMetadata->getType($key);
+ } catch (FilesMetadataNotFoundException $e) {
+ return;
+ }
+
+ /**
+ * might look harsh, but a lot simpler than comparing current indexed data, as we can expect
+ * conflict with a change of types.
+ * We assume that each time one random metadata were modified we can drop all index for this
+ * key and recreate them.
+ * To make it slightly cleaner, we'll use transaction
+ */
+ $this->dbConnection->beginTransaction();
+ try {
+ $this->dropIndex($fileId, $key);
+ match ($metadataType) {
+ IMetadataValueWrapper::TYPE_STRING => $this->insertIndexString($fileId, $key, $filesMetadata->getString($key)),
+ IMetadataValueWrapper::TYPE_INT => $this->insertIndexInt($fileId, $key, $filesMetadata->getInt($key)),
+ IMetadataValueWrapper::TYPE_BOOL => $this->insertIndexBool($fileId, $key, $filesMetadata->getBool($key)),
+ IMetadataValueWrapper::TYPE_STRING_LIST => $this->insertIndexStringList($fileId, $key, $filesMetadata->getStringList($key)),
+ IMetadataValueWrapper::TYPE_INT_LIST => $this->insertIndexIntList($fileId, $key, $filesMetadata->getIntList($key))
+ };
+ } catch (FilesMetadataNotFoundException|FilesMetadataTypeException|DbException $e) {
+ $this->dbConnection->rollBack();
+ $this->logger->warning('issue while updateIndex', ['exception' => $e, 'fileId' => $fileId, 'key' => $key]);
+ }
+
+ $this->dbConnection->commit();
+ }
+
+ /**
+ * insert a new entry in the metadata_index table for a string value
+ *
+ * @param int $fileId file id
+ * @param string $key metadata key
+ * @param string $value metadata value
+ *
+ * @throws DbException
+ */
+ private function insertIndexString(int $fileId, string $key, string $value): void {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->insert(self::TABLE_METADATA_INDEX)
+ ->setValue('meta_key', $qb->createNamedParameter($key))
+ ->setValue('meta_value_string', $qb->createNamedParameter($value))
+ ->setValue('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT));
+ $qb->executeStatement();
+ }
+
+ /**
+ * insert a new entry in the metadata_index table for an int value
+ *
+ * @param int $fileId file id
+ * @param string $key metadata key
+ * @param int $value metadata value
+ *
+ * @throws DbException
+ */
+ public function insertIndexInt(int $fileId, string $key, int $value): void {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->insert(self::TABLE_METADATA_INDEX)
+ ->setValue('meta_key', $qb->createNamedParameter($key))
+ ->setValue('meta_value_int', $qb->createNamedParameter($value, IQueryBuilder::PARAM_INT))
+ ->setValue('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT));
+ $qb->executeStatement();
+ }
+
+ /**
+ * insert a new entry in the metadata_index table for a bool value
+ *
+ * @param int $fileId file id
+ * @param string $key metadata key
+ * @param bool $value metadata value
+ *
+ * @throws DbException
+ */
+ public function insertIndexBool(int $fileId, string $key, bool $value): void {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->insert(self::TABLE_METADATA_INDEX)
+ ->setValue('meta_key', $qb->createNamedParameter($key))
+ ->setValue('meta_value_int', $qb->createNamedParameter(($value) ? '1' : '0', IQueryBuilder::PARAM_INT))
+ ->setValue('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT));
+ $qb->executeStatement();
+ }
+
+ /**
+ * insert entries in the metadata_index table for list of string
+ *
+ * @param int $fileId file id
+ * @param string $key metadata key
+ * @param string[] $values metadata values
+ *
+ * @throws DbException
+ */
+ public function insertIndexStringList(int $fileId, string $key, array $values): void {
+ foreach ($values as $value) {
+ $this->insertIndexString($fileId, $key, $value);
+ }
+ }
+
+ /**
+ * insert entries in the metadata_index table for list of int
+ *
+ * @param int $fileId file id
+ * @param string $key metadata key
+ * @param int[] $values metadata values
+ *
+ * @throws DbException
+ */
+ public function insertIndexIntList(int $fileId, string $key, array $values): void {
+ foreach ($values as $value) {
+ $this->insertIndexInt($fileId, $key, $value);
+ }
+ }
+
+ /**
+ * drop indexes related to a file id
+ * if a key is specified, only drop entries related to it
+ *
+ * @param int $fileId file id
+ * @param string $key metadata key
+ *
+ * @throws DbException
+ */
+ public function dropIndex(int $fileId, string $key = ''): void {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $expr = $qb->expr();
+ $qb->delete(self::TABLE_METADATA_INDEX)
+ ->where($expr->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
+
+ if ($key !== '') {
+ $qb->andWhere($expr->eq('meta_key', $qb->createNamedParameter($key)));
+ }
+
+ $qb->executeStatement();
+ }
+}
diff --git a/lib/private/FilesMetadata/Service/MetadataRequestService.php b/lib/private/FilesMetadata/Service/MetadataRequestService.php
new file mode 100644
index 00000000000..c308ae1c9c8
--- /dev/null
+++ b/lib/private/FilesMetadata/Service/MetadataRequestService.php
@@ -0,0 +1,192 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+namespace OC\FilesMetadata\Service;
+
+use OC\FilesMetadata\Model\FilesMetadata;
+use OCP\DB\Exception;
+use OCP\DB\QueryBuilder\IQueryBuilder;
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\Model\IFilesMetadata;
+use OCP\IDBConnection;
+use Psr\Log\LoggerInterface;
+
+/**
+ * manage sql request to the metadata table
+ */
+class MetadataRequestService {
+ public const TABLE_METADATA = 'files_metadata';
+
+ public function __construct(
+ private IDBConnection $dbConnection,
+ private LoggerInterface $logger,
+ ) {
+ }
+
+ private function getStorageId(IFilesMetadata $filesMetadata): int {
+ if ($filesMetadata instanceof FilesMetadata) {
+ $storage = $filesMetadata->getStorageId();
+ if ($storage) {
+ return $storage;
+ }
+ }
+ // all code paths that lead to saving metadata *should* have the storage id set
+ // this fallback is there just in case
+ $query = $this->dbConnection->getQueryBuilder();
+ $query->select('storage')
+ ->from('filecache')
+ ->where($query->expr()->eq('fileid', $query->createNamedParameter($filesMetadata->getFileId(), IQueryBuilder::PARAM_INT)));
+ $storageId = $query->executeQuery()->fetchColumn();
+
+ if ($filesMetadata instanceof FilesMetadata) {
+ $filesMetadata->setStorageId($storageId);
+ }
+ return $storageId;
+ }
+
+ /**
+ * store metadata into database
+ *
+ * @param IFilesMetadata $filesMetadata
+ *
+ * @throws Exception
+ */
+ public function store(IFilesMetadata $filesMetadata): void {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->insert(self::TABLE_METADATA)
+ ->hintShardKey('storage', $this->getStorageId($filesMetadata))
+ ->setValue('file_id', $qb->createNamedParameter($filesMetadata->getFileId(), IQueryBuilder::PARAM_INT))
+ ->setValue('json', $qb->createNamedParameter(json_encode($filesMetadata->jsonSerialize())))
+ ->setValue('sync_token', $qb->createNamedParameter($this->generateSyncToken()))
+ ->setValue('last_update', (string)$qb->createFunction('NOW()'));
+ $qb->executeStatement();
+ }
+
+ /**
+ * returns metadata for a file id
+ *
+ * @param int $fileId file id
+ *
+ * @return IFilesMetadata
+ * @throws FilesMetadataNotFoundException if no metadata are found in database
+ */
+ public function getMetadataFromFileId(int $fileId): IFilesMetadata {
+ try {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->select('json', 'sync_token')->from(self::TABLE_METADATA);
+ $qb->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
+ $result = $qb->executeQuery();
+ $data = $result->fetch();
+ $result->closeCursor();
+ } catch (Exception $e) {
+ $this->logger->warning('exception while getMetadataFromDatabase()', ['exception' => $e, 'fileId' => $fileId]);
+ throw new FilesMetadataNotFoundException();
+ }
+
+ if ($data === false) {
+ throw new FilesMetadataNotFoundException();
+ }
+
+ $metadata = new FilesMetadata($fileId);
+ $metadata->importFromDatabase($data);
+
+ return $metadata;
+ }
+
+ /**
+ * returns metadata for multiple file ids
+ *
+ * @param array $fileIds file ids
+ *
+ * @return array File ID is the array key, files without metadata are not returned in the array
+ * @psalm-return array<int, IFilesMetadata>
+ */
+ public function getMetadataFromFileIds(array $fileIds): array {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->select('file_id', 'json', 'sync_token')->from(self::TABLE_METADATA);
+ $qb->where($qb->expr()->in('file_id', $qb->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY)));
+
+ $list = [];
+ $result = $qb->executeQuery();
+ while ($data = $result->fetch()) {
+ $fileId = (int)$data['file_id'];
+ $metadata = new FilesMetadata($fileId);
+ try {
+ $metadata->importFromDatabase($data);
+ } catch (FilesMetadataNotFoundException) {
+ continue;
+ }
+ $list[$fileId] = $metadata;
+ }
+ $result->closeCursor();
+
+ return $list;
+ }
+
+ /**
+ * drop metadata related to a file id
+ *
+ * @param int $fileId file id
+ *
+ * @return void
+ * @throws Exception
+ */
+ public function dropMetadata(int $fileId): void {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->delete(self::TABLE_METADATA)
+ ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)));
+ $qb->executeStatement();
+ }
+
+ /**
+ * update metadata in the database
+ *
+ * @param IFilesMetadata $filesMetadata metadata
+ *
+ * @return int number of affected rows
+ * @throws Exception
+ */
+ public function updateMetadata(IFilesMetadata $filesMetadata): int {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $expr = $qb->expr();
+
+ $qb->update(self::TABLE_METADATA)
+ ->hintShardKey('files_metadata', $this->getStorageId($filesMetadata))
+ ->set('json', $qb->createNamedParameter(json_encode($filesMetadata->jsonSerialize())))
+ ->set('sync_token', $qb->createNamedParameter($this->generateSyncToken()))
+ ->set('last_update', $qb->createFunction('NOW()'))
+ ->where(
+ $expr->andX(
+ $expr->eq('file_id', $qb->createNamedParameter($filesMetadata->getFileId(), IQueryBuilder::PARAM_INT)),
+ $expr->eq('sync_token', $qb->createNamedParameter($filesMetadata->getSyncToken()))
+ )
+ );
+
+ return $qb->executeStatement();
+ }
+
+ /**
+ * generate a random token
+ * @return string
+ */
+ private function generateSyncToken(): string {
+ $chars = 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890';
+
+ $str = '';
+ $max = strlen($chars);
+ for ($i = 0; $i < 7; $i++) {
+ try {
+ $str .= $chars[random_int(0, $max - 2)];
+ } catch (\Exception $e) {
+ $this->logger->warning('exception during generateSyncToken', ['exception' => $e]);
+ }
+ }
+
+ return $str;
+ }
+}