aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/FilesMetadata
diff options
context:
space:
mode:
authorMaxence Lange <maxence@artificial-owl.com>2023-11-07 00:21:29 -0100
committerMaxence Lange <maxence@artificial-owl.com>2023-11-07 00:21:38 -0100
commite62e9e3dbf1a2573554b1a9eabbf5b59b652dae6 (patch)
tree85e9af03c9569df0dfce03b4390869c024b6f2de /lib/private/FilesMetadata
parentd4393174fcb95358f2cbaf3261253e407c6ed356 (diff)
downloadnextcloud-server-e62e9e3dbf1a2573554b1a9eabbf5b59b652dae6.tar.gz
nextcloud-server-e62e9e3dbf1a2573554b1a9eabbf5b59b652dae6.zip
IFilesMetadata
Signed-off-by: Maxence Lange <maxence@artificial-owl.com>
Diffstat (limited to 'lib/private/FilesMetadata')
-rw-r--r--lib/private/FilesMetadata/FilesMetadataManager.php280
-rw-r--r--lib/private/FilesMetadata/Job/UpdateSingleMetadata.php66
-rw-r--r--lib/private/FilesMetadata/Listener/MetadataDelete.php61
-rw-r--r--lib/private/FilesMetadata/Listener/MetadataUpdate.php61
-rw-r--r--lib/private/FilesMetadata/Model/FilesMetadata.php589
-rw-r--r--lib/private/FilesMetadata/Model/MetadataQuery.php165
-rw-r--r--lib/private/FilesMetadata/Model/MetadataValueWrapper.php397
-rw-r--r--lib/private/FilesMetadata/Service/IndexRequestService.php195
-rw-r--r--lib/private/FilesMetadata/Service/MetadataRequestService.php160
9 files changed, 1974 insertions, 0 deletions
diff --git a/lib/private/FilesMetadata/FilesMetadataManager.php b/lib/private/FilesMetadata/FilesMetadataManager.php
new file mode 100644
index 00000000000..7e941234ce3
--- /dev/null
+++ b/lib/private/FilesMetadata/FilesMetadataManager.php
@@ -0,0 +1,280 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+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\Model\MetadataQuery;
+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\Events\Node\NodeCreatedEvent;
+use OCP\Files\Events\Node\NodeDeletedEvent;
+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\Exceptions\FilesMetadataException;
+use OCP\FilesMetadata\Exceptions\FilesMetadataNotFoundException;
+use OCP\FilesMetadata\IFilesMetadataManager;
+use OCP\FilesMetadata\Model\IFilesMetadata;
+use OCP\FilesMetadata\Model\IMetadataQuery;
+use OCP\FilesMetadata\Model\IMetadataValueWrapper;
+use OCP\IConfig;
+use Psr\Log\LoggerInterface;
+
+/**
+ * @inheritDoc
+ * @since 28.0.0
+ */
+class FilesMetadataManager implements IFilesMetadataManager {
+ public const CONFIG_KEY = 'files_metadata';
+ private const JSON_MAXSIZE = 100000;
+
+ private ?IFilesMetadata $all = null;
+
+ public function __construct(
+ private IEventDispatcher $eventDispatcher,
+ private IJobList $jobList,
+ private IConfig $config,
+ 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
+ ): IFilesMetadata {
+ try {
+ $metadata = $this->metadataRequestService->getMetadataFromFileId($node->getId());
+ } catch (FilesMetadataNotFoundException) {
+ $metadata = new FilesMetadata($node->getId());
+ }
+
+ // if $process is LIVE, we enforce LIVE
+ if ((self::PROCESS_LIVE & $process) !== 0) {
+ $event = new MetadataLiveEvent($node, $metadata);
+ } 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
+ *
+ * @inheritDoc
+ * @return IFilesMetadata
+ * @throws FilesMetadataNotFoundException if not found
+ * @since 28.0.0
+ */
+ public function getMetadata(int $fileId): IFilesMetadata {
+ return $this->metadataRequestService->getMetadataFromFileId($fileId);
+ }
+
+ /**
+ * @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) {
+ throw new FilesMetadataException('json cannot exceed ' . self::JSON_MAXSIZE . ' characters long');
+ }
+
+ 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->config->setAppValue('core', self::CONFIG_KEY, json_encode($current));
+ }
+
+ /**
+ * @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->getKnownMetadata(), $fileTableAlias, $fileIdField);
+ }
+
+ /**
+ * @inheritDoc
+ * @return IFilesMetadata
+ * @since 28.0.0
+ */
+ public function getKnownMetadata(): IFilesMetadata {
+ if (null !== $this->all) {
+ return $this->all;
+ }
+ $this->all = new FilesMetadata();
+
+ try {
+ $data = json_decode($this->config->getAppValue('core', self::CONFIG_KEY, '[]'), true, 127, JSON_THROW_ON_ERROR);
+ $this->all->import($data);
+ } 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
+ *
+ * @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
+ */
+ public function initMetadataIndex(string $key, string $type): void {
+ $current = $this->getKnownMetadata();
+ try {
+ if ($current->getType($key) === $type && $current->isIndex($key)) {
+ return; // if key exists, with same type and is already indexed, we do nothing.
+ }
+ } catch (FilesMetadataNotFoundException) {
+ // if value does not exist, we keep on the writing of course
+ }
+
+ $current->import([$key => ['type' => $type, 'indexed' => true]]);
+ $this->config->setAppValue('core', self::CONFIG_KEY, json_encode($current));
+ }
+
+ /**
+ * load listeners
+ *
+ * @param IEventDispatcher $eventDispatcher
+ */
+ public static function loadListeners(IEventDispatcher $eventDispatcher): void {
+ $eventDispatcher->addServiceListener(NodeCreatedEvent::class, MetadataUpdate::class);
+ $eventDispatcher->addServiceListener(NodeWrittenEvent::class, MetadataUpdate::class);
+ $eventDispatcher->addServiceListener(NodeDeletedEvent::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..d628e468cdd
--- /dev/null
+++ b/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OC\FilesMetadata\Job;
+
+use OC\FilesMetadata\FilesMetadataManager;
+use OC\User\NoUserException;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\BackgroundJob\QueuedJob;
+use OCP\Files\IRootFolder;
+use OCP\Files\NotPermittedException;
+use OCP\FilesMetadata\Event\MetadataLiveEvent;
+use OCP\FilesMetadata\IFilesMetadataManager;
+
+/**
+ * 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,
+ ) {
+ parent::__construct($time);
+ }
+
+ protected function run($argument) {
+ [$userId, $fileId] = $argument;
+
+ try {
+ $node = $this->rootFolder->getUserFolder($userId)->getById($fileId);
+ if (count($node) > 0) {
+ $file = array_shift($node);
+ $this->filesMetadataManager->refreshMetadata($file, IFilesMetadataManager::PROCESS_BACKGROUND);
+ }
+ } catch (NotPermittedException|NoUserException $e) {
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/Listener/MetadataDelete.php b/lib/private/FilesMetadata/Listener/MetadataDelete.php
new file mode 100644
index 00000000000..7f8fd035735
--- /dev/null
+++ b/lib/private/FilesMetadata/Listener/MetadataDelete.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OC\FilesMetadata\Listener;
+
+use Exception;
+use OCP\EventDispatcher\Event;
+use OCP\EventDispatcher\IEventListener;
+use OCP\Files\Events\Node\NodeDeletedEvent;
+use OCP\FilesMetadata\IFilesMetadataManager;
+
+/**
+ * Handle file deletion event and remove stored metadata related to the deleted file
+ *
+ * @template-implements IEventListener<NodeDeletedEvent>
+ */
+class MetadataDelete implements IEventListener {
+ public function __construct(
+ private IFilesMetadataManager $filesMetadataManager,
+ ) {
+ }
+
+ /**
+ * @param Event $event
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof NodeDeletedEvent)) {
+ return;
+ }
+
+ try {
+ $nodeId = (int)$event->getNode()->getId();
+ if ($nodeId > 0) {
+ $this->filesMetadataManager->deleteMetadata($nodeId);
+ }
+ } catch (Exception $e) {
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/Listener/MetadataUpdate.php b/lib/private/FilesMetadata/Listener/MetadataUpdate.php
new file mode 100644
index 00000000000..64c8bb474b1
--- /dev/null
+++ b/lib/private/FilesMetadata/Listener/MetadataUpdate.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+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;
+
+/**
+ * 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,
+ ) {
+ }
+
+ /**
+ * @param Event $event
+ */
+ public function handle(Event $event): void {
+ if (!($event instanceof NodeCreatedEvent) && !($event instanceof NodeWrittenEvent)) {
+ return;
+ }
+
+ try {
+ $this->filesMetadataManager->refreshMetadata($event->getNode());
+ } catch (Exception $e) {
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/Model/FilesMetadata.php b/lib/private/FilesMetadata/Model/FilesMetadata.php
new file mode 100644
index 00000000000..a94c7a9b6ff
--- /dev/null
+++ b/lib/private/FilesMetadata/Model/FilesMetadata.php
@@ -0,0 +1,589 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+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 = '';
+
+ public function __construct(
+ private int $fileId = 0
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ * @return int related file id
+ * @since 28.0.0
+ */
+ public function getFileId(): int {
+ return $this->fileId;
+ }
+
+ /**
+ * @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 string metadata value
+ * @throws FilesMetadataNotFoundException
+ * @throws FilesMetadataTypeException
+ * @since 28.0.0
+ */
+ public function get(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 set(string $key, string $value, bool $index = false): IFilesMetadata {
+ $this->confirmKeyFormat($key);
+ try {
+ if ($this->get($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_STRING_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 $e) {
+ throw new FilesMetadataNotFoundException();
+ }
+ }
+}
diff --git a/lib/private/FilesMetadata/Model/MetadataQuery.php b/lib/private/FilesMetadata/Model/MetadataQuery.php
new file mode 100644
index 00000000000..9ff69e9516e
--- /dev/null
+++ b/lib/private/FilesMetadata/Model/MetadataQuery.php
@@ -0,0 +1,165 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OC\FilesMetadata\Model;
+
+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\Model\IFilesMetadata;
+use OCP\FilesMetadata\Model\IMetadataQuery;
+use OCP\FilesMetadata\Model\IMetadataValueWrapper;
+
+/**
+ * @inheritDoc
+ * @since 28.0.0
+ */
+class MetadataQuery implements IMetadataQuery {
+ private array $knownJoinedIndex = [];
+ public function __construct(
+ private IQueryBuilder $queryBuilder,
+ private IFilesMetadata $knownMetadata,
+ private string $fileTableAlias = 'fc',
+ private string $fileIdField = 'fileid',
+ private string $alias = 'meta',
+ private string $aliasIndexPrefix = 'meta_index'
+ ) {
+ }
+
+ /**
+ * @inheritDoc
+ * @see self::extractMetadata()
+ * @since 28.0.0
+ */
+ public function retrieveMetadata(): void {
+ $this->queryBuilder->selectAlias($this->alias . '.json', 'meta_json');
+ $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->rightJoin(
+ $this->fileTableAlias,
+ IndexRequestService::TABLE_METADATA_INDEX,
+ $aliasIndex,
+ $andX
+ );
+ } else {
+ $this->queryBuilder->leftJoin(
+ $this->fileTableAlias,
+ IndexRequestService::TABLE_METADATA_INDEX,
+ $aliasIndex,
+ $andX
+ );
+ }
+
+ return $aliasIndex;
+ }
+
+ /**
+ * @throws FilesMetadataNotFoundException
+ */
+ public 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 {
+ return match ($this->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/MetadataValueWrapper.php b/lib/private/FilesMetadata/Model/MetadataValueWrapper.php
new file mode 100644
index 00000000000..159cd1e6fd1
--- /dev/null
+++ b/lib/private/FilesMetadata/Model/MetadataValueWrapper.php
@@ -0,0 +1,397 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+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 bool $indexed = false;
+
+ /**
+ * @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 (null === $this->value) {
+ 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 (null === $this->value) {
+ 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 (null === $this->value) {
+ 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 (null === $this->value) {
+ 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 (null === $this->value) {
+ 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 (null === $this->value) {
+ 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 (null === $this->value) {
+ 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 (null === $this->value) {
+ throw new FilesMetadataNotFoundException('value is not set');
+ }
+
+ return $this->value;
+ }
+
+ /**
+ * @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 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->setIndexed($data['indexed'] ?? false);
+
+ return $this;
+ }
+
+ public function jsonSerialize(bool $emptyValues = false): array {
+ return [
+ 'value' => ($emptyValues) ? null : $this->value,
+ 'type' => $this->getType(),
+ 'indexed' => $this->isIndexed()
+ ];
+ }
+}
diff --git a/lib/private/FilesMetadata/Service/IndexRequestService.php b/lib/private/FilesMetadata/Service/IndexRequestService.php
new file mode 100644
index 00000000000..6530dabd1c3
--- /dev/null
+++ b/lib/private/FilesMetadata/Service/IndexRequestService.php
@@ -0,0 +1,195 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+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->get($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..85874e92d4a
--- /dev/null
+++ b/lib/private/FilesMetadata/Service/MetadataRequestService.php
@@ -0,0 +1,160 @@
+<?php
+
+declare(strict_types=1);
+/**
+ * @copyright 2023 Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @author Maxence Lange <maxence@artificial-owl.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+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
+ ) {
+ }
+
+ /**
+ * store metadata into database
+ *
+ * @param IFilesMetadata $filesMetadata
+ *
+ * @throws Exception
+ */
+ public function store(IFilesMetadata $filesMetadata): void {
+ $qb = $this->dbConnection->getQueryBuilder();
+ $qb->insert(self::TABLE_METADATA)
+ ->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;
+ }
+
+ /**
+ * 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)
+ ->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;
+ }
+}