Completed
Push — master ( 123852...550d67 )
by
unknown
09:16
created

DocumentModel::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 12
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 12
rs 9.4285
ccs 0
cts 7
cp 0
cc 1
eloc 10
nc 1
nop 4
crap 2
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 Graviton\RestBundle\Event\ModelEvent;
11
use Graviton\Rql\Node\SearchNode;
12
use Graviton\SchemaBundle\Model\SchemaModel;
13
use Symfony\Bridge\Monolog\Logger;
14
use Symfony\Component\HttpFoundation\Request;
15
use Doctrine\ODM\MongoDB\Query\Builder;
16
use Graviton\Rql\Visitor\MongoOdm as Visitor;
17
use Xiag\Rql\Parser\Node\LimitNode;
18
use Xiag\Rql\Parser\Node\Query\AbstractLogicOperatorNode;
19
use Xiag\Rql\Parser\Query;
20
use Graviton\ExceptionBundle\Exception\RecordOriginModifiedException;
21
use Xiag\Rql\Parser\Exception\SyntaxErrorException as RqlSyntaxErrorException;
22
use Xiag\Rql\Parser\Query as XiagQuery;
23
use \Doctrine\ODM\MongoDB\Query\Builder as MongoBuilder;
24
use Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher as EventDispatcher;
25
26
/**
27
 * Use doctrine odm as backend
28
 *
29
 * @author  List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
30
 * @license http://opensource.org/licenses/gpl-license.php GNU Public License
31
 * @link    http://swisscom.ch
32
 */
33
class DocumentModel extends SchemaModel implements ModelInterface
34
{
35
    /**
36
     * @var string
37
     */
38
    protected $description;
39
    /**
40
     * @var string[]
41
     */
42
    protected $fieldTitles;
43
    /**
44
     * @var string[]
45
     */
46
    protected $fieldDescriptions;
47
    /**
48
     * @var string[]
49
     */
50
    protected $requiredFields = array();
51
    /**
52
     * @var string[]
53
     */
54
    protected $searchableFields = array();
55
    /**
56
     * @var string[]
57
     */
58
    protected $textIndexes = array();
59
    /**
60
     * @var DocumentRepository
61
     */
62
    private $repository;
63
    /**
64
     * @var Visitor
65
     */
66
    private $visitor;
67
    /**
68
     * @var array
69
     */
70
    protected $notModifiableOriginRecords;
71
    /**
72
     * @var  integer
73
     */
74
    private $paginationDefaultLimit;
75
76
    /**
77
     * @var boolean
78
     */
79
    protected $filterByAuthUser;
80
81
    /**
82
     * @var string
83
     */
84
    protected $filterByAuthField;
85
86
    /**
87
     * @var DocumentManager
88
     */
89
    protected $manager;
90
91
    /** @var EventDispatcher */
92
    protected $eventDispatcher;
93
94
    /**
95
     * @param Visitor         $visitor                    rql query visitor
96
     * @param EventDispatcher $eventDispatcher            Kernel event dispatcher
97
     * @param array           $notModifiableOriginRecords strings with not modifiable recordOrigin values
98
     * @param integer         $paginationDefaultLimit     amount of data records to be returned when in pagination cnt
99
     */
100
    public function __construct(
101
        Visitor $visitor,
102
        $eventDispatcher,
103
        $notModifiableOriginRecords,
104
        $paginationDefaultLimit
105
    ) {
106
        parent::__construct();
107
        $this->visitor = $visitor;
108
        $this->eventDispatcher = $eventDispatcher;
109
        $this->notModifiableOriginRecords = $notModifiableOriginRecords;
110
        $this->paginationDefaultLimit = (int) $paginationDefaultLimit;
111
    }
112
113
    /**
114
     * get repository instance
115
     *
116
     * @return DocumentRepository
117
     */
118
    public function getRepository()
119
    {
120
        return $this->repository;
121
    }
122
123
    /**
124
     * create new app model
125
     *
126
     * @param DocumentRepository $repository Repository of countries
127
     *
128
     * @return \Graviton\RestBundle\Model\DocumentModel
129
     */
130
    public function setRepository(DocumentRepository $repository)
131
    {
132
        $this->repository = $repository;
133
        $this->manager = $repository->getDocumentManager();
134
135
        return $this;
136
    }
137
138
    /**
139
     * {@inheritDoc}
140
     *
141
     * @param Request $request The request object
142
     *
143
     * @return array
144
     */
145
    public function findAll(Request $request)
146
    {
147
        $pageNumber = $request->query->get('page', 1);
148
        $numberPerPage = (int) $request->query->get('perPage', $this->getDefaultLimit());
149
        $startAt = ($pageNumber - 1) * $numberPerPage;
150
151
        /** @var XiagQuery $xiagQuery */
152
        $xiagQuery = $request->attributes->get('rqlQuery');
153
154
        /** @var MongoBuilder $queryBuilder */
155
        $queryBuilder = $this->repository
156
            ->createQueryBuilder();
157
158
        // Setting RQL Query
159
        if ($xiagQuery) {
160
            // Clean up Search rql param and set it as Doctrine query
161
            if ($xiagQuery->getQuery() && $this->hasCustomSearchIndex() && (float) $this->getMongoDBVersion() >= 2.6) {
162
                $searchQueries = $this->buildSearchQuery($xiagQuery, $queryBuilder);
163
                $xiagQuery = $searchQueries['xiagQuery'];
164
                $queryBuilder = $searchQueries['queryBuilder'];
165
            }
166
            $queryBuilder = $this->doRqlQuery(
167
                $queryBuilder,
168
                $xiagQuery
169
            );
170
        } else {
171
            // @todo [lapistano]: seems the offset is missing for this query.
172
            /** @var \Doctrine\ODM\MongoDB\Query\Builder $qb */
173
            $queryBuilder->find($this->repository->getDocumentName());
174
        }
175
176
        /** @var LimitNode $rqlLimit */
177
        $rqlLimit = $xiagQuery instanceof XiagQuery ? $xiagQuery->getLimit() : false;
178
179
        // define offset and limit
180
        if (!$rqlLimit || !$rqlLimit->getOffset()) {
181
            $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...
182
        } else {
183
            $startAt = (int) $rqlLimit->getOffset();
184
            $queryBuilder->skip($startAt);
185
        }
186
187
        if (!$rqlLimit || is_null($rqlLimit->getLimit())) {
188
            $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...
189
        } else {
190
            $numberPerPage = (int) $rqlLimit->getLimit();
191
            $queryBuilder->limit($numberPerPage);
192
        }
193
194
        // Limit can not be negative nor null.
195
        if ($numberPerPage < 1) {
196
            throw new RqlSyntaxErrorException('negative or null limit in rql');
197
        }
198
199
        /**
200
         * add a default sort on id if none was specified earlier
201
         *
202
         * not specifying something to sort on leads to very weird cases when fetching references.
203
         */
204
        if (!array_key_exists('sort', $queryBuilder->getQuery()->getQuery())) {
205
            $queryBuilder->sort('_id');
206
        }
207
208
        // run query
209
        $query = $queryBuilder->getQuery();
210
        $records = array_values($query->execute()->toArray());
211
212
        $totalCount = $query->count();
213
        $numPages = (int) ceil($totalCount / $numberPerPage);
214
        $page = (int) ceil($startAt / $numberPerPage) + 1;
215
        if ($numPages > 1) {
216
            $request->attributes->set('paging', true);
217
            $request->attributes->set('page', $page);
218
            $request->attributes->set('numPages', $numPages);
219
            $request->attributes->set('startAt', $startAt);
220
            $request->attributes->set('perPage', $numberPerPage);
221
            $request->attributes->set('totalCount', $totalCount);
222
        }
223
224
        return $records;
225
    }
226
227
    /**
228
     * @param XiagQuery    $xiagQuery    Xiag Builder
229
     * @param MongoBuilder $queryBuilder Mongo Doctrine query builder
230
     * @return array
0 ignored issues
show
Documentation introduced by
Consider making the return type a bit more specific; maybe use array<string,Query|Builder>.

This check looks for the generic type array as a return type and suggests a more specific type. This type is inferred from the actual code.

Loading history...
231
     */
232
    private function buildSearchQuery(XiagQuery $xiagQuery, MongoBuilder $queryBuilder)
233
    {
234
        $innerQuery = $xiagQuery->getQuery();
235
        $hasSearch = false;
236
        $nodes = [];
237
        if ($innerQuery instanceof AbstractLogicOperatorNode) {
238
            foreach ($innerQuery->getQueries() as $key => $innerRql) {
239
                if ($innerRql instanceof SearchNode) {
240
                    if (!$hasSearch) {
241
                        $queryBuilder = $this->buildSearchTextQuery($queryBuilder, $innerRql);
242
                        $hasSearch = true;
243
                    }
244
                } else {
245
                    $nodes[] = $innerRql;
246
                }
247
            }
248
        } elseif ($innerQuery instanceof SearchNode) {
249
            $queryBuilder = $this->repository->createQueryBuilder();
250
            $queryBuilder = $this->buildSearchTextQuery($queryBuilder, $innerQuery);
251
            $hasSearch = true;
252
        }
253
        // Remove the Search from RQL xiag
254
        if ($hasSearch && $nodes) {
255
            $newXiagQuery = new XiagQuery();
256
            if ($xiagQuery->getLimit()) {
257
                $newXiagQuery->setLimit($xiagQuery->getLimit());
258
            }
259
            if ($xiagQuery->getSelect()) {
260
                $newXiagQuery->setSelect($xiagQuery->getSelect());
261
            }
262
            if ($xiagQuery->getSort()) {
263
                $newXiagQuery->setSort($xiagQuery->getSort());
264
            }
265
            $binderClass = get_class($innerQuery);
266
            /** @var AbstractLogicOperatorNode $newBinder */
267
            $newBinder = new $binderClass();
268
            foreach ($nodes as $node) {
269
                $newBinder->addQuery($node);
270
            }
271
            $newXiagQuery->setQuery($newBinder);
272
            // Reset original query, so that there is no Search param
273
            $xiagQuery = $newXiagQuery;
274
        }
275
        if ($hasSearch) {
276
            $queryBuilder->sortMeta('score', 'textScore');
277
        }
278
        return [
279
            'xiagQuery'     => $xiagQuery,
280
            'queryBuilder'  => $queryBuilder
281
        ];
282
    }
283
284
    /**
285
     * Check if collection has search indexes in DB
286
     *
287
     * @param string $prefix the prefix for custom text search indexes
288
     * @return bool
289
     */
290
    private function hasCustomSearchIndex($prefix = 'search')
291
    {
292
        $metadata = $this->repository->getClassMetadata();
293
        $indexes = $metadata->getIndexes();
294
        if (count($indexes) < 1) {
295
            return false;
296
        }
297
        $collectionsName = substr($metadata->getName(), strrpos($metadata->getName(), '\\') + 1);
298
        $searchIndexName = $prefix.$collectionsName.'Index';
299
        // We reverse as normally the search index is the last.
300
        foreach (array_reverse($indexes) as $index) {
301
            if (array_key_exists('keys', $index) && array_key_exists($searchIndexName, $index['keys'])) {
302
                return true;
303
            }
304
        }
305
        return false;
306
    }
307
308
    /**
309
     * Build Search text index
310
     *
311
     * @param MongoBuilder $queryBuilder Doctrine mongo query builder object
312
     * @param SearchNode   $searchNode   Graviton Search node
313
     * @return MongoBuilder
314
     */
315
    private function buildSearchTextQuery(MongoBuilder $queryBuilder, SearchNode $searchNode)
316
    {
317
        $searchArr = [];
318
        foreach ($searchNode->getSearchTerms() as $string) {
319
            if (!empty(trim($string))) {
320
                $searchArr[] = (strpos($string, '.') !== false) ? "\"{$string}\"" : $string;
321
            }
322
        }
323
        if (!empty($searchArr)) {
324
            $queryBuilder->addAnd($queryBuilder->expr()->text(implode(' ', $searchArr)));
325
        }
326
        return $queryBuilder;
327
    }
328
329
    /**
330
     * @return string the version of the MongoDB as a string
331
     */
332
    private function getMongoDBVersion()
333
    {
334
        $buildInfo = $this->repository->getDocumentManager()->getDocumentDatabase(
335
            $this->repository->getClassName()
336
        )->command(['buildinfo'=>1]);
337
        if (isset($buildInfo['version'])) {
338
            return $buildInfo['version'];
339
        } else {
340
            return "unkown";
341
        }
342
    }
343
344
    /**
345
     * @param object $entity       entity to insert
346
     * @param bool   $returnEntity true to return entity
347
     * @param bool   $doFlush      if we should flush or not after insert
348
     *
349
     * @return Object|null
350
     */
351
    public function insertRecord($entity, $returnEntity = true, $doFlush = true)
352
    {
353
        $this->manager->persist($entity);
354
355
        if ($doFlush) {
356
            $this->manager->flush($entity);
357
        }
358
359
        // Fire ModelEvent
360
        $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_INSERT, $entity);
361
362
        if ($returnEntity) {
363
            return $this->find($entity->getId());
364
        }
365
    }
366
367
    /**
368
     * @param string  $documentId id of entity to find
369
     * @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...
370
     *
371
     * @return Object
372
     */
373
    public function find($documentId, Request $request = null)
374
    {
375
        if ($request instanceof Request) {
376
            // if we are provided a Request, we apply RQL
377
378
            /** @var MongoBuilder $queryBuilder */
379
            $queryBuilder = $this->repository
380
                ->createQueryBuilder();
381
382
            /** @var XiagQuery $query */
383
            $query = $request->attributes->get('rqlQuery');
384
385
            if ($query instanceof XiagQuery) {
386
                $queryBuilder = $this->doRqlQuery(
387
                    $queryBuilder,
388
                    $query
389
                );
390
            }
391
392
            $queryBuilder->field('id')->equals($documentId);
393
394
            $query = $queryBuilder->getQuery();
395
            return $query->getSingleResult();
396
        }
397
398
        return $this->repository->find($documentId);
399
    }
400
401
    /**
402
     * {@inheritDoc}
403
     *
404
     * @param string $documentId   id of entity to update
405
     * @param Object $entity       new entity
406
     * @param bool   $returnEntity true to return entity
407
     *
408
     * @return Object|null
409
     */
410
    public function updateRecord($documentId, $entity, $returnEntity = true)
411
    {
412
        if (!is_null($documentId)) {
413
            $this->deleteById($documentId);
414
            // detach so odm knows it's gone
415
            $this->manager->detach($entity);
416
            $this->manager->clear();
417
        }
418
419
        $entity = $this->manager->merge($entity);
420
421
        $this->manager->persist($entity);
422
        $this->manager->flush($entity);
423
        
424
        // Fire ModelEvent
425
        $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_UPDATE, $entity);
426
427
        if ($returnEntity) {
428
            return $entity;
429
        }
430
    }
431
432
    /**
433
     * {@inheritDoc}
434
     *
435
     * @param string|object $id id of entity to delete or entity instance
436
     *
437
     * @return null|Object
438
     */
439
    public function deleteRecord($id)
440
    {
441
        if (is_object($id)) {
442
            $entity = $id;
443
        } else {
444
            $entity = $this->find($id);
445
        }
446
447
        $this->checkIfOriginRecord($entity);
448
        $return = $entity;
449
450
        if (is_callable([$entity, 'getId']) && $entity->getId() != null) {
451
            $this->deleteById($entity->getId());
452
            // detach so odm knows it's gone
453
            $this->manager->detach($entity);
454
            $this->manager->clear();
455
            // Dispatch ModelEvent
456
            $this->dispatchModelEvent(ModelEvent::MODEL_EVENT_DELETE, $return);
457
            $return = null;
458
        }
459
460
        return $return;
461
    }
462
463
    /**
464
     * Triggers a flush on the DocumentManager
465
     *
466
     * @param null $document optional document
467
     *
468
     * @return void
469
     */
470
    public function flush($document = null)
471
    {
472
        $this->manager->flush($document);
473
    }
474
475
    /**
476
     * A low level delete without any checks
477
     *
478
     * @param mixed $id record id
479
     *
480
     * @return void
481
     */
482
    private function deleteById($id)
483
    {
484
        $builder = $this->repository->createQueryBuilder();
485
        $builder
486
            ->remove()
487
            ->field('id')->equals($id)
488
            ->getQuery()
489
            ->execute();
490
    }
491
492
    /**
493
     * Checks in a performant way if a certain record id exists in the database
494
     *
495
     * @param mixed $id record id
496
     *
497
     * @return bool true if it exists, false otherwise
498
     */
499
    public function recordExists($id)
0 ignored issues
show
Coding Style introduced by
function recordExists() does not seem to conform to the naming convention (^(?:is|has|should|may|supports)).

This check examines a number of code elements and verifies that they conform to the given naming conventions.

You can set conventions for local variables, abstract classes, utility classes, constant, properties, methods, parameters, interfaces, classes, exceptions and special methods.

Loading history...
500
    {
501
        return is_array($this->selectSingleFields($id, ['id'], false));
502
    }
503
504
    /**
505
     * Returns a set of fields from an existing resource in a performant manner.
506
     * If you need to check certain fields on an object (and don't need everything), this
507
     * is a better way to get what you need.
508
     * If the record is not present, you will receive null. If you don't need an hydrated
509
     * instance, make sure to pass false there.
510
     *
511
     * @param mixed $id      record id
512
     * @param array $fields  list of fields you need.
513
     * @param bool  $hydrate whether to hydrate object or not
514
     *
515
     * @return array|null|object
516
     */
517
    public function selectSingleFields($id, array $fields, $hydrate = true)
518
    {
519
        $builder = $this->repository->createQueryBuilder();
520
        $idField = $this->repository->getClassMetadata()->getIdentifier()[0];
521
522
        $record = $builder
523
            ->field($idField)->equals($id)
524
            ->select($fields)
525
            ->hydrate($hydrate)
526
            ->getQuery()
527
            ->getSingleResult();
528
529
        return $record;
530
    }
531
532
    /**
533
     * get classname of entity
534
     *
535
     * @return string|null
536
     */
537
    public function getEntityClass()
538
    {
539
        if ($this->repository instanceof DocumentRepository) {
540
            return $this->repository->getDocumentName();
541
        }
542
543
        return null;
544
    }
545
546
    /**
547
     * {@inheritDoc}
548
     *
549
     * Currently this is being used to build the route id used for redirecting
550
     * to newly made documents. It might benefit from having a different name
551
     * for those purposes.
552
     *
553
     * We might use a convention based mapping here:
554
     * Graviton\CoreBundle\Document\App -> mongodb://graviton_core
555
     * Graviton\CoreBundle\Entity\Table -> mysql://graviton_core
556
     *
557
     * @todo implement this in a more convention based manner
558
     *
559
     * @return string
560
     */
561
    public function getConnectionName()
562
    {
563
        $bundle = strtolower(substr(explode('\\', get_class($this))[1], 0, -6));
564
565
        return 'graviton.' . $bundle;
566
    }
567
568
    /**
569
     * Does the actual query using the RQL Bundle.
570
     *
571
     * @param Builder $queryBuilder Doctrine ODM QueryBuilder
572
     * @param Query   $query        query from parser
573
     *
574
     * @return array
0 ignored issues
show
Documentation introduced by
Should the return type not be Builder|\Doctrine\ODM\MongoDB\Query\Expr?

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...
575
     */
576
    protected function doRqlQuery($queryBuilder, Query $query)
577
    {
578
        $this->visitor->setBuilder($queryBuilder);
579
580
        return $this->visitor->visit($query);
581
    }
582
583
    /**
584
     * Checks the recordOrigin attribute of a record and will throw an exception if value is not allowed
585
     *
586
     * @param Object $record record
587
     *
588
     * @return void
589
     */
590
    protected function checkIfOriginRecord($record)
591
    {
592
        if ($record instanceof RecordOriginInterface
593
            && !$record->isRecordOriginModifiable()
594
        ) {
595
            $values = $this->notModifiableOriginRecords;
596
            $originValue = strtolower(trim($record->getRecordOrigin()));
597
598
            if (in_array($originValue, $values)) {
599
                $msg = sprintf("Must not be one of the following keywords: %s", implode(', ', $values));
600
601
                throw new RecordOriginModifiedException($msg);
602
            }
603
        }
604
    }
605
606
    /**
607
     * Determines the configured amount fo data records to be returned in pagination context.
608
     *
609
     * @return int
610
     */
611
    private function getDefaultLimit()
612
    {
613
        if (0 < $this->paginationDefaultLimit) {
614
            return $this->paginationDefaultLimit;
615
        }
616
617
        return 10;
618
    }
619
620
    /**
621
     * Will fire a ModelEvent
622
     *
623
     * @param string $action     insert or update
624
     * @param Object $collection the changed Document
625
     *
626
     * @return void
627
     */
628
    private function dispatchModelEvent($action, $collection)
629
    {
630
        if (!($this->repository instanceof DocumentRepository)) {
631
            return;
632
        }
633
        if (!method_exists($collection, 'getId')) {
634
            return;
635
        }
636
637
        $event = new ModelEvent();
638
        $event->setCollectionId($collection->getId());
639
        $event->setActionByDispatchName($action);
640
        $event->setCollectionName($this->repository->getClassMetadata()->getCollection());
641
        $event->setCollectionClass($this->repository->getClassName());
642
        $event->setCollection($collection);
643
        
644
        $this->eventDispatcher->dispatch($action, $event);
645
    }
646
}
647