Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
54a8758
IBX-6773: Bookmarks for non-accessible contents cause exception
vidarl Feb 7, 2025
5324ef2
Fixed PHPStan error for URLAliasService
vidarl Feb 24, 2026
e8062af
fixup! IBX-6773: Bookmarks for non-accessible contents cause exceptio…
vidarl Feb 24, 2026
070e342
fixup! IBX-6773: Bookmarks for non-accessible contents cause exceptio…
vidarl Feb 24, 2026
245cba0
fixup! IBX-6773: Bookmarks for non-accessible contents cause exceptio…
vidarl Feb 24, 2026
9635474
Update tests/lib/Persistence/Legacy/Filter/CriterionQueryBuilder/Loca…
vidarl Feb 24, 2026
6cc6903
[PHPStan] Aligned baseline with the upstream changes
alongosz Feb 27, 2026
0d293e5
Apply suggestions from code review
vidarl Mar 10, 2026
8e63f7e
Added integration test for using Criterion\IsBookmarked and LocationS…
vidarl Mar 10, 2026
310d457
fixup! Added integration test for using Criterion\IsBookmarked and Lo…
vidarl Mar 10, 2026
3594b4f
fixup! Added integration test for using Criterion\IsBookmarked and Lo…
vidarl Mar 10, 2026
5fb1a53
fixup! fixup! Added integration test for using Criterion\IsBookmarked…
vidarl Mar 10, 2026
6c862de
Apply suggestion from @konradoboza
vidarl Mar 10, 2026
ca42c8d
Fixed deprecation notices
vidarl Apr 21, 2026
2e5d7c7
Removed dependency to Criterion
vidarl Apr 21, 2026
c700f40
Fixed so that Bookmark criterion has parameters isBookmarked and userId
vidarl Apr 21, 2026
7368c0d
tests/integration/Core/Repository/BookmarkServiceTest.php
vidarl Apr 21, 2026
abf0fff
Removed use of magic property
vidarl Apr 21, 2026
fb61a41
fixup! Fixed so that Bookmark criterion has parameters isBookmarked a…
vidarl Apr 21, 2026
690322f
Revert "Removed dependency to Criterion"
vidarl Apr 21, 2026
4d3f4b7
fixup! tests/integration/Core/Repository/BookmarkServiceTest.php
vidarl Apr 21, 2026
80cb93e
fixup! Revert "Removed dependency to Criterion"
vidarl Apr 22, 2026
89d995e
fixup! Fixed so that Bookmark criterion has parameters isBookmarked a…
vidarl Apr 22, 2026
6a32843
Fixed test Ibexa\Tests\Core\Repository\Service\Mock\BookmarkTest::tes…
vidarl Apr 22, 2026
2f69957
Fixed PHPstan error
vidarl Apr 22, 2026
83deac2
fixup! fixup! Fixed so that Bookmark criterion has parameters isBookm…
vidarl Apr 23, 2026
ba5cf53
Added integration test for Criterion\IsBookmarked
vidarl Apr 23, 2026
4d5dc1e
fixup! Added integration test for Criterion\IsBookmarked
vidarl Apr 23, 2026
0c025cd
fixup! fixup! Fixed so that Bookmark criterion has parameters isBookm…
vidarl Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -32256,12 +32256,6 @@ parameters:
count: 1
path: tests/integration/Core/Repository/BaseURLServiceTest.php

-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertInstanceOf\(\) with ''Ibexa\\\\Contracts\\\\Core\\\\Repository\\\\Values\\\\Bookmark\\\\BookmarkList'' and Ibexa\\Contracts\\Core\\Repository\\Values\\Bookmark\\BookmarkList will always evaluate to true\.$#'
identifier: method.alreadyNarrowedType
count: 1
path: tests/integration/Core/Repository/BookmarkServiceTest.php

-
message: '#^Method Ibexa\\Tests\\Integration\\Core\\Repository\\BookmarkServiceTest\:\:testCreateBookmark\(\) has no return type specified\.$#'
identifier: missingType.return
Expand Down Expand Up @@ -69570,12 +69564,6 @@ parameters:
count: 1
path: tests/lib/Repository/Service/Mock/BookmarkTest.php

-
message: '#^Method Ibexa\\Tests\\Core\\Repository\\Service\\Mock\\BookmarkTest\:\:testLoadBookmarksEmptyList\(\) has no return type specified\.$#'
identifier: missingType.return
count: 1
path: tests/lib/Repository/Service/Mock/BookmarkTest.php

-
message: '#^Method Ibexa\\Tests\\Core\\Repository\\Service\\Mock\\BookmarkTest\:\:testLocationShouldBeBookmarked\(\) has no return type specified\.$#'
identifier: missingType.return
Expand Down
4 changes: 4 additions & 0 deletions src/contracts/Persistence/Bookmark/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ public function loadUserIdsByLocation(Location $location): array;
/**
* Loads bookmarks owned by user.
*
* @deprecated 4.6.30 The "Handler::loadUserBookmarks()" method is deprecated, will be removed in 6.0.0. Use "LocationService::find()" and "Criterion\IsBookmarked" instead.
*
* @param int $userId
* @param int $offset the start offset for paging
* @param int $limit the number of bookmarked locations returned
Expand All @@ -61,6 +63,8 @@ public function loadUserBookmarks(int $userId, int $offset = 0, int $limit = -1)
/**
* Count bookmarks owned by user.
*
* @deprecated 4.6.30 The "Handler::countUserBookmarks()" method is deprecated, will be removed in 6.0.0. Use "LocationService::count()" and "Criterion\IsBookmarked" instead.
*
* @param int $userId
*
* @return int
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion;

use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion\Operator\Specifications;
use Ibexa\Contracts\Core\Repository\Values\Filter\FilteringCriterion;

/**
* A criterion that matches locations of bookmarks for a given user id.
*
* Supported operators:
* - EQ: matches against a unique user id
*/
final class IsBookmarked extends Criterion implements FilteringCriterion
{
public ?int $userId = null;

public function __construct(
bool $isBookmarked = true,
?int $userId = null
) {
$this->userId = $userId;
parent::__construct(null, null, $isBookmarked);
}

public function getSpecifications(): array
{
return [
new Specifications(Operator::EQ, Specifications::FORMAT_SINGLE, Specifications::TYPE_BOOLEAN),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause;

use Ibexa\Contracts\Core\Repository\Values\Content\Query;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause;
use Ibexa\Contracts\Core\Repository\Values\Filter\FilteringSortClause;

/**
* Sets sort direction on the bookmark id for a location query containing IsBookmarked criterion.
*/
final class BookmarkId extends SortClause implements FilteringSortClause
{
public function __construct(string $sortDirection = Query::SORT_ASC)
{
parent::__construct('id', $sortDirection);
}
}
4 changes: 4 additions & 0 deletions src/lib/Persistence/Legacy/Bookmark/Gateway.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ abstract public function loadUserIdsByLocation(Location $location): array;
/**
* Load data for all bookmarks owned by given $userId.
*
* @deprecated 4.6.30 Gateway::loadUserBookmarks()" method is deprecated, will be removed in 6.0.0. Use "LocationService::find()" and "Criterion\IsBookmarked" instead.
*
* @param int $userId ID of user
* @param int $offset Offset to start listing from, 0 by default
* @param int $limit Limit for the listing. -1 by default (no limit)
Expand All @@ -63,6 +65,8 @@ abstract public function loadUserBookmarks(int $userId, int $offset = 0, int $li
/**
* Count bookmarks owned by given $userId.
*
* @deprecated 4.6.30 The "Gateway::countUserBookmarks()" method is deprecated, will be removed in 6.0.0. Use "LocationService::count()" and "Criterion\IsBookmarked" instead.
*
* @param int $userId ID of user
*
* @return int
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Core\Persistence\Legacy\Filter\CriterionQueryBuilder\Location;

use Doctrine\DBAL\ParameterType;
use Ibexa\Contracts\Core\Persistence\Filter\Doctrine\FilteringQueryBuilder;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion\IsBookmarked;
use Ibexa\Contracts\Core\Repository\Values\Filter\FilteringCriterion;
use Ibexa\Core\Persistence\Legacy\Bookmark\Gateway\DoctrineDatabase;
use Ibexa\Core\Repository\Permission\PermissionResolver;

/**
* @internal for internal use by Repository Filtering
*/
final class BookmarkQueryBuilder extends BaseLocationCriterionQueryBuilder
{
private PermissionResolver $permissionResolver;

public function __construct(
PermissionResolver $permissionResolver
) {
$this->permissionResolver = $permissionResolver;
}

public function accepts(FilteringCriterion $criterion): bool
{
return $criterion instanceof IsBookmarked;
}

public function buildQueryConstraint(
FilteringQueryBuilder $queryBuilder,
FilteringCriterion $criterion
): string {
/** @var \Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion\IsBookmarked $criterion */
$isBookmarked = $criterion->value[0] ?? null;
if (!is_bool($isBookmarked)) {
throw new \InvalidArgumentException('IsBookmarked criterion value must be boolean at index 0.');
}
$userId = $criterion->userId ?? $this->permissionResolver->getCurrentUserReference()->getUserId();

Copy link
Copy Markdown
Contributor Author

@vidarl vidarl Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alongosz
There is one problem with this queryBuilder.. In Bookmark table, we store node_id, so we need to join with ezcontentobject_tree. That is no problem if LocationService::find() is used.
However, ATM it won't work with Content filtering (ContentService::find() as that table is already join in that case

I don't see a safe way to detect in the queryBuilder if we are either in Content or Location context..
I have a proposal below using $queryBuilder->getExistingTableAliasJoinCondition but not sure if it is really safe. It checks if alias "location" is joined at that time or not. But not sure if order is deterministic and if this good enough, but not sure if there is any other alternative ?

The other option is somehow to state that this filter can only use with Location ?

Suggested change
$isContentQuery = $queryBuilder->getExistingTableAliasJoinCondition('location') === null;
if ($isContentQuery) {
$connection = $queryBuilder->getConnection();
$subQb = $connection->createQueryBuilder();
$paramName = $queryBuilder->createNamedParameter(
$userId,
ParameterType::INTEGER
);
$subQuerySql = $subQb
->select('1')
->from(Gateway::CONTENT_TREE_TABLE, 'l')
->innerJoin('l', DoctrineDatabase::TABLE_BOOKMARKS, 'b', 'l.node_id = b.node_id')
->where('l.contentobject_id = content.id')
->andWhere("b.user_id = $paramName")
->getSQL();
return $isBookmarked
? "EXISTS ($subQuerySql)"
: "NOT EXISTS ($subQuerySql)";
}

if ($isBookmarked) {
$queryBuilder
->joinOnce(
'location',
DoctrineDatabase::TABLE_BOOKMARKS,
'bookmark',
'location.node_id = bookmark.node_id'
);

return $queryBuilder->expr()->eq(
'bookmark.user_id',
$queryBuilder->createNamedParameter(
$userId,
ParameterType::INTEGER
)
);
} else {
$queryBuilder
->leftJoinOnce(
'location',
DoctrineDatabase::TABLE_BOOKMARKS,
'bookmark',
'location.node_id = bookmark.node_id AND bookmark.user_id = :userId'
)
->setParameter('userId', $userId);

return $queryBuilder->expr()->isNull('bookmark.id');
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Core\Persistence\Legacy\Filter\SortClauseQueryBuilder\Bookmark;

use Ibexa\Contracts\Core\Persistence\Filter\Doctrine\FilteringQueryBuilder;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause\BookmarkId;
use Ibexa\Contracts\Core\Repository\Values\Filter\FilteringSortClause;
use Ibexa\Contracts\Core\Repository\Values\Filter\SortClauseQueryBuilder;

final class IdSortClauseQueryBuilder implements SortClauseQueryBuilder
{
public function accepts(FilteringSortClause $sortClause): bool
{
return $sortClause instanceof BookmarkId;
}

public function buildQuery(
FilteringQueryBuilder $queryBuilder,
FilteringSortClause $sortClause
): void {
if (!$sortClause instanceof BookmarkId) {
throw new \InvalidArgumentException(sprintf(
'Expected %s, got %s',
BookmarkId::class,
get_class($sortClause),
));
}
/** @var \Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause\BookmarkId $sortClause */
$queryBuilder->addSelect('bookmark.id');
$queryBuilder->addOrderBy('bookmark.id', $sortClause->direction);
}
}
45 changes: 30 additions & 15 deletions src/lib/Repository/BookmarkService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,40 @@
namespace Ibexa\Core\Repository;

use Exception;
use Ibexa\Contracts\Core\Persistence\Bookmark\Bookmark;
use Ibexa\Contracts\Core\Persistence\Bookmark\CreateStruct;
use Ibexa\Contracts\Core\Persistence\Bookmark\Handler as BookmarkHandler;
use Ibexa\Contracts\Core\Repository\BookmarkService as BookmarkServiceInterface;
use Ibexa\Contracts\Core\Repository\Exceptions\BadStateException;
use Ibexa\Contracts\Core\Repository\Repository as RepositoryInterface;
use Ibexa\Contracts\Core\Repository\Values\Bookmark\BookmarkList;
use Ibexa\Contracts\Core\Repository\Values\Content\Location;
use Ibexa\Contracts\Core\Repository\Values\Content\Query;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\SortClause;
use Ibexa\Contracts\Core\Repository\Values\Filter\Filter;
use Ibexa\Core\Base\Exceptions\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;

class BookmarkService implements BookmarkServiceInterface
{
/** @var \Ibexa\Contracts\Core\Repository\Repository */
protected $repository;
protected RepositoryInterface $repository;

/** @var \Ibexa\Contracts\Core\Persistence\Bookmark\Handler */
protected $bookmarkHandler;
protected BookmarkHandler $bookmarkHandler;

private LoggerInterface $logger;

/**
* BookmarkService constructor.
*
* @param \Ibexa\Contracts\Core\Repository\Repository $repository
* @param \Ibexa\Contracts\Core\Persistence\Bookmark\Handler $bookmarkHandler
*/
public function __construct(RepositoryInterface $repository, BookmarkHandler $bookmarkHandler)
public function __construct(RepositoryInterface $repository, BookmarkHandler $bookmarkHandler, ?LoggerInterface $logger = null)
{
$this->repository = $repository;
$this->bookmarkHandler = $bookmarkHandler;
$this->logger = $logger ?? new NullLogger();
}

/**
Expand Down Expand Up @@ -95,17 +102,25 @@ public function deleteBookmark(Location $location): void
*/
public function loadBookmarks(int $offset = 0, int $limit = 25): BookmarkList
{
$currentUserId = $this->getCurrentUserId();
$filter = new Filter();
try {
$filter
->withCriterion(new Criterion\IsBookmarked())
->withSortClause(new SortClause\BookmarkId(Query::SORT_DESC))
->sliceBy($limit, $offset);

$result = $this->repository->getLocationService()->find($filter, []);
} catch (BadStateException $e) {
$this->logger->debug($e->getMessage(), [
'exception' => $e,
]);

return new BookmarkList();
}

$list = new BookmarkList();
$list->totalCount = $this->bookmarkHandler->countUserBookmarks($currentUserId);
if ($list->totalCount > 0) {
$bookmarks = $this->bookmarkHandler->loadUserBookmarks($currentUserId, $offset, $limit);

$list->items = array_map(function (Bookmark $bookmark) {
return $this->repository->getLocationService()->loadLocation($bookmark->locationId);
}, $bookmarks);
}
$list->totalCount = $result->totalCount;
$list->items = iterator_to_array($result->getIterator());

return $list;
}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/Repository/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -608,7 +608,8 @@ public function getBookmarkService(): BookmarkServiceInterface
if ($this->bookmarkService === null) {
$this->bookmarkService = new BookmarkService(
$this,
$this->persistenceHandler->bookmarkHandler()
$this->persistenceHandler->bookmarkHandler(),
$this->logger
);
}

Expand Down
20 changes: 16 additions & 4 deletions tests/integration/Core/Repository/BookmarkServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
namespace Ibexa\Tests\Integration\Core\Repository;

use Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException;
use Ibexa\Contracts\Core\Repository\Values\Bookmark\BookmarkList;
use Ibexa\Contracts\Core\Repository\Values\Content\Query\Criterion;
use Ibexa\Contracts\Core\Repository\Values\Filter\Filter;

/**
* Test case for the BookmarkService.
Expand Down Expand Up @@ -143,13 +144,24 @@ public function testLoadBookmarks()
$bookmarks = $repository->getBookmarkService()->loadBookmarks(1, 3);
/* END: Use Case */

$this->assertInstanceOf(BookmarkList::class, $bookmarks);
$this->assertEquals($bookmarks->totalCount, 5);
self::assertEquals(5, $bookmarks->totalCount);
// Assert bookmarks order: recently added should be first
$this->assertEquals([15, 13, 12], array_map(static function ($location) {
self::assertEquals([15, 13, 12], array_map(static function ($location) {
return $location->id;
}, $bookmarks->items));
}

public function testCountBookmarks(): void
{
$repository = $this->getRepository();

$filter = new Filter();
$filter
->withCriterion(new Criterion\IsBookmarked(true, 14));
$count = $repository->getLocationService()->count($filter, []);

self::assertEquals(5, $count);
}
}

class_alias(BookmarkServiceTest::class, 'eZ\Publish\API\Repository\Tests\BookmarkServiceTest');
Loading
Loading