Completed
Push — develop ( e125f2...8852f9 )
by
unknown
09:03
created

DocumentModel::getSerialised()   C

Complexity

Conditions 7
Paths 5

Size

Total Lines 26
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 26
ccs 0
cts 17
cp 0
rs 6.7272
cc 7
eloc 18
nc 5
nop 3
crap 56
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\DocumentBundle\Service\CollectionCache;
13
use Graviton\RestBundle\Event\ModelEvent;
14
use Graviton\Rql\Node\SearchNode;
15
use Graviton\Rql\Visitor\MongoOdm as Visitor;
16
use Graviton\SchemaBundle\Model\SchemaModel;
17
use Graviton\RestBundle\Service\RestUtils;
18
use MongoDB\Exception\InvalidArgumentException;
19
use Symfony\Component\HttpFoundation\Request;
20
use Xiag\Rql\Parser\Node\LimitNode;
21
use Xiag\Rql\Parser\Node\Query\LogicOperator\AndNode;
22
use Xiag\Rql\Parser\Query;
23
use Xiag\Rql\Parser\Exception\SyntaxErrorException as RqlSyntaxErrorException;
24
use Xiag\Rql\Parser\Query as XiagQuery;
25
use Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher as EventDispatcher;
26
use Graviton\ExceptionBundle\Exception\NotFoundException;
27
use Graviton\ExceptionBundle\Exception\RecordOriginModifiedException;
28
29
/**
30
 * Use doctrine odm as backend
31
 *
32
 * @author  List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
33
 * @license https://opensource.org/licenses/MIT MIT License
34
 * @link    http://swisscom.ch
35
 */
36
class DocumentModel extends SchemaModel implements ModelInterface
37
{
38
    /**
39
     * @var string
40
     */
41
    protected $description;
42
    /**
43
     * @var string[]
44
     */
45
    protected $fieldTitles;
46
    /**
47
     * @var string[]
48
     */
49
    protected $fieldDescriptions;
50
    /**
51
     * @var string[]
52
     */
53
    protected $requiredFields = array();
54
    /**
55
     * @var string[]
56
     */
57
    protected $searchableFields = array();
58
    /**
59
     * @var string[]
60
     */
61
    protected $textIndexes = array();
62
    /**
63
     * @var DocumentRepository
64
     */
65
    private $repository;
66
    /**
67
     * @var Visitor
68
     */
69
    private $visitor;
70
    /**
71
     * @var array
72
     */
73
    protected $notModifiableOriginRecords;
74
    /**
75
     * @var  integer
76
     */
77
    private $paginationDefaultLimit;
78
79
    /**
80
     * @var boolean
81
     */
82
    protected $filterByAuthUser;
83
84
    /**
85
     * @var string
86
     */
87
    protected $filterByAuthField;
88
89
    /**
90
     * @var DocumentManager
91
     */
92
    protected $manager;
93
94
    /** @var EventDispatcher */
95
    protected $eventDispatcher;
96
97
    /** @var $collectionCache */
98
    protected $cache;
99
100
    /**
101
     * @var RestUtils
102
     */
103
    private $restUtils;
104
105
    /**
106
     * @param Visitor         $visitor                    rql query visitor
107
     * @param RestUtils       $restUtils                  Rest utils
108
     * @param EventDispatcher $eventDispatcher            Kernel event dispatcher
109
     * @param CollectionCache $collectionCache            Cache Service
110
     * @param array           $notModifiableOriginRecords strings with not modifiable recordOrigin values
111
     * @param integer         $paginationDefaultLimit     amount of data records to be returned when in pagination cnt
112
     */
113
    public function __construct(
114
        Visitor $visitor,
115
        RestUtils $restUtils,
116
        $eventDispatcher,
117
        CollectionCache $collectionCache,
118
        $notModifiableOriginRecords,
119
        $paginationDefaultLimit
120
    ) {
121
        parent::__construct();
122
        $this->visitor = $visitor;
123
        $this->eventDispatcher = $eventDispatcher;
124
        $this->notModifiableOriginRecords = $notModifiableOriginRecords;
125
        $this->paginationDefaultLimit = (int) $paginationDefaultLimit;
126
        $this->cache = $collectionCache;
127
        $this->restUtils = $restUtils;
128
    }
129
130
    /**
131
     * get repository instance
132
     *
133
     * @return DocumentRepository
134
     */
135
    public function getRepository()
136
    {
137
        return $this->repository;
138
    }
139
140
    /**
141
     * create new app model
142
     *
143
     * @param DocumentRepository $repository Repository of countries
144
     *
145
     * @return \Graviton\RestBundle\Model\DocumentModel
146
     */
147
    public function setRepository(DocumentRepository $repository)
148
    {
149
        $this->repository = $repository;
150
        $this->manager = $repository->getDocumentManager();
151
152
        return $this;
153
    }
154
155
    /**
156
     * {@inheritDoc}
157
     *
158
     * @param Request $request The request object
159
     *
160
     * @return array
161
     */
162
    public function findAll(Request $request)
163
    {
164
        $pageNumber = $request->query->get('page', 1);
165
        $numberPerPage = (int) $request->query->get('perPage', $this->getDefaultLimit());
166
        $startAt = ($pageNumber - 1) * $numberPerPage;
167
168
        /** @var XiagQuery $xiagQuery */
169
        $xiagQuery = $request->attributes->get('rqlQuery');
170
171
        /** @var Builder $queryBuilder */
172
        $queryBuilder = $this->repository
173
            ->createQueryBuilder();
174
175
        // Setting RQL Query
176
        if ($xiagQuery) {
177
            // Check if search and if this Repository have search indexes.
178
            if ($query = $xiagQuery->getQuery()) {
179
                if ($query instanceof AndNode) {
180
                    foreach ($query->getQueries() as $xq) {
181
                        if ($xq instanceof SearchNode && !$this->hasCustomSearchIndex()) {
182
                            throw new InvalidArgumentException('Current api request have search index');
183
                        }
184
                    }
185
                } elseif ($query instanceof SearchNode && !$this->hasCustomSearchIndex()) {
186
                    throw new InvalidArgumentException('Current api request have search index');
187
                }
188
            }
189
            // Clean up Search rql param and set it as Doctrine query
190
            $queryBuilder = $this->doRqlQuery(
191
                $queryBuilder,
192
                $xiagQuery
193
            );
194
        } else {
195
            // @todo [lapistano]: seems the offset is missing for this query.
196
            /** @var \Doctrine\ODM\MongoDB\Query\Builder $qb */
197
            $queryBuilder->find($this->repository->getDocumentName());
198
        }
199
200
        /** @var LimitNode $rqlLimit */
201
        $rqlLimit = $xiagQuery instanceof XiagQuery ? $xiagQuery->getLimit() : false;
202
203
        // define offset and limit
204
        if (!$rqlLimit || !$rqlLimit->getOffset()) {
205
            $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...
206
        } else {
207
            $startAt = (int) $rqlLimit->getOffset();
208
            $queryBuilder->skip($startAt);
209
        }
210
211
        if (!$rqlLimit || is_null($rqlLimit->getLimit())) {
212
            $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...
213
        } else {
214
            $numberPerPage = (int) $rqlLimit->getLimit();
215
            $queryBuilder->limit($numberPerPage);
216
        }
217
218
        // Limit can not be negative nor null.
219
        if ($numberPerPage < 1) {
220
            throw new RqlSyntaxErrorException('negative or null limit in rql');
221
        }
222
223
        /**
224
         * add a default sort on id if none was specified earlier
225
         *
226
         * not specifying something to sort on leads to very weird cases when fetching references.
227
         */
228
        if (!array_key_exists('sort', $queryBuilder->getQuery()->getQuery())) {
229
            $queryBuilder->sort('_id');
230
        }
231
232
        // run query
233
        $query = $queryBuilder->getQuery();
234
        $records = array_values($query->execute()->toArray());
235
236
        $totalCount = $query->count();
237
        $numPages = (int) ceil($totalCount / $numberPerPage);
238
        $page = (int) ceil($startAt / $numberPerPage) + 1;
239
        if ($numPages > 1) {
240
            $request->attributes->set('paging', true);
241
            $request->attributes->set('page', $page);
242
            $request->attributes->set('numPages', $numPages);
243
            $request->attributes->set('startAt', $startAt);
244
            $request->attributes->set('perPage', $numberPerPage);
245
            $request->attributes->set('totalCount', $totalCount);
246
        }
247
248
        return $records;
249
    }
250
251
    /**
252
     * Check if collection has search indexes in DB
253
     *
254
     * @param string $prefix the prefix for custom text search indexes
255
     * @return bool
256
     */
257
    private function hasCustomSearchIndex($prefix = 'search_')
258
    {
259
        $metadata = $this->repository->getClassMetadata();
260
        $indexes = $metadata->getIndexes();
261
        if (count($indexes) < 1) {
262
            return false;
263
        }
264
        $collectionsName = substr($metadata->getName(), strrpos($metadata->getName(), '\\') + 1);
265
        $searchIndexName = $prefix.$collectionsName.'_index';
266
        // We reverse as normally the search index is the last.
267
        foreach (array_reverse($indexes) as $index) {
268
            if (array_key_exists('options', $index) &&
269
                array_key_exists('name', $index['options']) &&
270
                $searchIndexName == $index['options']['name']
271
            ) {
272
                return true;
273
            }
274
        }
275
        return false;
276
    }
277
278
    /**
279
     * @param object $entity       entity to insert
280
     * @param bool   $returnEntity true to return entity
281
     * @param bool   $doFlush      if we should flush or not after insert
282
     *
283
     * @return Object|null
284
     */
285
    public function insertRecord($entity, $returnEntity = true, $doFlush = true)
286
    {
287
        $this->manager->persist($entity);
288
289
        if ($doFlush) {
290
            $this->manager->flush($entity);
291
        }
292
293
        // Fire ModelEvent
294
        $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_INSERT, $entity);
295
296
        if ($returnEntity) {
297
            return $this->find($entity->getId());
298
        }
299
        return null;
300
    }
301
302
    /**
303
     * @param string $documentId id of entity to find
304
     *
305
     * @throws NotFoundException
306
     * @return Object
307
     */
308
    public function find($documentId)
309
    {
310
        $result = $this->repository->find($documentId);
311
312
        if (empty($result)) {
313
            throw new NotFoundException("Entry with id " . $documentId . " not found!");
314
        }
315
316
        return $result;
317
    }
318
319
    /**
320
     * Will attempt to find Document by ID.
321
     * If config cache is enabled for document it will save it.
322
     *
323
     * @param string  $documentId id of entity to find
324
     * @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...
325
     * @param bool    $skipLock   if true, we don't check for the lock
326
     *
327
     * @throws NotFoundException
328
     * @return string Serialised object
0 ignored issues
show
Documentation introduced by
Should the return type not be object|string?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
329
     */
330
    public function getSerialised($documentId, Request $request = null, $skipLock = false)
331
    {
332
        if (($request instanceof Request)  &&
333
            ($query = $request->attributes->get('rqlQuery')) &&
334
            (($query instanceof XiagQuery))
335
        ) {
336
            /** @var Builder $queryBuilder */
337
            $queryBuilder = $this->doRqlQuery($this->repository->createQueryBuilder(), $query);
338
            $queryBuilder->field('id')->equals($documentId);
339
            $result = $queryBuilder->getQuery()->getSingleResult();
340
            if (empty($result)) {
341
                throw new NotFoundException("Entry with id " . $documentId . " not found!");
342
            }
343
            $document = $this->restUtils->serialize($result);
344
        } elseif ($cached = $this->cache->getByRepository($this->repository, $documentId)) {
345
            $document = $cached;
346
        } else {
347
            if (!$skipLock) {
348
                $this->cache->updateOperationCheck($this->repository, $documentId);
349
            }
350
            $document = $this->restUtils->serialize($this->find($documentId));
351
            $this->cache->setByRepository($this->repository, $document, $documentId);
352
        }
353
354
        return $document;
355
    }
356
357
    /**
358
     * {@inheritDoc}
359
     *
360
     * @param string $documentId   id of entity to update
361
     * @param Object $entity       new entity
362
     * @param bool   $returnEntity true to return entity
363
     *
364
     * @return Object|null
365
     */
366
    public function updateRecord($documentId, $entity, $returnEntity = true)
367
    {
368
        if (!is_null($documentId)) {
369
            $this->deleteById($documentId);
370
            // detach so odm knows it's gone
371
            $this->manager->detach($entity);
372
            $this->manager->clear();
373
        }
374
375
        $entity = $this->manager->merge($entity);
376
377
        $this->manager->persist($entity);
378
        $this->manager->flush($entity);
379
380
        // Fire ModelEvent
381
        $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_UPDATE, $entity);
382
383
        if ($returnEntity) {
384
            return $entity;
385
        }
386
        return null;
387
    }
388
389
    /**
390
     * {@inheritDoc}
391
     *
392
     * @param string|object $id id of entity to delete or entity instance
393
     *
394
     * @return null|Object
395
     */
396
    public function deleteRecord($id)
397
    {
398
        // Check and wait if another update is being processed, avoid double delete
399
        $this->cache->updateOperationCheck($this->repository, $id);
0 ignored issues
show
Bug introduced by
It seems like $id defined by parameter $id on line 396 can also be of type object; however, Graviton\DocumentBundle\...:updateOperationCheck() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
400
        $this->cache->addUpdateLock($this->repository, $id, 1);
0 ignored issues
show
Bug introduced by
It seems like $id defined by parameter $id on line 396 can also be of type object; however, Graviton\DocumentBundle\...nCache::addUpdateLock() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
401
402
        if (is_object($id)) {
403
            $entity = $id;
404
        } else {
405
            $entity = $this->find($id);
406
        }
407
408
        $this->checkIfOriginRecord($entity);
409
        $return = $entity;
410
411
        if (is_callable([$entity, 'getId']) && $entity->getId() != null) {
412
            $this->deleteById($entity->getId());
413
            // detach so odm knows it's gone
414
            $this->manager->detach($entity);
415
            $this->manager->clear();
416
            // Dispatch ModelEvent
417
            $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_DELETE, $return);
418
            $return = null;
419
        }
420
421
        $this->cache->releaseUpdateLock($this->repository, $id);
0 ignored issues
show
Bug introduced by
It seems like $id defined by parameter $id on line 396 can also be of type object; however, Graviton\DocumentBundle\...he::releaseUpdateLock() does only seem to accept string, maybe add an additional type check?

This check looks at variables that have been passed in as parameters and are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
422
423
        return $return;
424
    }
425
426
    /**
427
     * Triggers a flush on the DocumentManager
428
     *
429
     * @param null $document optional document
430
     *
431
     * @return void
432
     */
433
    public function flush($document = null)
434
    {
435
        $this->manager->flush($document);
436
    }
437
438
    /**
439
     * A low level delete without any checks
440
     *
441
     * @param mixed $id record id
442
     *
443
     * @return void
444
     */
445
    private function deleteById($id)
446
    {
447
        $builder = $this->repository->createQueryBuilder();
448
        $builder
449
            ->remove()
450
            ->field('id')->equals($id)
451
            ->getQuery()
452
            ->execute();
453
    }
454
455
    /**
456
     * Checks in a performant way if a certain record id exists in the database
457
     *
458
     * @param mixed $id record id
459
     *
460
     * @return bool true if it exists, false otherwise
461
     */
462
    public function recordExists($id)
463
    {
464
        return is_array($this->selectSingleFields($id, ['id'], false));
465
    }
466
467
    /**
468
     * Returns a set of fields from an existing resource in a performant manner.
469
     * If you need to check certain fields on an object (and don't need everything), this
470
     * is a better way to get what you need.
471
     * If the record is not present, you will receive null. If you don't need an hydrated
472
     * instance, make sure to pass false there.
473
     *
474
     * @param mixed $id      record id
475
     * @param array $fields  list of fields you need.
476
     * @param bool  $hydrate whether to hydrate object or not
477
     *
478
     * @return array|null|object
479
     */
480
    public function selectSingleFields($id, array $fields, $hydrate = true)
481
    {
482
        $builder = $this->repository->createQueryBuilder();
483
        $idField = $this->repository->getClassMetadata()->getIdentifier()[0];
484
485
        $record = $builder
486
            ->field($idField)->equals($id)
487
            ->select($fields)
488
            ->hydrate($hydrate)
489
            ->getQuery()
490
            ->getSingleResult();
491
492
        return $record;
493
    }
494
495
    /**
496
     * get classname of entity
497
     *
498
     * @return string|null
499
     */
500
    public function getEntityClass()
501
    {
502
        if ($this->repository instanceof DocumentRepository) {
503
            return $this->repository->getDocumentName();
504
        }
505
506
        return null;
507
    }
508
509
    /**
510
     * {@inheritDoc}
511
     *
512
     * Currently this is being used to build the route id used for redirecting
513
     * to newly made documents. It might benefit from having a different name
514
     * for those purposes.
515
     *
516
     * We might use a convention based mapping here:
517
     * Graviton\CoreBundle\Document\App -> mongodb://graviton_core
518
     * Graviton\CoreBundle\Entity\Table -> mysql://graviton_core
519
     *
520
     * @todo implement this in a more convention based manner
521
     *
522
     * @return string
523
     */
524
    public function getConnectionName()
525
    {
526
        $bundle = strtolower(substr(explode('\\', get_class($this))[1], 0, -6));
527
528
        return 'graviton.' . $bundle;
529
    }
530
531
    /**
532
     * Does the actual query using the RQL Bundle.
533
     *
534
     * @param Builder $queryBuilder Doctrine ODM QueryBuilder
535
     * @param Query   $query        query from parser
536
     *
537
     * @return Builder|Expr
538
     */
539
    protected function doRqlQuery($queryBuilder, Query $query)
540
    {
541
        $this->visitor->setBuilder($queryBuilder);
542
543
        return $this->visitor->visit($query);
544
    }
545
546
    /**
547
     * Checks the recordOrigin attribute of a record and will throw an exception if value is not allowed
548
     *
549
     * @param Object $record record
550
     *
551
     * @return void
552
     */
553
    protected function checkIfOriginRecord($record)
554
    {
555
        if ($record instanceof RecordOriginInterface
556
            && !$record->isRecordOriginModifiable()
557
        ) {
558
            $values = $this->notModifiableOriginRecords;
559
            $originValue = strtolower(trim($record->getRecordOrigin()));
560
561
            if (in_array($originValue, $values)) {
562
                $msg = sprintf("Must not be one of the following keywords: %s", implode(', ', $values));
563
564
                throw new RecordOriginModifiedException($msg);
565
            }
566
        }
567
    }
568
569
    /**
570
     * Determines the configured amount fo data records to be returned in pagination context.
571
     *
572
     * @return int
573
     */
574
    private function getDefaultLimit()
575
    {
576
        if (0 < $this->paginationDefaultLimit) {
577
            return $this->paginationDefaultLimit;
578
        }
579
580
        return 10;
581
    }
582
583
    /**
584
     * Will fire a ModelEvent
585
     *
586
     * @param string $action     insert or update
587
     * @param Object $collection the changed Document
588
     *
589
     * @return void
590
     */
591
    private function dispatchModelEvent($action, $collection)
592
    {
593
        if (!($this->repository instanceof DocumentRepository)) {
594
            return;
595
        }
596
        if (!method_exists($collection, 'getId')) {
597
            return;
598
        }
599
600
        $event = new ModelEvent();
601
        $event->setCollectionId($collection->getId());
602
        $event->setActionByDispatchName($action);
603
        $event->setCollectionName($this->repository->getClassMetadata()->getCollection());
604
        $event->setCollectionClass($this->repository->getClassName());
605
        $event->setCollection($collection);
606
607
        $this->eventDispatcher->dispatch($action, $event);
608
    }
609
}
610