Completed
Push — feature/EVO-5751-text-search-j... ( c6ed70...86d6f0 )
by
unknown
293:11 queued 287:20
created

DocumentModel::deleteRecord()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 4
Bugs 0 Features 0
Metric Value
c 4
b 0
f 0
dl 0
loc 18
ccs 0
cts 13
cp 0
rs 9.4285
cc 3
eloc 12
nc 4
nop 1
crap 12
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\AbstractNode;
19
use Xiag\Rql\Parser\Node\LimitNode;
20
use Xiag\Rql\Parser\Node\Query\AbstractLogicOperatorNode;
21
use Xiag\Rql\Parser\Query;
22
use Graviton\ExceptionBundle\Exception\RecordOriginModifiedException;
23
use Xiag\Rql\Parser\Exception\SyntaxErrorException as RqlSyntaxErrorException;
24
use Graviton\SchemaBundle\Document\Schema as SchemaDocument;
25
use Xiag\Rql\Parser\Query as XiagQuery;
26
27
/**
28
 * Use doctrine odm as backend
29
 *
30
 * @author  List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
31
 * @license http://opensource.org/licenses/gpl-license.php GNU Public License
32
 * @link    http://swisscom.ch
33
 */
34
class DocumentModel extends SchemaModel implements ModelInterface
35
{
36
    /**
37
     * @var string
38
     */
39
    protected $description;
40
    /**
41
     * @var string[]
42
     */
43
    protected $fieldTitles;
44
    /**
45
     * @var string[]
46
     */
47
    protected $fieldDescriptions;
48
    /**
49
     * @var string[]
50
     */
51
    protected $requiredFields = array();
52
    /**
53
     * @var string[]
54
     */
55
    protected $searchableFields = array();
56
    /**
57
     * @var DocumentRepository
58
     */
59
    private $repository;
60
    /**
61
     * @var Visitor
62
     */
63
    private $visitor;
64
    /**
65
     * @var array
66
     */
67
    protected $notModifiableOriginRecords;
68
    /**
69
     * @var  integer
70
     */
71
    private $paginationDefaultLimit;
72
73
    /**
74
     * @var boolean
75
     */
76
    protected $filterByAuthUser;
77
78
    /**
79
     * @var string
80
     */
81
    protected $filterByAuthField;
82
83
    /**
84
     * @var RqlTranslator
85
     */
86
    protected $translator;
87
88
    /**
89
     * @var DocumentManager
90
     */
91
    protected $manager;
92
93
    /**
94
     * @param Visitor       $visitor                    rql query visitor
95
     * @param RqlTranslator $translator                 Translator for query modification
96
     * @param array         $notModifiableOriginRecords strings with not modifiable recordOrigin values
97
     * @param integer       $paginationDefaultLimit     amount of data records to be returned when in pagination context
98
     */
99 4
    public function __construct(
100
        Visitor $visitor,
101
        RqlTranslator $translator,
102
        $notModifiableOriginRecords,
103
        $paginationDefaultLimit
104
    ) {
105 4
        parent::__construct();
106 4
        $this->visitor = $visitor;
107 4
        $this->translator = $translator;
108 4
        $this->notModifiableOriginRecords = $notModifiableOriginRecords;
109 4
        $this->paginationDefaultLimit = (int) $paginationDefaultLimit;
110 4
    }
111
112
    /**
113
     * get repository instance
114
     *
115
     * @return DocumentRepository
116
     */
117 2
    public function getRepository()
118
    {
119 2
        return $this->repository;
120
    }
121
122
    /**
123
     * create new app model
124
     *
125
     * @param DocumentRepository $repository Repository of countries
126
     *
127
     * @return \Graviton\RestBundle\Model\DocumentModel
128
     */
129 4
    public function setRepository(DocumentRepository $repository)
130
    {
131 4
        $this->repository = $repository;
132 4
        $this->manager = $repository->getDocumentManager();
133
134 4
        return $this;
135
    }
136
137
    /**
138
     * {@inheritDoc}
139
     *
140
     * @param Request        $request The request object
141
     * @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...
142
     * @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...
143
     *
144
     * @return array
145
     */
146
    public function findAll(Request $request, SecurityUser $user = null, SchemaDocument $schema = null)
147
    {
148
        $pageNumber = $request->query->get('page', 1);
149
        $numberPerPage = (int) $request->query->get('perPage', $this->getDefaultLimit());
150
        $startAt = ($pageNumber - 1) * $numberPerPage;
151
        // Only 1 search text node allowed.
152
        $hasSearch = false;
153
        $queryParams = new XiagQuery();
154
155
        /** @var \Doctrine\ODM\MongoDB\Query\Builder $queryBuilder */
156
        $queryBuilder = $this->repository
157
            ->createQueryBuilder();
158
159
        if ($this->filterByAuthUser && $user && $user->hasRole(SecurityUser::ROLE_USER)) {
160
            $queryBuilder->field($this->filterByAuthField)->equals($user->getUser()->getId());
161
        }
162
163
        // *** do we have an RQL expression, do we need to filter data?
164
        if ($request->attributes->get('hasRql', false)) {
165
            $innerQuery = $request->attributes->get('rqlQuery')->getQuery();
166
            /** @var XiagQuery $queryParams */
167
            $queryParams = $request->attributes->get('rqlQuery');
168
169
            $xiagQuery = new XiagQuery();
170
            // can we perform a search in an index instead of filtering?
171
            if ($innerQuery instanceof AbstractLogicOperatorNode) {
172
                foreach ($innerQuery->getQueries() as $innerRql) {
173
                    if (!$hasSearch && $innerRql instanceof SearchNode) {
174
                        $searchString = implode('&', $innerRql->getSearchTerms());
175
                        $queryBuilder->addAnd(
176
                            $queryBuilder->expr()->text($searchString)
177
                        );
178
                        $hasSearch = true;
179
                    } else {
180
                        $xiagQuery->setQuery($innerRql);
181
                    }
182
                }
183
            } elseif ($this->hasCustomSearchIndex() && ($innerQuery instanceof SearchNode)) {
184
                $searchString = implode('&', $innerQuery->getSearchTerms());
185
                $queryBuilder->addAnd(
186
                    $queryBuilder->expr()->text($searchString)
187
                );
188
                $hasSearch = true;
189
            } elseif ($innerQuery instanceof AbstractLogicOperatorNode) {
190
                /** @var AbstractLogicOperatorNode $innerQuery */
191
                foreach ($innerQuery->getQueries() as $innerRql) {
192
                    if (!$innerRql instanceof SearchNode) {
193
                        $xiagQuery->setQuery($innerRql);
194
                    }
195
                }
196
            } elseif ($innerQuery instanceof AbstractNode) {
197
                $xiagQuery->setQuery($innerQuery);
0 ignored issues
show
Compatibility introduced by
$innerQuery of type object<Xiag\Rql\Parser\AbstractNode> is not a sub-type of object<Xiag\Rql\Parser\Node\AbstractQueryNode>. It seems like you assume a child class of the class Xiag\Rql\Parser\AbstractNode to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
198
            }
199
200
            $queryBuilder = $this->doRqlQuery(
201
                $queryBuilder,
202
                $xiagQuery
203
            );
204
        } else {
205
            // @todo [lapistano]: seems the offset is missing for this query.
206
            /** @var \Doctrine\ODM\MongoDB\Query\Builder $qb */
207
            $queryBuilder->find($this->repository->getDocumentName());
208
        }
209
210
        /** @var LimitNode $rqlLimit */
211
        $rqlLimit = $queryParams->getLimit();
212
        
213
        // define offset and limit
214
        if (!$rqlLimit || !$rqlLimit->getOffset()) {
215
            $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...
216
        } else {
217
            $startAt = (int) $queryParams->getLimit()->getOffset();
218
            $queryBuilder->skip($startAt);
219
        }
220
221
        if (!$rqlLimit || !$rqlLimit->getLimit()) {
222
            $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...
223
        } else {
224
            $numberPerPage = (int) $queryParams->getLimit()->getLimit();
225
            $queryBuilder->limit($numberPerPage);
226
        }
227
228
        // Limit can not be negative nor null.
229
        if ($numberPerPage < 1) {
230
            throw new RqlSyntaxErrorException('negative or null limit in rql');
231
        }
232
233
        /**
234
         * add a default sort on id if none was specified earlier
235
         *
236
         * not specifying something to sort on leads to very weird cases when fetching references
237
         * If search node, sort by Score
238
         * TODO Review this sorting, not 100% sure
239
         */
240
        if ($hasSearch && !array_key_exists('sort', $queryBuilder->getQuery()->getQuery())) {
241
            $queryBuilder->sortMeta('score', 'textScore');
0 ignored issues
show
Bug introduced by
The method sortMeta 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...
242
        } elseif (!array_key_exists('sort', $queryBuilder->getQuery()->getQuery())) {
243
            $queryBuilder->sort('_id');
244
        }
245
246
        // run query
247
        $query = $queryBuilder->getQuery();
248
        $records = array_values($query->execute()->toArray());
249
250
        $totalCount = $query->count();
251
        $numPages = (int) ceil($totalCount / $numberPerPage);
252
        $page = (int) ceil($startAt / $numberPerPage) + 1;
253
        if ($numPages > 1) {
254
            $request->attributes->set('paging', true);
255
            $request->attributes->set('page', $page);
256
            $request->attributes->set('numPages', $numPages);
257
            $request->attributes->set('startAt', $startAt);
258
            $request->attributes->set('perPage', $numberPerPage);
259
            $request->attributes->set('totalCount', $totalCount);
260
        }
261
262
        return $records;
263
    }
264
265
    /**
266
     * @param string $prefix the prefix for custom text search indexes
267
     * @return bool
268
     * @throws \Doctrine\ODM\MongoDB\MongoDBException
269
     */
270
    private function hasCustomSearchIndex($prefix = 'search')
271
    {
272
        $collection = $this->repository->getDocumentManager()->getDocumentCollection($this->repository->getClassName());
273
        $indexesInfo = $collection->getIndexInfo();
274
        foreach ($indexesInfo as $indexInfo) {
275
            if ($indexInfo['name']==$prefix.$collection->getName().'Index') {
276
                return true;
277
            }
278
        }
279
        return false;
280
    }
281
282
    /**
283
     * @return string the version of the MongoDB as a string
284
     */
285
    private function getMongoDBVersion()
0 ignored issues
show
Unused Code introduced by
This method is not used, and could be removed.
Loading history...
286
    {
287
        $buildInfo = $this->repository->getDocumentManager()->getDocumentDatabase(
288
            $this->repository->getClassName()
289
        )->command(['buildinfo'=>1]);
290
        if (isset($buildInfo['version'])) {
291
            return $buildInfo['version'];
292
        } else {
293
            return "unkown";
294
        }
295
    }
296
297
    /**
298
     * @param object $entity       entity to insert
299
     * @param bool   $returnEntity true to return entity
300
     * @param bool   $doFlush      if we should flush or not after insert
301
     *
302
     * @return Object|null
303
     */
304
    public function insertRecord($entity, $returnEntity = true, $doFlush = true)
305
    {
306
        $this->checkIfOriginRecord($entity);
307
        $this->manager->persist($entity);
308
309
        if ($doFlush) {
310
            $this->manager->flush($entity);
311
        }
312
        if ($returnEntity) {
313
            return $this->find($entity->getId());
314
        }
315
    }
316
317
    /**
318
     * @param string $documentId id of entity to find
319
     *
320
     * @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...
321
     */
322 4
    public function find($documentId)
323
    {
324 4
        return $this->repository->find($documentId);
325
    }
326
327
    /**
328
     * {@inheritDoc}
329
     *
330
     * @param string $documentId   id of entity to update
331
     * @param Object $entity       new entity
332
     * @param bool   $returnEntity true to return entity
333
     *
334
     * @return Object|null
335
     */
336 2
    public function updateRecord($documentId, $entity, $returnEntity = true)
337
    {
338
        // In both cases the document attribute named originRecord must not be 'core'
339 2
        $this->checkIfOriginRecord($entity);
340 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...
341
342 2
        if (!is_null($documentId)) {
343 2
            $this->deleteById($documentId);
344
            // detach so odm knows it's gone
345 2
            $this->manager->detach($entity);
346 2
            $this->manager->clear();
347 1
        }
348
349 2
        $entity = $this->manager->merge($entity);
350
351 2
        $this->manager->persist($entity);
352 2
        $this->manager->flush($entity);
353
354 2
        if ($returnEntity) {
355
            return $entity;
356
        }
357 2
    }
358
359
    /**
360
     * {@inheritDoc}
361
     *
362
     * @param string|object $id id of entity to delete or entity instance
363
     *
364
     * @return null|Object
365
     */
366
    public function deleteRecord($id)
367
    {
368
        if (is_object($id)) {
369
            $entity = $id;
370
        } else {
371
            $entity = $this->find($id);
372
        }
373
374
        $return = $entity;
375
        if ($entity) {
376
            $this->checkIfOriginRecord($entity);
377
            $this->manager->remove($entity);
378
            $this->manager->flush();
379
            $return = null;
380
        }
381
382
        return $return;
383
    }
384
385
    /**
386
     * Triggers a flush on the DocumentManager
387
     *
388
     * @param null $document optional document
389
     *
390
     * @return void
391
     */
392
    public function flush($document = null)
393
    {
394
        $this->manager->flush($document);
395
    }
396
397
    /**
398
     * A low level delete without any checks
399
     *
400
     * @param mixed $id record id
401
     *
402
     * @return void
403
     */
404 2
    private function deleteById($id)
405
    {
406 2
        $builder = $this->repository->createQueryBuilder();
407
        $builder
408 2
            ->remove()
409 2
            ->field('id')->equals($id)
410 2
            ->getQuery()
411 2
            ->execute();
412 2
    }
413
414
    /**
415
     * Checks in a performant way if a certain record id exists in the database
416
     *
417
     * @param mixed $id record id
418
     *
419
     * @return bool true if it exists, false otherwise
420
     */
421 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...
422
    {
423 4
        return is_array($this->selectSingleFields($id, ['id'], false));
424
    }
425
426
    /**
427
     * Returns a set of fields from an existing resource in a performant manner.
428
     * If you need to check certain fields on an object (and don't need everything), this
429
     * is a better way to get what you need.
430
     * If the record is not present, you will receive null. If you don't need an hydrated
431
     * instance, make sure to pass false there.
432
     *
433
     * @param mixed $id      record id
434
     * @param array $fields  list of fields you need.
435
     * @param bool  $hydrate whether to hydrate object or not
436
     *
437
     * @return array|null|object
438
     */
439 4
    public function selectSingleFields($id, array $fields, $hydrate = true)
440
    {
441 4
        $builder = $this->repository->createQueryBuilder();
442 4
        $idField = $this->repository->getClassMetadata()->getIdentifier()[0];
443
444
        $record = $builder
445 4
            ->field($idField)->equals($id)
446 4
            ->select($fields)
447 4
            ->hydrate($hydrate)
448 4
            ->getQuery()
449 4
            ->getSingleResult();
450
451 4
        return $record;
452
    }
453
454
    /**
455
     * get classname of entity
456
     *
457
     * @return string|null
458
     */
459 4
    public function getEntityClass()
460
    {
461 4
        if ($this->repository instanceof DocumentRepository) {
462 4
            return $this->repository->getDocumentName();
463
        }
464
465
        return null;
466
    }
467
468
    /**
469
     * {@inheritDoc}
470
     *
471
     * Currently this is being used to build the route id used for redirecting
472
     * to newly made documents. It might benefit from having a different name
473
     * for those purposes.
474
     *
475
     * We might use a convention based mapping here:
476
     * Graviton\CoreBundle\Document\App -> mongodb://graviton_core
477
     * Graviton\CoreBundle\Entity\Table -> mysql://graviton_core
478
     *
479
     * @todo implement this in a more convention based manner
480
     *
481
     * @return string
482
     */
483
    public function getConnectionName()
484
    {
485
        $bundle = strtolower(substr(explode('\\', get_class($this))[1], 0, -6));
486
487
        return 'graviton.' . $bundle;
488
    }
489
490
    /**
491
     * Does the actual query using the RQL Bundle.
492
     *
493
     * @param Builder $queryBuilder Doctrine ODM QueryBuilder
494
     * @param Query   $query        query from parser
495
     *
496
     * @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...
497
     */
498
    protected function doRqlQuery($queryBuilder, Query $query)
499
    {
500
        $this->visitor->setBuilder($queryBuilder);
501
502
        return $this->visitor->visit($query);
503
    }
504
505
    /**
506
     * Checks the recordOrigin attribute of a record and will throw an exception if value is not allowed
507
     *
508
     * @param Object $record record
509
     *
510
     * @return void
511
     */
512 14
    protected function checkIfOriginRecord($record)
513
    {
514 7
        if ($record instanceof RecordOriginInterface
515 14
            && !$record->isRecordOriginModifiable()
516 7
        ) {
517 6
            $values = $this->notModifiableOriginRecords;
518 6
            $originValue = strtolower(trim($record->getRecordOrigin()));
519
520 6
            if (in_array($originValue, $values)) {
521 2
                $msg = sprintf("Must not be one of the following keywords: %s", implode(', ', $values));
522
523 2
                throw new RecordOriginModifiedException($msg);
524
            }
525 2
        }
526 12
    }
527
528
    /**
529
     * Determines the configured amount fo data records to be returned in pagination context.
530
     *
531
     * @return int
532
     */
533
    private function getDefaultLimit()
534
    {
535
        if (0 < $this->paginationDefaultLimit) {
536
            return $this->paginationDefaultLimit;
537
        }
538
539
        return 10;
540
    }
541
542
    /**
543
     * @param Boolean $active active
544
     * @param String  $field  field
545
     * @return void
546
     */
547 4
    public function setFilterByAuthUser($active, $field)
548
    {
549 4
        $this->filterByAuthUser = is_bool($active) ? $active : false;
550 4
        $this->filterByAuthField = $field;
551 4
    }
552
}
553