Passed
Pull Request — main (#39)
by Tom
02:35
created

ResolveCollectionFactory::parseArrayValue()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 3
nc 2
nop 3
dl 0
loc 7
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace ApiSkeletons\Doctrine\ORM\GraphQL\Resolve;
6
7
use ApiSkeletons\Doctrine\ORM\GraphQL\Config;
8
use ApiSkeletons\Doctrine\ORM\GraphQL\Event\Criteria as CriteriaEvent;
9
use ApiSkeletons\Doctrine\ORM\GraphQL\Filter\Filters;
10
use ApiSkeletons\Doctrine\ORM\GraphQL\Type\Entity;
11
use ApiSkeletons\Doctrine\ORM\GraphQL\Type\TypeManager;
12
use ArrayObject;
13
use Closure;
14
use Doctrine\Common\Collections\Collection;
15
use Doctrine\Common\Collections\Criteria;
16
use Doctrine\Common\Util\ClassUtils;
17
use Doctrine\ORM\EntityManager;
18
use Doctrine\ORM\PersistentCollection;
19
use GraphQL\Type\Definition\ResolveInfo;
20
use League\Event\EventDispatcher;
21
22
use function base64_decode;
23
use function base64_encode;
24
use function count;
25
26
/**
27
 * Build a resolver for collections
28
 */
29
class ResolveCollectionFactory
30
{
31
    public function __construct(
32
        protected EntityManager $entityManager,
33
        protected Config $config,
34
        protected FieldResolver $fieldResolver,
35
        protected TypeManager $typeManager,
36
        protected EventDispatcher $eventDispatcher,
37
        protected ArrayObject $metadata,
38
    ) {
39
    }
40
41
    public function get(Entity $entity): Closure
42
    {
43
        return function ($source, array $args, $context, ResolveInfo $info) {
44
            $fieldResolver = $this->fieldResolver;
45
            $collection    = $fieldResolver($source, $args, $context, $info);
46
47
            $entityClassName = ClassUtils::getRealClass($source::class);
48
49
            $targetClassName = (string) $this->entityManager->getMetadataFactory()
50
                ->getMetadataFor($entityClassName)
51
                ->getAssociationTargetClass($info->fieldName);
52
53
            return $this->buildPagination(
54
                $entityClassName,
55
                $targetClassName,
56
                $args['pagination'] ?? [],
57
                $collection,
58
                $this->buildCriteria($args['filter'] ?? []),
59
                $this->metadata[$entityClassName]['fields'][$info->fieldName]['criteriaEventName'],
60
                $source,
61
                $args,
62
                $context,
63
                $info,
64
            );
65
        };
66
    }
67
68
    /** @param mixed[] $filter */
69
    protected function buildCriteria(array $filter): Criteria
70
    {
71
        $orderBy  = [];
72
        $criteria = Criteria::create();
73
74
        foreach ($filter as $field => $filters) {
75
            foreach ($filters as $filter => $value) {
76
                switch (Filters::from($filter)) {
77
                    case Filters::IN:
78
                    case Filters::NOTIN:
79
                        $criteria->andWhere($criteria->expr()->$filter($field, $value));
80
                        break;
81
                    case Filters::ISNULL:
82
                        $criteria->andWhere($criteria->expr()->$filter($field));
83
                        break;
84
                    case Filters::BETWEEN:
85
                        $criteria->andWhere($criteria->expr()->gte($field, $value['from']));
86
                        $criteria->andWhere($criteria->expr()->lte($field, $value['to']));
87
                        break;
88
                    case Filters::SORT:
89
                        $orderBy[$field] = $value;
90
                        break;
91
                    default:
92
                        $criteria->andWhere($criteria->expr()->$filter($field, $value));
93
                        break;
94
                }
95
            }
96
        }
97
98
        if (! empty($orderBy)) {
99
            $criteria->orderBy($orderBy);
100
        }
101
102
        return $criteria;
103
    }
104
105
    /**
106
     * @param mixed[] $pagination
107
     *
108
     * @return mixed[]
109
     */
110
    protected function buildPagination(
111
        string $entityClassName,
112
        string $targetClassName,
113
        array $pagination,
114
        PersistentCollection $collection,
115
        Criteria $criteria,
116
        string|null $criteriaEventName,
117
        mixed ...$resolve,
118
    ): array {
119
        $paginationFields = [
120
            'first' => 0,
121
            'last' => 0,
122
            'after' => 0,
123
            'before' => 0,
124
        ];
125
126
        // Pagination
127
        foreach ($pagination as $field => $value) {
128
            switch ($field) {
129
                case 'after':
130
                    $paginationFields[$field] = (int) base64_decode($value, true) + 1;
131
                    break;
132
                case 'before':
133
                    $paginationFields[$field] = (int) base64_decode($value, true);
134
                    break;
135
                default:
136
                    $paginationFields[$field] = $value;
137
                    break;
138
            }
139
        }
140
141
        $itemCount = count($collection->matching($criteria));
142
143
        $offsetAndLimit = $this->calculateOffsetAndLimit($resolve[3]->fieldName, $entityClassName, $targetClassName, $paginationFields, $itemCount);
144
        if ($offsetAndLimit['offset']) {
145
            $criteria->setFirstResult($offsetAndLimit['offset']);
146
        }
147
148
        if ($offsetAndLimit['limit']) {
149
            $criteria->setMaxResults($offsetAndLimit['limit']);
150
        }
151
152
        /**
153
         * Fire the event dispatcher using the passed event name.
154
         */
155
        if ($criteriaEventName) {
156
            $this->eventDispatcher->dispatch(
157
                new CriteriaEvent(
158
                    $criteria,
159
                    $criteriaEventName,
160
                    ...$resolve,
161
                ),
162
            );
163
        }
164
165
        $edgesAndCursors = $this->buildEdgesAndCursors($collection->matching($criteria), $offsetAndLimit, $itemCount);
166
167
        // Return entities
168
        return [
169
            'edges' => $edgesAndCursors['edges'],
170
            'totalCount' => $itemCount,
171
            'pageInfo' => [
172
                'endCursor' => $edgesAndCursors['cursors']['end'],
173
                'startCursor' => $edgesAndCursors['cursors']['start'],
174
                'hasNextPage' => $edgesAndCursors['cursors']['end'] !== $edgesAndCursors['cursors']['last'],
175
                'hasPreviousPage' => $edgesAndCursors['cursors']['first'] !== null
176
                    && $edgesAndCursors['cursors']['start'] !== $edgesAndCursors['cursors']['first'],
177
            ],
178
        ];
179
    }
180
181
    /**
182
     * @param array<string, int>     $offsetAndLimit
183
     * @param Collection<int, mixed> $items
184
     *
185
     * @return array<string, mixed>
186
     */
187
    protected function buildEdgesAndCursors(Collection $items, array $offsetAndLimit, int $itemCount): array
188
    {
189
        $edges   = [];
190
        $index   = 0;
191
        $cursors = [
192
            'first' => null,
193
            'last'  => base64_encode((string) 0),
194
            'start' => base64_encode((string) 0),
195
        ];
196
197
        foreach ($items as $item) {
198
            $cursors['last'] = base64_encode((string) ($index + $offsetAndLimit['offset']));
199
200
            $edges[] = [
201
                'node' => $item,
202
                'cursor' => $cursors['last'],
203
            ];
204
205
            if (! $cursors['first']) {
206
                $cursors['first'] = $cursors['last'];
207
            }
208
209
            $index++;
210
        }
211
212
        $endIndex       = $itemCount ? $itemCount - 1 : 0;
213
        $cursors['end'] = base64_encode((string) $endIndex);
214
215
        return [
216
            'cursors' => $cursors,
217
            'edges'   => $edges,
218
        ];
219
    }
220
221
    /**
222
     * @param array<string, int> $paginationFields
223
     *
224
     * @return array<string, int>
225
     */
226
    protected function calculateOffsetAndLimit(
227
        string $associationName,
228
        string $entityClassName,
229
        string $targetClassName,
230
        array $paginationFields,
231
        int $itemCount,
232
    ): array {
233
        $offset = 0;
234
235
        $limit            = $this->metadata[$targetClassName]['limit'];
236
        $associationLimit = $this->metadata[$entityClassName]['fields'][$associationName]['limit'] ?? null;
237
238
        if ($associationLimit) {
239
            $limit = $associationLimit;
240
        }
241
242
        if (! $limit) {
243
            $limit = $this->config->getLimit();
244
        }
245
246
        $adjustedLimit = $paginationFields['first'] ?: $paginationFields['last'] ?: $limit;
247
248
        if ($adjustedLimit < $limit) {
249
            $limit = $adjustedLimit;
250
        }
251
252
        if ($paginationFields['after']) {
253
            $offset = $paginationFields['after'];
254
        } elseif ($paginationFields['before']) {
255
            $offset = $paginationFields['before'] - $limit;
256
        }
257
258
        if ($offset < 0) {
259
            $limit += $offset;
260
            $offset = 0;
261
        }
262
263
        if ($paginationFields['last'] && ! $paginationFields['before']) {
264
            $offset = $itemCount - $paginationFields['last'];
265
        }
266
267
        return [
268
            'offset' => $offset,
269
            'limit'  => $limit,
270
        ];
271
    }
272
}
273