Completed
Push — master ( 554f9d...0d589d )
by
unknown
13:57
created

DocumentModel::buildSearchTextQuery()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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