Completed
Pull Request — master (#5)
by Jacob
03:12
created

Formatter::getQueryIdConverter()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
c 1
b 0
f 1
dl 0
loc 6
rs 9.4285
cc 1
eloc 3
nc 1
nop 1
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 identifiers.
222
        if (null !== $result = $this->formatQueryElementId($key, $value, $metadata)) {
223
            return $result;
224
        }
225
226
        // Handle polymorphic model type fields.
227
        if (null !== $result = $this->formatQueryElementType($key, $value)) {
228
            return $result;
229
        }
230
231
        // Handle attributes.
232
        if (null !== $result = $this->formatQueryElementAttr($key, $value, $metadata, $store)) {
233
            return $result;
234
        }
235
236
        // Handle relationships.
237
        if (null !== $result = $this->formatQueryElementRel($key, $value, $metadata, $store)) {
238
            return $result;
239
        }
240
241
        // Handle dot notated fields.
242
        if (null !== $result = $this->formatQueryElementDotted($key, $value, $metadata, $store)) {
243
            return $result;
244
        }
245
246
        // Pass remaining elements unconverted.
247
        return [$key, $value];
248
    }
249
250
    /**
251
     * Formats an attribute query element.
252
     * Returns a tuple of the formatted key and value, or null if the key is not an attribute field.
253
     *
254
     * @param   string          $key
255
     * @param   mixed           $value
256
     * @param   EntityMetadata  $metadata
257
     * @param   Store           $store
258
     * @return  array|null
259
     */
260
    private function formatQueryElementAttr($key, $value, EntityMetadata $metadata, Store $store)
261
    {
262
        if (null !== $attrMeta = $metadata->getAttribute($key)) {
263
            return;
264
        }
265
266
        $converter = $this->getQueryAttrConverter($store, $attrMeta);
0 ignored issues
show
Documentation introduced by
$attrMeta is of type null, but the function expects a object<As3\Modlr\Metadata\AttributeMetadata>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
267
268
        if (is_array($value)) {
269
270
            if (true === $this->hasOperators($value)) {
271
                return [$key, $this->formatQueryExpression($value, $converter)];
272
            }
273
274
            if (in_array($attrMeta->dataType, ['array', 'object'])) {
275
                return [$key, $value];
276
            }
277
            return [$key, $this->formatQueryExpression(['$in' => $value], $converter)];
278
        }
279
        return [$key, $converter($value)];
280
    }
281
282
    /**
283
     * Formats a dot-notated field.
284
     * Returns a tuple of the formatted key and value, or null if the key is not a dot-notated field, or cannot be handled.
285
     *
286
     * @param   string          $key
287
     * @param   mixed           $value
288
     * @param   EntityMetadata  $metadata
289
     * @param   Store           $store
290
     * @return  array|null
291
     */
292
    private function formatQueryElementDotted($key, $value, EntityMetadata $metadata, Store $store)
293
    {
294
        if (false === stripos($key, '.')) {
295
            return;
296
        }
297
298
        $parts = explode('.', $key);
299
        $root = array_shift($parts);
300
        if (false === $metadata->hasRelationship($root)) {
301
            // Nothing to format. Allow the dotted field to pass normally.
302
            return [$key, $value];
303
        }
304
        $hasIndex = is_numeric($parts[0]);
305
306
        if (true === $hasIndex) {
307
            $subKey = isset($parts[1]) ? $parts[1] : 'id';
308
        } else {
309
            $subKey = $parts[0];
310
        }
311
312 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...
313
            // Handle like a regular relationship
314
            list($key, $value) = $this->formatQueryElementRel($root, $value, $metadata, $store);
315
            $key = (true === $hasIndex) ? sprintf('%s.%s', $key, $parts[0]) : $key;
316
            return [$key, $value];
317
        }
318
319 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...
320
            // Handle as a model type field.
321
            list($key, $value) = $this->formatQueryElementType($subKey, $value);
322
            $key = (true === $hasIndex) ? sprintf('%s.%s.%s', $root, $parts[0], $key) : sprintf('%s.%s', $root, $key);
323
            return [$key, $value];
324
        }
325
        return [$key, $value];
326
    }
327
328
    /**
329
     * Formats an identifier query element.
330
     * Returns a tuple of the formatted key and value, or null if the key is not an identifier field.
331
     *
332
     * @param   string          $key
333
     * @param   mixed           $value
334
     * @param   EntityMetadata  $metadata
335
     * @return  array|null
336
     */
337 View Code Duplication
    private function formatQueryElementId($key, $value, EntityMetadata $metadata)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in 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...
338
    {
339
        if (false === $this->isIdentifierField($key)) {
340
            return;
341
        }
342
343
        $dbIdKey = Persister::IDENTIFIER_KEY;
344
        $converter = $this->getQueryIdConverter($metadata);
345
346
        if (is_array($value)) {
347
            $value = (true === $this->hasOperators($value)) ? $value : ['$in' => $value];
348
            return [$dbIdKey, $this->formatQueryExpression($value, $converter)];
349
        }
350
        return [$dbIdKey, $converter($value)];
351
    }
352
353
    /**
354
     * Formats a relationship query element.
355
     * Returns a tuple of the formatted key and value, or null if the key is not a relationship field.
356
     *
357
     * @param   string          $key
358
     * @param   mixed           $value
359
     * @param   EntityMetadata  $metadata
360
     * @param   Store           $store
361
     * @return  array|null
362
     */
363
    private function formatQueryElementRel($key, $value, EntityMetadata $metadata, Store $store)
364
    {
365
        if (null !== $relMeta = $metadata->getRelationship($key)) {
366
            return;
367
        }
368
369
        $converter = $this->getQueryRelConverter($store, $relMeta);
0 ignored issues
show
Documentation introduced by
$relMeta is of type null, but the function expects a object<As3\Modlr\Metadata\RelationshipMetadata>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
370
371
        if (true === $relMeta->isPolymorphic()) {
0 ignored issues
show
Bug introduced by
The method isPolymorphic cannot be called on $relMeta (of type null).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
372
            $key = sprintf('%s.%s', $key, Persister::IDENTIFIER_KEY);
373
        }
374
375
        if (is_array($value)) {
376
            $value = (true === $this->hasOperators($value)) ? $value : ['$in' => $value];
377
            return [$key, $this->formatQueryExpression($value, $converter)];
378
        }
379
        return [$key, $converter($value)];
380
    }
381
382
    /**
383
     * Formats a model type query element.
384
     * Returns a tuple of the formatted key and value, or null if the key is not a type field.
385
     *
386
     * @param   string  $key
387
     * @param   mixed   $value
388
     * @return  array|null
389
     */
390 View Code Duplication
    private function formatQueryElementType($key, $value)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in 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...
391
    {
392
        if (false === $this->isTypeField($key)) {
393
            return;
394
        }
395
396
        $dbTypeKey = Persister::POLYMORPHIC_KEY;
397
        $converter = $this->getQueryTypeConverter();
398
399
        if (is_array($value)) {
400
            $value = (true === $this->hasOperators($value)) ? $value : ['$in' => $value];
401
            return [$dbTypeKey, $this->formatQueryExpression($value, $converter)];
402
        }
403
        return [$dbTypeKey, $converter($value)];
404
    }
405
406
    /**
407
     * Formats a query expression.
408
     *
409
     * @param   array   $expression
410
     * @param   Closure $converter
411
     * @return  array
412
     */
413
    private function formatQueryExpression(array $expression, Closure $converter)
414
    {
415
        foreach ($expression as $key => $value) {
416
417
            if (true === $this->isOpType('ignore', $key)) {
418
                continue;
419
            }
420
421
            if (true === $this->isOpType('single', $key)) {
422
                $expression[$key] = $converter($value);
423
                continue;
424
            }
425
426
            if (true === $this->isOpType('multiple', $key)) {
427
                $value = (array) $value;
428
                foreach ($value as $subKey => $subValue) {
429
                    $expression[$key][$subKey] = $converter($subValue);
430
                }
431
                continue;
432
            }
433
434
            if (true === $this->isOpType('recursive', $key)) {
435
                $value = (array) $value;
436
                $expression[$key] = $this->formatQueryExpression($value, $converter);
437
                continue;
438
            }
439
440
            $expression[$key] = $converter($value);
441
        }
442
        return $expression;
443
    }
444
445
    /**
446
     * Gets the converter for handling attribute values in queries.
447
     *
448
     * @param   Store               $store
449
     * @param   AttributeMetadata   $attrMeta
450
     * @return  Closure
451
     */
452
    private function getQueryAttrConverter(Store $store, AttributeMetadata $attrMeta)
453
    {
454
        return function ($value) use ($store, $attrMeta) {
455
            $value = $store->convertAttributeValue($attrMeta->dataType, $value);
456
            return $this->getAttributeDbValue($attrMeta, $value);
457
        };
458
459
    }
460
461
    /**
462
     * Gets the converter for handling identifier values in queries.
463
     *
464
     * @param   EntityMetadata  $metadata
465
     * @return  Closure
466
     */
467
    private function getQueryIdConverter(EntityMetadata $metadata)
468
    {
469
        return function($value) use ($metadata) {
470
            return $this->getIdentifierDbValue($value, $metadata->persistence->idStrategy);
0 ignored issues
show
Bug introduced by
Accessing idStrategy on the interface As3\Modlr\Metadata\Inter...s\StorageLayerInterface suggest that you code against a concrete implementation. How about adding an instanceof check?

If you access a property on an interface, you most likely code against a concrete implementation of the interface.

Available Fixes

  1. Adding an additional type check:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeInterface $object) {
        if ($object instanceof SomeClass) {
            $a = $object->a;
        }
    }
    
  2. Changing the type hint:

    interface SomeInterface { }
    class SomeClass implements SomeInterface {
        public $a;
    }
    
    function someFunction(SomeClass $object) {
        $a = $object->a;
    }
    
Loading history...
471
        };
472
    }
473
474
    /**
475
     * Gets the converter for handling relationship values in queries.
476
     *
477
     * @param   Store                   $store
478
     * @param   RelationshipMetadata    $relMeta
479
     * @return  Closure
480
     */
481
    private function getQueryRelConverter(Store $store, RelationshipMetadata $relMeta)
482
    {
483
        $related = $store->getMetadataForType($relMeta->getEntityType());
484
        return $this->getQueryIdConverter($related);
485
    }
486
487
    /**
488
     * Gets the converter for handling model type values in queries.
489
     *
490
     * @return  Closure
491
     */
492
    private function getQueryTypeConverter()
493
    {
494
        return function($value) {
495
            return $value;
496
        };
497
    }
498
499
    /**
500
     * Determines whether a query value has additional query operators.
501
     *
502
     * @param   mixed   $value
503
     * @return  bool
504
     */
505
    private function hasOperators($value)
506
    {
507
508
        if (!is_array($value) && !is_object($value)) {
509
            return false;
510
        }
511
512
        if (is_object($value)) {
513
            $value = get_object_vars($value);
514
        }
515
516
        foreach ($value as $key => $subValue) {
517
            if (true === $this->isOperator($key)) {
518
                return true;
519
            }
520
        }
521
        return false;
522
    }
523
524
    /**
525
     * Determines if a key is a query operator.
526
     *
527
     * @param   string  $key
528
     * @return  bool
529
     */
530
    private function isOperator($key)
531
    {
532
        return isset($key[0]) && '$' === $key[0];
533
    }
534
535
    /**
536
     * Determines if a key is of a certain operator handling type.
537
     *
538
     * @param   string  $type
539
     * @param   string  $key
540
     * @return  bool
541
     */
542
    private function isOpType($type, $key)
543
    {
544
        if (!isset($this->ops[$type])) {
545
            return false;
546
        }
547
        return in_array($key, $this->ops[$type]);
548
    }
549
}
550