Completed
Push — feature/EVO-6985-find-search-i... ( 984804 )
by
unknown
11:36
created

DocumentModel   F

Complexity

Total Complexity 67

Size/Duplication

Total Lines 559
Duplicated Lines 0 %

Coupling/Cohesion

Components 2
Dependencies 18

Test Coverage

Coverage 0%

Importance

Changes 22
Bugs 6 Features 1
Metric Value
wmc 67
c 22
b 6
f 1
lcom 2
cbo 18
dl 0
loc 559
ccs 0
cts 219
cp 0
rs 2.9203

22 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 10 1
A getRepository() 0 4 1
A setRepository() 0 7 1
D findAll() 0 82 13
C buildSearchQuery() 0 51 13
B hasCustomSearchIndex() 0 17 5
B buildSearchTextQuery() 0 13 5
A getMongoDBVersion() 0 11 2
A insertRecord() 0 11 3
A find() 0 4 1
A updateRecord() 0 18 3
A deleteRecord() 0 18 3
A flush() 0 4 1
A deleteById() 0 9 1
A recordExists() 0 4 1
A selectSingleFields() 0 14 1
A getEntityClass() 0 8 2
A getConnectionName() 0 6 1
A doRqlQuery() 0 6 1
A checkIfOriginRecord() 0 15 4
A getDefaultLimit() 0 8 2
A setFilterByAuthUser() 0 5 2

How to fix   Complexity   

Complex Class

Complex classes like DocumentModel often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use DocumentModel, and based on these observations, apply Extract Interface, too.

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\Rql\Node\SearchNode;
11
use Graviton\SchemaBundle\Model\SchemaModel;
12
use Graviton\SecurityBundle\Entities\SecurityUser;
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 Graviton\SchemaBundle\Document\Schema as SchemaDocument;
23
use Xiag\Rql\Parser\Query as XiagQuery;
24
use \Doctrine\ODM\MongoDB\Query\Builder as MongoBuilder;
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
    /**
92
     * @param Visitor $visitor                    rql query visitor
93
     * @param array   $notModifiableOriginRecords strings with not modifiable recordOrigin values
94
     * @param integer $paginationDefaultLimit     amount of data records to be returned when in pagination context
95
     */
96
    public function __construct(
97
        Visitor $visitor,
98
        $notModifiableOriginRecords,
99
        $paginationDefaultLimit
100
    ) {
101
        parent::__construct();
102
        $this->visitor = $visitor;
103
        $this->notModifiableOriginRecords = $notModifiableOriginRecords;
104
        $this->paginationDefaultLimit = (int) $paginationDefaultLimit;
105
    }
106
107
    /**
108
     * get repository instance
109
     *
110
     * @return DocumentRepository
111
     */
112
    public function getRepository()
113
    {
114
        return $this->repository;
115
    }
116
117
    /**
118
     * create new app model
119
     *
120
     * @param DocumentRepository $repository Repository of countries
121
     *
122
     * @return \Graviton\RestBundle\Model\DocumentModel
123
     */
124
    public function setRepository(DocumentRepository $repository)
125
    {
126
        $this->repository = $repository;
127
        $this->manager = $repository->getDocumentManager();
128
129
        return $this;
130
    }
131
132
    /**
133
     * {@inheritDoc}
134
     *
135
     * @param Request        $request The request object
136
     * @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...
137
     * @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...
138
     *
139
     * @return array
140
     */
141
    public function findAll(Request $request, SecurityUser $user = null, SchemaDocument $schema = null)
142
    {
143
        $pageNumber = $request->query->get('page', 1);
144
        $numberPerPage = (int) $request->query->get('perPage', $this->getDefaultLimit());
145
        $startAt = ($pageNumber - 1) * $numberPerPage;
146
147
        /** @var XiagQuery $xiagQuery */
148
        $xiagQuery = $request->attributes->get('rqlQuery');
149
150
        /** @var MongoBuilder $queryBuilder */
151
        $queryBuilder = $this->repository
152
            ->createQueryBuilder();
153
154
        // Setting RQL Query
155
        if ($xiagQuery) {
156
            // Clean up Search rql param and set it as Doctrine query
157
            if ($xiagQuery->getQuery() && $this->hasCustomSearchIndex() && (float) $this->getMongoDBVersion() >= 2.6) {
158
                $searchQueries = $this->buildSearchQuery($xiagQuery, $queryBuilder);
159
                $xiagQuery = $searchQueries['xiagQuery'];
160
                $queryBuilder = $searchQueries['queryBuilder'];
161
            }
162
            $queryBuilder = $this->doRqlQuery(
163
                $queryBuilder,
164
                $xiagQuery
165
            );
166
        } else {
167
            // @todo [lapistano]: seems the offset is missing for this query.
168
            /** @var \Doctrine\ODM\MongoDB\Query\Builder $qb */
169
            $queryBuilder->find($this->repository->getDocumentName());
170
        }
171
172
173
        /** @var LimitNode $rqlLimit */
174
        $rqlLimit = $xiagQuery instanceof XiagQuery ? $xiagQuery->getLimit() : false;
175
176
        // define offset and limit
177
        if (!$rqlLimit || !$rqlLimit->getOffset()) {
178
            $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...
179
        } else {
180
            $startAt = (int) $rqlLimit->getOffset();
181
            $queryBuilder->skip($startAt);
182
        }
183
184
        if (!$rqlLimit || is_null($rqlLimit->getLimit())) {
185
            $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...
186
        } else {
187
            $numberPerPage = (int) $rqlLimit->getLimit();
188
            $queryBuilder->limit($numberPerPage);
189
        }
190
191
        // Limit can not be negative nor null.
192
        if ($numberPerPage < 1) {
193
            throw new RqlSyntaxErrorException('negative or null limit in rql');
194
        }
195
196
        /**
197
         * add a default sort on id if none was specified earlier
198
         *
199
         * not specifying something to sort on leads to very weird cases when fetching references.
200
         */
201
        if (!array_key_exists('sort', $queryBuilder->getQuery()->getQuery())) {
202
            $queryBuilder->sort('_id');
203
        }
204
205
        // run query
206
        $query = $queryBuilder->getQuery();
207
        $records = array_values($query->execute()->toArray());
208
209
        $totalCount = $query->count();
210
        $numPages = (int) ceil($totalCount / $numberPerPage);
211
        $page = (int) ceil($startAt / $numberPerPage) + 1;
212
        if ($numPages > 1) {
213
            $request->attributes->set('paging', true);
214
            $request->attributes->set('page', $page);
215
            $request->attributes->set('numPages', $numPages);
216
            $request->attributes->set('startAt', $startAt);
217
            $request->attributes->set('perPage', $numberPerPage);
218
            $request->attributes->set('totalCount', $totalCount);
219
        }
220
221
        return $records;
222
    }
223
224
    /**
225
     * @param XiagQuery    $xiagQuery    Xiag Builder
226
     * @param MongoBuilder $queryBuilder Mongo Doctrine query builder
227
     * @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...
228
     */
229
    private function buildSearchQuery(XiagQuery $xiagQuery, MongoBuilder $queryBuilder)
230
    {
231
        $innerQuery = $xiagQuery->getQuery();
232
        $hasSearch = false;
233
        $nodes = [];
234
        if ($innerQuery instanceof AbstractLogicOperatorNode) {
235
            foreach ($innerQuery->getQueries() as $key => $innerRql) {
236
                if ($innerRql instanceof SearchNode) {
237
                    if (!$hasSearch) {
238
                        $queryBuilder = $this->buildSearchTextQuery($queryBuilder, $innerRql);
239
                        $hasSearch = true;
240
                    }
241
                } else {
242
                    $nodes[] = $innerRql;
243
                }
244
            }
245
        } elseif ($innerQuery instanceof SearchNode) {
246
            $queryBuilder = $this->repository->createQueryBuilder();
247
            $queryBuilder = $this->buildSearchTextQuery($queryBuilder, $innerQuery);
248
            $hasSearch = true;
249
        }
250
        // Remove the Search from RQL xiag
251
        if ($hasSearch && $nodes) {
252
            $newXiagQuery = new XiagQuery();
253
            if ($xiagQuery->getLimit()) {
254
                $newXiagQuery->setLimit($xiagQuery->getLimit());
255
            }
256
            if ($xiagQuery->getSelect()) {
257
                $newXiagQuery->setSelect($xiagQuery->getSelect());
258
            }
259
            if ($xiagQuery->getSort()) {
260
                $newXiagQuery->setSort($xiagQuery->getSort());
261
            }
262
            $binderClass = get_class($innerQuery);
263
            /** @var AbstractLogicOperatorNode $newBinder */
264
            $newBinder = new $binderClass();
265
            foreach ($nodes as $node) {
266
                $newBinder->addQuery($node);
267
            }
268
            $newXiagQuery->setQuery($newBinder);
269
            // Reset original query, so that there is no Search param
270
            $xiagQuery = $newXiagQuery;
271
        }
272
        if ($hasSearch) {
273
            $queryBuilder->sortMeta('score', 'textScore');
274
        }
275
        return [
276
            'xiagQuery'     => $xiagQuery,
277
            'queryBuilder'  => $queryBuilder
278
        ];
279
    }
280
281
    /**
282
     * Check if collection has search indexes in DB
283
     *
284
     * @param string $prefix the prefix for custom text search indexes
285
     * @return bool
286
     */
287
    private function hasCustomSearchIndex($prefix = 'search')
288
    {
289
        $metadata = $this->repository->getClassMetadata();
290
        $indexes = $metadata->getIndexes();
291
        if (count($indexes) < 1) {
292
            return false;
293
        }
294
        $collectionsName = substr($metadata->getName(), strrpos($metadata->getName(), '\\') + 1);
295
        $searchIndexName = $prefix.$collectionsName.'Index';
296
        // We reverse as normally the search index is the last.
297
        foreach (array_reverse($indexes) as $index) {
298
            if (array_key_exists('keys', $index) && array_key_exists($searchIndexName, $index['keys'])) {
299
                return true;
300
            }
301
        }
302
        return false;
303
    }
304
305
    /**
306
     * Build Search text index
307
     *
308
     * @param MongoBuilder $queryBuilder Doctrine mongo query builder object
309
     * @param SearchNode   $searchNode   Graviton Search node
310
     * @return MongoBuilder
311
     */
312
    private function buildSearchTextQuery(MongoBuilder $queryBuilder, SearchNode $searchNode)
313
    {
314
        $searchArr = [];
315
        foreach ($searchNode->getSearchTerms() as $string) {
316
            if (!empty(trim($string))) {
317
                $searchArr[] = (strpos($string, '.') !== false) ? "\"{$string}\"" : $string;
318
            }
319
        }
320
        if (!empty($searchArr)) {
321
            $queryBuilder->addAnd($queryBuilder->expr()->text(implode(' ', $searchArr)));
322
        }
323
        return $queryBuilder;
324
    }
325
326
    /**
327
     * @return string the version of the MongoDB as a string
328
     */
329
    private function getMongoDBVersion()
330
    {
331
        $buildInfo = $this->repository->getDocumentManager()->getDocumentDatabase(
332
            $this->repository->getClassName()
333
        )->command(['buildinfo'=>1]);
334
        if (isset($buildInfo['version'])) {
335
            return $buildInfo['version'];
336
        } else {
337
            return "unkown";
338
        }
339
    }
340
341
    /**
342
     * @param object $entity       entity to insert
343
     * @param bool   $returnEntity true to return entity
344
     * @param bool   $doFlush      if we should flush or not after insert
345
     *
346
     * @return Object|null
347
     */
348
    public function insertRecord($entity, $returnEntity = true, $doFlush = true)
349
    {
350
        $this->manager->persist($entity);
351
352
        if ($doFlush) {
353
            $this->manager->flush($entity);
354
        }
355
        if ($returnEntity) {
356
            return $this->find($entity->getId());
357
        }
358
    }
359
360
    /**
361
     * @param string $documentId id of entity to find
362
     *
363
     * @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...
364
     */
365
    public function find($documentId)
366
    {
367
        return $this->repository->find($documentId);
368
    }
369
370
    /**
371
     * {@inheritDoc}
372
     *
373
     * @param string $documentId   id of entity to update
374
     * @param Object $entity       new entity
375
     * @param bool   $returnEntity true to return entity
376
     *
377
     * @return Object|null
378
     */
379
    public function updateRecord($documentId, $entity, $returnEntity = true)
380
    {
381
        if (!is_null($documentId)) {
382
            $this->deleteById($documentId);
383
            // detach so odm knows it's gone
384
            $this->manager->detach($entity);
385
            $this->manager->clear();
386
        }
387
388
        $entity = $this->manager->merge($entity);
389
390
        $this->manager->persist($entity);
391
        $this->manager->flush($entity);
392
393
        if ($returnEntity) {
394
            return $entity;
395
        }
396
    }
397
398
    /**
399
     * {@inheritDoc}
400
     *
401
     * @param string|object $id id of entity to delete or entity instance
402
     *
403
     * @return null|Object
404
     */
405
    public function deleteRecord($id)
406
    {
407
        if (is_object($id)) {
408
            $entity = $id;
409
        } else {
410
            $entity = $this->find($id);
411
        }
412
413
        $return = $entity;
414
        if ($entity) {
415
            $this->checkIfOriginRecord($entity);
416
            $this->manager->remove($entity);
417
            $this->manager->flush();
418
            $return = null;
419
        }
420
421
        return $return;
422
    }
423
424
    /**
425
     * Triggers a flush on the DocumentManager
426
     *
427
     * @param null $document optional document
428
     *
429
     * @return void
430
     */
431
    public function flush($document = null)
432
    {
433
        $this->manager->flush($document);
434
    }
435
436
    /**
437
     * A low level delete without any checks
438
     *
439
     * @param mixed $id record id
440
     *
441
     * @return void
442
     */
443
    private function deleteById($id)
444
    {
445
        $builder = $this->repository->createQueryBuilder();
446
        $builder
447
            ->remove()
448
            ->field('id')->equals($id)
449
            ->getQuery()
450
            ->execute();
451
    }
452
453
    /**
454
     * Checks in a performant way if a certain record id exists in the database
455
     *
456
     * @param mixed $id record id
457
     *
458
     * @return bool true if it exists, false otherwise
459
     */
460
    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...
461
    {
462
        return is_array($this->selectSingleFields($id, ['id'], false));
463
    }
464
465
    /**
466
     * Returns a set of fields from an existing resource in a performant manner.
467
     * If you need to check certain fields on an object (and don't need everything), this
468
     * is a better way to get what you need.
469
     * If the record is not present, you will receive null. If you don't need an hydrated
470
     * instance, make sure to pass false there.
471
     *
472
     * @param mixed $id      record id
473
     * @param array $fields  list of fields you need.
474
     * @param bool  $hydrate whether to hydrate object or not
475
     *
476
     * @return array|null|object
477
     */
478
    public function selectSingleFields($id, array $fields, $hydrate = true)
479
    {
480
        $builder = $this->repository->createQueryBuilder();
481
        $idField = $this->repository->getClassMetadata()->getIdentifier()[0];
482
483
        $record = $builder
484
            ->field($idField)->equals($id)
485
            ->select($fields)
486
            ->hydrate($hydrate)
487
            ->getQuery()
488
            ->getSingleResult();
489
490
        return $record;
491
    }
492
493
    /**
494
     * get classname of entity
495
     *
496
     * @return string|null
497
     */
498
    public function getEntityClass()
499
    {
500
        if ($this->repository instanceof DocumentRepository) {
501
            return $this->repository->getDocumentName();
502
        }
503
504
        return null;
505
    }
506
507
    /**
508
     * {@inheritDoc}
509
     *
510
     * Currently this is being used to build the route id used for redirecting
511
     * to newly made documents. It might benefit from having a different name
512
     * for those purposes.
513
     *
514
     * We might use a convention based mapping here:
515
     * Graviton\CoreBundle\Document\App -> mongodb://graviton_core
516
     * Graviton\CoreBundle\Entity\Table -> mysql://graviton_core
517
     *
518
     * @todo implement this in a more convention based manner
519
     *
520
     * @return string
521
     */
522
    public function getConnectionName()
523
    {
524
        $bundle = strtolower(substr(explode('\\', get_class($this))[1], 0, -6));
525
526
        return 'graviton.' . $bundle;
527
    }
528
529
    /**
530
     * Does the actual query using the RQL Bundle.
531
     *
532
     * @param Builder $queryBuilder Doctrine ODM QueryBuilder
533
     * @param Query   $query        query from parser
534
     *
535
     * @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...
536
     */
537
    protected function doRqlQuery($queryBuilder, Query $query)
538
    {
539
        $this->visitor->setBuilder($queryBuilder);
540
541
        return $this->visitor->visit($query);
542
    }
543
544
    /**
545
     * Checks the recordOrigin attribute of a record and will throw an exception if value is not allowed
546
     *
547
     * @param Object $record record
548
     *
549
     * @return void
550
     */
551
    protected function checkIfOriginRecord($record)
552
    {
553
        if ($record instanceof RecordOriginInterface
554
            && !$record->isRecordOriginModifiable()
555
        ) {
556
            $values = $this->notModifiableOriginRecords;
557
            $originValue = strtolower(trim($record->getRecordOrigin()));
558
559
            if (in_array($originValue, $values)) {
560
                $msg = sprintf("Must not be one of the following keywords: %s", implode(', ', $values));
561
562
                throw new RecordOriginModifiedException($msg);
563
            }
564
        }
565
    }
566
567
    /**
568
     * Determines the configured amount fo data records to be returned in pagination context.
569
     *
570
     * @return int
571
     */
572
    private function getDefaultLimit()
573
    {
574
        if (0 < $this->paginationDefaultLimit) {
575
            return $this->paginationDefaultLimit;
576
        }
577
578
        return 10;
579
    }
580
581
    /**
582
     * @param Boolean $active active
583
     * @param String  $field  field
584
     * @return void
585
     */
586
    public function setFilterByAuthUser($active, $field)
587
    {
588
        $this->filterByAuthUser = is_bool($active) ? $active : false;
589
        $this->filterByAuthField = $field;
590
    }
591
}
592