Refactor DB Mappers

* work with tree table
 * introduce QueryParameters
 * introduce return type hints
This commit is contained in:
Marcel Klehr 2020-03-05 21:58:34 +01:00
parent b2e95bc6f3
commit f5f6f3ceca
9 changed files with 557 additions and 620 deletions

View File

@ -14,7 +14,9 @@
namespace OCA\Bookmarks\AppInfo;
use OCA\Bookmarks\Db\FolderMapper;
use OCP\AppFramework\App;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IContainer;
use OCP\IUser;
@ -34,5 +36,10 @@ class Application extends App {
$container->registerService('request', function ($c) {
return $c->query('Request');
});
$dispatcher = $this->getContainer()->query(IEventDispatcher::class);
$dispatcher->addServiceListener('\OCA\Bookmarks::onBookmarkDelete', FolderMapper::class);
$dispatcher->addServiceListener('\OCA\Bookmarks::onBookmarkUpdate', FolderMapper::class);
$dispatcher->addServiceListener('\OCA\Bookmarks::onBookmarkCreate', FolderMapper::class);
}
}

View File

@ -2,21 +2,23 @@
namespace OCA\Bookmarks\Db;
use GuzzleHttp\Query;
use OCA\Bookmarks\Exception\AlreadyExistsError;
use OCA\Bookmarks\Exception\UrlParseError;
use OCA\Bookmarks\Exception\UserLimitExceededError;
use OCA\Bookmarks\QueryParameters;
use OCA\Bookmarks\Service\UrlNormalizer;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\EventDispatcher\Event;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IDBConnection;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\GenericEvent;
use OCP\EventDispatcher\IEventDispatcher;
/**
* Class BookmarkMapper
@ -28,7 +30,7 @@ class BookmarkMapper extends QBMapper {
/** @var IConfig */
private $config;
/** @var EventDispatcherInterface */
/** @var IEventDispatcher */
private $eventDispatcher;
/** @var UrlNormalizer */
@ -58,19 +60,19 @@ class BookmarkMapper extends QBMapper {
* BookmarkMapper constructor.
*
* @param IDBConnection $db
* @param EventDispatcherInterface $eventDispatcher
* @param IEventDispatcher $eventDispatcher
* @param UrlNormalizer $urlNormalizer
* @param IConfig $config
* @param PublicFolderMapper $publicMapper
* @param TagMapper $tagMapper
* @param ICacheFactory $cacheFactory
*/
public function __construct(IDBConnection $db, EventDispatcherInterface $eventDispatcher, UrlNormalizer $urlNormalizer, IConfig $config, PublicFolderMapper $publicMapper, TagMapper $tagMapper, ICacheFactory $cacheFactory) {
public function __construct(IDBConnection $db, IEventDispatcher $eventDispatcher, UrlNormalizer $urlNormalizer, IConfig $config, PublicFolderMapper $publicMapper, TagMapper $tagMapper, ICacheFactory $cacheFactory) {
parent::__construct($db, 'bookmarks', Bookmark::class);
$this->eventDispatcher = $eventDispatcher;
$this->urlNormalizer = $urlNormalizer;
$this->config = $config;
$this->limit = intval($config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', 0));
$this->limit = (int)$config->getAppValue('bookmarks', 'performance.maxBookmarksperAccount', 0);
$this->publicMapper = $publicMapper;
$this->tagMapper = $tagMapper;
$this->cache = $cacheFactory->createLocal('bookmarks:hashes');
@ -117,15 +119,12 @@ class BookmarkMapper extends QBMapper {
/**
* @param $userId
* @param array $filters
* @param string $conjunction
* @param string $sortBy
* @param int $offset
* @param int $limit
* @param QueryParameters $params
* @return array|Entity[]
*/
public function findAll($userId, array $filters, string $conjunction = 'and', string $sortBy = 'lastmodified', int $offset = 0, int $limit = -1) {
public function findAll($userId, array $filters, QueryParameters $params): array {
$qb = $this->db->getQueryBuilder();
$bookmark_cols = array_map(function ($c) {
$bookmark_cols = array_map(static function ($c) {
return 'b.' . $c;
}, Bookmark::$columns);
@ -143,18 +142,33 @@ class BookmarkMapper extends QBMapper {
->orWhere($qb->expr()->eq('p.user_id', $qb->createPositionalParameter($userId)));
$this->_findBookmarksBuildFilter($qb, $filters, $conjunction);
$this->_queryBuilderSortAndPaginate($qb, $sortBy, $offset, $limit);
$this->_findBookmarksBuildFilter($qb, $filters, $params);
$this->_queryBuilderSortAndPaginate($qb, $params);
return $this->findEntities($qb);
}
private function _queryBuilderSortAndPaginate(IQueryBuilder $qb, string $sortBy = 'lastmodified', int $offset = 0, int $limit = -1) {
if (!in_array($sortBy, Bookmark::$columns)) {
$sqlSortColumn = 'lastmodified';
} else {
$sqlSortColumn = $sortBy;
}
/**
* @param $userId
* @return int
*/
public function countAll($userId): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('b.id'));
// Finds bookmarks in 2-levels nested shares only
$qb
->from('bookmarks', 'b')
->where($qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId)));
$count = $qb->execute()->fetch(\PDO::FETCH_COLUMN)[0];
return (int) $count;
}
private function _queryBuilderSortAndPaginate(IQueryBuilder $qb, QueryParameters $params): void {
$sqlSortColumn = $params->getSortBy('lastmodified', Bookmark::$columns);
if ($sqlSortColumn === 'title') {
$qb->orderBy($qb->createFunction('UPPER(`title`)'), 'ASC');
@ -162,36 +176,35 @@ class BookmarkMapper extends QBMapper {
$qb->orderBy($sqlSortColumn, 'DESC');
}
if ($limit !== -1) {
$qb->setMaxResults($limit);
if ($params->getLimit() !== -1) {
$qb->setMaxResults($params->getLimit());
}
if ($offset !== 0) {
$qb->setFirstResult($offset);
if ($params->getOffset() !== 0) {
$qb->setFirstResult($params->getOffset());
}
}
/**
* @param IQueryBuilder $qb
* @param array $filters
* @param string $tagFilterConjunction
* @param QueryParameters $params
*/
private function _findBookmarksBuildFilter(&$qb, $filters, $tagFilterConjunction) {
private function _findBookmarksBuildFilter(&$qb, $filters, QueryParameters $params): void {
$dbType = $this->config->getSystemValue('dbtype', 'sqlite');
$connectWord = 'AND';
if ($tagFilterConjunction === 'or') {
if ($params->getConjunction() === 'or') {
$connectWord = 'OR';
}
if (count($filters) === 0) {
return;
}
if ($dbType === 'pgsql') {
$tags = $qb->createFunction("array_to_string(array_agg(" . $qb->getColumnName('t.tag') . "), ',')");
$tags = $qb->createFunction('array_to_string(array_agg(' . $qb->getColumnName('t.tag') . "), ',')");
} else {
$tags = $qb->createFunction('GROUP_CONCAT(' . $qb->getColumnName('t.tag') . ')');
}
$filterExpressions = [];
$otherColumns = ['b.url', 'b.title', 'b.description'];
$i = 0;
foreach ($filters as $filter) {
$expr = [];
$expr[] = $qb->expr()->iLike($tags, $qb->createPositionalParameter('%' . $this->db->escapeLikeParameter($filter) . '%'));
@ -202,7 +215,6 @@ class BookmarkMapper extends QBMapper {
);
}
$filterExpressions[] = call_user_func_array([$qb->expr(), 'orX'], $expr);
$i++;
}
if ($connectWord === 'AND') {
$filterExpression = call_user_func_array([$qb->expr(), 'andX'], $filterExpressions);
@ -215,12 +227,10 @@ class BookmarkMapper extends QBMapper {
/**
* @param string $userId
* @param string $tag
* @param string $sortBy
* @param int $offset
* @param int $limit
* @param QueryParameters $params
* @return array|Entity[]
*/
public function findByTag($userId, string $tag, string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
public function findByTag($userId, string $tag, QueryParameters $params): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Bookmark::$columns);
@ -230,21 +240,12 @@ class BookmarkMapper extends QBMapper {
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId)))
->andWhere($qb->expr()->eq('t.tag', $qb->createPositionalParameter($tag)));
$this->_queryBuilderSortAndPaginate($qb, $sortBy, $offset, $limit);
$this->_queryBuilderSortAndPaginate($qb, $params);
return $this->findEntities($qb);
}
/**
* @param string $userId
* @param array $tags
* @param string $sortBy
* @param int $offset
* @param int $limit
* @return array|Entity[]
*/
public function findByTags($userId, array $tags, string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
$dbType = $this->config->getSystemValue('dbtype', 'sqlite');
private function _findByTags($userId): IQueryBuilder {
$qb = $this->db->getQueryBuilder();
$qb->select(Bookmark::$columns);
@ -253,8 +254,21 @@ class BookmarkMapper extends QBMapper {
->leftJoin('b', 'bookmarks_tags', 't', $qb->expr()->eq('t.bookmark_id', 'b.id'))
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId)));
return $qb;
}
/**
* @param string $userId
* @param array $tags
* @param QueryParameters $params
* @return array|Entity[]
*/
public function findByTags($userId, array $tags, QueryParameters $params): array {
$qb = $this->_findByTags($userId);
$dbType = $this->config->getSystemValue('dbtype', 'sqlite');
if ($dbType === 'pgsql') {
$tagsCol = $qb->createFunction("array_to_string(array_agg(" . $qb->getColumnName('t.tag') . "), ',')");
$tagsCol = $qb->createFunction('array_to_string(array_agg(' . $qb->getColumnName('t.tag') . "), ',')");
} else {
$tagsCol = $qb->createFunction('GROUP_CONCAT(' . $qb->getColumnName('t.tag') . ')');
}
@ -267,38 +281,30 @@ class BookmarkMapper extends QBMapper {
$qb->groupBy(...Bookmark::$columns);
$qb->having($filterExpression);
$this->_queryBuilderSortAndPaginate($qb, $sortBy, $offset, $limit);
$this->_queryBuilderSortAndPaginate($qb, $params);
return $this->findEntities($qb);
}
/**
* @param $userId
* @param string $sortBy
* @param int $offset
* @param int $limit
* @param QueryParameters $params
* @return array|Entity[]
*/
public function findUntagged($userId, string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
public function findUntagged($userId, QueryParameters $params): array {
$qb = $this->_findByTags($userId);
$dbType = $this->config->getSystemValue('dbtype', 'sqlite');
$qb = $this->db->getQueryBuilder();
$qb->select(Bookmark::$columns);
$qb
->from('bookmarks', 'b')
->leftJoin('b', 'bookmarks_tags', 't', $qb->expr()->eq('t.bookmark_id', 'b.id'))
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId)));
if ($dbType === 'pgsql') {
$tagsCol = $qb->createFunction("array_to_string(array_agg(" . $qb->getColumnName('t.tag') . "), ',')");
$tagsCol = $qb->createFunction('array_to_string(array_agg(' . $qb->getColumnName('t.tag') . "), ',')");
} else {
$tagsCol = $qb->createFunction('GROUP_CONCAT(' . $qb->getColumnName('t.tag') . ')');
}
$qb->groupBy(...Bookmark::$columns);
$qb->having($qb->expr()->eql($tagsCol, $qb->createPositionalParameter('')));
$qb->having($qb->expr()->eq($tagsCol, $qb->createPositionalParameter('')));
$this->_queryBuilderSortAndPaginate($qb, $sortBy, $offset, $limit);
$this->_queryBuilderSortAndPaginate($qb, $params);
return $this->findEntities($qb);
}
@ -306,32 +312,27 @@ class BookmarkMapper extends QBMapper {
*
* @param $token
* @param array $filters
* @param string $conjunction
* @param string $sortBy
* @param int $offset
* @param int $limit
* @param QueryParameters $params
* @return array|Entity[]
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function findAllInPublicFolder($token, array $filters, string $conjunction = 'and', string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
public function findAllInPublicFolder($token, array $filters, QueryParameters $params): array {
$publicFolder = $this->publicMapper->find($token);
$bookmarks = $this->findByFolder($publicFolder->getFolderId(), $sortBy, $offset, $limit);
$bookmarks = $this->findByFolder($publicFolder->getFolderId(), $params);
// Really inefficient, but what can you do.
return array_filter($bookmarks, function ($bookmark) use ($filters, $conjunction) {
return array_filter($bookmarks, function (Bookmark $bookmark) use ($filters, $params) {
$tagsFound = $this->tagMapper->findByBookmark($bookmark->getId());
return array_reduce($filters, function ($isMatch, $filter) use ($bookmark, $tagsFound, $conjunction) {
return array_reduce($filters, static function ($isMatch, $filter) use ($bookmark, $tagsFound, $params) {
$filter = strtolower($filter);
$res = in_array($filter, $tagsFound)
$res = in_array($filter, $tagsFound, true)
|| str_contains($filter, strtolower($bookmark->getTitle()))
|| str_contains($filter, strtolower($bookmark->getDescription()))
|| str_contains($filter, strtolower($bookmark->getUrl()));
return $conjunction === 'and' ? $res && $isMatch : $res || $isMatch;
}, $conjunction === 'and' ? true : false);
return $params->getConjunction() === 'and' ? $res && $isMatch : $res || $isMatch;
}, $params->getConjunction() === 'and');
});
}
@ -339,23 +340,20 @@ class BookmarkMapper extends QBMapper {
*
* @param $token
* @param array $tags
* @param string $sortBy
* @param int $offset
* @param int $limit
* @param QueryParameters $params
* @return array|Entity[]
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function findByTagsInPublicFolder($token, array $tags = [], string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
public function findByTagsInPublicFolder($token, array $tags, QueryParameters $params): array {
$publicFolder = $this->publicMapper->find($token);
$bookmarks = $this->findByFolder($publicFolder->getFolderId(), $sortBy, $offset, $limit);
$bookmarks = $this->findByFolder($publicFolder->getFolderId(), $params);
// Really inefficient, but what can you do.
return array_filter($bookmarks, function ($bookmark) use ($tags) {
return array_filter($bookmarks, function (Bookmark $bookmark) use ($tags) {
$tagsFound = $this->tagMapper->findByBookmark($bookmark->getId());
return array_reduce($tags, function ($isFound, $tag) use ($tagsFound) {
return in_array($tag, $tagsFound) && $isFound;
return array_reduce($tags, static function ($isFound, $tag) use ($tagsFound) {
return in_array($tag, $tagsFound, true) && $isFound;
}, true);
});
}
@ -363,20 +361,17 @@ class BookmarkMapper extends QBMapper {
/**
*
* @param $token
* @param string $sortBy
* @param int $offset
* @param int $limit
* @param QueryParameters $params
* @return array|Entity[]
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function findUntaggedInPublicFolder($token, string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
public function findUntaggedInPublicFolder($token, QueryParameters $params): array {
$publicFolder = $this->publicMapper->find($token);
$bookmarks = $this->findByFolder($publicFolder->getFolderId(), $sortBy, $offset, $limit);
$bookmarks = $this->findByFolder($publicFolder->getFolderId(), $params);
// Really inefficient, but what can you do.
return array_filter($bookmarks, function ($bookmark) {
return array_filter($bookmarks, function (Bookmark $bookmark) {
$tags = $this->tagMapper->findByBookmark($bookmark->getId());
return count($tags) === 0;
});
@ -384,53 +379,17 @@ class BookmarkMapper extends QBMapper {
/**
* @param $userId
* @param int $folderId
* @param string $sortBy
* @param int $offset
* @param int $limit
* @param QueryParameters $params
* @return array|Entity[]
*/
public function findByUserFolder($userId, int $folderId, string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
if ($folderId !== -1) {
return $this->findByFolder($folderId, $sortBy, $offset, $limit);
} else {
return $this->findByRootFolder($userId, $sortBy, $offset, $limit);
}
}
/**
* @param int $folderId
* @param string $sortBy
* @param int $offset
* @param int $limit
* @return array|Entity[]
*/
public function findByFolder(int $folderId, string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
public function findByFolder(int $folderId, QueryParameters $params): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Bookmark::$columns)
->from('bookmarks', 'b')
->leftJoin('b', 'bookmarks_folders_bookmarks', 'f', $qb->expr()->eq('f.bookmark_id', 'b.id'))
->where($qb->expr()->eq('f.folder_id', $qb->createPositionalParameter($folderId)));
$this->_queryBuilderSortAndPaginate($qb, $sortBy, $offset, $limit);
return $this->findEntities($qb);
}
/**
* @param $userId
* @param string $sortBy
* @param int $offset
* @param int $limit
* @return array|Entity[]
*/
public function findByRootFolder($userId, string $sortBy = 'lastmodified', int $offset = 0, int $limit = 10) {
$qb = $this->db->getQueryBuilder();
$qb->select(Bookmark::$columns)
->from('bookmarks', 'b')
->leftJoin('b', 'bookmarks_folders_bookmarks', 'f', $qb->expr()->eq('f.bookmark_id', 'b.id'))
->where($qb->expr()->eq('f.folder_id', $qb->createPositionalParameter(-1)))
->andWhere($qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId)));
$this->_queryBuilderSortAndPaginate($qb, $sortBy, $offset, $limit);
$this->_queryBuilderSortAndPaginate($qb, $params);
return $this->findEntities($qb);
}
@ -439,7 +398,7 @@ class BookmarkMapper extends QBMapper {
* @param int $stalePeriod
* @return array|Entity[]
*/
public function findPendingPreviews(int $limit, int $stalePeriod) {
public function findPendingPreviews(int $limit, int $stalePeriod): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*');
$qb->from('bookmarks', 'b');
@ -457,7 +416,6 @@ class BookmarkMapper extends QBMapper {
$returnedEntity = parent::delete($entity);
$id = $entity->getId();
$userId = $entity->getUserId();
$qb = $this->db->getQueryBuilder();
$qb
@ -473,9 +431,11 @@ class BookmarkMapper extends QBMapper {
$this->eventDispatcher->dispatch(
'\OCA\Bookmarks::onBookmarkDelete',
new GenericEvent(null, ['id' => $id, 'userId' => $userId])
new Event($entity)
);
$this->invalidateCache($entity->getId());
return $returnedEntity;
}
@ -486,10 +446,7 @@ class BookmarkMapper extends QBMapper {
*/
public function update(Entity $entity): Entity {
// normalize url
$entity->setUrl($this->urlNormalizer->normalize($entity->getUrl()));
$entity->setLastmodified(time());
$newEntity = parent::update($entity);
@ -497,9 +454,11 @@ class BookmarkMapper extends QBMapper {
// trigger event
$this->eventDispatcher->dispatch(
'\OCA\Bookmarks::onBookmarkUpdate',
new GenericEvent(null, ['id' => $entity->getId(), 'userId' => $entity->getUserId()])
new Event($entity)
);
$this->invalidateCache($entity->getId());
return $newEntity;
}
@ -512,23 +471,19 @@ class BookmarkMapper extends QBMapper {
*/
public function insert(Entity $entity): Entity {
// Enforce user limit
if ($this->limit > 0 && $this->limit <= count($this->findAll($entity->getUserId(), []))) {
throw new UserLimitExceededError();
if ($this->limit > 0 && $this->limit <= $this->countAll($entity->getUserId())) {
throw new UserLimitExceededError('Exceeded user limit of ' . $this->limit . ' bookmarks');
}
// normalize url
$entity->setUrl($this->urlNormalizer->normalize($entity->getUrl()));
if ($entity->getAdded() === null) $entity->setAdded(time());
if ($entity->getAdded() === null) {
$entity->setAdded(time());
}
$entity->setLastmodified(time());
$entity->setAdded(time());
$entity->setLastPreview(0);
$entity->setClickcount(0);
$exists = true;
@ -547,10 +502,10 @@ class BookmarkMapper extends QBMapper {
parent::insert($entity);
$this->invalidateCache($entity->getId());
$this->eventDispatcher->dispatch(
'\OCA\Bookmarks::onBookmarkCreate',
new GenericEvent(null, ['id' => $entity->getId(), 'userId' => $entity->getUserId()])
new Event($entity->getId())
);
return $entity;
}
@ -589,11 +544,11 @@ class BookmarkMapper extends QBMapper {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function hash(int $bookmarkId, array $fields) {
public function hash(int $bookmarkId, array $fields): string {
$key = $this->getCacheKey($bookmarkId);
$hash = $this->cache->get($key);
$selector = implode(',', $fields);
if (isset($hash) && isset($hash[$selector])) {
if (isset($hash[$selector])) {
return $hash[$selector];
}
if (!isset($hash)) {
@ -608,7 +563,7 @@ class BookmarkMapper extends QBMapper {
}
}
$hash[$selector] = hash('sha256', json_encode($bookmark, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->cache->set($key, $hash, 60*60*24);
$this->cache->set($key, $hash, 60 * 60 * 24);
return $hash[$selector];
}
@ -616,14 +571,14 @@ class BookmarkMapper extends QBMapper {
* @param int $bookmarkId
* @return string
*/
private function getCacheKey(int $bookmarkId) {
private function getCacheKey(int $bookmarkId): string {
return 'bm:' . $bookmarkId;
}
/**
* @param int $bookmarkId
*/
public function invalidateCache(int $bookmarkId) {
public function invalidateCache(int $bookmarkId): void {
$key = $this->getCacheKey($bookmarkId);
$this->cache->remove($key);
}

View File

@ -3,7 +3,7 @@
namespace OCA\Bookmarks\Db;
use OCA\Bookmarks\Exception\ChildrenOrderValidationError;
use OCA\Bookmarks\Exception\UnauthorizedAccessError;
use OCA\Bookmarks\Exception\UnsupportedOperation;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
@ -12,16 +12,19 @@ use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IDBConnection;
use UnexpectedValueException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/**
* Class FolderMapper
*
* @package OCA\Bookmarks\Db
*/
class FolderMapper extends QBMapper {
class FolderMapper extends QBMapper implements IEventListener {
const TYPE_BOOKMARK = 'bookmark';
const TYPE_FOLDER = 'folder';
public const TYPE_SHARE = 'share';
public const TYPE_FOLDER = 'folder';
public const TYPE_BOOKMARK = 'bookmark';
/**
* @var BookmarkMapper
@ -44,6 +47,8 @@ class FolderMapper extends QBMapper {
*/
protected $cache;
protected $cachedFolders;
/**
* FolderMapper constructor.
*
@ -59,6 +64,7 @@ class FolderMapper extends QBMapper {
$this->shareMapper = $shareMapper;
$this->sharedFolderMapper = $sharedFolderMapper;
$this->cache = $cacheFactory->createLocal('bookmarks:hashes');
$this->cachedFolders = [];
}
/**
@ -68,75 +74,80 @@ class FolderMapper extends QBMapper {
* @throws MultipleObjectsReturnedException if more than one result
*/
public function find(int $id): Entity {
if (isset($this->cachedFolders[$id]) && $this->cachedFolders[$id] !== null) {
return $this->cachedFolders[$id];
}
$qb = $this->db->getQueryBuilder();
$qb
->select('*')
->from('bookmarks_folders')
->where($qb->expr()->eq('id', $qb->createNamedParameter($id)));
$this->cachedFolders[$id] = $this->findEntity($qb);
return $this->cachedFolders[$id];
}
/**
* @param $userId
* @return Entity
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function findRootFolder($userId): Entity {
$qb = $this->db->getQueryBuilder();
$qb
->select(Folder::$columns)
->from('bookmarks_folders', 'f')
->join('f', 'bookmarks_root_folders', 't', $qb->expr()->eq('id', 'folder_id'))
->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId)));
return $this->findEntity($qb);
}
/**
* @param $userId
* @param int $folderId
* @return array|Entity[]
*/
public function findChildFolders(int $folderId): array {
$qb = $this->db->getQueryBuilder();
$qb
->select(Folder::$columns)
->from('bookmarks_folders', 'f')
->join('f', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 'f.id'))
->where($qb->expr()->eq('t.parent_folder', $qb->createPositionalParameter($folderId)))
->andWhere($qb->expr()->eq('t.type', self::TYPE_FOLDER))
->orderBy('t.index', 'ASC');
return $this->findEntities($qb);
}
/**
* @param int $folderId
* @return Entity
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws UnauthorizedAccessError
*/
public function findByUserFolder($userId, int $folderId) {
if ($folderId !== -1) {
if ($this->find($folderId) !== $userId) {
throw new UnauthorizedAccessError();
}
return $this->findByParentFolder($folderId);
} else {
return $this->findByRootFolder($userId);
}
}
/**
* @param int $folderId
* @return array|Entity[]
*/
public function findByParentFolder(int $folderId) {
public function findParentOfFolder(int $folderId): Entity {
$qb = $this->db->getQueryBuilder();
$qb
->select('*')
->from('bookmarks_folders')
->where($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId)))
->orderBy('title', 'DESC');
return $this->findEntities($qb);
}
/**
* @param $userId
* @return array|Entity[]
*/
public function findByRootFolder($userId) {
$qb = $this->db->getQueryBuilder();
$qb
->select('*')
->from('bookmarks_folders')
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId)))
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter(-1)))
->orderBy('title', 'DESC');
return $this->findEntities($qb);
->select(Folder::$columns)
->from('bookmarks_folders', 'f')
->join('f', 'bookmarks_tree', 't', $qb->expr()->eq('t.parent_folder', 'f.id'))
->where($qb->expr()->eq('t.id', $qb->createPositionalParameter($folderId)))
->andWhere($qb->expr()->eq('type', self::TYPE_FOLDER));
return $this->findEntity($qb);
}
/**
* @param $folderId
* @return array|Entity[]
*/
public function findByAncestorFolder($folderId) {
public function findByAncestorFolder($folderId): array {
$descendants = [];
$newDescendants = $this->findByParentFolder($folderId);
$newDescendants = $this->findChildFolders($folderId);
do {
$newDescendants = array_flatten(array_map(function ($descendant) {
return $this->findByParentFolder($descendant);
return $this->findChildFolders($descendant);
}, $newDescendants));
array_push($descendants, $newDescendants);
$descendants[] = $newDescendants;
} while (count($newDescendants) > 0);
return $descendants;
}
@ -145,46 +156,34 @@ class FolderMapper extends QBMapper {
* @param $folderId
* @param $descendantFolderId
* @return bool
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function hasDescendantFolder($folderId, $descendantFolderId) {
$descendant = $this->find($descendantFolderId);
public function hasDescendantFolder($folderId, $descendantFolderId): bool {
do {
$descendant = $this->find($descendant->getParentFolder());
} while ($descendant->getId() !== $folderId && $descendant->getParentFolder() !== -1);
return ($descendant->getId() === $folderId);
try {
$descendant = $this->findParentOfFolder($descendantFolderId);
} catch (DoesNotExistException $e) {
return false;
}
} while ($descendant->getId() !== $folderId);
return true;
}
/**
* @param int $bookmarkId
* @return array|Entity[]
*/
public function findByBookmark(int $bookmarkId) {
public function findParentsOfBookmark(int $bookmarkId): array {
$qb = $this->db->getQueryBuilder();
$qb->select(Folder::$columns);
$qb
->from('bookmarks_folders', 'f')
->innerJoin('f', 'bookmarks_folders_bookmarks', 'b', $qb->expr()->eq('b.folder_id', 'f.id'))
->where($qb->expr()->eq('b.bookmark_id', $qb->createPositionalParameter($bookmarkId)));
->join('f', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 'f.id'))
->where($qb->expr()->eq('t.id', $qb->createPositionalParameter($bookmarkId)))
->andWhere($qb->expr()->eq('t.type', self::TYPE_BOOKMARK));
$entities = $this->findEntities($qb);
$qb = $this->db->getQueryBuilder();
$qb->select('*');
$qb
->from('bookmarks_folders_bookmarks')
->where($qb->expr()->eq('bookmark_id', $qb->createPositionalParameter($bookmarkId)))
->andWhere($qb->expr()->eq('folder_id', $qb->createPositionalParameter(-1)));
if ($qb->execute()->fetch()) {
$root = new Folder();
$root->setId(-1);
array_push($entities, $root);
}
return $entities;
return $this->findEntities($qb);
}
/**
@ -192,20 +191,20 @@ class FolderMapper extends QBMapper {
* @param $descendantBookmarkId
* @return bool
*/
public function hasDescendantBookmark($folderId, $descendantBookmarkId) {
$newAncestors = $this->findByBookmark($descendantBookmarkId);
do {
foreach ($newAncestors as $ancestor) {
if ($ancestor->getId() === $folderId) {
public function hasDescendantBookmark($folderId, $descendantBookmarkId): bool {
$newAncestors = $this->findParentsOfBookmark($descendantBookmarkId);
foreach ($newAncestors as $ancestor) {
if ($ancestor->getId() === $folderId) {
return true;
}
try {
if ($this->hasDescendantFolder($folderId, $ancestor->getId())) {
return true;
}
} catch (MultipleObjectsReturnedException $e) {
continue;
}
$newAncestors = array_map(function ($ancestor) {
return $this->find($ancestor->getParentFolder());
}, array_filter($newAncestors, function ($ancestor) {
return $ancestor->getParentFolder() !== -1 && $ancestor->getId() !== -1;
}));
} while (count($newAncestors) > 0);
}
return false;
}
@ -214,86 +213,75 @@ class FolderMapper extends QBMapper {
* @return Entity
*/
public function delete(Entity $entity): Entity {
$childFolders = $this->findByParentFolder($entity->id);
$childFolders = $this->findChildFolders($entity->getId());
foreach ($childFolders as $folder) {
$this->delete($folder);
}
$childBookmarks = $this->bookmarkMapper->findByFolder($entity->id);
foreach ($childBookmarks as $bookmark) {
$this->bookmarkMapper->delete($bookmark);
$qb = $this->db->getQueryBuilder();
$qb
->select(array_merge(Bookmark::$columns, [$qb->func()->count('t2.parent_folder', 'parent_count')]))
->from('bookmarks', 'b')
->join('b', 'bookmarks_tree', 't1', $qb->expr()->eq('t1.id', 'b.id'))
->join('t', 'bookmarks_tree', 't2', $qb->expr()->eq('t1.id', 't2.id'))
->where($qb->expr()->eq('t2.parent_folder', $qb->createPositionalParameter($entity->getId())))
->andWhere($qb->expr()->eq('t1.type', self::TYPE_BOOKMARK))
->andWhere($qb->expr()->eq('t1.type', self::TYPE_BOOKMARK))
->andWhere($qb->expr()->eq('t2.type', self::TYPE_BOOKMARK))
->andWhere($qb->expr()->lte('parent_count', 1));
$bookmarks = $qb->execute();
foreach ($bookmarks as $bookmarkId) {
try {
$this->bookmarkMapper->delete($this->bookmarkMapper->find($bookmarkId));
} catch (DoesNotExistException $e) {
continue;
} catch (MultipleObjectsReturnedException $e) {
continue;
}
}
$qb = $this->db->getQueryBuilder();
$qb
->delete('bookmarks_tree', 't')
->where($qb->expr()->eq('t.parent_folder', $qb->createPositionalParameter($entity->getId())))
->andWhere($qb->expr()->eq('t.type', self::TYPE_BOOKMARK));
$qb->execute();
$this->cachedFolders[$entity->getId()] = null;
$this->invalidateCache($entity->getUserId(), $entity->getId());
return parent::delete($entity);
}
/**
* @param $userId
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function deleteAll($userId) {
$childFolders = $this->findByRootFolder($userId);
foreach ($childFolders as $folder) {
$this->delete($folder);
}
$childBookmarks = $this->bookmarkMapper->findByRootFolder($userId);
foreach ($childBookmarks as $bookmark) {
$this->bookmarkMapper->delete($bookmark);
}
$rootFolder = $this->findRootFolder($userId);
$this->delete($rootFolder);
}
/**
* @param Entity $entity
* @return Entity
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function update(Entity $entity): Entity {
if ($entity->getParentFolder() !== -1) {
$this->find($entity->getParentFolder());
}
$this->cachedFolders[$entity->getId()] = $entity;
$this->invalidateCache($entity->getUserId(), $entity->getId());
return parent::update($entity);
}
/**
* @param Entity $entity
* @return Entity
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function insertOrUpdate(Entity $entity): Entity {
if ($entity->getParentFolder() !== -1) {
$this->find($entity->getParentFolder());
}
return parent::insertOrUpdate($entity);
}
/**
* @param Entity $entity
* @return Entity
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function insert(Entity $entity): Entity {
if ($entity->getParentFolder() !== -1) {
$this->find($entity->getParentFolder());
}
return parent::insert($entity);
}
/**
* @brief Lists bookmark folders' child folders (helper)
* @param $userId
* @param int $folderId
* @param array $newChildrenOrder
* @return void
* @throws ChildrenOrderValidationError
*/
public function setUserFolderChildren($userId, int $folderId, array $newChildrenOrder) {
if ($folderId !== -1) {
$this->setChildren($folderId, $newChildrenOrder);
return;
} else {
$this->setRootChildren($userId, $newChildrenOrder);
return;
}
parent::insert($entity);
$this->cachedFolders[$entity->getId()] = $entity;
$this->invalidateCache($entity->getUserId(), $entity->getId());
return $entity;
}
/**
@ -303,143 +291,62 @@ class FolderMapper extends QBMapper {
* @return void
* @throws ChildrenOrderValidationError
*/
public function setChildren(int $folderId, array $newChildrenOrder) {
public function setChildren(int $folderId, array $newChildrenOrder): void {
try {
$folder = $this->find($folderId);
} catch (DoesNotExistException $e) {
throw new ChildrenOrderValidationError();
throw new ChildrenOrderValidationError('Folder not found');
} catch (MultipleObjectsReturnedException $e) {
throw new ChildrenOrderValidationError();
throw new ChildrenOrderValidationError('Multiple folders found');
}
$existingChildren = $this->getChildren($folderId);
foreach ($existingChildren as $child) {
if (!in_array($child, $newChildrenOrder)) {
throw new ChildrenOrderValidationError();
if (!in_array($child, $newChildrenOrder, true)) {
throw new ChildrenOrderValidationError('A child is missing');
}
if (!isset($child['id'], $child['type'])) {
throw new ChildrenOrderValidationError();
throw new ChildrenOrderValidationError('A child item is missing properties');
}
}
if (count($newChildrenOrder) !== count($existingChildren)) {
throw new ChildrenOrderValidationError();
throw new ChildrenOrderValidationError('To many children');
}
foreach ($newChildrenOrder as $i => $child) {
switch ($child['type']) {
case'bookmark':
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_folders_bookmarks')
->set('index', $qb->createPositionalParameter($i))
->where($qb->expr()->eq('bookmark_id', $qb->createPositionalParameter($child['id'])))
->andWhere($qb->expr()->eq('folder_id', $qb->createPositionalParameter($folderId)));
$qb->execute();
break;
case 'folder':
try {
$childFolder = $this->find($child['id']);
} catch (DoesNotExistException $e) {
throw new ChildrenOrderValidationError();
} catch (MultipleObjectsReturnedException $e) {
throw new ChildrenOrderValidationError();
}
if ($childFolder->getUserId() !== $folder->getUserId()) {
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_shared')
->innerJoin('p', 'bookmarks_shares', 's', 's.id = p.share_id')
->set('index', $qb->createPositionalParameter($i))
->where($qb->expr()->eq('s.folder_id', $qb->createPositionalParameter($child['id'])))
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter(-1)));
$qb->execute();
break;
}
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_folders')
->set('index', $qb->createPositionalParameter($i))
->where($qb->expr()->eq('id', $qb->createPositionalParameter($child['id'])))
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId)));
$qb->execute();
break;
}
}
}
/**
* @brief Lists bookmark folders' child folders (helper)
* @param $userId
* @param array $newChildrenOrder
* @return void
* @throws ChildrenOrderValidationError
*/
public function setRootChildren($userId, array $newChildrenOrder) {
$existingChildren = $this->getRootChildren($userId);
foreach ($existingChildren as $child) {
if (!in_array($child, $newChildrenOrder)) {
throw new ChildrenOrderValidationError();
}
if (!isset($child['id'], $child['type'])) {
throw new ChildrenOrderValidationError();
}
}
if (count($newChildrenOrder) !== count($existingChildren)) {
throw new ChildrenOrderValidationError();
}
foreach ($newChildrenOrder as $i => $child) {
switch ($child['type']) {
case'bookmark':
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_folders_bookmarks')
->set('index', $qb->createPositionalParameter($i))
->where($qb->expr()->eq('bookmark_id', $qb->createPositionalParameter($child['id'])))
->andWhere($qb->expr()->eq('folder_id', $qb->createPositionalParameter(-1)));
$qb->execute();
break;
case 'folder':
try {
$folder = $this->find($child['id']);
} catch (DoesNotExistException $e) {
throw new ChildrenOrderValidationError();
} catch (MultipleObjectsReturnedException $e) {
throw new ChildrenOrderValidationError();
}
if ($folder->getUserId() !== $userId) {
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_shared')
->innerJoin('p', 'bookmarks_shares', 's', 's.id = p.share_id')
->set('index', $qb->createPositionalParameter($i))
->where($qb->expr()->eq('s.folder_id', $qb->createPositionalParameter($child['id'])))
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter(-1)));
$qb->execute();
break;
}
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_folders')
->set('index', $qb->createPositionalParameter($i))
->where($qb->expr()->eq('id', $qb->createPositionalParameter($child['id'])))
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter(-1)));
$qb->execute();
break;
}
}
}
$qb = $this->db->getQueryBuilder();
$qb
->select('folder_id', 'share_id')
->from('bookmarks_shares', 's')
->innerJoin('s', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 's.id'))
->where($qb->expr()->eq('t.parent_folder', $qb->createPositionalParameter($folderId)))
->where($qb->expr()->eq('t.type', self::TYPE_SHARE))
->orderBy('t.index', 'ASC');
$childShares = $qb->execute()->fetchAll();
/**
* @brief Lists bookmark folders' child folders (helper)
* @param $userId
* @param int $folderId
* @param int $layers
* @return array
*/
public function getUserFolderChildren($userId, int $folderId, int $layers) {
if ($folderId !== -1) {
return $this->getChildren($folderId, $layers);
} else {
return $this->getRootChildren($userId, $layers);
$foldersToShares = array_reduce($childShares, static function ($dict, $shareRec) {
$dict[$shareRec['folder_id']] = $shareRec['share_id'];
return $dict;
}, []);
foreach ($newChildrenOrder as $i => $child) {
if (!in_array($child['type'], [self::TYPE_FOLDER, self::TYPE_BOOKMARK], true)) {
continue;
}
if (($child['type'] === self::TYPE_FOLDER) && isset($foldersToShares[$child['id']])) {
$child['type'] = self::TYPE_SHARE;
$child['id'] = $foldersToShares[$child['id']];
}
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_tree')
->set('index', $qb->createPositionalParameter($i))
->where($qb->expr()->eq('id', $qb->createPositionalParameter($child['id'])))
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId)))
->andWhere($qb->expr()->eq('type', $child['type']));
$qb->execute();
}
$this->invalidateCache($folder->getUserId(), $folderId);
}
/**
@ -448,96 +355,41 @@ class FolderMapper extends QBMapper {
* @param int $layers The amount of levels to return
* @return array the children each in the format ["id" => int, "type" => 'bookmark' | 'folder' ]
*/
public function getChildren($folderId, $layers = 1) {
public function getChildren($folderId, $layers = 1): array {
$qb = $this->db->getQueryBuilder();
$qb
->select('id', 'title', 'parent_folder', 'index')
->from('bookmarks_folders')
->select('id', 'type', 'index')
->from('bookmarks_tree')
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId)))
->orderBy('index', 'ASC');
$childFolders = $qb->execute()->fetchAll();
$children = $qb->execute()->fetchAll();
$qb = $this->db->getQueryBuilder();
$qb
->select('folder_id', 'index')
->from('bookmarks_shares', 's')
->innerJoin('s', 'bookmarks_shared', 'p', $qb->expr()->eq('s.id', 'p.share_id'))
->where($qb->expr()->eq('p.parent_folder', $qb->createPositionalParameter($folderId)))
->orderBy('index', 'ASC');
->innerJoin('s', 'bookmarks_tree', 't', $qb->expr()->eq('t.id', 's.id'))
->where($qb->expr()->eq('t.parent_folder', $qb->createPositionalParameter($folderId)))
->where($qb->expr()->eq('t.type', self::TYPE_SHARE))
->orderBy('t.index', 'ASC');
$childShares = $qb->execute()->fetchAll();
$qb = $this->db->getQueryBuilder();
$qb
->select('bookmark_id', 'index')
->from('bookmarks_folders_bookmarks', 'f')
->innerJoin('f', 'bookmarks', 'b', $qb->expr()->eq('b.id', 'f.bookmark_id'))
->where($qb->expr()->eq('folder_id', $qb->createPositionalParameter($folderId)))
->orderBy('index', 'ASC');
$childBookmarks = $qb->execute()->fetchAll();
return $this->_getChildren($childFolders, $childShares, $childBookmarks, $layers);
}
/**
* @brief Lists bookmark folders' child folders (helper)
* @param $userId
* @param int $layers The amount of levels to return
* @return array the children each in the format ["id" => int, "type" => 'bookmark' | 'folder' ]
*/
public function getRootChildren($userId, $layers = 1) {
$qb = $this->db->getQueryBuilder();
$qb
->select('id', 'title', 'parent_folder', 'index')
->from('bookmarks_folders')
->where($qb->expr()->eq('user_id', $qb->createPositionalParameter($userId)))
->andWhere($qb->expr()->eq('parent_folder', $qb->createPositionalParameter(-1)))
->orderBy('index', 'ASC');
$childFolders = $qb->execute()->fetchAll();
$qb = $this->db->getQueryBuilder();
$qb
->select('folder_id', 'index')
->from('bookmarks_shares', 's')
->innerJoin('s', 'bookmarks_shared', 'p', $qb->expr()->eq('s.id', 'p.share_id'))
->where($qb->expr()->eq('p.parent_folder', $qb->createPositionalParameter(-1)))
->andWhere($qb->expr()->eq('p.user_id', $qb->createPositionalParameter($userId)))
->orderBy('index', 'ASC');
$childShares = $qb->execute()->fetchAll();
$qb = $this->db->getQueryBuilder();
$qb
->select('bookmark_id', 'index')
->from('bookmarks_folders_bookmarks', 'f')
->innerJoin('f', 'bookmarks', 'b', $qb->expr()->eq('b.id', 'f.bookmark_id'))
->where($qb->expr()->eq('folder_id', $qb->createPositionalParameter(-1)))
->andWhere($qb->expr()->eq('b.user_id', $qb->createPositionalParameter($userId)))
->orderBy('index', 'ASC');
$childBookmarks = $qb->execute()->fetchAll();
return $this->_getChildren($childFolders, $childShares, $childBookmarks, $layers);
}
private function _getChildren($childFolders, $childShares, $childBookmarks, $layers): array {
$children = array_merge($childFolders, $childShares, $childBookmarks);
array_multisort(array_column($children, 'index'), \SORT_ASC, $children);
$children = array_map(function ($child) use ($layers) {
$children = array_map(function ($child) use ($layers, $childShares) {
if (isset($child['bookmark_id'])) {
return ['type' => self::TYPE_BOOKMARK, 'id' => (int) $child['bookmark_id']];
} else {
$id = isset($child['id']) ? $child['id'] : $child['folder_id'];
if ($layers === 1) {
return ['type' => self::TYPE_FOLDER, 'id' => (int) $id];
} else {
return [
'type' => self::TYPE_FOLDER,
'id' => (int) $id,
'children' => $this->getChildren($id, $layers - 1),
];
}
return ['type' => self::TYPE_BOOKMARK, 'id' => (int)$child['bookmark_id']];
}
$item = $item = ['type' => $child['type'], 'id' => $child['id']];
if ($item['type'] === self::TYPE_SHARE) {
$item['type'] = 'folder';
$item['id'] = array_shift($childShares)['folder_id'];
}
if ($item['type'] === self::TYPE_FOLDER && $layers > 1) {
$item['children'] = $this->getChildren($item['id'], $layers - 1);
}
return $item;
}, $children);
return $children;
}
@ -547,7 +399,7 @@ class FolderMapper extends QBMapper {
* @param $folderId
* @return string
*/
private function getCacheKey(string $userId, int $folderId) {
private function getCacheKey(string $userId, int $folderId) : string {
return 'folder:' . $userId . ',' . $folderId;
}
@ -565,7 +417,8 @@ class FolderMapper extends QBMapper {
// Invalidate parent
try {
$folder = $this->find($folderId);
$this->invalidateCache($userId, $folder->getParentFolder());
$parentFolder = $this->findParentOfFolder($folderId);
$this->invalidateCache($userId, $parentFolder->getId());
} catch (DoesNotExistException $e) {
return;
} catch (MultipleObjectsReturnedException $e) {
@ -578,7 +431,7 @@ class FolderMapper extends QBMapper {
// invalidate shared folders
$sharedFolders = $this->sharedFolderMapper->findByFolder($folderId);
foreach($sharedFolders as $sharedFolder) {
foreach ($sharedFolders as $sharedFolder) {
$this->invalidateCache($sharedFolder->getUserId(), $folderId);
}
@ -592,11 +445,11 @@ class FolderMapper extends QBMapper {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function hashFolder(string $userId, int $folderId, $fields = ['title', 'url']) {
public function hashFolder(string $userId, int $folderId, $fields = ['title', 'url']) : string {
$key = $this->getCacheKey($userId, $folderId);
$hash = $this->cache->get($key);
$selector = implode(',', $fields);
if (isset($hash) && isset($hash[$selector])) {
if (isset($hash[$selector])) {
return $hash[$selector];
}
if (!isset($hash)) {
@ -624,41 +477,7 @@ class FolderMapper extends QBMapper {
$folder['children'] = $childHashes;
$hash[$selector] = hash('sha256', json_encode($folder, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->cache->set($key, $hash, 60*60*24);
return $hash[$selector];
}
/**
* @param $userId
* @param array $fields
* @return string
*/
public function hashRootFolder($userId, $fields = ['title', 'url']) {
$key = $this->getCacheKey($userId, -1);
$hash = $this->cache->get($key);
$selector = implode(',', $fields);
if (isset($hash) && isset($hash[$selector])) {
return $hash[$selector];
}
if (!isset($hash)) {
$hash = [];
}
$children = $this->getRootChildren($userId);
$childHashes = array_map(function ($item) use ($fields, $userId) {
switch ($item['type']) {
case self::TYPE_BOOKMARK:
return $this->bookmarkMapper->hash($item['id'], $fields);
case self::TYPE_FOLDER:
return $this->hashFolder($userId, $item['id'], $fields);
default:
throw new UnexpectedValueException('Expected bookmark or folder, but not ' . $item['type']);
}
}, $children);
$folder = [];
$folder['children'] = $childHashes;
$hash[$selector] = hash('sha256', json_encode($folder, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->cache->set($key, $hash, 60*60*24);
$this->cache->set($key, $hash, 60 * 60 * 24);
return $hash[$selector];
}
@ -667,19 +486,20 @@ class FolderMapper extends QBMapper {
* @param int $layers
* @return array
*/
public function getSubFolders($root = -1, $layers = 0) {
public function getSubFolders($root, $layers = 0) : array {
$folders = array_map(function (Folder $folder) use ($layers) {
$array = $folder->toArray();
if ($layers - 1 != 0) {
if ($layers - 1 !== 0) {
$array['children'] = $this->getSubFolders($folder->getId(), $layers - 1);
}
return $array;
}, $this->findByParentFolder($root));
$shares = array_map(function (Share $folder) use ($layers) {
}, $this->findChildFolders($root));
$shares = array_map(function (SharedFolder $folder) use ($layers) {
$share = $this->shareMapper->find($folder->getShareId());
$array = $folder->toArray();
$array['id'] = $share->getFolderId();
if ($layers - 1 != 0) {
$array['userId'] = $share->getUserId();
if ($layers - 1 !== 0) {
$array['children'] = $this->getSubFolders($share->getFolderId(), $layers - 1);
}
return $array;
@ -690,6 +510,40 @@ class FolderMapper extends QBMapper {
return $folders;
}
/**
* @param int $folderId
* @param int $newParentFolderId
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws UnsupportedOperation
*/
public function move(int $folderId, int $newParentFolderId) {
$folder = $this->find($folderId);
try {
$currentParent = $this->findParentOfFolder($folderId);
}catch(DoesNotExistException $e) {
$currentParent = null;
}
$newParent = $this->find($newParentFolderId);
if (isset($currentParent)) {
$this->invalidateCache($folder->getUserId(), $currentParent->getId());
}
$this->invalidateCache($folder->getUserId(), $newParent->getId());
if ($folder->getUserId() !== $newParent->getUserId()) {
throw new UnsupportedOperation('Cannot move between user trees');
}
$qb = $this->db->getQueryBuilder();
$qb
->update('bookmarks_tree')
->values([
'parent_folder' => $qb->createPositionalParameter($newParentFolderId),
])
->where($qb->expr()->eq('id', $qb->createPositionalParameter($folderId)))
->andWhere($qb->expr()->eq('type', self::TYPE_FOLDER));
$qb->execute();
}
/**
* @brief Add a bookmark to a set of folders
@ -703,14 +557,12 @@ class FolderMapper extends QBMapper {
return;
}
$currentFolders = $this->findByBookmark($bookmarkId);
$currentFolders = $this->findParentsOfBookmark($bookmarkId);
$this->addToFolders($bookmarkId, $folders);
$this->removeFromFolders($bookmarkId, array_map(function ($f) {
$this->removeFromFolders($bookmarkId, array_map(static function (Folder $f) {
return $f->getId();
}, array_filter($currentFolders, function ($folder) use ($folders) {
return !in_array($folder->getId(), $folders);
}, array_filter($currentFolders, static function (Folder $folder) use ($folders) {
return !in_array($folder->getId(), $folders, true);
})));
}
@ -722,34 +574,31 @@ class FolderMapper extends QBMapper {
* @throws MultipleObjectsReturnedException
*/
public function addToFolders(int $bookmarkId, array $folders) {
$bookmark = $this->bookmarkMapper->find($bookmarkId);
$this->bookmarkMapper->find($bookmarkId);
$currentFolders = array_map(static function (Folder $f) {
return $f->getId();
}, $this->findParentsOfBookmark($bookmarkId));
$folders = array_filter($folders, static function ($folderId) use ($currentFolders) {
return !in_array($folderId, $currentFolders, true);
});
foreach ($folders as $folderId) {
// check if folder exists
if ($folderId !== -1 && $folderId !== '-1') {
$folder = $this->find($folderId);
}
// check if this folder<->bookmark mapping already exists
$qb = $this->db->getQueryBuilder();
$qb
->select('*')
->from('bookmarks_folders_bookmarks')
->where($qb->expr()->eq('bookmark_id', $qb->createNamedParameter($bookmarkId)))
->andWhere($qb->expr()->eq('folder_id', $qb->createNamedParameter($folderId)));
if ($qb->execute()->fetch()) {
continue;
}
$folder = $this->find($folderId);
$qb = $this->db->getQueryBuilder();
$qb
->insert('bookmarks_folders_bookmarks')
->insert('bookmarks_tree')
->values([
'folder_id' => $qb->createNamedParameter($folderId),
'bookmark_id' => $qb->createNamedParameter($bookmarkId),
'index' => $folderId !== -1 ? count($this->getChildren($folderId)) : count($this->getRootChildren($bookmark->getUserId())),
'parent_folder' => $qb->createNamedParameter($folderId),
'type' => self::TYPE_BOOKMARK,
'id' => $qb->createNamedParameter($bookmarkId),
'index' => $this->countChildren($folderId),
]);
$qb->execute();
$this->invalidateCache($folder->getUserId(), $folderId);
}
}
@ -763,37 +612,51 @@ class FolderMapper extends QBMapper {
public function removeFromFolders(int $bookmarkId, array $folders) {
$bm = $this->bookmarkMapper->find($bookmarkId);
$foldersLeft = count($this->findByBookmark($bookmarkId));
$foldersLeft = count($this->findParentsOfBookmark($bookmarkId));
foreach ($folders as $folderId) {
// check if folder exists
if ($folderId !== -1 && $folderId !== '-1') {
$this->find($folderId);
}
// check if this folder<->bookmark mapping exists
foreach ($folders as $folder) {
$folderId = $folder->getId();
$qb = $this->db->getQueryBuilder();
$qb
->select('*')
->from('bookmarks_folders_bookmarks')
->where($qb->expr()->eq('bookmark_id', $qb->createNamedParameter($bookmarkId)))
->andWhere($qb->expr()->eq('folder_id', $qb->createNamedParameter($folderId)));
if (!$qb->execute()->fetch()) {
continue;
}
$qb = $this->db->getQueryBuilder();
$qb
->delete('bookmarks_folders_bookmarks')
->where($qb->expr()->eq('folder_id', $qb->createNamedParameter($folderId)))
->andwhere($qb->expr()->eq('bookmark_id', $qb->createNamedParameter($bookmarkId)));
->delete('bookmarks_tree')
->where($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId)))
->andWhere($qb->expr()->eq('id', $qb->createPositionalParameter($bookmarkId)))
->andWhere($qb->expr()->eq('t.type', self::TYPE_BOOKMARK));
$qb->execute();
$this->invalidateCache($folder->getUserId(), $folderId);
$foldersLeft--;
}
if ($foldersLeft <= 0) {
$this->bookmarkMapper->delete($bm);
}
}
/**
* @brief Count the children in the given folder
* @param int $folderId
* @return mixed
*/
public function countChildren(int $folderId) {
$qb = $this->db->getQueryBuilder();
$qb
->select($qb->func()->count('index', 'count'))
->from('bookmarks_tree')
->where($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId)));
return $qb->execute()->fetch(\PDO::FETCH_COLUMN);
}
/**
* Handle onBookmark{Create,Update,Delete} events
*
* @param Event $event
*/
public function handle(Event $event): void {
$bookmark = $event->getSubject();
$folders = $this->findParentsOfBookmark($bookmark->getId());
foreach($folders as $folder) {
$this->invalidateCache($folder->getUserId(), $folder->getId());
}
}
}

View File

@ -39,7 +39,7 @@ class ShareMapper extends QBMapper {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function find(int $shareId) {
public function find(int $shareId): Entity {
$qb = $this->db->getQueryBuilder();
$qb->select(Share::$columns)
->from('bookmarks_shares')

View File

@ -34,7 +34,7 @@ class SharedFolderMapper extends QBMapper {
* @param int $shareId
* @return Entity[]
*/
public function findByShare(int $shareId) {
public function findByShare(int $shareId): array {
$qb = $this->db->getQueryBuilder();
$qb->select(SharedFolder::$columns)
->from('bookmarks_shared')
@ -46,9 +46,9 @@ class SharedFolderMapper extends QBMapper {
* @param int $folderId
* @return Entity[]
*/
public function findByFolder(int $folderId) {
public function findByFolder(int $folderId): array {
$qb = $this->db->getQueryBuilder();
$qb->select(array_map(function ($c) {
$qb->select(array_map(static function ($c) {
return 'p.' . $c;
}, SharedFolder::$columns))
->from('bookmarks_shared', 'p')
@ -61,11 +61,12 @@ class SharedFolderMapper extends QBMapper {
* @param int $folderId
* @return Entity[]
*/
public function findByParentFolder(int $folderId) {
public function findByParentFolder(int $folderId): array {
$qb = $this->db->getQueryBuilder();
$qb->select(SharedFolder::$columns)
->from('bookmarks_shared')
->where($qb->expr()->eq('parent_folder', $qb->createPositionalParameter($folderId)));
->join('p', 'bookmarks_tree', 't', $qb->expr()->eq('t.parent_folder', 'p.id'))
->where($qb->expr()->eq('t.id', $qb->createPositionalParameter($folderId)));
return $this->findEntities($qb);
}
@ -73,9 +74,9 @@ class SharedFolderMapper extends QBMapper {
* @param string $userId
* @return array|Entity[]
*/
public function findByOwner(string $userId) {
public function findByOwner(string $userId): array {
$qb = $this->db->getQueryBuilder();
$qb->select(array_map(function ($c) {
$qb->select(array_map(static function ($c) {
return 'p.' . $c;
}, SharedFolder::$columns))
->from('bookmarks_shared', 'p')
@ -89,7 +90,7 @@ class SharedFolderMapper extends QBMapper {
* @param string $participant
* @return array|Entity[]
*/
public function findByParticipant(int $type, string $participant) {
public function findByParticipant(int $type, string $participant): array {
$qb = $this->db->getQueryBuilder();
$qb->select(array_map(function ($c) {
return 'p.' . $c;
@ -109,9 +110,9 @@ class SharedFolderMapper extends QBMapper {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function findByFolderAndParticipant(int $folderId, int $type, string $participant) {
public function findByFolderAndParticipant(int $folderId, int $type, string $participant): Entity {
$qb = $this->db->getQueryBuilder();
$qb->select(array_map(function ($c) {
$qb->select(array_map(static function ($c) {
return 'p.' . $c;
}, SharedFolder::$columns))
->from('bookmarks_shared', 'p')
@ -129,9 +130,9 @@ class SharedFolderMapper extends QBMapper {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function findByFolderAndUser(int $folderId, string $userId) {
public function findByFolderAndUser(int $folderId, string $userId): Entity {
$qb = $this->db->getQueryBuilder();
$qb->select(array_map(function ($c) {
$qb->select(array_map(static function ($c) {
return 'p.' . $c;
}, SharedFolder::$columns))
->from('bookmarks_shared', 'p')
@ -146,9 +147,9 @@ class SharedFolderMapper extends QBMapper {
* @param string $userId
* @return Entity[]
*/
public function findByOwnerAndUser(string $owner, string $userId) {
public function findByOwnerAndUser(string $owner, string $userId): array {
$qb = $this->db->getQueryBuilder();
$$qb->select(array_map(function ($c) {
$$qb->select(array_map(static function ($c) {
return 'p.' . $c;
}, SharedFolder::$columns))
->from('bookmarks_shared', 'p')

View File

@ -2,6 +2,7 @@
namespace OCA\Bookmarks\Db;
use Doctrine\DBAL\Driver\Statement;
use InvalidArgumentException;
use OCP\IDBConnection;
@ -30,7 +31,7 @@ class TagMapper {
* @param $userId
* @return array
*/
public function findAllWithCount($userId) {
public function findAllWithCount($userId): array {
$qb = $this->db->getQueryBuilder();
$qb
->select('t.tag')
@ -49,7 +50,7 @@ class TagMapper {
* @param $userId
* @return array
*/
public function findAll($userId) {
public function findAll($userId): array {
$qb = $this->db->getQueryBuilder();
$qb
->select('t.tag')
@ -64,7 +65,7 @@ class TagMapper {
* @param int $bookmarkId
* @return array
*/
public function findByBookmark(int $bookmarkId) {
public function findByBookmark(int $bookmarkId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('tag');
@ -78,7 +79,7 @@ class TagMapper {
/**
* @param $userId
* @param string $tag
* @return \Doctrine\DBAL\Driver\Statement|int
* @return Statement|int
*/
public function delete($userId, string $tag) {
$qb = $this->db->getQueryBuilder();
@ -92,7 +93,7 @@ class TagMapper {
/**
* @param $userId
* @return \Doctrine\DBAL\Driver\Statement|int
* @return Statement|int
*/
public function deleteAll(int $userId) {
$qb = $this->db->getQueryBuilder();
@ -107,7 +108,7 @@ class TagMapper {
* @param $tags
* @param int $bookmarkId
*/
public function addTo($tags, int $bookmarkId) {
public function addTo($tags, int $bookmarkId): void {
if (is_string($tags)) {
$tags = [$tags];
} else if (!is_array($tags)) {
@ -141,7 +142,7 @@ class TagMapper {
/**
* @param int $bookmarkId
*/
public function removeAllFrom(int $bookmarkId) {
public function removeAllFrom(int $bookmarkId): void {
// Remove old tags
$qb = $this->db->getQueryBuilder();
$qb
@ -154,7 +155,7 @@ class TagMapper {
* @param array $tags
* @param int $bookmarkId
*/
public function setOn(array $tags, int $bookmarkId) {
public function setOn(array $tags, int $bookmarkId): void {
$this->removeAllFrom($bookmarkId);
$this->addTo($tags, $bookmarkId);
}
@ -164,9 +165,8 @@ class TagMapper {
* @param $userId UserId
* @param string $old Old Tag Name
* @param string $new New Tag Name
* @return boolean Success of operation
*/
public function renameTag($userId, string $old, string $new) {
public function renameTag($userId, string $old, string $new): void {
// Remove about-to-be duplicated tags
$qb = $this->db->getQueryBuilder();
$qb
@ -205,6 +205,5 @@ class TagMapper {
->andWhere($qb->expr()->in('bookmark_id', array_map([$qb, 'createNamedParameter'], $bookmarks)));
$qb->execute();
}
return true;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace OCA\Bookmarks\Exception;
class UnsupportedOperation extends Exception {
}

92
lib/QueryParameters.php Normal file
View File

@ -0,0 +1,92 @@
<?php
namespace OCA\Bookmarks;
class QueryParameters {
public const CONJ_AND = 'and';
public const CONJ_OR = 'or';
private $limit = 10;
private $offset = 0;
private $sortBy = null;
private $conjunction = self::CONJ_AND;
/**
* @return int
*/
public function getLimit(): int {
return $this->limit;
}
/**
* @param int $limit
* @return QueryParameters
*/
public function setLimit(int $limit): QueryParameters {
$this->limit = $limit;
return $this;
}
/**
* @return int
*/
public function getOffset(): int {
return $this->offset;
}
/**
* @param int $offset
* @return QueryParameters
*/
public function setOffset(int $offset): QueryParameters {
$this->offset = $offset;
return $this;
}
/**
* @param string|null $default
* @param array|null $columns
* @return string
*/
public function getSortBy(string $default = null, array $columns = null): string {
if (isset($columns) && !in_array($columns, $this->sortBy, true)) {
return $default;
}
if (isset($default) && !isset($this->sortBy)) {
return $default;
}
return $this->sortBy;
}
/**
* @param string $sortBy
* @return QueryParameters
*/
public function setSortBy(string $sortBy): QueryParameters {
$this->sortBy = $sortBy;
return $this;
}
/**
* @return string
*/
public function getConjunction(): string {
return $this->conjunction;
}
/**
* @param string $conjunction
* @return QueryParameters
*/
public function setConjunction(string $conjunction): QueryParameters {
if ($conjunction !== self::CONJ_AND && $conjunction !== self::CONJ_OR) {
throw new \InvalidArgumentException("Conjunction value must be 'and' or 'or'");
}
$this->conjunction = $conjunction;
return $this;
}
}

View File

@ -6,7 +6,10 @@ namespace OCA\Bookmarks\Tests;
use OCA\Bookmarks\Db;
use OCA\Bookmarks\Db\Bookmark;
use OCA\Bookmarks\Db\Folder;
use OCA\Bookmarks\Exception\AlreadyExistsError;
use OCA\Bookmarks\Exception\UrlParseError;
use OCA\Bookmarks\Exception\UserLimitExceededError;
use OCA\Bookmarks\QueryParameters;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\Entity;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
@ -48,17 +51,22 @@ class FolderMapperTest extends TestCase {
* @return void
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws \OCA\Bookmarks\Exception\UnsupportedOperation
*/
public function testInsertAndFind() {
public function testInsertAndFind(): void {
$folder = new Db\Folder();
$folder->setTitle('foobar');
$folder->setParentFolder(-1);
$folder->setUserId($this->userId);
$insertedFolder = $this->folderMapper->insert($folder);
$rootFolder = $this->folderMapper->findRootFolder($this->userId);
$this->folderMapper->move($insertedFolder->getId(), $rootFolder->getId());
$foundEntity = $this->folderMapper->find($insertedFolder->getId());
$this->assertSame($foundEntity->getTitle(), $foundEntity->getTitle());
$this->assertSame($foundEntity->getParentFolder(), $foundEntity->getParentFolder());
return $insertedFolder;
$parent = $this->folderMapper->findParentOfFolder($insertedFolder->getId());
$this->assertSame($parent->getId(), $rootFolder->getId());
}
/**
@ -68,7 +76,7 @@ class FolderMapperTest extends TestCase {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function testUpdate(Entity $folder) {
public function testUpdate(Entity $folder): void {
$folder->setTitle('barbla');
$this->folderMapper->update($folder);
$foundEntity = $this->folderMapper->find($folder->getId());
@ -83,13 +91,16 @@ class FolderMapperTest extends TestCase {
* @return void
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws UrlParseError
* @throws AlreadyExistsError
* @throws UserLimitExceededError
*/
public function testAddBookmarks(Bookmark $bookmark, Folder $folder) {
public function testAddBookmarks(Bookmark $bookmark, Folder $folder): void {
$bookmark->setUserId($this->userId);
$insertedBookmark = $this->bookmarkMapper->insertOrUpdate($bookmark);
$this->folderMapper->addToFolders($insertedBookmark->getId(), [$folder->getId()]);
$bookmarks = $this->bookmarkMapper->findByFolder($folder->getId());
$this->assertContains($insertedBookmark->getId(), array_map(function($bookmark) {
$bookmarks = $this->bookmarkMapper->findByFolder($folder->getId(), new QueryParameters());
$this->assertContains($insertedBookmark->getId(), array_map(static function(Bookmark $bookmark) {
return $bookmark->getId();
}, $bookmarks));
}
@ -103,13 +114,15 @@ class FolderMapperTest extends TestCase {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws UrlParseError
* @throws AlreadyExistsError
* @throws UserLimitExceededError
*/
public function testRemoveBookmarks(Bookmark $bookmark, Folder $folder) {
public function testRemoveBookmarks(Bookmark $bookmark, Folder $folder): void {
$bookmark->setUserId($this->userId);
$insertedBookmark = $this->bookmarkMapper->insertOrUpdate($bookmark);
$this->folderMapper->removeFromFolders($insertedBookmark->getId(), [$folder->getId()]);
$bookmarks = $this->bookmarkMapper->findByFolder($folder->getId());
$this->assertNotContains($insertedBookmark->getId(), array_map(function($bookmark) {
$bookmarks = $this->bookmarkMapper->findByFolder($folder->getId(), new QueryParameters());
$this->assertNotContains($insertedBookmark->getId(), array_map(static function($bookmark) {
return $bookmark->getId();
}, $bookmarks));
}
@ -121,7 +134,7 @@ class FolderMapperTest extends TestCase {
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
*/
public function testDelete(Entity $folder) {
public function testDelete(Entity $folder): void {
$this->folderMapper->delete($folder);
$this->expectException(DoesNotExistException::class);
$this->folderMapper->find($folder->getId());
@ -130,8 +143,8 @@ class FolderMapperTest extends TestCase {
/**
* @return array
*/
public function singleBookmarksProvider() {
return array_map(function($props) {
public function singleBookmarksProvider(): array {
return array_map(static function($props) {
return [Db\Bookmark::fromArray($props)];
}, [
'Simple URL with title and description' => ['url' => 'https://google.com/', 'title' => 'Google', 'description' => 'Search engine'],