Completed
Push — develop ( 8852f9...b0ea9b )
by
unknown
11:33 queued 03:35
created

DocumentModel::getSerialised()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 20
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 30

Importance

Changes 0
Metric Value
dl 0
loc 20
ccs 0
cts 12
cp 0
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 13
nc 3
nop 3
crap 30
1
<?php
2
/**
3
 * Use doctrine odm as backend
4
 */
5
6
namespace Graviton\RestBundle\Model;
7
8
use Doctrine\ODM\MongoDB\DocumentManager;
9
use Doctrine\ODM\MongoDB\DocumentRepository;
10
use Doctrine\ODM\MongoDB\Query\Builder;
11
use Doctrine\ODM\MongoDB\Query\Expr;
12
use Graviton\RestBundle\Event\ModelEvent;
13
use Graviton\Rql\Node\SearchNode;
14
use Graviton\Rql\Visitor\MongoOdm as Visitor;
15
use Graviton\SchemaBundle\Model\SchemaModel;
16
use Graviton\RestBundle\Service\RestUtils;
17
use MongoDB\Exception\InvalidArgumentException;
18
use Symfony\Component\HttpFoundation\Request;
19
use Xiag\Rql\Parser\Node\LimitNode;
20
use Xiag\Rql\Parser\Node\Query\LogicOperator\AndNode;
21
use Xiag\Rql\Parser\Query;
22
use Xiag\Rql\Parser\Exception\SyntaxErrorException as RqlSyntaxErrorException;
23
use Xiag\Rql\Parser\Query as XiagQuery;
24
use Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher as EventDispatcher;
25
use Graviton\ExceptionBundle\Exception\NotFoundException;
26
use Graviton\ExceptionBundle\Exception\RecordOriginModifiedException;
27
28
/**
29
 * Use doctrine odm as backend
30
 *
31
 * @author  List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
32
 * @license https://opensource.org/licenses/MIT MIT License
33
 * @link    http://swisscom.ch
34
 */
35
class DocumentModel extends SchemaModel implements ModelInterface
36
{
37
    /**
38
     * @var string
39
     */
40
    protected $description;
41
    /**
42
     * @var string[]
43
     */
44
    protected $fieldTitles;
45
    /**
46
     * @var string[]
47
     */
48
    protected $fieldDescriptions;
49
    /**
50
     * @var string[]
51
     */
52
    protected $requiredFields = array();
53
    /**
54
     * @var string[]
55
     */
56
    protected $searchableFields = array();
57
    /**
58
     * @var string[]
59
     */
60
    protected $textIndexes = array();
61
    /**
62
     * @var DocumentRepository
63
     */
64
    private $repository;
65
    /**
66
     * @var Visitor
67
     */
68
    private $visitor;
69
    /**
70
     * @var array
71
     */
72
    protected $notModifiableOriginRecords;
73
    /**
74
     * @var  integer
75
     */
76
    private $paginationDefaultLimit;
77
78
    /**
79
     * @var boolean
80
     */
81
    protected $filterByAuthUser;
82
83
    /**
84
     * @var string
85
     */
86
    protected $filterByAuthField;
87
88
    /**
89
     * @var DocumentManager
90
     */
91
    protected $manager;
92
93
    /** @var EventDispatcher */
94
    protected $eventDispatcher;
95
96
    /** @var $collectionCache */
97
    protected $cache;
98
99
    /**
100
     * @var RestUtils
101
     */
102
    private $restUtils;
103
104
    /**
105
     * @param Visitor         $visitor                    rql query visitor
106
     * @param RestUtils       $restUtils                  Rest utils
107
     * @param EventDispatcher $eventDispatcher            Kernel event dispatcher
108
     * @param array           $notModifiableOriginRecords strings with not modifiable recordOrigin values
109
     * @param integer         $paginationDefaultLimit     amount of data records to be returned when in pagination cnt
110
     */
111
    public function __construct(
112
        Visitor $visitor,
113
        RestUtils $restUtils,
114
        $eventDispatcher,
115
        $notModifiableOriginRecords,
116
        $paginationDefaultLimit
117
    ) {
118
        parent::__construct();
119
        $this->visitor = $visitor;
120
        $this->eventDispatcher = $eventDispatcher;
121
        $this->notModifiableOriginRecords = $notModifiableOriginRecords;
122
        $this->paginationDefaultLimit = (int) $paginationDefaultLimit;
123
        $this->restUtils = $restUtils;
124
    }
125
126
    /**
127
     * get repository instance
128
     *
129
     * @return DocumentRepository
130
     */
131
    public function getRepository()
132
    {
133
        return $this->repository;
134
    }
135
136
    /**
137
     * create new app model
138
     *
139
     * @param DocumentRepository $repository Repository of countries
140
     *
141
     * @return \Graviton\RestBundle\Model\DocumentModel
142
     */
143
    public function setRepository(DocumentRepository $repository)
144
    {
145
        $this->repository = $repository;
146
        $this->manager = $repository->getDocumentManager();
147
148
        return $this;
149
    }
150
151
    /**
152
     * {@inheritDoc}
153
     *
154
     * @param Request $request The request object
155
     *
156
     * @return array
157
     */
158
    public function findAll(Request $request)
159
    {
160
        $pageNumber = $request->query->get('page', 1);
161
        $numberPerPage = (int) $request->query->get('perPage', $this->getDefaultLimit());
162
        $startAt = ($pageNumber - 1) * $numberPerPage;
163
164
        /** @var XiagQuery $xiagQuery */
165
        $xiagQuery = $request->attributes->get('rqlQuery');
166
167
        /** @var Builder $queryBuilder */
168
        $queryBuilder = $this->repository
169
            ->createQueryBuilder();
170
171
        // Setting RQL Query
172
        if ($xiagQuery) {
173
            // Check if search and if this Repository have search indexes.
174
            if ($query = $xiagQuery->getQuery()) {
175
                if ($query instanceof AndNode) {
176
                    foreach ($query->getQueries() as $xq) {
177
                        if ($xq instanceof SearchNode && !$this->hasCustomSearchIndex()) {
178
                            throw new InvalidArgumentException('Current api request have search index');
179
                        }
180
                    }
181
                } elseif ($query instanceof SearchNode && !$this->hasCustomSearchIndex()) {
182
                    throw new InvalidArgumentException('Current api request have search index');
183
                }
184
            }
185
            // Clean up Search rql param and set it as Doctrine query
186
            $queryBuilder = $this->doRqlQuery(
187
                $queryBuilder,
188
                $xiagQuery
189
            );
190
        } else {
191
            // @todo [lapistano]: seems the offset is missing for this query.
192
            /** @var \Doctrine\ODM\MongoDB\Query\Builder $qb */
193
            $queryBuilder->find($this->repository->getDocumentName());
194
        }
195
196
        /** @var LimitNode $rqlLimit */
197
        $rqlLimit = $xiagQuery instanceof XiagQuery ? $xiagQuery->getLimit() : false;
198
199
        // define offset and limit
200
        if (!$rqlLimit || !$rqlLimit->getOffset()) {
201
            $queryBuilder->skip($startAt);
0 ignored issues
show
Bug introduced by
The method skip does only exist in Doctrine\ODM\MongoDB\Query\Builder, but not in Doctrine\ODM\MongoDB\Query\Expr.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
202
        } else {
203
            $startAt = (int) $rqlLimit->getOffset();
204
            $queryBuilder->skip($startAt);
205
        }
206
207
        if (!$rqlLimit || is_null($rqlLimit->getLimit())) {
208
            $queryBuilder->limit($numberPerPage);
0 ignored issues
show
Bug introduced by
The method limit does only exist in Doctrine\ODM\MongoDB\Query\Builder, but not in Doctrine\ODM\MongoDB\Query\Expr.

It seems like the method you are trying to call exists only in some of the possible types.

Let’s take a look at an example:

class A
{
    public function foo() { }
}

class B extends A
{
    public function bar() { }
}

/**
 * @param A|B $x
 */
function someFunction($x)
{
    $x->foo(); // This call is fine as the method exists in A and B.
    $x->bar(); // This method only exists in B and might cause an error.
}

Available Fixes

  1. Add an additional type-check:

    /**
     * @param A|B $x
     */
    function someFunction($x)
    {
        $x->foo();
    
        if ($x instanceof B) {
            $x->bar();
        }
    }
    
  2. Only allow a single type to be passed if the variable comes from a parameter:

    function someFunction(B $x) { /** ... */ }
    
Loading history...
209
        } else {
210
            $numberPerPage = (int) $rqlLimit->getLimit();
211
            $queryBuilder->limit($numberPerPage);
212
        }
213
214
        // Limit can not be negative nor null.
215
        if ($numberPerPage < 1) {
216
            throw new RqlSyntaxErrorException('negative or null limit in rql');
217
        }
218
219
        /**
220
         * add a default sort on id if none was specified earlier
221
         *
222
         * not specifying something to sort on leads to very weird cases when fetching references.
223
         */
224
        if (!array_key_exists('sort', $queryBuilder->getQuery()->getQuery())) {
225
            $queryBuilder->sort('_id');
226
        }
227
228
        // run query
229
        $query = $queryBuilder->getQuery();
230
        $records = array_values($query->execute()->toArray());
231
232
        $totalCount = $query->count();
233
        $numPages = (int) ceil($totalCount / $numberPerPage);
234
        $page = (int) ceil($startAt / $numberPerPage) + 1;
235
        if ($numPages > 1) {
236
            $request->attributes->set('paging', true);
237
            $request->attributes->set('page', $page);
238
            $request->attributes->set('numPages', $numPages);
239
            $request->attributes->set('startAt', $startAt);
240
            $request->attributes->set('perPage', $numberPerPage);
241
            $request->attributes->set('totalCount', $totalCount);
242
        }
243
244
        return $records;
245
    }
246
247
    /**
248
     * Check if collection has search indexes in DB
249
     *
250
     * @param string $prefix the prefix for custom text search indexes
251
     * @return bool
252
     */
253
    private function hasCustomSearchIndex($prefix = 'search_')
254
    {
255
        $metadata = $this->repository->getClassMetadata();
256
        $indexes = $metadata->getIndexes();
257
        if (count($indexes) < 1) {
258
            return false;
259
        }
260
        $collectionsName = substr($metadata->getName(), strrpos($metadata->getName(), '\\') + 1);
261
        $searchIndexName = $prefix.$collectionsName.'_index';
262
        // We reverse as normally the search index is the last.
263
        foreach (array_reverse($indexes) as $index) {
264
            if (array_key_exists('options', $index) &&
265
                array_key_exists('name', $index['options']) &&
266
                $searchIndexName == $index['options']['name']
267
            ) {
268
                return true;
269
            }
270
        }
271
        return false;
272
    }
273
274
    /**
275
     * @param object $entity       entity to insert
276
     * @param bool   $returnEntity true to return entity
277
     * @param bool   $doFlush      if we should flush or not after insert
278
     *
279
     * @return Object|null
280
     */
281
    public function insertRecord($entity, $returnEntity = true, $doFlush = true)
282
    {
283
        $this->manager->persist($entity);
284
285
        if ($doFlush) {
286
            $this->manager->flush($entity);
287
        }
288
289
        // Fire ModelEvent
290
        $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_INSERT, $entity);
291
292
        if ($returnEntity) {
293
            return $this->find($entity->getId());
294
        }
295
        return null;
296
    }
297
298
    /**
299
     * @param string $documentId id of entity to find
300
     *
301
     * @throws NotFoundException
302
     * @return Object
303
     */
304
    public function find($documentId)
305
    {
306
        $result = $this->repository->find($documentId);
307
308
        if (empty($result)) {
309
            throw new NotFoundException("Entry with id " . $documentId . " not found!");
310
        }
311
312
        return $result;
313
    }
314
315
    /**
316
     * Will attempt to find Document by ID.
317
     * If config cache is enabled for document it will save it.
318
     *
319
     * @param string  $documentId id of entity to find
320
     * @param Request $request    request
0 ignored issues
show
Documentation introduced by
Should the type for parameter $request not be null|Request?

This check looks for @param annotations where the type inferred by our type inference engine differs from the declared type.

It makes a suggestion as to what type it considers more descriptive.

Most often this is a case of a parameter that can be null in addition to its declared types.

Loading history...
321
     * @param bool    $skipLock   if true, we don't check for the lock
322
     *
323
     * @throws NotFoundException
324
     * @return string Serialised object
325
     */
326
    public function getSerialised($documentId, Request $request = null, $skipLock = false)
327
    {
328
        if (($request instanceof Request)  &&
329
            ($query = $request->attributes->get('rqlQuery')) &&
330
            (($query instanceof XiagQuery))
331
        ) {
332
            /** @var Builder $queryBuilder */
333
            $queryBuilder = $this->doRqlQuery($this->repository->createQueryBuilder(), $query);
334
            $queryBuilder->field('id')->equals($documentId);
335
            $result = $queryBuilder->getQuery()->getSingleResult();
336
            if (empty($result)) {
337
                throw new NotFoundException("Entry with id " . $documentId . " not found!");
338
            }
339
            $document = $this->restUtils->serialize($result);
340
        } else {
341
            $document = $this->restUtils->serialize($this->find($documentId));
342
        }
343
344
        return $document;
345
    }
346
347
    /**
348
     * {@inheritDoc}
349
     *
350
     * @param string $documentId   id of entity to update
351
     * @param Object $entity       new entity
352
     * @param bool   $returnEntity true to return entity
353
     *
354
     * @return Object|null
355
     */
356
    public function updateRecord($documentId, $entity, $returnEntity = true)
357
    {
358
        if (!is_null($documentId)) {
359
            $this->deleteById($documentId);
360
            // detach so odm knows it's gone
361
            $this->manager->detach($entity);
362
            $this->manager->clear();
363
        }
364
365
        $entity = $this->manager->merge($entity);
366
367
        $this->manager->persist($entity);
368
        $this->manager->flush($entity);
369
370
        // Fire ModelEvent
371
        $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_UPDATE, $entity);
372
373
        if ($returnEntity) {
374
            return $entity;
375
        }
376
        return null;
377
    }
378
379
    /**
380
     * {@inheritDoc}
381
     *
382
     * @param string|object $id id of entity to delete or entity instance
383
     *
384
     * @return null|Object
385
     */
386
    public function deleteRecord($id)
387
    {
388
        if (is_object($id)) {
389
            $entity = $id;
390
        } else {
391
            $entity = $this->find($id);
392
        }
393
394
        $this->checkIfOriginRecord($entity);
395
        $return = $entity;
396
397
        if (is_callable([$entity, 'getId']) && $entity->getId() != null) {
398
            $this->deleteById($entity->getId());
399
            // detach so odm knows it's gone
400
            $this->manager->detach($entity);
401
            $this->manager->clear();
402
            // Dispatch ModelEvent
403
            $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_DELETE, $return);
404
            $return = null;
405
        }
406
407
        return $return;
408
    }
409
410
    /**
411
     * Triggers a flush on the DocumentManager
412
     *
413
     * @param null $document optional document
414
     *
415
     * @return void
416
     */
417
    public function flush($document = null)
418
    {
419
        $this->manager->flush($document);
420
    }
421
422
    /**
423
     * A low level delete without any checks
424
     *
425
     * @param mixed $id record id
426
     *
427
     * @return void
428
     */
429
    private function deleteById($id)
430
    {
431
        $builder = $this->repository->createQueryBuilder();
432
        $builder
433
            ->remove()
434
            ->field('id')->equals($id)
435
            ->getQuery()
436
            ->execute();
437
    }
438
439
    /**
440
     * Checks in a performant way if a certain record id exists in the database
441
     *
442
     * @param mixed $id record id
443
     *
444
     * @return bool true if it exists, false otherwise
445
     */
446
    public function recordExists($id)
447
    {
448
        return is_array($this->selectSingleFields($id, ['id'], false));
449
    }
450
451
    /**
452
     * Returns a set of fields from an existing resource in a performant manner.
453
     * If you need to check certain fields on an object (and don't need everything), this
454
     * is a better way to get what you need.
455
     * If the record is not present, you will receive null. If you don't need an hydrated
456
     * instance, make sure to pass false there.
457
     *
458
     * @param mixed $id      record id
459
     * @param array $fields  list of fields you need.
460
     * @param bool  $hydrate whether to hydrate object or not
461
     *
462
     * @return array|null|object
463
     */
464
    public function selectSingleFields($id, array $fields, $hydrate = true)
465
    {
466
        $builder = $this->repository->createQueryBuilder();
467
        $idField = $this->repository->getClassMetadata()->getIdentifier()[0];
468
469
        $record = $builder
470
            ->field($idField)->equals($id)
471
            ->select($fields)
472
            ->hydrate($hydrate)
473
            ->getQuery()
474
            ->getSingleResult();
475
476
        return $record;
477
    }
478
479
    /**
480
     * get classname of entity
481
     *
482
     * @return string|null
483
     */
484
    public function getEntityClass()
485
    {
486
        if ($this->repository instanceof DocumentRepository) {
487
            return $this->repository->getDocumentName();
488
        }
489
490
        return null;
491
    }
492
493
    /**
494
     * {@inheritDoc}
495
     *
496
     * Currently this is being used to build the route id used for redirecting
497
     * to newly made documents. It might benefit from having a different name
498
     * for those purposes.
499
     *
500
     * We might use a convention based mapping here:
501
     * Graviton\CoreBundle\Document\App -> mongodb://graviton_core
502
     * Graviton\CoreBundle\Entity\Table -> mysql://graviton_core
503
     *
504
     * @todo implement this in a more convention based manner
505
     *
506
     * @return string
507
     */
508
    public function getConnectionName()
509
    {
510
        $bundle = strtolower(substr(explode('\\', get_class($this))[1], 0, -6));
511
512
        return 'graviton.' . $bundle;
513
    }
514
515
    /**
516
     * Does the actual query using the RQL Bundle.
517
     *
518
     * @param Builder $queryBuilder Doctrine ODM QueryBuilder
519
     * @param Query   $query        query from parser
520
     *
521
     * @return Builder|Expr
522
     */
523
    protected function doRqlQuery($queryBuilder, Query $query)
524
    {
525
        $this->visitor->setBuilder($queryBuilder);
526
527
        return $this->visitor->visit($query);
528
    }
529
530
    /**
531
     * Checks the recordOrigin attribute of a record and will throw an exception if value is not allowed
532
     *
533
     * @param Object $record record
534
     *
535
     * @return void
536
     */
537
    protected function checkIfOriginRecord($record)
538
    {
539
        if ($record instanceof RecordOriginInterface
540
            && !$record->isRecordOriginModifiable()
541
        ) {
542
            $values = $this->notModifiableOriginRecords;
543
            $originValue = strtolower(trim($record->getRecordOrigin()));
544
545
            if (in_array($originValue, $values)) {
546
                $msg = sprintf("Must not be one of the following keywords: %s", implode(', ', $values));
547
548
                throw new RecordOriginModifiedException($msg);
549
            }
550
        }
551
    }
552
553
    /**
554
     * Determines the configured amount fo data records to be returned in pagination context.
555
     *
556
     * @return int
557
     */
558
    private function getDefaultLimit()
559
    {
560
        if (0 < $this->paginationDefaultLimit) {
561
            return $this->paginationDefaultLimit;
562
        }
563
564
        return 10;
565
    }
566
567
    /**
568
     * Will fire a ModelEvent
569
     *
570
     * @param string $action     insert or update
571
     * @param Object $collection the changed Document
572
     *
573
     * @return void
574
     */
575
    private function dispatchModelEvent($action, $collection)
576
    {
577
        if (!($this->repository instanceof DocumentRepository)) {
578
            return;
579
        }
580
        if (!method_exists($collection, 'getId')) {
581
            return;
582
        }
583
584
        $event = new ModelEvent();
585
        $event->setCollectionId($collection->getId());
586
        $event->setActionByDispatchName($action);
587
        $event->setCollectionName($this->repository->getClassMetadata()->getCollection());
588
        $event->setCollectionClass($this->repository->getClassName());
589
        $event->setCollection($collection);
590
591
        $this->eventDispatcher->dispatch($action, $event);
592
    }
593
}
594