Completed
Push — feature/EVO-5751-text-index-mo... ( 8fecb1...ad25d5 )
by
unknown
62:35
created

DocumentModel::hasCustomSearchIndex()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 11
ccs 0
cts 3
cp 0
rs 9.4285
cc 3
eloc 7
nc 3
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\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\Query;
18
use Graviton\ExceptionBundle\Exception\RecordOriginModifiedException;
19
use Xiag\Rql\Parser\Exception\SyntaxErrorException as RqlSyntaxErrorException;
20
use Graviton\SchemaBundle\Document\Schema as SchemaDocument;
21
22
/**
23
 * Use doctrine odm as backend
24
 *
25
 * @author  List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
26
 * @license http://opensource.org/licenses/gpl-license.php GNU Public License
27
 * @link    http://swisscom.ch
28
 */
29
class DocumentModel extends SchemaModel implements ModelInterface
30
{
31
    /**
32
     * @var string
33
     */
34
    protected $description;
35
    /**
36
     * @var string[]
37
     */
38
    protected $fieldTitles;
39
    /**
40
     * @var string[]
41
     */
42
    protected $fieldDescriptions;
43
    /**
44
     * @var string[]
45
     */
46
    protected $requiredFields = array();
47
    /**
48
     * @var string[]
49
     */
50
    protected $searchableFields = array();
51
    /**
52
     * @var DocumentRepository
53
     */
54
    private $repository;
55
    /**
56
     * @var Visitor
57
     */
58
    private $visitor;
59
    /**
60
     * @var array
61
     */
62
    protected $notModifiableOriginRecords;
63
    /**
64
     * @var  integer
65
     */
66
    private $paginationDefaultLimit;
67
68
    /**
69
     * @var boolean
70
     */
71
    protected $filterByAuthUser;
72
73
    /**
74
     * @var string
75
     */
76
    protected $filterByAuthField;
77
78
    /**
79
     * @var RqlTranslator
80
     */
81
    protected $translator;
82
83
    /**
84
     * @var DocumentManager
85
     */
86
    protected $manager;
87
88
    /**
89
     * @param Visitor       $visitor                    rql query visitor
90
     * @param RqlTranslator $translator                 Translator for query modification
91
     * @param array         $notModifiableOriginRecords strings with not modifiable recordOrigin values
92
     * @param integer       $paginationDefaultLimit     amount of data records to be returned when in pagination context
93
     * @param Logger        $logger                     The defined system logger
94
     */
95
    public function __construct(
96
        Visitor $visitor,
97
        RqlTranslator $translator,
98
        $notModifiableOriginRecords,
99
        $paginationDefaultLimit,
100 4
        $logger
0 ignored issues
show
Unused Code introduced by
The parameter $logger is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
101
    ) {
102
        parent::__construct();
103
        $this->visitor = $visitor;
104
        $this->translator = $translator;
105
        $this->notModifiableOriginRecords = $notModifiableOriginRecords;
106
        $this->paginationDefaultLimit = (int) $paginationDefaultLimit;
107 4
    }
108 4
109 4
    /**
110 4
     * get repository instance
111 4
     *
112 4
     * @return DocumentRepository
113 4
     */
114
    public function getRepository()
115
    {
116
        return $this->repository;
117
    }
118
119
    /**
120 2
     * create new app model
121
     *
122 2
     * @param DocumentRepository $repository Repository of countries
123
     *
124
     * @return \Graviton\RestBundle\Model\DocumentModel
125
     */
126
    public function setRepository(DocumentRepository $repository)
127
    {
128
        $this->repository = $repository;
129
        $this->manager = $repository->getDocumentManager();
130
131
        return $this;
132 4
    }
133
134 4
    /**
135 4
     * {@inheritDoc}
136
     *
137 4
     * @param Request        $request The request object
138
     * @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...
139
     * @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...
140
     *
141
     * @return array
142
     */
143
    public function findAll(Request $request, SecurityUser $user = null, SchemaDocument $schema = null)
144
    {
145
        $pageNumber = $request->query->get('page', 1);
146
        $numberPerPage = (int) $request->query->get('perPage', $this->getDefaultLimit());
147
        $startAt = ($pageNumber - 1) * $numberPerPage;
148
149
        /** @var \Doctrine\ODM\MongoDB\Query\Builder $queryBuilder */
150
        $queryBuilder = $this->repository
151
            ->createQueryBuilder();
152
153
        if ($this->filterByAuthUser && $user && $user->hasRole(SecurityUser::ROLE_USER)) {
154
            $queryBuilder->field($this->filterByAuthField)->equals($user->getUser()->getId());
155
        }
156
157
158
        $searchableFields = $this->getSearchableFields();
159
        if (!is_null($schema)) {
160
            $searchableFields = $schema->getSearchable();
161
        }
162
163
        // *** do we have an RQL expression, do we need to filter data?
164
        if ($request->attributes->get('hasRql', false)) {
165
            $queryBuilder = $this->doRqlQuery(
166
                $queryBuilder,
167
                $this->translator->translateSearchQuery(
168
                    $request->attributes->get('rqlQuery'),
169
                    $searchableFields
0 ignored issues
show
Bug introduced by
It seems like $searchableFields defined by $schema->getSearchable() on line 160 can also be of type null; however, Graviton\RestBundle\Serv...:translateSearchQuery() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

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