Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions config/data_index_filters.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ services:
Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\MultipleIdFilter:
tags: [ 'pimcore.studio_backend.search_index.filter' ]

Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\WorkflowFilter:
tags: [ 'pimcore.studio_backend.search_index.filter' ]

Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\IntegerFilter:
tags: [ 'pimcore.studio_backend.search_index.filter' ]

Expand Down
18 changes: 18 additions & 0 deletions doc/01_Architecture_Overview/01_Grid.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ Available filters are:
| crm.consent | array | `true` or `false` | true |
| dataobject.relation | array | array of `type`, `ids` objects | true |
| system.unreferenced | boolean | Asset grid only — unreferenced assets | false |
| workflow | string or null | place name; omit/`null` = all states of the workflow | true |


### Examples:
Expand Down Expand Up @@ -255,6 +256,23 @@ Filter unreferenced assets (asset grid only):
...
```

Filter by Workflow place:
The `workflow` filter restricts an element grid to the elements currently in a given workflow place.
The `key` is the workflow name and `filterValue` is the place name; omit `filterValue` (or send `null`)
to include the elements in **all** states of the workflow. Folders are excluded, and the matching
element ids are resolved server-side (asset and data-object grids).
```json
...
"columnFilters" [
{
"key": "product_workflow",
"type": "workflow",
"filterValue": "in_review"
}
]
...
```

## Advanced Columns

Advanced columns combine multiple data sources and transformers. Data source types:
Expand Down
122 changes: 122 additions & 0 deletions src/DataIndex/Filter/WorkflowFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter;

use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\AssetQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\DataObjectQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\DocumentQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\QueryInterface;
use Pimcore\Bundle\StudioBackendBundle\Exception\Api\InvalidArgumentException;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFilter;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFiltersParameterInterface;
use Pimcore\Bundle\StudioBackendBundle\Util\Constant\ElementTypes;
use Pimcore\Bundle\StudioBackendBundle\Workflow\Repository\WorkflowElementsRepositoryInterface;
use Psr\Log\LoggerInterface;
use function array_slice;
use function count;
use function is_string;

/**
* Restricts an element grid query to the elements in a given workflow place. Reuses the
* workflow-state repository (folders already excluded) to resolve the matching element ids and
* applies the standard `searchByIds` modifier, so the native listing filters server-side without
* enumerating ids on the client.
*
* @internal
*/
final readonly class WorkflowFilter implements FilterInterface
{
public const string FILTER_TYPE = 'workflow';

/**
* Upper bound on ids handed to the search index, guarding against the OpenSearch terms limit
* for pathologically large states. Realistic states stay far below this; if a state ever
* exceeds it the excess is dropped and a warning is logged (not surfaced to the user).
*/
private const int MAX_IDS = 10000;

public function __construct(

Check notice on line 49 in src/DataIndex/Filter/WorkflowFilter.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/DataIndex/Filter/WorkflowFilter.php#L49

Expected 2 blank lines before function; 1 found
private WorkflowElementsRepositoryInterface $elementsRepository,
private LoggerInterface $logger,
) {
}

Check notice on line 53 in src/DataIndex/Filter/WorkflowFilter.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/DataIndex/Filter/WorkflowFilter.php#L53

Expected 1 blank line before closing function brace; 0 found

Check notice on line 53 in src/DataIndex/Filter/WorkflowFilter.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/DataIndex/Filter/WorkflowFilter.php#L53

Expected 2 blank lines after function; 1 found

public function apply(mixed $parameters, QueryInterface $query): QueryInterface
{
if (!$parameters instanceof ColumnFiltersParameterInterface) {
return $query;
}

$elementType = $this->resolveElementType($query);
if ($elementType === null) {
return $query;
}

foreach ($parameters->getColumnFilterByType(self::FILTER_TYPE) as $column) {
$query = $this->applyWorkflowFilter($column, $query, $elementType);
}

return $query;
}

private function applyWorkflowFilter(
ColumnFilter $column,
QueryInterface $query,
string $elementType
): QueryInterface {
$workflowName = $column->getKeyWithOutLocale();
$place = $column->getFilterValue();

if ($workflowName === '') {
throw new InvalidArgumentException('Workflow filter requires a workflow name (key).');
}

// A specific place narrows to that state; a missing place means "any place in the
// workflow" (the widget's show-all action), resolved by passing a null state name.
$stateName = is_string($place) && $place !== '' ? $place : null;

$rows = $this->elementsRepository->fetchByWorkflowState($workflowName, $stateName, $elementType);

$ids = [];
foreach ($rows as $row) {
$cid = (int) $row['cid'];
// Skip orphaned/invalid workflow-state rows (e.g. cid 0): the search index's
// IdsFilter requires strictly positive ids and throws otherwise.
if ($cid > 0) {
$ids[] = $cid;
}
}

if (count($ids) > self::MAX_IDS) {
$this->logger->warning(
'Workflow drill-down id set exceeds the search cap and was truncated.',
['workflow' => $workflowName, 'place' => $place, 'total' => count($ids), 'cap' => self::MAX_IDS]
);
$ids = array_slice($ids, 0, self::MAX_IDS);
}

// Empty id set intentionally matches nothing (rather than the whole index).
return $query->searchByIds($ids);
}

private function resolveElementType(QueryInterface $query): ?string
{
return match (true) {
$query instanceof DataObjectQueryInterface => ElementTypes::TYPE_DATA_OBJECT,
$query instanceof AssetQueryInterface => ElementTypes::TYPE_ASSET,
$query instanceof DocumentQueryInterface => ElementTypes::TYPE_DOCUMENT,
default => null,
};
}
}
6 changes: 6 additions & 0 deletions src/Workflow/Repository/WorkflowElementsRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public function fetchByWorkflowState(
'ews', 'documents', 'd', "ews.ctype = 'document' AND d.id = ews.cid"
)
->where('ews.workflow = :workflow')
// Folders share their element's ctype but are never workflow subjects the widgets
// should list. Exclude only rows that positively resolve to a folder; the IS NULL
// guard keeps non-matching joins and orphaned states (element deleted) unaffected.
->andWhere("(a.type IS NULL OR a.type != 'folder')")
->andWhere("(o.type IS NULL OR o.type != 'folder')")
->andWhere("(d.type IS NULL OR d.type != 'folder')")
->setParameter('workflow', $workflowName)
->orderBy('modificationDate', 'ASC')
->addOrderBy('ews.cid', 'ASC')
Expand Down
139 changes: 139 additions & 0 deletions tests/Unit/DataIndex/Filter/WorkflowFilterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\Tests\Unit\DataIndex\Filter;

use Codeception\Test\Unit;
use PHPUnit\Framework\MockObject\MockObject;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Filter\WorkflowFilter;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\AssetQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\DataIndex\Query\DataObjectQueryInterface;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFilter;
use Pimcore\Bundle\StudioBackendBundle\MappedParameter\Filter\ColumnFiltersParameterInterface;
use Pimcore\Bundle\StudioBackendBundle\Util\Constant\ElementTypes;
use Pimcore\Bundle\StudioBackendBundle\Workflow\Repository\WorkflowElementsRepositoryInterface;
use Psr\Log\LoggerInterface;

/**
* @internal
*/
final class WorkflowFilterTest extends Unit
{
private MockObject|WorkflowElementsRepositoryInterface $repository;

Check notice on line 32 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L32

Expected 1 blank line(s) before first member var; 0 found

private WorkflowFilter $filter;

protected function _before(): void

Check notice on line 36 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L36

Expected 2 blank lines before function; 1 found

Check notice on line 36 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L36

Method name "_before" should not be prefixed with an underscore to indicate visibility

Check notice on line 36 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L36

The method _before is not named in camelCase.
{
$this->repository = $this->createMock(WorkflowElementsRepositoryInterface::class);
$this->filter = new WorkflowFilter($this->repository, $this->createMock(LoggerInterface::class));
}

Check notice on line 40 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L40

Expected 1 blank line before closing function brace; 0 found

Check notice on line 40 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L40

Expected 2 blank lines after function; 1 found

public function testItIgnoresParametersThatAreNotColumnFilters(): void
{
$query = $this->createMock(DataObjectQueryInterface::class);
$query->expects($this->never())->method('searchByIds');
$this->repository->expects($this->never())->method('fetchByWorkflowState');

self::assertSame($query, $this->filter->apply(new \stdClass(), $query));
}

public function testItResolvesObjectIdsForTheWorkflowPlaceAndSearchesByThem(): void
{
$query = $this->createMock(DataObjectQueryInterface::class);

// Data-object query -> element type 'data-object' passed to the repository.
$this->repository->expects($this->once())
->method('fetchByWorkflowState')
->with('product_workflow', 'in_review', ElementTypes::TYPE_DATA_OBJECT)
->willReturn([

Check notice on line 59 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L59

Opening parenthesis of a multi-line function call must be the last content on the line
['cid' => 3, 'ctype' => 'object'],
['cid' => 7, 'ctype' => 'object'],
]);

Check notice on line 62 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L62

Closing parenthesis of a multi-line function call must be on a line by itself

$query->expects($this->once())->method('searchByIds')->with([3, 7])->willReturnSelf();

$result = $this->filter->apply($this->parametersWith('product_workflow', 'in_review'), $query);

self::assertSame($query, $result);
}

public function testItSkipsOrphanedNonPositiveIds(): void
{
$query = $this->createMock(DataObjectQueryInterface::class);

// Orphaned element_workflow_state rows can carry cid 0; the search index's IdsFilter
// requires strictly positive ids, so they must be dropped before searchByIds.
$this->repository->method('fetchByWorkflowState')->willReturn([

Check notice on line 77 in tests/Unit/DataIndex/Filter/WorkflowFilterTest.php

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

tests/Unit/DataIndex/Filter/WorkflowFilterTest.php#L77

Opening parenthesis of a multi-line function call must be the last content on the line
['cid' => 0, 'ctype' => 'object'],
['cid' => 7, 'ctype' => 'object'],
]);

$query->expects($this->once())->method('searchByIds')->with([7])->willReturnSelf();

$this->filter->apply($this->parametersWith('product_workflow', 'in_review'), $query);
}

public function testItUsesAssetElementTypeForAssetQueries(): void
{
$query = $this->createMock(AssetQueryInterface::class);

$this->repository->expects($this->once())
->method('fetchByWorkflowState')
->with('asset_workflow', 'to_review', ElementTypes::TYPE_ASSET)
->willReturn([['cid' => 42, 'ctype' => 'asset']]);

$query->expects($this->once())->method('searchByIds')->with([42])->willReturnSelf();

$this->filter->apply($this->parametersWith('asset_workflow', 'to_review'), $query);
}

public function testItResolvesAllPlacesWhenNoPlaceIsGiven(): void
{
$query = $this->createMock(DataObjectQueryInterface::class);

// No place -> null state name -> the whole workflow (the widget's show-all action).
$this->repository->expects($this->once())
->method('fetchByWorkflowState')
->with('product_workflow', null, ElementTypes::TYPE_DATA_OBJECT)
->willReturn([['cid' => 5, 'ctype' => 'object']]);

$query->expects($this->once())->method('searchByIds')->with([5])->willReturnSelf();

$this->filter->apply($this->parametersWith('product_workflow', null), $query);
}

public function testItSearchesByAnEmptyIdSetWhenNoElementsMatch(): void
{
$query = $this->createMock(DataObjectQueryInterface::class);

$this->repository->method('fetchByWorkflowState')->willReturn([]);
// Empty id set -> the grid shows nothing (rather than everything).
$query->expects($this->once())->method('searchByIds')->with([])->willReturnSelf();

$this->filter->apply($this->parametersWith('product_workflow', 'in_review'), $query);
}

private function parametersWith(string $workflow, ?string $place): ColumnFiltersParameterInterface&MockObject
{
// ColumnFilter is a final readonly value object -> construct it, never mock it.
$columnFilter = new ColumnFilter($workflow, WorkflowFilter::FILTER_TYPE, $place);

$parameters = $this->createMock(ColumnFiltersParameterInterface::class);
$parameters->method('getColumnFilterByType')
->with(WorkflowFilter::FILTER_TYPE)
->willReturn([$columnFilter]);

return $parameters;
}
}
71 changes: 71 additions & 0 deletions tests/Unit/Workflow/Repository/WorkflowElementsRepositoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\Tests\Unit\Workflow\Repository;

use Codeception\Test\Unit;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use PHPUnit\Framework\MockObject\MockObject;
use Pimcore\Bundle\StaticResolverBundle\Db\DbResolverInterface;
use Pimcore\Bundle\StudioBackendBundle\Workflow\Repository\WorkflowElementsRepository;

/**
* @internal
*/
final class WorkflowElementsRepositoryTest extends Unit
{
private MockObject|DbResolverInterface $dbResolver;

private MockObject|Connection $connection;

private WorkflowElementsRepository $repository;

protected function _before(): void
{
$this->dbResolver = $this->createMock(DbResolverInterface::class);
$this->connection = $this->createMock(Connection::class);
$this->repository = new WorkflowElementsRepository($this->dbResolver);
}

public function testFetchByWorkflowStateExcludesFolders(): void
{
$wheres = [];
$queryBuilder = $this->createMock(QueryBuilder::class);
foreach (['select', 'addSelect', 'from', 'where', 'setParameter', 'orderBy', 'addOrderBy'] as $method) {
$queryBuilder->method($method)->willReturnSelf();
}
// The asset/object/document subtype tables are joined so their type can be inspected.
$queryBuilder->expects($this->exactly(3))->method('leftJoin')->willReturnSelf();
$queryBuilder->method('andWhere')->willReturnCallback(
function (string $condition) use (&$wheres, $queryBuilder) {
$wheres[] = $condition;

return $queryBuilder;
}
);
$queryBuilder->method('fetchAllAssociative')->willReturn([]);

$this->connection->method('createQueryBuilder')->willReturn($queryBuilder);
$this->dbResolver->method('get')->willReturn($this->connection);

$this->repository->fetchByWorkflowState('product_workflow');

// Folder rows are filtered out in SQL for every element type. The IS NULL guard is
// essential: a plain "type != 'folder'" would drop every non-matching LEFT JOIN row
// (NULL type) via three-valued logic, emptying the result.
$this->assertContains("(a.type IS NULL OR a.type != 'folder')", $wheres);
$this->assertContains("(o.type IS NULL OR o.type != 'folder')", $wheres);
$this->assertContains("(d.type IS NULL OR d.type != 'folder')", $wheres);
}
}
Loading