LinkCrawler::crawlBody()   B
last analyzed

Complexity

Conditions 8
Paths 6

Size

Total Lines 37
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 21
nc 6
nop 3
dl 0
loc 37
rs 8.4444
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace BEAR\Resource;
6
7
use BEAR\Resource\Annotation\Link;
8
use BEAR\Resource\DataLoader\DataLoader;
9
use Override;
10
use ReflectionMethod;
11
12
use function array_filter;
13
use function array_key_exists;
14
use function array_keys;
15
use function array_map;
16
use function array_pop;
17
use function assert;
18
use function count;
19
use function is_array;
20
use function is_numeric;
21
use function ucfirst;
22
use function uri_template;
23
24
/**
25
 * Sequential link crawler
26
 *
27
 * @psalm-import-type Body from Types
28
 * @psalm-import-type BodyList from Types
29
 * @psalm-import-type BodyOrStringList from Types
30
 * @psalm-import-type Query from Types
31
 * @psalm-import-type QueryList from Types
32
 */
33
final class LinkCrawler implements LinkCrawlerInterface
34
{
35
    /** @var array<string, array<mixed>|null> */
36
    private array $cache = [];
37
38
    public function __construct(
39
        private readonly InvokerInterface $invoker,
40
        private readonly FactoryInterface $factory,
41
        private readonly DataLoader|null $dataLoader = null,
42
    ) {
43
    }
44
45
    #[Override]
46
    public function crawl(array $annotations, LinkType $link, array &$bodyList): void
47
    {
48
        // Process DataLoader-enabled links first
49
        /** @var list<array<string, mixed>> $bodyList */
50
        $this->dataLoader?->load($annotations, $link, $bodyList);
0 ignored issues
show
Bug introduced by
$bodyList of type BEAR\Resource\list is incompatible with the type array expected by parameter $bodyList of BEAR\Resource\DataLoader\DataLoader::load(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

50
        $this->dataLoader?->load($annotations, $link, /** @scrutinizer ignore-type */ $bodyList);
Loading history...
51
52
        // Process non-DataLoader links
53
        foreach ($bodyList as &$body) {
54
            $this->crawlBody($annotations, $link, $body);
55
        }
56
57
        unset($body);
58
    }
59
60
    /**
61
     * @param list<Link>           $annotations
0 ignored issues
show
Bug introduced by
The type BEAR\Resource\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
62
     * @param array<string, mixed> $body
63
     *
64
     * @param-out array<string, mixed> $body
65
     */
66
    private function crawlBody(array $annotations, LinkType $link, array &$body): void
67
    {
68
        foreach ($annotations as $annotation) {
69
            if ($annotation->crawl !== $link->key) {
70
                continue;
71
            }
72
73
            // Skip DataLoader-enabled links (already processed by DataLoader)
74
            if ($annotation->dataLoader !== null && $this->dataLoader !== null) {
75
                continue;
76
            }
77
78
            $uri = uri_template($annotation->href, $body);
79
            $rel = $this->factory->newInstance($uri);
80
            $query = (new Uri($uri))->query;
81
            $request = new Request($this->invoker, $rel, Request::GET, $query);
0 ignored issues
show
Bug introduced by
$query of type BEAR\Resource\Query is incompatible with the type array expected by parameter $query of BEAR\Resource\Request::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

81
            $request = new Request($this->invoker, $rel, Request::GET, /** @scrutinizer ignore-type */ $query);
Loading history...
82
            $hash = $request->hash();
83
84
            if (array_key_exists($hash, $this->cache)) {
85
                $body[$annotation->rel] = $this->cache[$hash];
86
87
                continue;
88
            }
89
90
            // Execute request and get result
91
            $ro = $this->invoker->invoke($request);
92
            $result = $ro->body;
93
            assert(is_array($result) || $result === null);
94
            $this->cache[$hash] = $result;
95
            $body[$annotation->rel] = $result;
96
97
            // Process nested crawl recursively (even for empty arrays to trigger DataLoader)
98
            if (! is_array($result)) {
99
                continue;
100
            }
101
102
            $this->processNestedCrawl($ro, $request->method, $link, $annotation->rel, $body);
103
        }
104
    }
105
106
    /**
107
     * Process nested crawl for child resources
108
     *
109
     * @param array<string, mixed> $body
110
     *
111
     * @param-out array<string, mixed> $body
112
     */
113
    private function processNestedCrawl(
114
        ResourceObject $ro,
115
        string $method,
116
        LinkType $link,
117
        string $rel,
118
        array &$body,
119
    ): void {
120
        $nestedAnnotations = $this->getLinkAnnotations($ro, $method);
121
122
        // Check if there are any crawl annotations for this link
123
        $hasCrawlAnnotation = false;
124
        foreach ($nestedAnnotations as $annotation) {
125
            if ($annotation->crawl === $link->key) {
126
                $hasCrawlAnnotation = true;
127
                break;
128
            }
129
        }
130
131
        if (! $hasCrawlAnnotation) {
132
            return;
133
        }
134
135
        /** @var array<mixed>|null $result */
136
        $result = $body[$rel];
137
        assert(is_array($result));
138
139
        // Handle empty arrays - still call crawl() to trigger DataLoader
140
        if ($result === []) {
141
            /** @var list<array<string, mixed>> $emptyList */
142
            $emptyList = [];
143
            $this->crawl($nestedAnnotations, $link, $emptyList);
0 ignored issues
show
Bug introduced by
$emptyList of type BEAR\Resource\list is incompatible with the type array expected by parameter $bodyList of BEAR\Resource\LinkCrawler::crawl(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

143
            $this->crawl($nestedAnnotations, $link, /** @scrutinizer ignore-type */ $emptyList);
Loading history...
144
145
            return;
146
        }
147
148
        $isList = $this->isList($result);
149
        /** @var list<array<string, mixed>> $nestedBodyList */
150
        $nestedBodyList = $isList ? $result : [$result];
151
152
        // Recursively process nested level
153
        $this->crawl($nestedAnnotations, $link, $nestedBodyList);
154
155
        // Update body with nested results
156
        $body[$rel] = $isList ? $nestedBodyList : $nestedBodyList[0];
157
    }
158
159
    /**
160
     * Get Link annotations from a ResourceObject method using PHP 8 attributes
161
     *
162
     * @return list<Link>
163
     */
164
    private function getLinkAnnotations(ResourceObject $ro, string $method): array
165
    {
166
        $classMethod = 'on' . ucfirst($method);
167
        $refMethod = new ReflectionMethod($ro, $classMethod);
168
        $attributes = $refMethod->getAttributes(Link::class);
169
170
        return array_map(
0 ignored issues
show
Bug Best Practice introduced by
The expression return array_map(functio... ... */ }, $attributes) returns the type array which is incompatible with the documented return type BEAR\Resource\list.
Loading history...
171
            static fn ($attr) => $attr->newInstance(),
172
            $attributes,
173
        );
174
    }
175
176
    /**
177
     * Determine if the value is a list (multiple rows) or a single row
178
     *
179
     * List: [['id' => 1], ['id' => 2]] - crawl processes each row
180
     * Single row: ['id' => 1, 'name' => 'foo'] - crawl wraps in array, processes, unwraps
181
     */
182
    #[Override]
183
    public function isList(mixed $value): bool
184
    {
185
        assert(is_array($value));
186
        /** @var BodyList $list */
187
        $list = $value;
188
        /** @var mixed $firstRow */
189
        $firstRow = array_pop($list);
0 ignored issues
show
Bug introduced by
$list of type BEAR\Resource\BodyList is incompatible with the type array expected by parameter $array of array_pop(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

189
        $firstRow = array_pop(/** @scrutinizer ignore-type */ $list);
Loading history...
190
        $keys = array_keys((array) $firstRow);
191
192
        return $this->isSingleColumnList($value, $keys, $list)
193
            || $this->isMultiColumnMultiRowList($keys, $list)
194
            || $this->isMultiColumnList($value, $firstRow);
195
    }
196
197
    /**
198
     * Multiple rows with same column structure
199
     *
200
     * Example: [['id' => 1, 'name' => 'a'], ['id' => 2, 'name' => 'b']]
201
     *
202
     * @param list<array-key> $keys
203
     * @psalm-param BodyList   $list
204
     */
205
    private function isMultiColumnMultiRowList(array $keys, array $list): bool
206
    {
207
        if ($keys === [0 => 0]) {
208
            return false;
209
        }
210
211
        foreach ($list as $item) {
212
            if ($keys !== array_keys((array) $item)) {
213
                return false;
214
            }
215
        }
216
217
        return true;
218
    }
219
220
    /**
221
     * Numeric-indexed array where each element is an array
222
     *
223
     * Example: [0 => ['id' => 1], 1 => ['id' => 2]]
224
     *
225
     * @param array<mixed> $value
226
     */
227
    private function isMultiColumnList(array $value, mixed $firstRow): bool
228
    {
229
        return is_array($firstRow) && array_filter(array_keys($value), is_numeric(...)) === array_keys($value);
0 ignored issues
show
Bug introduced by
The type is_numeric was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
230
    }
231
232
    /**
233
     * Single element list
234
     *
235
     * Example: [0 => ['id' => 1]]
236
     *
237
     * @param array<mixed>    $value
238
     * @param list<array-key> $keys
239
     * @param array<mixed>    $list
240
     */
241
    private function isSingleColumnList(array $value, array $keys, array $list): bool
242
    {
243
        return (count($value) === 1) && $keys === array_keys($list);
244
    }
245
}
246