Completed
Pull Request — master (#34)
by Phil
02:14
created

attachRelationshipsToCollection()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 31
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 31
ccs 0
cts 21
cp 0
rs 8.5806
cc 4
eloc 19
nc 6
nop 3
crap 20
1
<?php
2
3
namespace Percy\Repository;
4
5
use Aura\Sql\ExtendedPdoInterface;
6
use InvalidArgumentException;
7
use Percy\Decorator\DecoratorTrait;
8
use Percy\Entity\Collection;
9
use Percy\Entity\CollectionBuilderTrait;
10
use Percy\Entity\EntityInterface;
11
use Percy\Http\QueryStringParserTrait;
12
use Percy\Store\StoreInterface;
13
use Psr\Http\Message\ServerRequestInterface;
14
use RuntimeException;
15
16
abstract class AbstractSqlRepository implements RepositoryInterface
17
{
18
    use CollectionBuilderTrait;
19
    use DecoratorTrait;
20
    use QueryStringParserTrait;
21
22
    /**
23
     * @var \Aura\Sql\ExtendedPdoInterface
24
     */
25
    protected $dbal;
26
27
    /**
28
     *
29
     * @var mixed
30
     */
31
    protected $relationships = [];
32
33
    /**
34
     * Construct.
35
     *
36
     * @param \Aura\Sql\ExtendedPdoInterface $dbal
37
     */
38 3
    public function __construct(ExtendedPdoInterface $dbal)
39
    {
40 3
        $this->dbal = $dbal;
41 3
    }
42
43
    /**
44
     * {@inheritdoc}
45
     */
46 1
    public function countFromRequest(ServerRequestInterface $request)
47
    {
48 1
        $rules = $this->parseQueryString($request->getUri()->getQuery());
49 1
        list($query, $params) = $this->buildQueryFromRules($rules, true);
50
51 1
        return (int) $this->dbal->fetchOne($query, $params)['total'];
52
    }
53
54
    /**
55
     * {@inheritdoc}
56
     */
57 2
    public function getFromRequest(ServerRequestInterface $request)
58
    {
59 2
        $rules = $this->parseQueryString($request->getUri()->getQuery());
60
61 2
        list($query, $params) = $this->buildQueryFromRules($rules);
62
63 2
        if (array_key_exists('sort', $rules) && ! array_key_exists('search', $rules)) {
64 2
            $entity    = $this->getEntityType();
65 2
            $entity    = new $entity;
66 2
            $mapping   = $entity->getMapping();
67 2
            $whitelist = array_keys($mapping);
68
69 2
            $query .= $this->buildSortPart($rules['sort'], $this->getTable(), $whitelist);
70 1
        }
71
72 1
        if (array_key_exists('search', $rules) && $this->acceptableField($rules['search']['fields'])) {
73 1
            $query .= sprintf(' ORDER BY MATCH (%s) AGAINST (:match_bind) > :score_bind', $rules['search']['fields']);
74 1
        }
75
76 1
        if (array_key_exists('limit', $rules)) {
77 1
            $query .= ' LIMIT ';
78 1
            $query .= (array_key_exists('offset', $rules)) ? sprintf('%d,', $rules['offset']) : '';
79 1
            $query .= sprintf('%d', $rules['limit']);
80 1
        }
81
82 1
        $query = trim(preg_replace('!\s+!', ' ', $query));
83
84 1
        $collection = $this->buildCollection($this->dbal->fetchAll($query, $params))
85 1
                           ->setTotal($this->countFromRequest($request));
86
87 1
        $this->decorate($collection, StoreInterface::ON_READ);
88
89 1
        return $collection;
90
    }
91
92
    /**
93
     * Build the sort part of the query.
94
     *
95
     * @param array|string $sorts
96
     * @param string       $table
97
     * @param array        $whitelist
98
     *
99
     * @return string
100
     */
101 2
    protected function buildSortPart($sorts, $table, array $whitelist)
102
    {
103 2
        if (is_string($sorts) && $sorts === 'RAND()') {
104 1
            return ' ORDER BY RAND()';
105
        }
106
107 2
        if (! is_array($sorts)) {
108
            return '';
109
        }
110
111 2
        $fields = [];
112
113 2
        foreach ($sorts as $sort) {
114 2
            $field = explode('.', $sort['field']);
115
116 2
            if (count($field) !== 2) {
117 1
                throw new InvalidArgumentException('Sort paramater is formatted incorrectly');
118
            }
119
120 2
            if ($field[0] !== $table && count($sorts) > 1) {
121 1
                continue;
122
            }
123
124 2
            if ($field[0] !== $table && count($sorts) < 2) {
125 1
                throw new InvalidArgumentException(
126 1
                    sprintf('(%s) is not a whitelisted field to sort by', $sort['field'])
127 1
                );
128
            }
129
130 2
            if (! in_array($field[1], $whitelist)) {
131 1
                throw new InvalidArgumentException(
132 1
                    sprintf('(%s) is not a whitelisted field to sort by', $sort['field'])
133 1
                );
134
            }
135
136 1
            $fields[] = sprintf('%s %s', $sort['field'], strtoupper($sort['direction']));
137 1
        }
138
139 1
        return (empty($fields)) ? '' : sprintf(' ORDER BY %s', implode(', ', $fields));
140
    }
141
142
    /**
143
     * Build a base query without sorting and limits from filter rules.
144
     *
145
     * @param array   $rules
146
     * @param boolean $count
147
     *
148
     * @return array
149
     */
150 2
    protected function buildQueryFromRules(array $rules, $count = false)
151
    {
152 2
        $start = ($count === false) ? 'SELECT * FROM ' : 'SELECT *, COUNT(*) as total FROM ';
153 2
        $query = $start . $this->getTable();
154
155 2
        $params = [];
156
157 2
        if (array_key_exists('filter', $rules)) {
158 2
            foreach ($rules['filter'] as $key => $where) {
159 2
                $this->acceptableField($where['field']);
160
161 1
                $keyword   = ($key === 0) ? ' WHERE' : ' AND';
162 1
                $delimiter = strtoupper($where['delimiter']);
163 1
                $binding   = (in_array($delimiter, ['IN', 'NOT IN'])) ? sprintf('(:%s)', $where['binding']) : ':' . $where['binding'];
164 1
                $query    .= sprintf('%s %s %s %s', $keyword, $where['field'], $delimiter, $binding);
165
166 1
                $params[$where['binding']] = $where['value'];
167 1
            }
168 1
        }
169
170 2
        if (array_key_exists('search', $rules) && $this->acceptableField($rules['search']['fields'])) {
171 1
            $keyword = (array_key_exists('filter', $rules)) ? ' AND' : ' WHERE';
172 1
            $query  .= sprintf('%s MATCH (%s) AGAINST (:match_bind IN BOOLEAN MODE)', $keyword, $rules['search']['fields']);
173 1
            $query  .= sprintf(' HAVING MATCH (%s) AGAINST (:match_bind) > :score_bind', $rules['search']['fields']);
174
175 1
            $params['match_bind'] = $rules['search']['term'];
176 1
            $params['score_bind'] = (array_key_exists('minscore', $rules)) ? $rules['minscore'] : 0;
177 1
        }
178
179 2
        return [$query, $params];
180
    }
181
182
    /**
183
     * Asserts that a field is acceptable to filter on.
184
     *
185
     * @param string $name
186
     *
187
     * @return boolean
188
     */
189 2
    protected function acceptableField($name)
190
    {
191 2
        $entity = $this->getEntityType();
192 2
        $entity = new $entity;
193
194 2
        foreach (explode(',', $name) as $field) {
195 2
            if (! array_key_exists($name, $entity->getMapping())) {
196 1
                throw new InvalidArgumentException(
197 1
                    sprintf('(%s) is not a whitelisted field to filter, search or sort by', $name)
198 1
                );
199
            }
200 1
        }
201
202 1
        return true;
203
    }
204
205
    /**
206
     * {@inheritdoc}
207
     */
208 1
    public function countByField($field, $value, ServerRequestInterface $request = null)
209
    {
210 1
        $query = sprintf(
211 1
            "SELECT COUNT(*) as total FROM %s WHERE %s.%s IN (:%s)",
212 1
            $this->getTable(),
213 1
            $this->getTable(),
214 1
            $field,
215
            $field
216 1
        );
217
218
        $params = [
219 1
            $field => implode(',', (array) $value)
220 1
        ];
221
222 1
        return (int) $this->dbal->fetchOne($query, $params)['total'];
223
    }
224
225
    /**
226
     * {@inheritdoc}
227
     */
228 1
    public function getByField($field, $value, ServerRequestInterface $request = null)
229
    {
230 1
        $query = sprintf(
231 1
            'SELECT * FROM %s WHERE %s.%s IN (:%s)',
232 1
            $this->getTable(),
233 1
            $this->getTable(),
234 1
            $field,
235
            $field
236 1
        );
237
238
        // @todo - allow extra filtering from request
239
240
        $params = [
241 1
            $field => implode(',', (array) $value)
242 1
        ];
243
244 1
        $collection = $this->buildCollection($this->dbal->fetchAll($query, $params))
245 1
                           ->setTotal($this->countByField($field, $value));
246
247 1
        $this->decorate($collection, StoreInterface::ON_READ);
248
249 1
        return $collection;
250
    }
251
252
    /**
253
     * {@inheritdoc}
254
     */
255
    public function attachRelationships(
256
        Collection $collection,
257
        $include                        = null,
258
        ServerRequestInterface $request = null
259
    ) {
260
        if (is_null($include)) {
261
            return;
262
        }
263
264
        $rels = $collection->getIterator()->current()->getRelationshipMap();
265
266
        $rules = ($request instanceof ServerRequestInterface)
267
               ? $this->parseQueryString($request->getUri()->getQuery())
268
               : [];
269
270
        foreach ($this->getRelationshipMap() as $key => $map) {
271
            if (is_array($include) && ! in_array($key, $include)) {
272
                continue;
273
            }
274
275
            $binds = $this->getRelationshipBinds($collection, $key, $map['defined_in']['entity']);
276
277
            if (empty($binds)) {
278
                continue;
279
            }
280
281
            $query = sprintf(
282
                'SELECT * FROM %s LEFT JOIN %s ON %s.%s = %s.%s WHERE %s.%s IN (%s)',
283
                $map['defined_in']['table'],
284
                $map['target']['table'],
285
                $map['target']['table'],
286
                $map['target']['primary'],
287
                $map['defined_in']['table'],
288
                $map['target']['relationship'],
289
                $map['defined_in']['table'],
290
                $map['defined_in']['primary'],
291
                implode(',', $binds)
292
            );
293
294
            // @todo allow for further filtering of rels via request
295
296
            if (array_key_exists('sort', $rules)) {
297
                $whitelist = [];
298
299
                if (array_key_exists($key, $rels)) {
300
                    $entity    = $rels[$key];
301
                    $entity    = new $entity;
302
                    $mapping   = $entity->getMapping();
303
                    $whitelist = array_keys($mapping);
304
                }
305
306
                $query .= $this->buildSortPart($rules['sort'], $map['target']['table'], $whitelist);
307
            }
308
309
            $result = $this->dbal->fetchAll($query, []);
310
311
            $this->attachRelationshipsToCollection($collection, $key, $result);
312
        }
313
    }
314
315
    /**
316
     * Iterate a result set and attach the relationship to it's correct entity
317
     * within a collection.
318
     *
319
     * @param \Percy\Entity\Collection $collection
320
     * @param string                   $relationship
321
     * @param array                    $data
322
     *
323
     * @return void
324
     */
325
    protected function attachRelationshipsToCollection(Collection $collection, $relationship, array $data)
326
    {
327
        $map           = $this->getRelationshipMap($relationship);
328
        $relationships = array_column($data, $map['defined_in']['primary']);
329
330
        $remove = [$map['defined_in']['primary'], $map['target']['relationship']];
331
332
        foreach ($data as &$resource) {
333
            $resource = array_filter($resource, function ($key) use ($remove) {
334
                return (! in_array($key, $remove));
335
            }, ARRAY_FILTER_USE_KEY);
336
        }
337
338
        foreach ($collection->getIterator() as $entity) {
339
            $entityRels = $entity->getRelationshipMap();
340
341
            if (! array_key_exists($relationship, $entityRels)) {
342
                continue;
343
            }
344
345
            $keys = array_keys(preg_grep("/{$entity[$map['defined_in']['entity']]}/", $relationships));
346
            $rels = array_filter($data, function ($key) use ($keys) {
347
                return in_array($key, $keys);
348
            }, ARRAY_FILTER_USE_KEY);
349
350
            $rels = $this->buildCollection($rels, $entityRels[$relationship])->setTotal(count($rels));
351
            $this->decorate($rels, StoreInterface::ON_READ);
352
353
            $entity->addRelationship($relationship, $rels);
354
        }
355
    }
356
357
    /**
358
     * Return relationship bind conditional.
359
     *
360
     * @param \Percy\Entity\Collection $collection
361
     * @param string                   $relationship
362
     * @param string                   $key
363
     *
364
     * @return string
365
     */
366
    protected function getRelationshipBinds(Collection $collection, $relationship, $key)
367
    {
368
        $primaries = [];
369
370
        foreach ($collection->getIterator() as $entity) {
371
            if (! array_key_exists($relationship, $entity->getRelationshipMap())) {
372
                continue;
373
            }
374
375
            $primaries[] = "'{$entity[$key]}'";
376
        }
377
378
        return $primaries;
379
    }
380
381
    /**
382
     * Get possible relationships and the properties attached to them.
383
     *
384
     * @param string $relationship
385
     *
386
     * @throws \InvalidArgumentException when requested relationship is not defined
387
     * @throws \RuntimeException when map structure is defined incorrectly
388
     *
389
     * @return array
390
     */
391
    public function getRelationshipMap($relationship = null)
392
    {
393
        if (is_null($relationship)) {
394
            return $this->relationships;
395
        }
396
397
        if (! array_key_exists($relationship, $this->relationships)) {
398
            throw new InvalidArgumentException(
399
                sprintf('(%s) is not defined in the relationship map on (%s)', $relationship, get_class($this))
400
            );
401
        }
402
403
        $map = $this->relationships[$relationship];
404
405
        foreach ([
406
            'defined_in' => ['table', 'primary', 'entity'],
407
            'target'     => ['table', 'primary', 'relationship']
408
        ] as $key => $value) {
409
            if (! array_key_exists($key, $map) || ! is_array($map[$key])) {
410
                throw new RuntimeException(
411
                    sprintf(
412
                        'Relationship (%s) should contain the (%s) key and should be of type array on (%s)',
413
                        $relationship, $key, get_class($this)
414
                    )
415
                );
416
            }
417
418
            if (! empty(array_diff($value, array_keys($map[$key])))) {
419
                throw new RuntimeException(
420
                    sprintf(
421
                        '(%s) for relationship (%s) should contain keys (%s) on (%s)',
422
                        $key, $relationship, implode(', ', $value), get_class($this)
423
                    )
424
                );
425
            }
426
        }
427
428
        return $map;
429
    }
430
431
    /**
432
     * Returns table that repository is reading from.
433
     *
434
     * @return string
435
     */
436
    abstract protected function getTable();
437
}
438