Completed
Pull Request — master (#5)
by Jacob
02:31
created

Formatter::formatQueryExpression()   C

Complexity

Conditions 7
Paths 7

Size

Total Lines 31
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 1
Metric Value
c 2
b 1
f 1
dl 0
loc 31
rs 6.7272
cc 7
eloc 18
nc 7
nop 2
1
<?php
2
3
namespace As3\Modlr\Persister\MongoDb;
4
5
use \Closure;
6
use \MongoId;
7
use As3\Modlr\Metadata\AttributeMetadata;
8
use As3\Modlr\Metadata\EntityMetadata;
9
use As3\Modlr\Metadata\RelationshipMetadata;
10
use As3\Modlr\Models\Model;
11
use As3\Modlr\Persister\PersisterException;
12
use As3\Modlr\Store\Store;
13
14
/**
15
 * Handles persistence formatting operations for MongoDB.
16
 * - Formats query criteria to proper keys and values.
17
 * - Formats attribute and relationships values for insertion to the db.
18
 * - Formats identifier values for insertion to the db.
19
 *
20
 * @author Jacob Bare <[email protected]>
21
 */
22
final class Formatter
23
{
24
    /**
25
     * Query operators.
26
     * Organized into handling groups.
27
     *
28
     * @var array
29
     */
30
    private $ops = [
31
        'root'      => ['$and', '$or', '$nor'],
32
        'single'    => ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne'],
33
        'multiple'  => ['$in', '$nin', '$all'],
34
        'recursive' => ['$not', '$elemMatch'],
35
        'ignore'    => ['$exists', '$type', '$mod', '$size', '$regex', '$text', '$where'],
36
    ];
37
38
    /**
39
     * Formats a set of query criteria for a Model.
40
     * Ensures the id and type fields are properly applied.
41
     * Ensures that values are properly converted to their database equivalents: e.g dates, mongo ids, etc.
42
     *
43
     * @param   EntityMetadata  $metadata
44
     * @param   Store           $store
45
     * @param   array           $criteria
46
     * @return  array
47
     */
48
    public function formatQuery(EntityMetadata $metadata, Store $store, array $criteria)
49
    {
50
        $formatted = [];
51
        foreach ($criteria as $key => $value) {
52
53
            if ($this->isOpType('root', $key) && is_array($value)) {
54
                foreach ($value as $subKey => $subValue) {
55
                    $formatted[$key][$subKey] = $this->formatQuery($metadata, $store, $subValue);
56
                }
57
                continue;
58
            }
59
60
            if ($this->isOperator($key) && is_array($value)) {
61
                $formatted[$key] = $this->formatQuery($metadata, $store, $value);
62
                continue;
63
            }
64
65
            list($key, $value) = $this->formatQueryElement($key, $value, $metadata, $store);
66
            $formatted[$key] = $value;
67
        }
68
        return $formatted;
69
    }
70
71
    /**
72
     * Prepares and formats an attribute value for proper insertion into the database.
73
     *
74
     * @param   AttributeMetadata   $attrMeta
75
     * @param   mixed               $value
76
     * @return  mixed
77
     */
78
    public function getAttributeDbValue(AttributeMetadata $attrMeta, $value)
79
    {
80
        // Handle data type conversion, if needed.
81
        if ('date' === $attrMeta->dataType && $value instanceof \DateTime) {
82
            return new \MongoDate($value->getTimestamp(), $value->format('u'));
83
        }
84
        return $value;
85
    }
86
87
    /**
88
     * Prepares and formats a has-one relationship model for proper insertion into the database.
89
     *
90
     * @param   RelationshipMetadata    $relMeta
91
     * @param   Model|null              $model
92
     * @return  mixed
93
     */
94
    public function getHasOneDbValue(RelationshipMetadata $relMeta, Model $model = null)
95
    {
96
        if (null === $model || true === $relMeta->isInverse) {
97
            return null;
98
        }
99
        return $this->createReference($relMeta, $model);
100
    }
101
102
    /**
103
     * Prepares and formats a has-many relationship model set for proper insertion into the database.
104
     *
105
     * @param   RelationshipMetadata    $relMeta
106
     * @param   Model[]|null            $models
107
     * @return  mixed
108
     */
109
    public function getHasManyDbValue(RelationshipMetadata $relMeta, array $models = null)
110
    {
111
        if (null === $models || true === $relMeta->isInverse) {
112
            return null;
113
        }
114
        $references = [];
115
        foreach ($models as $model) {
116
            $references[] = $this->createReference($relMeta, $model);
117
        }
118
        return empty($references) ? null : $references;
119
    }
120
121
    /**
122
     * {@inheritDoc}
123
     */
124
    public function getIdentifierDbValue($identifier, $strategy = null)
125
    {
126
        if (false === $this->isIdStrategySupported($strategy)) {
127
            throw PersisterException::nyi('ID conversion currently only supports an object strategy, or none at all.');
128
        }
129
        if ($identifier instanceof MongoId) {
130
            return $identifier;
131
        }
132
        return new MongoId($identifier);
133
    }
134
135
    /**
136
     * Gets all possible identifier field keys (internal and persistence layer).
137
     *
138
     * @return  array
139
     */
140
    public function getIdentifierFields()
141
    {
142
        return [Persister::IDENTIFIER_KEY, EntityMetadata::ID_KEY];
143
    }
144
145
    /**
146
     * Gets all possible model type keys (internal and persistence layer).
147
     *
148
     * @return  array
149
     */
150
    public function getTypeFields()
151
    {
152
        return [Persister::POLYMORPHIC_KEY, EntityMetadata::TYPE_KEY];
153
    }
154
155
    /**
156
     * Determines if a field key is an identifier.
157
     * Uses both the internal and persistence identifier keys.
158
     *
159
     * @param   string  $key
160
     * @return  bool
161
     */
162
    public function isIdentifierField($key)
163
    {
164
        return in_array($key, $this->getIdentifierFields());
165
    }
166
167
    /**
168
     * Determines if the provided id strategy is supported.
169
     *
170
     * @param   string|null     $strategy
171
     * @return  bool
172
     */
173
    public function isIdStrategySupported($strategy)
174
    {
175
        return (null === $strategy || 'object' === $strategy);
176
    }
177
178
    /**
179
     * Determines if a field key is a model type field.
180
     * Uses both the internal and persistence model type keys.
181
     *
182
     * @param   string  $key
183
     * @return  bool
184
     */
185
    public function isTypeField($key)
186
    {
187
        return in_array($key, $this->getTypeFields());
188
    }
189
190
    /**
191
     * Creates a reference for storage of a related model in the database
192
     *
193
     * @param   RelationshipMetadata    $relMeta
194
     * @param   Model                   $model
195
     * @return  mixed
196
     */
197
    private function createReference(RelationshipMetadata $relMeta, Model $model)
198
    {
199
        $reference = [];
200
        $identifier = $this->getIdentifierDbValue($model->getId());
201
        if (true === $relMeta->isPolymorphic()) {
202
            $reference[Persister::IDENTIFIER_KEY] = $identifier;
203
            $reference[Persister::TYPE_KEY] = $model->getType();
204
            return $reference;
205
        }
206
        return $identifier;
207
    }
208
209
    /**
210
     * Formats a query element and ensures the correct key and value are set.
211
     * Returns a tuple of the formatted key and value.
212
     *
213
     * @param   string          $key
214
     * @param   mixed           $value
215
     * @param   EntityMetadata  $metadata
216
     * @param   Store           $store
217
     * @return  array
218
     */
219
    private function formatQueryElement($key, $value, EntityMetadata $metadata, Store $store)
220
    {
221
        // Handle root fields: id or model type.
222
        if (null !== $result = $this->formatQueryElementRoot($key, $value, $metadata)) {
223
            return $result;
224
        }
225
226
        // Handle attributes.
227
        if (null !== $result = $this->formatQueryElementAttr($key, $value, $metadata, $store)) {
228
            return $result;
229
        }
230
231
        // Handle relationships.
232
        if (null !== $result = $this->formatQueryElementRel($key, $value, $metadata, $store)) {
233
            return $result;
234
        }
235
236
        // Handle dot notated fields.
237
        if (null !== $result = $this->formatQueryElementDotted($key, $value, $metadata, $store)) {
238
            return $result;
239
        }
240
241
        // Pass remaining elements unconverted.
242
        return [$key, $value];
243
    }
244
245
    /**
246
     * Formats an attribute query element.
247
     * Returns a tuple of the formatted key and value, or null if the key is not an attribute field.
248
     *
249
     * @param   string          $key
250
     * @param   mixed           $value
251
     * @param   EntityMetadata  $metadata
252
     * @param   Store           $store
253
     * @return  array|null
254
     */
255
    private function formatQueryElementAttr($key, $value, EntityMetadata $metadata, Store $store)
256
    {
257
        if (null === $attrMeta = $metadata->getAttribute($key)) {
258
            return;
259
        }
260
261
        $converter = $this->getQueryAttrConverter($store, $attrMeta);
262
263
        if (is_array($value)) {
264
265
            if (true === $this->hasOperators($value)) {
266
                return [$key, $this->formatQueryExpression($value, $converter)];
267
            }
268
269
            if (in_array($attrMeta->dataType, ['array', 'object'])) {
270
                return [$key, $value];
271
            }
272
            return [$key, $this->formatQueryExpression(['$in' => $value], $converter)];
273
        }
274
        return [$key, $converter($value)];
275
    }
276
277
    /**
278
     * Formats a dot-notated field.
279
     * Returns a tuple of the formatted key and value, or null if the key is not a dot-notated field, or cannot be handled.
280
     *
281
     * @param   string          $key
282
     * @param   mixed           $value
283
     * @param   EntityMetadata  $metadata
284
     * @param   Store           $store
285
     * @return  array|null
286
     */
287
    private function formatQueryElementDotted($key, $value, EntityMetadata $metadata, Store $store)
288
    {
289
        if (false === stripos($key, '.')) {
290
            return;
291
        }
292
293
        $parts = explode('.', $key);
294
        $root = array_shift($parts);
295
        if (false === $metadata->hasRelationship($root)) {
296
            // Nothing to format. Allow the dotted field to pass normally.
297
            return [$key, $value];
298
        }
299
        $hasIndex = is_numeric($parts[0]);
300
301
        if (true === $hasIndex) {
302
            $subKey = isset($parts[1]) ? $parts[1] : 'id';
303
        } else {
304
            $subKey = $parts[0];
305
        }
306
307 View Code Duplication
        if ($this->isIdentifierField($subKey)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
308
            // Handle like a regular relationship
309
            list($key, $value) = $this->formatQueryElementRel($root, $value, $metadata, $store);
310
            $key = (true === $hasIndex) ? sprintf('%s.%s', $key, $parts[0]) : $key;
311
            return [$key, $value];
312
        }
313
314 View Code Duplication
        if ($this->isTypeField($subKey)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
315
            // Handle as a model type field.
316
            list($key, $value) = $this->formatQueryElementType($subKey, $value);
0 ignored issues
show
Bug introduced by
The method formatQueryElementType() does not exist on As3\Modlr\Persister\MongoDb\Formatter. Did you maybe mean formatQuery()?

This check marks calls to methods that do not seem to exist on an object.

This is most likely the result of a method being renamed without all references to it being renamed likewise.

Loading history...
317
            $key = (true === $hasIndex) ? sprintf('%s.%s.%s', $root, $parts[0], $key) : sprintf('%s.%s', $root, $key);
318
            return [$key, $value];
319
        }
320
        return [$key, $value];
321
    }
322
323
    /**
324
     * Formats a relationship query element.
325
     * Returns a tuple of the formatted key and value, or null if the key is not a relationship field.
326
     *
327
     * @param   string          $key
328
     * @param   mixed           $value
329
     * @param   EntityMetadata  $metadata
330
     * @param   Store           $store
331
     * @return  array|null
332
     */
333
    private function formatQueryElementRel($key, $value, EntityMetadata $metadata, Store $store)
334
    {
335
        if (null === $relMeta = $metadata->getRelationship($key)) {
336
            return;
337
        }
338
339
        $converter = $this->getQueryRelConverter($store, $relMeta);
340
341
        if (true === $relMeta->isPolymorphic()) {
342
            $key = sprintf('%s.%s', $key, Persister::IDENTIFIER_KEY);
343
        }
344
345 View Code Duplication
        if (is_array($value)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
346
            $value = (true === $this->hasOperators($value)) ? $value : ['$in' => $value];
347
            return [$key, $this->formatQueryExpression($value, $converter)];
348
        }
349
        return [$key, $converter($value)];
350
    }
351
352
353
    /**
354
     * Formats a root query element: either id or model type.
355
     * Returns a tuple of the formatted key and value, or null if the key is not a root field.
356
     *
357
     * @param   string          $key
358
     * @param   mixed           $value
359
     * @param   EntityMetadata  $metadata
360
     * @return  array|null
361
     */
362
    private function formatQueryElementRoot($key, $value, EntityMetadata $metadata)
363
    {
364
        if (true === $this->isIdentifierField($key)) {
365
            $dbKey = Persister::IDENTIFIER_KEY;
366
        } elseif (true === $this->isTypeField($key)) {
367
            $dbKey = Persister::POLYMORPHIC_KEY;
368
        } else {
369
            return;
370
        }
371
372
        $converter = $this->getQueryRootConverter($metadata, $dbKey);
373 View Code Duplication
        if (is_array($value)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
374
            $value = (true === $this->hasOperators($value)) ? $value : ['$in' => $value];
375
            return [$dbKey, $this->formatQueryExpression($value, $converter)];
376
        }
377
        return [$dbKey, $converter($value)];
378
    }
379
380
    /**
381
     * Formats a query expression.
382
     *
383
     * @param   array   $expression
384
     * @param   Closure $converter
385
     * @return  array
386
     */
387
    private function formatQueryExpression(array $expression, Closure $converter)
388
    {
389
        foreach ($expression as $key => $value) {
390
391
            if (true === $this->isOpType('ignore', $key)) {
392
                continue;
393
            }
394
395
            if (true === $this->isOpType('single', $key)) {
396
                $expression[$key] = $converter($value);
397
                continue;
398
            }
399
400
            if (true === $this->isOpType('multiple', $key)) {
401
                $value = (array) $value;
402
                foreach ($value as $subKey => $subValue) {
403
                    $expression[$key][$subKey] = $converter($subValue);
404
                }
405
                continue;
406
            }
407
408
            if (true === $this->isOpType('recursive', $key)) {
409
                $value = (array) $value;
410
                $expression[$key] = $this->formatQueryExpression($value, $converter);
411
                continue;
412
            }
413
414
            $expression[$key] = $converter($value);
415
        }
416
        return $expression;
417
    }
418
419
    /**
420
     * Gets the converter for handling attribute values in queries.
421
     *
422
     * @param   Store               $store
423
     * @param   AttributeMetadata   $attrMeta
424
     * @return  Closure
425
     */
426
    private function getQueryAttrConverter(Store $store, AttributeMetadata $attrMeta)
427
    {
428
        return function ($value) use ($store, $attrMeta) {
429
            $value = $store->convertAttributeValue($attrMeta->dataType, $value);
430
            return $this->getAttributeDbValue($attrMeta, $value);
431
        };
432
433
    }
434
435
    /**
436
     * Gets the converter for handling relationship values in queries.
437
     *
438
     * @param   Store                   $store
439
     * @param   RelationshipMetadata    $relMeta
440
     * @return  Closure
441
     */
442
    private function getQueryRelConverter(Store $store, RelationshipMetadata $relMeta)
443
    {
444
        $related = $store->getMetadataForType($relMeta->getEntityType());
445
        return $this->getQueryRootConverter($related, $related->getKey());
0 ignored issues
show
Bug introduced by
The method getKey() does not seem to exist on object<As3\Modlr\Metadata\EntityMetadata>.

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
446
    }
447
448
    /**
449
     * Gets the converter for handling root field values in queries (id or model type).
450
     *
451
     * @param   EntityMetadata  $metadata
452
     * @param   string          $key
453
     * @return  Closure
454
     */
455
    private function getQueryRootConverter(EntityMetadata $metadata, $key)
456
    {
457
        return function($value) use ($metadata, $key) {
458
            if ($key === Persister::POLYMORPHIC_KEY) {
459
                return $value;
460
            }
461
            $strategy = ($metadata->persistence instanceof StorageMetadata) ? $metadata->persistence->idStrategy : null;
462
            return $this->getIdentifierDbValue($value, $strategy);
463
        };
464
    }
465
466
    /**
467
     * Determines whether a query value has additional query operators.
468
     *
469
     * @param   mixed   $value
470
     * @return  bool
471
     */
472
    private function hasOperators($value)
473
    {
474
475
        if (!is_array($value) && !is_object($value)) {
476
            return false;
477
        }
478
479
        if (is_object($value)) {
480
            $value = get_object_vars($value);
481
        }
482
483
        foreach ($value as $key => $subValue) {
484
            if (true === $this->isOperator($key)) {
485
                return true;
486
            }
487
        }
488
        return false;
489
    }
490
491
    /**
492
     * Determines if a key is a query operator.
493
     *
494
     * @param   string  $key
495
     * @return  bool
496
     */
497
    private function isOperator($key)
498
    {
499
        return isset($key[0]) && '$' === $key[0];
500
    }
501
502
    /**
503
     * Determines if a key is of a certain operator handling type.
504
     *
505
     * @param   string  $type
506
     * @param   string  $key
507
     * @return  bool
508
     */
509
    private function isOpType($type, $key)
510
    {
511
        if (!isset($this->ops[$type])) {
512
            return false;
513
        }
514
        return in_array($key, $this->ops[$type]);
515
    }
516
}
517