Failed Conditions
Push — master ( a0e504...43d634 )
by Adrien
04:33
created

Standard   A

Complexity

Total Complexity 26

Size/Duplication

Total Lines 339
Duplicated Lines 0 %

Test Coverage

Coverage 88.14%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
wmc 26
eloc 159
c 2
b 0
f 0
dl 0
loc 339
ccs 171
cts 194
cp 0.8814
rs 10

9 Methods

Rating   Name   Duplication   Size   Complexity  
A filterInput() 0 9 4
A getDefaultSorting() 0 22 4
A buildMutation() 0 77 3
A buildQuery() 0 34 1
A createFilteredQueryBuilder() 0 19 2
A buildRelationMutation() 0 67 4
A getSingleArguments() 0 7 1
A customTypesToScalar() 0 13 5
A getListArguments() 0 19 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Application\Api\Field;
6
7
use Application\Api\Helper;
8
use Application\Model\AbstractModel;
9
use Application\Model\Account;
10
use Application\Model\Bookable;
11
use Application\Model\ExpenseClaim;
12
use Application\Model\TransactionLine;
13
use Application\Model\User;
14
use BackedEnum;
15
use Doctrine\ORM\QueryBuilder;
16
use Ecodev\Felix\Api\Field\FieldInterface;
17
use Ecodev\Felix\Api\Input\PaginationInputType;
18
use Ecodev\Felix\Api\Plural;
19
use GraphQL\Type\Definition\Type;
20
use Money\Money;
21
use ReflectionClass;
22
23
/**
24
 * Provide easy way to build standard fields to query and mutate objects.
25
 *
26
 * @phpstan-import-type PermissiveFieldsConfig from FieldInterface
27
 */
28
abstract class Standard
29
{
30
    /**
31
     * Returns standard fields to query the object.
32
     *
33
     * @param class-string<AbstractModel> $class
34
     *
35
     * @return PermissiveFieldsConfig
36
     */
37 6
    public static function buildQuery(string $class): iterable
38
    {
39 1
        $metadata = _em()->getClassMetadata($class);
40 1
        $reflect = $metadata->getReflectionClass();
41 1
        $name = lcfirst($reflect->getShortName());
42 1
        $shortName = $reflect->getShortName();
43 1
        $plural = Plural::make($name);
44
45 1
        $listArgs = self::getListArguments($class);
46 1
        $singleArgs = self::getSingleArguments($class);
47
48 1
        yield $plural => fn () => [
0 ignored issues
show
Bug Best Practice introduced by
The expression yield $plural => function(...) { /* ... */ } returns the type Generator which is incompatible with the documented return type Application\Api\Field\PermissiveFieldsConfig.
Loading history...
49 6
            'type' => Type::nonNull(_types()->get($shortName . 'Pagination')),
50 6
            'args' => $listArgs,
51 6
            'resolve' => function ($root, array $args) use ($class): array {
52 5
                $qb = self::createFilteredQueryBuilder($class, $args);
53
54 5
                $items = Helper::paginate($args['pagination'], $qb);
55 5
                $aggregatedFields = Helper::aggregatedFields($class, $qb);
56 5
                $result = array_merge($aggregatedFields, $items);
57
58 5
                return $result;
59 6
            },
60 6
        ];
61
62 1
        yield $name => fn () => [
63 1
            'type' => Type::nonNull(_types()->getOutput($class)),
64 1
            'args' => $singleArgs,
65 1
            'resolve' => function ($root, array $args): ?AbstractModel {
66 1
                $object = $args['id']->getEntity();
67
68 1
                Helper::throwIfDenied($object, 'read');
69
70 1
                return $object;
71 1
            },
72 1
        ];
73
    }
74
75
    /**
76
     * Returns standard fields to mutate the object.
77
     *
78
     * @param class-string<AbstractModel> $class
79
     *
80
     * @return PermissiveFieldsConfig
81
     */
82 7
    public static function buildMutation(string $class): iterable
83
    {
84 1
        $reflect = new ReflectionClass($class);
85 1
        $name = $reflect->getShortName();
86 1
        $plural = Plural::make($name);
87
88 1
        yield 'create' . $name => fn () => [
0 ignored issues
show
Bug Best Practice introduced by
The expression yield 'create' . $name =...tion(...) { /* ... */ } returns the type Generator which is incompatible with the documented return type Application\Api\Field\PermissiveFieldsConfig.
Loading history...
89 7
            'type' => Type::nonNull(_types()->getOutput($class)),
90 7
            'description' => 'Create a new ' . $name,
91 7
            'args' => [
92 7
                'input' => Type::nonNull(_types()->getInput($class)),
93 7
            ],
94 7
            'resolve' => function ($root, array $args) use ($class): AbstractModel {
95
                // Do it
96 6
                $object = new $class();
97 6
                $input = $args['input'];
98 6
                Helper::hydrate($object, $input);
99
100
                // Check ACL
101 6
                Helper::throwIfDenied($object, 'create');
102
103 5
                _em()->persist($object);
104 5
                _em()->flush();
105
106 5
                return $object;
107 7
            },
108 7
        ];
109
110 1
        yield 'update' . $name => fn () => [
111 7
            'type' => Type::nonNull(_types()->getOutput($class)),
112 7
            'description' => 'Update an existing ' . $name,
113 7
            'args' => [
114 7
                'id' => Type::nonNull(_types()->getId($class)),
115 7
                'input' => Type::nonNull(_types()->getPartialInput($class)),
116 7
            ],
117 7
            'resolve' => function ($root, array $args) use ($class): AbstractModel {
118 6
                $object = $args['id']->getEntity();
119
120
                // Check ACL
121 6
                Helper::throwIfDenied($object, 'update');
122
123
                // Do it
124 4
                $input = $args['input'];
125 4
                $filteredInput = self::filterInput($class, $input);
126 4
                Helper::hydrate($object, $filteredInput);
127
128 4
                _em()->flush();
129
130
                // Refresh budgetBalance that is computed by the DB
131 4
                if ($class === Account::class) {
132 1
                    _em()->refresh($object);
133
                }
134
135 4
                return $object;
136 7
            },
137 7
        ];
138
139 1
        yield 'delete' . $plural => fn () => [
140 1
            'type' => Type::nonNull(Type::boolean()),
141 1
            'description' => 'Delete one or several existing ' . $name,
142 1
            'args' => [
143 1
                'ids' => Type::nonNull(Type::listOf(Type::nonNull(_types()->getId($class)))),
144 1
            ],
145 1
            'resolve' => function ($root, array $args): bool {
146 2
                foreach ($args['ids'] as $id) {
147 2
                    $object = $id->getEntity();
148
149
                    // Check ACL
150 2
                    Helper::throwIfDenied($object, 'delete');
151
152
                    // Do it
153 1
                    _em()->remove($object);
154
                }
155
156 1
                _em()->flush();
157
158 1
                return true;
159 1
            },
160 1
        ];
161
    }
162
163
    /**
164
     * @param class-string<AbstractModel> $class
165
     */
166 4
    private static function filterInput(string $class, array $input): array
167
    {
168 4
        $user = User::getCurrent();
169 4
        if ($class === Bookable::class && $user && $user->getRole() === User::ROLE_TRAINER) {
170
            // whitelist
171 1
            $input = array_intersect_key($input, array_flip(['remarks']));
172
        }
173
174 4
        return $input;
175
    }
176
177
    /**
178
     * Returns standard mutations to manage many-to-many relations between two given class.
179
     *
180
     * @param class-string<AbstractModel> $ownerClass The class owning the relation
181
     * @param class-string<AbstractModel> $otherClass The other class, not-owning the relation
182
     * @param null|string $otherName a specific semantic, if needed, to be use as adder. If `$otherName = 'Parent'`, then we will call `addParent()`
183
     *
184
     * @return PermissiveFieldsConfig
185
     */
186 1
    public static function buildRelationMutation(string $ownerClass, string $otherClass, ?string $otherName = null): iterable
187
    {
188 1
        $ownerReflect = new ReflectionClass($ownerClass);
189 1
        $ownerName = $ownerReflect->getShortName();
190 1
        $lowerOwnerName = lcfirst($ownerName);
191
192 1
        $otherReflect = new ReflectionClass($otherClass);
193 1
        $otherClassName = $otherReflect->getShortName();
194 1
        if ($otherName) {
195 1
            $semantic = ' as ' . $otherName;
196
        } else {
197 1
            $semantic = '';
198 1
            $otherName = $otherClassName;
199
        }
200 1
        $lowerOtherName = lcfirst($otherName);
201
202 1
        if ($lowerOwnerName === $lowerOtherName) {
203
            $lowerOwnerName .= 1;
204
            $lowerOtherName .= 2;
205
        }
206
207 1
        $args = [
208 1
            $lowerOwnerName => Type::nonNull(_types()->getId($ownerClass)),
209 1
            $lowerOtherName => Type::nonNull(_types()->getId($otherClass)),
210 1
        ];
211
212 1
        yield 'link' . $ownerName . $otherName => fn () => [
0 ignored issues
show
Bug Best Practice introduced by
The expression yield 'link' . $ownerNam...tion(...) { /* ... */ } returns the type Generator which is incompatible with the documented return type Application\Api\Field\PermissiveFieldsConfig.
Loading history...
213 1
            'type' => Type::nonNull(_types()->getOutput($ownerClass)),
214 1
            'description' => 'Create a relation between ' . $ownerName . ' and ' . $otherClassName . $semantic . '.' . PHP_EOL . PHP_EOL
215 1
                . 'If the relation already exists, it will have no effect.',
216 1
            'args' => $args,
217 1
            'resolve' => function ($root, array $args) use ($lowerOwnerName, $lowerOtherName, $otherName): AbstractModel {
218
                $owner = $args[$lowerOwnerName]->getEntity();
219
                $other = $args[$lowerOtherName]->getEntity();
220
221
                // Check ACL
222
                Helper::throwIfDenied($owner, 'update');
223
224
                // Do it
225
                $method = 'add' . $otherName;
226
                $owner->$method($other);
227
                _em()->flush();
228
229
                return $owner;
230 1
            },
231 1
        ];
232
233 1
        yield 'unlink' . $ownerName . $otherName => fn () => [
234 1
            'type' => Type::nonNull(_types()->getOutput($ownerClass)),
235 1
            'description' => 'Delete a relation between ' . $ownerName . ' and ' . $otherClassName . $semantic . '.' . PHP_EOL . PHP_EOL
236 1
                . 'If the relation does not exist, it will have no effect.',
237 1
            'args' => $args,
238 1
            'resolve' => function ($root, array $args) use ($lowerOwnerName, $lowerOtherName, $otherName): AbstractModel {
239
                $owner = $args[$lowerOwnerName]->getEntity();
240
                $other = $args[$lowerOtherName]->getEntity();
241
242
                // Check ACL
243
                Helper::throwIfDenied($owner, 'update');
244
245
                // Do it
246
                if ($other) {
247
                    $method = 'remove' . $otherName;
248
                    $owner->$method($other);
249
                    _em()->flush();
250
                }
251
252
                return $owner;
253 1
            },
254 1
        ];
255
    }
256
257
    /**
258
     * Return arguments used for the list.
259
     *
260
     * @param class-string<AbstractModel> $class
261
     */
262 1
    public static function getListArguments(string $class, bool $includePagination = true): array
263
    {
264 1
        $listArgs = [
265 1
            [
266 1
                'name' => 'filter',
267 1
                'type' => _types()->getFilter($class),
268 1
            ],
269 1
            [
270 1
                'name' => 'sorting',
271 1
                'type' => _types()->getSorting($class),
272 1
                'defaultValue' => self::getDefaultSorting($class),
273 1
            ],
274 1
        ];
275
276 1
        if ($includePagination) {
277 1
            $listArgs[] = PaginationInputType::build(_types());
278
        }
279
280 1
        return $listArgs;
281
    }
282
283
    /**
284
     * Return arguments used for single item.
285
     *
286
     * @param class-string<AbstractModel> $class
287
     */
288 1
    private static function getSingleArguments(string $class): array
289
    {
290 1
        $args = [
291 1
            'id' => Type::nonNull(_types()->getId($class)),
292 1
        ];
293
294 1
        return $args;
295
    }
296
297
    /**
298
     * Get default sorting values with some fallback for some special cases.
299
     *
300
     * @param class-string<AbstractModel> $class
301
     */
302 5
    private static function getDefaultSorting(string $class): array
303
    {
304 5
        $defaultSorting = [];
305
306 5
        if ($class === Account::class) {
307 1
            $defaultSorting[] = [
308 1
                'field' => 'code',
309 1
                'order' => 'ASC',
310 1
            ];
311 5
        } elseif ($class === TransactionLine::class) {
312 1
            $defaultSorting[] = [
313 1
                'field' => 'transactionDate',
314 1
                'order' => 'DESC',
315 1
            ];
316 5
        } elseif ($class === ExpenseClaim::class) {
317 1
            $defaultSorting[] = [
318 1
                'field' => 'creationDate',
319 1
                'order' => 'DESC',
320 1
            ];
321
        }
322
323 5
        return $defaultSorting;
324
    }
325
326
    /**
327
     * Recursively convert custom scalars that don't implement __toString() to their scalar
328
     * representation to injected back into DQL/SQL.
329
     */
330 5
    private static function customTypesToScalar(array $args): array
331
    {
332 5
        foreach ($args as &$p) {
333
            if (is_array($p)) {
334
                $p = self::customTypesToScalar($p);
335
            } elseif ($p instanceof Money) {
336
                $p = $p->getAmount();
337
            } elseif ($p instanceof BackedEnum) {
338
                $p = $p->value;
339
            }
340
        }
341
342 5
        return $args;
343
    }
344
345
    /**
346
     * @param class-string<AbstractModel> $class
347
     */
348 5
    public static function createFilteredQueryBuilder(string $class, array $args): QueryBuilder
349
    {
350 5
        $filters = self::customTypesToScalar($args['filter'] ?? []);
351
352
        // If null or empty list is provided by client, fallback on default sorting
353 5
        $sorting = $args['sorting'] ?? [];
354 5
        if (!$sorting) {
355 4
            $sorting = self::getDefaultSorting($class);
356
        }
357
358
        // And **always** sort by ID
359 5
        $sorting[] = [
360 5
            'field' => 'id',
361 5
            'order' => 'ASC',
362 5
        ];
363
364 5
        $qb = _types()->createFilteredQueryBuilder($class, $filters, $sorting);
365
366 5
        return $qb;
367
    }
368
}
369