Completed
Push — feature/EVO-5751-text-search-j... ( ec7578...dc3da0 )
by
unknown
10:40
created

DocumentModel::doRqlQuery()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 6
ccs 0
cts 3
cp 0
rs 9.4285
c 0
b 0
f 0
cc 1
eloc 3
nc 1
nop 2
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\Service\RqlTranslator;
11
use Graviton\Rql\Node\SearchNode;
12
use Graviton\SchemaBundle\Model\SchemaModel;
13
use Graviton\SecurityBundle\Entities\SecurityUser;
14
use Symfony\Bridge\Monolog\Logger;
15
use Symfony\Component\HttpFoundation\Request;
16
use Doctrine\ODM\MongoDB\Query\Builder;
17
use Graviton\Rql\Visitor\MongoOdm as Visitor;
18
use Xiag\Rql\Parser\Node\Query\AbstractLogicOperatorNode;
19
use Xiag\Rql\Parser\Node\Query\AbstractScalarOperatorNode;
20
use Xiag\Rql\Parser\Query;
21
use Graviton\ExceptionBundle\Exception\RecordOriginModifiedException;
22
use Xiag\Rql\Parser\Exception\SyntaxErrorException as RqlSyntaxErrorException;
23
use Graviton\SchemaBundle\Document\Schema as SchemaDocument;
24
use Xiag\Rql\Parser\Query as XiagQuery;
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 DocumentRepository
57
     */
58
    private $repository;
59
    /**
60
     * @var Visitor
61
     */
62
    private $visitor;
63
    /**
64
     * @var array
65
     */
66
    protected $notModifiableOriginRecords;
67
    /**
68
     * @var  integer
69
     */
70
    private $paginationDefaultLimit;
71
72
    /**
73
     * @var boolean
74
     */
75
    protected $filterByAuthUser;
76
77
    /**
78
     * @var string
79
     */
80
    protected $filterByAuthField;
81
82
    /**
83
     * @var RqlTranslator
84
     */
85
    protected $translator;
86
87
    /**
88
     * @var DocumentManager
89
     */
90
    protected $manager;
91
92
    /**
93
     * @param Visitor       $visitor                    rql query visitor
94
     * @param RqlTranslator $translator                 Translator for query modification
95
     * @param array         $notModifiableOriginRecords strings with not modifiable recordOrigin values
96
     * @param integer       $paginationDefaultLimit     amount of data records to be returned when in pagination context
97
     */
98 4
    public function __construct(
99
        Visitor $visitor,
100
        RqlTranslator $translator,
101
        $notModifiableOriginRecords,
102
        $paginationDefaultLimit
103
    ) {
104 4
        parent::__construct();
105 4
        $this->visitor = $visitor;
106 4
        $this->translator = $translator;
107 4
        $this->notModifiableOriginRecords = $notModifiableOriginRecords;
108 4
        $this->paginationDefaultLimit = (int) $paginationDefaultLimit;
109 4
    }
110
111
    /**
112
     * get repository instance
113
     *
114
     * @return DocumentRepository
115
     */
116 2
    public function getRepository()
117
    {
118 2
        return $this->repository;
119
    }
120
121
    /**
122
     * create new app model
123
     *
124
     * @param DocumentRepository $repository Repository of countries
125
     *
126
     * @return \Graviton\RestBundle\Model\DocumentModel
127
     */
128 4
    public function setRepository(DocumentRepository $repository)
129
    {
130 4
        $this->repository = $repository;
131 4
        $this->manager = $repository->getDocumentManager();
132
133 4
        return $this;
134
    }
135
136
    /**
137
     * {@inheritDoc}
138
     *
139
     * @param Request        $request The request object
140
     * @param SecurityUser   $user    SecurityUser Object
0 ignored issues
show
Documentation introduced by
Should the type for parameter $user not be null|SecurityUser?

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...
141
     * @param SchemaDocument $schema  Schema model used for search fields extraction
0 ignored issues
show
Documentation introduced by
Should the type for parameter $schema not be null|SchemaDocument?

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...
142
     *
143
     * @return array
144
     */
145
    public function findAll(Request $request, SecurityUser $user = null, SchemaDocument $schema = null)
146
    {
147
        $pageNumber = $request->query->get('page', 1);
148
        $numberPerPage = (int) $request->query->get('perPage', $this->getDefaultLimit());
149
        $startAt = ($pageNumber - 1) * $numberPerPage;
150
        // Only 1 search text node allowed.
151
        $hasSearch = false;
152
153
        /** @var \Doctrine\ODM\MongoDB\Query\Builder $queryBuilder */
154
        $queryBuilder = $this->repository
155
            ->createQueryBuilder();
156
157
        if ($this->filterByAuthUser && $user && $user->hasRole(SecurityUser::ROLE_USER)) {
158
            $queryBuilder->field($this->filterByAuthField)->equals($user->getUser()->getId());
159
        }
160
161
        // *** do we have an RQL expression, do we need to filter data?
162
        if ($request->attributes->get('hasRql', false)) {
163
            $innerQuery = $request->attributes->get('rqlQuery')->getQuery();
164
            $xiagQuery = new XiagQuery();
165
            // can we perform a search in an index instead of filtering?
166
            if ($innerQuery instanceof AbstractLogicOperatorNode) {
167
                foreach ($innerQuery->getQueries() as $innerRql) {
168
                    if (!$hasSearch && $innerRql instanceof SearchNode) {
169
                        $searchString = implode('&', $innerRql->getSearchTerms());
170
                        $queryBuilder->addAnd(
171
                            $queryBuilder->expr()->text($searchString)
172
                        );
173
                        $hasSearch = true;
174
                    } else {
175
                        $xiagQuery->setQuery($innerRql);
176
                    }
177
                }
178
            } elseif ($this->hasCustomSearchIndex() && ($innerQuery instanceof SearchNode)) {
179
                $searchString = implode('&', $innerQuery->getSearchTerms());
180
                $queryBuilder->addAnd(
181
                    $queryBuilder->expr()->text($searchString)
182
                );
183
                $hasSearch = true;
184
            } else {
185
                if ($innerQuery instanceof AbstractScalarOperatorNode) {
186
                    $xiagQuery->setQuery($innerQuery);
187
                } else {
188
                    /** @var AbstractLogicOperatorNode $innerQuery */
189
                    foreach ($innerQuery->getQueries() as $innerRql) {
190
                        if (!$innerRql instanceof SearchNode) {
191
                            $xiagQuery->setQuery($innerRql);
192
                        }
193
                    }
194
                }
195
            }
196
197
            $queryBuilder = $this->doRqlQuery(
198
                $queryBuilder,
199
                $xiagQuery
200
            );
201
        } else {
202
            // @todo [lapistano]: seems the offset is missing for this query.
203
            /** @var \Doctrine\ODM\MongoDB\Query\Builder $qb */
204
            $queryBuilder->find($this->repository->getDocumentName());
205
        }
206
207
        // define offset and limit
208
        if (!array_key_exists('skip', $queryBuilder->getQuery()->getQuery())) {
209
            $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...
210
        } else {
211
            $startAt = (int) $queryBuilder->getQuery()->getQuery()['skip'];
212
        }
213
214
        if (!array_key_exists('limit', $queryBuilder->getQuery()->getQuery())) {
215
            $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...
216
        } else {
217
            $numberPerPage = (int) $queryBuilder->getQuery()->getQuery()['limit'];
218
        }
219
220
        // Limit can not be negative nor null.
221
        if ($numberPerPage < 1) {
222
            throw new RqlSyntaxErrorException('negative or null limit in rql');
223
        }
224
225
        /**
226
         * add a default sort on id if none was specified earlier
227
         *
228
         * not specifying something to sort on leads to very weird cases when fetching references
229
         * If search node, sort by Score
230
         */
231
        if ($hasSearch) {
232
            $queryBuilder->sort('score', 'desc');
233
        } elseif (!array_key_exists('sort', $queryBuilder->getQuery()->getQuery())) {
234
            $queryBuilder->sort('_id');
235
        }
236
237
        // run query
238
        $query = $queryBuilder->getQuery();
239
        $records = array_values($query->execute()->toArray());
240
241
        $totalCount = $query->count();
242
        $numPages = (int) ceil($totalCount / $numberPerPage);
243
        $page = (int) ceil($startAt / $numberPerPage) + 1;
244
        if ($numPages > 1) {
245
            $request->attributes->set('paging', true);
246
            $request->attributes->set('page', $page);
247
            $request->attributes->set('numPages', $numPages);
248
            $request->attributes->set('startAt', $startAt);
249
            $request->attributes->set('perPage', $numberPerPage);
250
            $request->attributes->set('totalCount', $totalCount);
251
        }
252
253
        return $records;
254
    }
255
256
    /**
257
     * @param string $prefix the prefix for custom text search indexes
258
     * @return bool
259
     * @throws \Doctrine\ODM\MongoDB\MongoDBException
260
     */
261
    private function hasCustomSearchIndex($prefix = 'search')
262
    {
263
        $collection = $this->repository->getDocumentManager()->getDocumentCollection($this->repository->getClassName());
264
        $indexesInfo = $collection->getIndexInfo();
265
        foreach ($indexesInfo as $indexInfo) {
266
            if ($indexInfo['name']==$prefix.$collection->getName().'Index') {
267
                return true;
268
            }
269
        }
270
        return false;
271
    }
272
273
    /**
274
     * @return string the version of the MongoDB as a string
275
     */
276
    private function getMongoDBVersion()
0 ignored issues
show
Unused Code introduced by
This method is not used, and could be removed.
Loading history...
277
    {
278
        $buildInfo = $this->repository->getDocumentManager()->getDocumentDatabase(
279
            $this->repository->getClassName()
280
        )->command(['buildinfo'=>1]);
281
        if (isset($buildInfo['version'])) {
282
            return $buildInfo['version'];
283
        } else {
284
            return "unkown";
285
        }
286
    }
287
288
    /**
289
     * @param object $entity       entity to insert
290
     * @param bool   $returnEntity true to return entity
291
     * @param bool   $doFlush      if we should flush or not after insert
292
     *
293
     * @return Object|null
294
     */
295
    public function insertRecord($entity, $returnEntity = true, $doFlush = true)
296
    {
297
        $this->checkIfOriginRecord($entity);
298
        $this->manager->persist($entity);
299
300
        if ($doFlush) {
301
            $this->manager->flush($entity);
302
        }
303
        if ($returnEntity) {
304
            return $this->find($entity->getId());
305
        }
306
    }
307
308
    /**
309
     * @param string $documentId id of entity to find
310
     *
311
     * @return Object
0 ignored issues
show
Documentation introduced by
Should the return type not be object|null?

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...
312
     */
313 4
    public function find($documentId)
314
    {
315 4
        return $this->repository->find($documentId);
316
    }
317
318
    /**
319
     * {@inheritDoc}
320
     *
321
     * @param string $documentId   id of entity to update
322
     * @param Object $entity       new entity
323
     * @param bool   $returnEntity true to return entity
324
     *
325
     * @return Object|null
326
     */
327 2
    public function updateRecord($documentId, $entity, $returnEntity = true)
328
    {
329
        // In both cases the document attribute named originRecord must not be 'core'
330 2
        $this->checkIfOriginRecord($entity);
331 2
        $this->checkIfOriginRecord($this->selectSingleFields($documentId, ['recordOrigin']));
0 ignored issues
show
Bug introduced by
It seems like $this->selectSingleField... array('recordOrigin')) targeting Graviton\RestBundle\Mode...l::selectSingleFields() can also be of type array or null; however, Graviton\RestBundle\Mode...::checkIfOriginRecord() does only seem to accept object, maybe add an additional type check?

This check looks at variables that 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...
332
333 2
        if (!is_null($documentId)) {
334 2
            $this->deleteById($documentId);
335
            // detach so odm knows it's gone
336 2
            $this->manager->detach($entity);
337 2
            $this->manager->clear();
338 1
        }
339
340 2
        $entity = $this->manager->merge($entity);
341
342 2
        $this->manager->persist($entity);
343 2
        $this->manager->flush($entity);
344
345 2
        if ($returnEntity) {
346
            return $entity;
347
        }
348 2
    }
349
350
    /**
351
     * {@inheritDoc}
352
     *
353
     * @param string|object $id id of entity to delete or entity instance
354
     *
355
     * @return null|Object
356
     */
357
    public function deleteRecord($id)
358
    {
359
        if (is_object($id)) {
360
            $entity = $id;
361
        } else {
362
            $entity = $this->find($id);
363
        }
364
365
        $return = $entity;
366
        if ($entity) {
367
            $this->checkIfOriginRecord($entity);
368
            $this->manager->remove($entity);
369
            $this->manager->flush();
370
            $return = null;
371
        }
372
373
        return $return;
374
    }
375
376
    /**
377
     * Triggers a flush on the DocumentManager
378
     *
379
     * @param null $document optional document
380
     *
381
     * @return void
382
     */
383
    public function flush($document = null)
384
    {
385
        $this->manager->flush($document);
386
    }
387
388
    /**
389
     * A low level delete without any checks
390
     *
391
     * @param mixed $id record id
392
     *
393
     * @return void
394
     */
395 2
    private function deleteById($id)
396
    {
397 2
        $builder = $this->repository->createQueryBuilder();
398
        $builder
399 2
            ->remove()
400 2
            ->field('id')->equals($id)
401 2
            ->getQuery()
402 2
            ->execute();
403 2
    }
404
405
    /**
406
     * Checks in a performant way if a certain record id exists in the database
407
     *
408
     * @param mixed $id record id
409
     *
410
     * @return bool true if it exists, false otherwise
411
     */
412 4
    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...
413
    {
414 4
        return is_array($this->selectSingleFields($id, ['id'], false));
415
    }
416
417
    /**
418
     * Returns a set of fields from an existing resource in a performant manner.
419
     * If you need to check certain fields on an object (and don't need everything), this
420
     * is a better way to get what you need.
421
     * If the record is not present, you will receive null. If you don't need an hydrated
422
     * instance, make sure to pass false there.
423
     *
424
     * @param mixed $id      record id
425
     * @param array $fields  list of fields you need.
426
     * @param bool  $hydrate whether to hydrate object or not
427
     *
428
     * @return array|null|object
429
     */
430 4
    public function selectSingleFields($id, array $fields, $hydrate = true)
431
    {
432 4
        $builder = $this->repository->createQueryBuilder();
433 4
        $idField = $this->repository->getClassMetadata()->getIdentifier()[0];
434
435
        $record = $builder
436 4
            ->field($idField)->equals($id)
437 4
            ->select($fields)
438 4
            ->hydrate($hydrate)
439 4
            ->getQuery()
440 4
            ->getSingleResult();
441
442 4
        return $record;
443
    }
444
445
    /**
446
     * get classname of entity
447
     *
448
     * @return string
0 ignored issues
show
Documentation introduced by
Should the return type not be string|null?

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...
449
     */
450 4
    public function getEntityClass()
451
    {
452 4
        return $this->repository ? $this->repository->getDocumentName() : null;
453
    }
454
455
    /**
456
     * {@inheritDoc}
457
     *
458
     * Currently this is being used to build the route id used for redirecting
459
     * to newly made documents. It might benefit from having a different name
460
     * for those purposes.
461
     *
462
     * We might use a convention based mapping here:
463
     * Graviton\CoreBundle\Document\App -> mongodb://graviton_core
464
     * Graviton\CoreBundle\Entity\Table -> mysql://graviton_core
465
     *
466
     * @todo implement this in a more convention based manner
467
     *
468
     * @return string
469
     */
470
    public function getConnectionName()
471
    {
472
        $bundle = strtolower(substr(explode('\\', get_class($this))[1], 0, -6));
473
474
        return 'graviton.' . $bundle;
475
    }
476
477
    /**
478
     * Does the actual query using the RQL Bundle.
479
     *
480
     * @param Builder $queryBuilder Doctrine ODM QueryBuilder
481
     * @param Query   $query        query from parser
482
     *
483
     * @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...
484
     */
485
    protected function doRqlQuery($queryBuilder, Query $query)
486
    {
487
        $this->visitor->setBuilder($queryBuilder);
488
489
        return $this->visitor->visit($query);
490
    }
491
492
    /**
493
     * Checks the recordOrigin attribute of a record and will throw an exception if value is not allowed
494
     *
495
     * @param Object $record record
496
     *
497
     * @return void
498
     */
499 14
    protected function checkIfOriginRecord($record)
500
    {
501 7
        if ($record instanceof RecordOriginInterface
502 14
            && !$record->isRecordOriginModifiable()
503 7
        ) {
504 6
            $values = $this->notModifiableOriginRecords;
505 6
            $originValue = strtolower(trim($record->getRecordOrigin()));
506
507 6
            if (in_array($originValue, $values)) {
508 2
                $msg = sprintf("Must not be one of the following keywords: %s", implode(', ', $values));
509
510 2
                throw new RecordOriginModifiedException($msg);
511
            }
512 2
        }
513 12
    }
514
515
    /**
516
     * Determines the configured amount fo data records to be returned in pagination context.
517
     *
518
     * @return int
519
     */
520
    private function getDefaultLimit()
521
    {
522
        if (0 < $this->paginationDefaultLimit) {
523
            return $this->paginationDefaultLimit;
524
        }
525
526
        return 10;
527
    }
528
529
    /**
530
     * @param Boolean $active active
531
     * @param String  $field  field
532
     * @return void
533
     */
534 4
    public function setFilterByAuthUser($active, $field)
535
    {
536 4
        $this->filterByAuthUser = is_bool($active) ? $active : false;
537 4
        $this->filterByAuthField = $field;
538 4
    }
539
}
540