Issues (169)

src/LinkCrawler.php (1 issue)

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);
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
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);
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);
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);
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);
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