Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.14% covered (success)
99.14%
115 / 116
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
RecordNavigationService
100.00% covered (success)
100.00%
115 / 115
100.00% covered (success)
100.00%
9 / 9
25
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveNavigation
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
5
 computeNavigationResult
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
3
 resolveFallbackPosition
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 resolvePrevId
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 resolveNextId
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 fetchPageWindow
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 executeOrderedIdsQuery
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 buildContextHash
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
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
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Engine\Application\Navigation;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Query\UniversalQueryBuilder;
12use App\Core\Engine\Application\Service\SystemLevelResolver;
13use App\Core\Engine\Application\Service\SystemLevelResolverInterface;
14use App\Core\Engine\Application\Security\PermissionGuard;
15use App\Core\Engine\Domain\Model\FieldMetadata;
16use App\Core\Engine\Domain\Model\FilterMetadata;
17use App\Core\Engine\Domain\Model\ModuleMetadata;
18use App\Core\Engine\Domain\Model\PermissionContext;
19use App\Core\Engine\Domain\Model\RecordNavigationResult;
20use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
21use App\Core\Grid\GridRequest;
22use Throwable;
23use 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 */
33final 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 */
330final 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}