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
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
use MiniShop3\Services\Category\CategoryProductScopeService;
use MiniShop3\Services\Category\CategoryProductsListService;
use MiniShop3\Services\FilterConfigManager;
use MODX\Revolution\modAccessResourceGroup;
use MODX\Revolution\modUser;
use MODX\Revolution\modX;

/**
Expand All @@ -25,6 +27,9 @@ class CategoryProductsController
{
protected modX $modx;

/** Кэш на запрос: ACL документов проверяется один раз. */
private ?bool $documentAclInert = null;

public function __construct(modX $modx)
{
$this->modx = $modx;
Expand Down Expand Up @@ -85,6 +90,26 @@ public function getList(array $params = []): array
return $this->errorResponse('ms3_err_category_products_list_service', HttpStatus::INTERNAL_SERVER_ERROR);
}

// Когда ACL документов никого не ограничивает, страницу отдаёт один
// запрос с LIMIT и один COUNT — так листинг и работал до #473.
if ($this->documentAclIsInert()) {
$page = $listService->getPage(
$categoryId,
$params,
$nested,
$gridFields,
$start,
$limit,
(string) $sortBy,
$sortDir
);

return Response::success([
'results' => $page['results'],
'total' => $page['total'],
])->getData();
}

$results = $this->collectVisibleListPage(
$listService,
$categoryId,
Expand Down Expand Up @@ -113,6 +138,44 @@ public function getList(array $params = []): array
])->getData();
}

/**
* Может ли построчная проверка checkPolicy('view') хоть что-то отбросить.
*
* #473 добавил ACL уровня документа в грид товаров категории, и ради него
* прямой LIMIT/OFFSET заменили сканированием: проверять политику можно
* только по загруженному объекту. Из-за этого на каждый запрос страницы
* вычитывалась вся категория — для total всегда, плюс от начала до start
* для самой страницы. На 2114 товарах это около секунды, и чем дальше
* страница, тем дороже.
*
* Ограничить видимость ресурса может только группа ресурсов с записью
* доступа: modResource::findPolicy() собирает политику джойном
* modAccessResourceGroup + modResourceGroupResource и возвращает пустой
* набор, когда настройка контекста выключена. Значит ни одной записи
* доступа по группам ресурсов — ни одного закрытого товара, и фильтр
* заведомо пропускает всё. Для sudo он пропускает всё всегда.
*
* В этих случаях работает быстрый путь. Если группы ресурсов на сайте
* настроены, остаётся прежнее сканирование: права важнее скорости.
*/
private function documentAclIsInert(): bool
{
if ($this->documentAclInert !== null) {
return $this->documentAclInert;
}

$user = $this->modx->user;
if ($user instanceof modUser && $user->get('sudo')) {
return $this->documentAclInert = true;
}

if (!$this->modx->getOption('access_resource_group_enabled', null, true)) {
return $this->documentAclInert = true;
}

return $this->documentAclInert = $this->modx->getCount(modAccessResourceGroup::class) === 0;
}

/**
* Get filters configuration
* GET /api/mgr/categories/{id}/products/filters
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace MiniShop3\Tests\Unit\Controllers;

use MiniShop3\Controllers\Api\Manager\CategoryProductsController;
use MODX\Revolution\modAccessResourceGroup;
use MODX\Revolution\modX;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;

/**
* Быстрый путь листинга товаров категории (регрессия производительности #473).
*
* #473 добавил ACL уровня документа и ради него заменил LIMIT/OFFSET
* сканированием: на каждый запрос страницы вычитывалась вся категория.
* Быстрый путь возвращает один SELECT с LIMIT и один COUNT, но только когда
* построчная проверка заведомо никого не отбрасывает.
*
* Здесь проверяется именно условие переключения: ошибка в нём либо вернёт
* медленный листинг, либо, что хуже, обойдёт проверку прав.
*/
final class CategoryProductsListFastPathTest extends TestCase
{
protected function setUp(): void
{
if (!class_exists(modX::class, false)) {
require_once dirname(__DIR__, 2) . '/stubs/ModxStub.php';
}
}

public function testInertWhenNoResourceGroupAclExists(): void
{
// Типичный сайт: группы ресурсов не настроены — отбрасывать нечего.
self::assertTrue($this->isInert(aclRows: 0, groupsEnabled: true));
}

public function testNotInertWhenResourceGroupAclExists(): void
{
// Появилась хоть одна запись доступа — возвращаемся к проверке прав.
self::assertFalse($this->isInert(aclRows: 1, groupsEnabled: true));
self::assertFalse($this->isInert(aclRows: 25, groupsEnabled: true));
}

public function testInertWhenResourceGroupAccessDisabledInContext(): void
{
// modResource::findPolicy() при выключенной настройке отдаёт пустую
// политику, то есть ограничений нет даже при наличии записей.
self::assertTrue($this->isInert(aclRows: 5, groupsEnabled: false));
}

/**
* Результат кэшируется на запрос: условие дёргается один раз на листинг.
*/
public function testResultIsMemoisedPerRequest(): void
{
$modx = $this->makeModx(aclRows: 0, groupsEnabled: true);
$controller = new CategoryProductsController($modx);
$method = new ReflectionMethod($controller, 'documentAclIsInert');
$method->setAccessible(true);

self::assertTrue($method->invoke($controller));

// Меняем ответ хранилища: закэшированное решение не должно поехать.
$modx->counts[modAccessResourceGroup::class] = 3;

self::assertTrue($method->invoke($controller));
}

private function isInert(int $aclRows, bool $groupsEnabled): bool
{
$controller = new CategoryProductsController($this->makeModx($aclRows, $groupsEnabled));
$method = new ReflectionMethod($controller, 'documentAclIsInert');
$method->setAccessible(true);

return (bool) $method->invoke($controller);
}

private function makeModx(int $aclRows, bool $groupsEnabled): modX
{
$modx = new modX();
$modx->optionOverrides['access_resource_group_enabled'] = $groupsEnabled ? '1' : '0';
$modx->counts[modAccessResourceGroup::class] = $aclRows;

return $modx;
}
}
20 changes: 19 additions & 1 deletion core/components/minishop3/tests/stubs/ModxStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,31 @@ public function load(string ...$topics): void
};
}

/** @var array<string, mixed> Переопределения для getOption(). */
public array $optionOverrides = [];

/** @var array<string, int> Ответы getCount() по имени класса. */
public array $counts = [];

/**
* @param mixed $default
* @return mixed
*/
public function getOption(string $key, $options = null, $default = null)
{
return $default;
return array_key_exists($key, $this->optionOverrides) ? $this->optionOverrides[$key] : $default;
}

/**
* Подпись намеренно без типов — тесты переопределяют метод в анонимных
* подклассах со своей, более слабой.
*
* @param mixed $className
* @param mixed $criteria
*/
public function getCount($className, $criteria = null)
{
return $this->counts[(string) $className] ?? 0;
}

/**
Expand Down
Loading