Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.14% |
115 / 116 |
|
100.00% |
9 / 9 |
CRAP | |
100.00% |
1 / 1 |
| RecordNavigationService | |
100.00% |
115 / 115 |
|
100.00% |
9 / 9 |
25 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resolveNavigation | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
5 | |||
| computeNavigationResult | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
3 | |||
| resolveFallbackPosition | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
3 | |||
| resolvePrevId | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| resolveNextId | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| fetchPageWindow | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| executeOrderedIdsQuery | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
1 | |||
| buildContextHash | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| NavigationScope | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | n/a |
0 / 0 |
|||
| __construct | n/a |
0 / 0 |
n/a |
0 / 0 |
1 | |||||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Engine\Application\Navigation; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Engine\Application\Query\UniversalQueryBuilder; |
| 12 | use App\Core\Engine\Application\Service\SystemLevelResolver; |
| 13 | use App\Core\Engine\Application\Service\SystemLevelResolverInterface; |
| 14 | use App\Core\Engine\Application\Security\PermissionGuard; |
| 15 | use App\Core\Engine\Domain\Model\FieldMetadata; |
| 16 | use App\Core\Engine\Domain\Model\FilterMetadata; |
| 17 | use App\Core\Engine\Domain\Model\ModuleMetadata; |
| 18 | use App\Core\Engine\Domain\Model\PermissionContext; |
| 19 | use App\Core\Engine\Domain\Model\RecordNavigationResult; |
| 20 | use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface; |
| 21 | use App\Core\Grid\GridRequest; |
| 22 | use Throwable; |
| 23 | use Yiisoft\Cache\CacheInterface; |
| 24 | |
| 25 | /** |
| 26 | * Record Navigation Application Service. |
| 27 | * |
| 28 | * Resolves previous/next record primary keys and dataset position within the active |
| 29 | * list context (filters, sorting, pagination) using cached sliding ID windows. |
| 30 | * |
| 31 | * @package App\Core\Engine\Application\Navigation |
| 32 | */ |
| 33 | final readonly class RecordNavigationService implements RecordNavigationServiceInterface |
| 34 | { |
| 35 | private const int CACHE_TTL_SECONDS = 300; |
| 36 | |
| 37 | /** |
| 38 | * RecordNavigationService constructor. |
| 39 | * |
| 40 | * @param MetadataRepositoryInterface $metadata Metadata repository. |
| 41 | * @param PermissionGuard $guard Central access control. |
| 42 | * @param UniversalQueryBuilder $queryBuilder Dynamic SQL query builder. |
| 43 | * @param CacheInterface $cache PSR-16 / Yii3 cache instance. |
| 44 | */ |
| 45 | public function __construct( |
| 46 | private MetadataRepositoryInterface $metadata, |
| 47 | private PermissionGuard $guard, |
| 48 | private UniversalQueryBuilder $queryBuilder, |
| 49 | private CacheInterface $cache, |
| 50 | private ?SystemLevelResolverInterface $systemLevelResolver = null, |
| 51 | ) { |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Resolves previous and next record IDs and list position for the given record. |
| 56 | * |
| 57 | * @param string $moduleName Module machine name. |
| 58 | * @param int $recordId Current record primary key. |
| 59 | * @param GridRequest $request Active list filter/sort/page state. |
| 60 | * @param PermissionContext $context Security context. |
| 61 | * @param int|null $filterId Optional active filter ID. |
| 62 | * @return RecordNavigationResult Computed navigation result. |
| 63 | */ |
| 64 | public function resolveNavigation( |
| 65 | string $moduleName, |
| 66 | int $recordId, |
| 67 | GridRequest $request, |
| 68 | PermissionContext $context, |
| 69 | ?int $filterId = null, |
| 70 | ): RecordNavigationResult { |
| 71 | $module = $this->metadata->findModule($moduleName); |
| 72 | $this->guard->assertReadAccess($module, $context); |
| 73 | |
| 74 | if ($module->type === 'dynamic' || $module->tableName === '') { |
| 75 | return new RecordNavigationResult(null, null, 1, 0, 1, 1); |
| 76 | } |
| 77 | |
| 78 | $fields = $this->metadata->findFields($module->id); |
| 79 | $filter = $this->metadata->findFilter($module->id, $filterId); |
| 80 | $applyOwner = $this->guard->shouldApplyOwnerScope($module, $context); |
| 81 | |
| 82 | if ($this->systemLevelResolver !== null && $this->systemLevelResolver->isLevelScopedModule($moduleName)) { |
| 83 | $effectiveLevel = $request->level ?? SystemLevelResolver::DEFAULT_LEVEL; |
| 84 | $resolvedTable = $this->systemLevelResolver->resolveTableName($module, $effectiveLevel); |
| 85 | $module = $module->withTableName($resolvedTable); |
| 86 | $fields = $this->systemLevelResolver->adaptFieldsForLevel($fields, $effectiveLevel); |
| 87 | } |
| 88 | $contextHash = $this->buildContextHash($moduleName, $filterId, $request, $context, $applyOwner); |
| 89 | |
| 90 | $scope = new NavigationScope( |
| 91 | $module, |
| 92 | $fields, |
| 93 | $filter, |
| 94 | $request, |
| 95 | $context, |
| 96 | $applyOwner, |
| 97 | $contextHash |
| 98 | ); |
| 99 | |
| 100 | return $this->computeNavigationResult($scope, $recordId); |
| 101 | } |
| 102 | |
| 103 | /** |
| 104 | * Computes navigation result from scope and current record ID. |
| 105 | * |
| 106 | * @param NavigationScope $scope Navigation context scope. |
| 107 | * @param int $recordId Record identifier. |
| 108 | * @return RecordNavigationResult Navigation result. |
| 109 | */ |
| 110 | private function computeNavigationResult(NavigationScope $scope, int $recordId): RecordNavigationResult |
| 111 | { |
| 112 | $limit = max(1, min(200, $scope->request->limit)); |
| 113 | $page = max(1, $scope->request->page); |
| 114 | $window = $this->fetchPageWindow($scope, $page); |
| 115 | $ids = $window['ids']; |
| 116 | $total = $window['total']; |
| 117 | $totalPages = max(1, (int) ceil($total / $limit)); |
| 118 | |
| 119 | $index = array_search($recordId, $ids, true); |
| 120 | if ($index === false) { |
| 121 | $fallback = $this->resolveFallbackPosition($scope, $recordId, $limit, $total); |
| 122 | if ($fallback !== null) { |
| 123 | return $fallback; |
| 124 | } |
| 125 | $page = max(1, (int) ceil($this->queryBuilder->findRecordPosition( |
| 126 | $scope->module, |
| 127 | $scope->fields, |
| 128 | $scope->filter, |
| 129 | $scope->request, |
| 130 | $scope->context, |
| 131 | $scope->applyOwner, |
| 132 | $recordId |
| 133 | ) / $limit)); |
| 134 | $window = $this->fetchPageWindow($scope, $page); |
| 135 | $ids = $window['ids']; |
| 136 | $index = (int) array_search($recordId, $ids, true); |
| 137 | } |
| 138 | |
| 139 | $currentPos = ($page - 1) * $limit + (int) $index + 1; |
| 140 | $prevId = $this->resolvePrevId((int) $index, $ids, $page, $scope); |
| 141 | $nextId = $this->resolveNextId((int) $index, $ids, $page, $totalPages, $scope); |
| 142 | |
| 143 | return new RecordNavigationResult($prevId, $nextId, $currentPos, $total, $page, $totalPages); |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Resolves fallback navigation result when record is not in current window. |
| 148 | * |
| 149 | * @param NavigationScope $scope Navigation scope. |
| 150 | * @param int $recordId Record identifier. |
| 151 | * @param int $limit Page size limit. |
| 152 | * @param int $total Total records count. |
| 153 | * @return RecordNavigationResult|null Result or null if position is valid. |
| 154 | */ |
| 155 | private function resolveFallbackPosition( |
| 156 | NavigationScope $scope, |
| 157 | int $recordId, |
| 158 | int $limit, |
| 159 | int $total, |
| 160 | ): ?RecordNavigationResult { |
| 161 | $pos = $this->queryBuilder->findRecordPosition( |
| 162 | $scope->module, |
| 163 | $scope->fields, |
| 164 | $scope->filter, |
| 165 | $scope->request, |
| 166 | $scope->context, |
| 167 | $scope->applyOwner, |
| 168 | $recordId |
| 169 | ); |
| 170 | |
| 171 | $totalPages = max(1, (int) ceil($total / $limit)); |
| 172 | if ($pos <= 0) { |
| 173 | return new RecordNavigationResult(null, null, 1, $total, 1, $totalPages); |
| 174 | } |
| 175 | |
| 176 | $targetPage = max(1, (int) ceil($pos / $limit)); |
| 177 | $window = $this->fetchPageWindow($scope, $targetPage); |
| 178 | if (!in_array($recordId, $window['ids'], true)) { |
| 179 | return new RecordNavigationResult(null, null, $pos, $total, $targetPage, $totalPages); |
| 180 | } |
| 181 | |
| 182 | return null; |
| 183 | } |
| 184 | |
| 185 | /** |
| 186 | * Resolves the previous record ID, checking prior page window if at start boundary. |
| 187 | * |
| 188 | * @param int $index Index in current page window. |
| 189 | * @param array<int, int> $ids Current page IDs. |
| 190 | * @param int $page Current page number. |
| 191 | * @param NavigationScope $scope Navigation context scope. |
| 192 | * @return int|null Previous record ID or null. |
| 193 | */ |
| 194 | private function resolvePrevId( |
| 195 | int $index, |
| 196 | array $ids, |
| 197 | int $page, |
| 198 | NavigationScope $scope, |
| 199 | ): ?int { |
| 200 | if ($index > 0) { |
| 201 | return $ids[$index - 1]; |
| 202 | } |
| 203 | if ($page <= 1) { |
| 204 | return null; |
| 205 | } |
| 206 | |
| 207 | $prevWindow = $this->fetchPageWindow($scope, $page - 1); |
| 208 | $prevIds = $prevWindow['ids']; |
| 209 | |
| 210 | return !empty($prevIds) ? $prevIds[count($prevIds) - 1] : null; |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Resolves the next record ID, checking subsequent page window if at end boundary. |
| 215 | * |
| 216 | * @param int $index Index in current page window. |
| 217 | * @param array<int, int> $ids Current page IDs. |
| 218 | * @param int $page Current page number. |
| 219 | * @param int $totalPages Total available pages. |
| 220 | * @param NavigationScope $scope Navigation context scope. |
| 221 | * @return int|null Next record ID or null. |
| 222 | */ |
| 223 | private function resolveNextId( |
| 224 | int $index, |
| 225 | array $ids, |
| 226 | int $page, |
| 227 | int $totalPages, |
| 228 | NavigationScope $scope, |
| 229 | ): ?int { |
| 230 | if ($index < count($ids) - 1) { |
| 231 | return $ids[$index + 1]; |
| 232 | } |
| 233 | if ($page >= $totalPages) { |
| 234 | return null; |
| 235 | } |
| 236 | |
| 237 | $nextWindow = $this->fetchPageWindow($scope, $page + 1); |
| 238 | $nextIds = $nextWindow['ids']; |
| 239 | |
| 240 | return !empty($nextIds) ? $nextIds[0] : null; |
| 241 | } |
| 242 | |
| 243 | /** |
| 244 | * Fetches or retrieves from cache an ID window for a specific page. |
| 245 | * |
| 246 | * @param NavigationScope $scope Navigation scope. |
| 247 | * @param int $page Target page number. |
| 248 | * @return array{ids: array<int, int>, total: int} Page ID window and total count. |
| 249 | */ |
| 250 | private function fetchPageWindow( |
| 251 | NavigationScope $scope, |
| 252 | int $page, |
| 253 | ): array { |
| 254 | $cacheKey = sprintf('engine:nav:%s:%s:p%d', $scope->module->name, $scope->contextHash, $page); |
| 255 | |
| 256 | try { |
| 257 | return $this->cache->getOrSet( |
| 258 | $cacheKey, |
| 259 | fn(): array => $this->executeOrderedIdsQuery($scope, $page), |
| 260 | self::CACHE_TTL_SECONDS |
| 261 | ); |
| 262 | } catch (Throwable) { |
| 263 | return $this->executeOrderedIdsQuery($scope, $page); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Executes dynamic ordered IDs SQL query for specified page. |
| 269 | * |
| 270 | * @param NavigationScope $scope Navigation scope. |
| 271 | * @param int $page Target page number. |
| 272 | * @return array{ids: array<int, int>, total: int} Ordered IDs and total. |
| 273 | */ |
| 274 | private function executeOrderedIdsQuery(NavigationScope $scope, int $page): array |
| 275 | { |
| 276 | $pageRequest = new GridRequest( |
| 277 | page: $page, |
| 278 | limit: $scope->request->limit, |
| 279 | sortColumn: $scope->request->sortColumn, |
| 280 | sortDirection: $scope->request->sortDirection, |
| 281 | filters: $scope->request->filters, |
| 282 | ); |
| 283 | |
| 284 | return $this->queryBuilder->fetchOrderedIds( |
| 285 | $scope->module, |
| 286 | $scope->fields, |
| 287 | $scope->filter, |
| 288 | $pageRequest, |
| 289 | $scope->context, |
| 290 | $scope->applyOwner |
| 291 | ); |
| 292 | } |
| 293 | |
| 294 | /** |
| 295 | * Generates a deterministic short hash representing the unique query state. |
| 296 | * |
| 297 | * @param string $moduleName Module machine name. |
| 298 | * @param int|null $filterId Active filter ID. |
| 299 | * @param GridRequest $request Grid request. |
| 300 | * @param PermissionContext $context Security context. |
| 301 | * @param bool $applyOwner Whether owner scope is applied. |
| 302 | * @return string 16-character SHA-256 hash substring. |
| 303 | */ |
| 304 | private function buildContextHash( |
| 305 | string $moduleName, |
| 306 | ?int $filterId, |
| 307 | GridRequest $request, |
| 308 | PermissionContext $context, |
| 309 | bool $applyOwner, |
| 310 | ): string { |
| 311 | $payload = [ |
| 312 | 'm' => $moduleName, |
| 313 | 'f' => $filterId ?? 0, |
| 314 | 'flt' => $request->filters, |
| 315 | 'sc' => $request->sortColumn ?? '', |
| 316 | 'sd' => $request->sortDirection, |
| 317 | 'l' => $request->limit, |
| 318 | 'u' => $applyOwner ? $context->actorUserId : 0, |
| 319 | ]; |
| 320 | |
| 321 | return substr(hash('sha256', (string) json_encode($payload)), 0, 16); |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | /** |
| 326 | * Value object encapsulating navigation query scope and metadata. |
| 327 | * |
| 328 | * @internal |
| 329 | */ |
| 330 | final readonly class NavigationScope |
| 331 | { |
| 332 | /** |
| 333 | * NavigationScope constructor. |
| 334 | * |
| 335 | * @param ModuleMetadata $module Module metadata. |
| 336 | * @param array<int, FieldMetadata> $fields Field metadata list. |
| 337 | * @param FilterMetadata $filter Filter metadata. |
| 338 | * @param GridRequest $request Grid request. |
| 339 | * @param PermissionContext $context Security context. |
| 340 | * @param bool $applyOwner Whether to apply owner scope. |
| 341 | * @param string $contextHash Unique query hash. |
| 342 | */ |
| 343 | // @codeCoverageIgnoreStart |
| 344 | public function __construct( |
| 345 | public ModuleMetadata $module, |
| 346 | public array $fields, |
| 347 | public FilterMetadata $filter, |
| 348 | public GridRequest $request, |
| 349 | public PermissionContext $context, |
| 350 | public bool $applyOwner, |
| 351 | public string $contextHash, |
| 352 | ) { |
| 353 | } |
| 354 | // @codeCoverageIgnoreEnd |
| 355 | } |