Completed
Push — master ( 863bd0...3a718e )
by Phil
8s
created

AbstractSqlRepository::acceptableField()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3.243

Importance

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