Completed
Push — master ( 262d71...f4720a )
by Nekrasov
02:40
created

BaseModel::query()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
c 3
b 0
f 0
dl 0
loc 4
rs 10
cc 1
eloc 2
nc 1
nop 0
1
<?php
2
3
namespace Arrilot\BitrixModels\Models;
4
5
use Arrilot\BitrixModels\ModelEventsTrait;
6
use Arrilot\BitrixModels\Queries\BaseQuery;
7
use Arrilot\BitrixModels\Relations\BaseRelation;
8
use Exception;
9
use InvalidArgumentException;
10
use LogicException;
11
12
abstract class BaseModel extends ArrayableModel
13
{
14
    use ModelEventsTrait;
15
16
    /**
17
     * The loaded relationships for the model.
18
     *
19
     * @var array
20
     */
21
    protected $relations = [];
22
23
    /**
24
     * Have fields been already fetched from DB?
25
     *
26
     * @var bool
27
     */
28
    protected $fieldsAreFetched = false;
29
30
    /**
31
     * List of additional params that can modify query.
32
     *
33
     * @var array
34
     */
35
    protected static $additionalQueryModifiers = [];
36
37
    /**
38
     * Refresh model from database and place data to $this->fields.
39
     *
40
     * @return array
41
     */
42
    abstract public function refresh();
43
44
    /**
45
     * Refresh model fields from database and place them to $this->fields.
46
     *
47
     * @return array
48
     */
49
    abstract public function refreshFields();
50
51
    /**
52
     * Constructor.
53
     *
54
     * @param $id
55
     * @param $fields
56
     */
57
    public function __construct($id = null, $fields = null)
58
    {
59
        static::instantiateObject();
60
61
        $this->id = $id;
62
63
        $this->fill($fields);
64
    }
65
66
    /**
67
     * Get all model attributes from cache or database.
68
     *
69
     * @return array
70
     */
71
    public function get()
72
    {
73
        if (!$this->fieldsAreFetched) {
74
            $this->refresh();
75
        }
76
77
        return $this->fields;
78
    }
79
80
    /**
81
     * Get user groups from cache or database.
82
     *
83
     * @return array
84
     */
85
    public function getFields()
86
    {
87
        if ($this->fieldsAreFetched) {
88
            return $this->fields;
89
        }
90
91
        return $this->refreshFields();
92
    }
93
94
    /**
95
     * Fill model fields if they are already known.
96
     * Saves DB queries.
97
     *
98
     * @param array $fields
99
     *
100
     * @return null
101
     */
102
    public function fill($fields)
103
    {
104
        if (!is_array($fields)) {
105
            return;
106
        }
107
108
        if (isset($fields['ID'])) {
109
            $this->id = $fields['ID'];
110
        }
111
112
        $this->fields = $fields;
113
114
        $this->fieldsAreFetched = true;
115
116
        if (method_exists($this, 'afterFill')) {
117
            $this->afterFill();
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Arrilot\BitrixModels\Models\BaseModel as the method afterFill() does only exist in the following sub-classes of Arrilot\BitrixModels\Models\BaseModel: Arrilot\BitrixModels\Models\ElementModel, Arrilot\BitrixModels\Models\UserModel. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
118
        }
119
    }
120
121
    /**
122
     * Activate model.
123
     *
124
     * @return bool
125
     */
126
    public function activate()
127
    {
128
        $this->fields['ACTIVE'] = 'Y';
129
130
        return $this->save(['ACTIVE']);
131
    }
132
133
    /**
134
     * Deactivate model.
135
     *
136
     * @return bool
137
     */
138
    public function deactivate()
139
    {
140
        $this->fields['ACTIVE'] = 'N';
141
142
        return $this->save(['ACTIVE']);
143
    }
144
145
    /**
146
     * Create new item in database.
147
     *
148
     * @param $fields
149
     *
150
     * @throws Exception
151
     *
152
     * @return static
153
     */
154
    public static function create($fields)
155
    {
156
        $model = new static(null, $fields);
157
158
        if ($model->onBeforeSave() === false || $model->onBeforeCreate() === false) {
159
            return false;
0 ignored issues
show
Bug Best Practice introduced by
The return type of return false; (false) is incompatible with the return type documented by Arrilot\BitrixModels\Models\BaseModel::create of type Arrilot\BitrixModels\Models\BaseModel.

If you return a value from a function or method, it should be a sub-type of the type that is given by the parent type f.e. an interface, or abstract method. This is more formally defined by the Lizkov substitution principle, and guarantees that classes that depend on the parent type can use any instance of a child type interchangably. This principle also belongs to the SOLID principles for object oriented design.

Let’s take a look at an example:

class Author {
    private $name;

    public function __construct($name) {
        $this->name = $name;
    }

    public function getName() {
        return $this->name;
    }
}

abstract class Post {
    public function getAuthor() {
        return 'Johannes';
    }
}

class BlogPost extends Post {
    public function getAuthor() {
        return new Author('Johannes');
    }
}

class ForumPost extends Post { /* ... */ }

function my_function(Post $post) {
    echo strtoupper($post->getAuthor());
}

Our function my_function expects a Post object, and outputs the author of the post. The base class Post returns a simple string and outputting a simple string will work just fine. However, the child class BlogPost which is a sub-type of Post instead decided to return an object, and is therefore violating the SOLID principles. If a BlogPost were passed to my_function, PHP would not complain, but ultimately fail when executing the strtoupper call in its body.

Loading history...
160
        }
161
162
        $bxObject = static::instantiateObject();
163
        $id = $bxObject->add($fields);
164
        $model->setId($id);
165
166
        $result = $id ? true : false;
167
168
        $model->onAfterCreate($result);
169
        $model->onAfterSave($result);
170
171
        if (!$result) {
172
            throw new Exception($bxObject->LAST_ERROR);
173
        }
174
175
        return $model;
176
    }
177
178
    /**
179
     * Get count of items that match $filter.
180
     *
181
     * @param array $filter
182
     *
183
     * @return int
184
     */
185
    public static function count(array $filter = [])
186
    {
187
        return static::query()->filter($filter)->count();
188
    }
189
190
    /**
191
     * Get item by its id.
192
     *
193
     * @param int $id
194
     *
195
     * @return static
196
     */
197
    public static function find($id)
198
    {
199
        return static::query()->getById($id);
200
    }
201
202
    /**
203
     * Create a new query and apply modifiers according to $params.
204
     *
205
     * @param array $params
206
     *
207
     * @return BaseQuery
208
     */
209
    protected static function createQueryWithModifiers($params)
210
    {
211
        $query = static::query();
212
213
        $modifiers = array_merge(static::$additionalQueryModifiers, [
214
            'sort',
215
            'filter',
216
            'navigation',
217
            'select',
218
            'keyBy',
219
            'limit',
220
            'take',
221
        ]);
222
223
        foreach ($modifiers as $modifier) {
224
            if (isset($params[$modifier])) {
225
                $query = $query->{$modifier}($params[$modifier]);
226
            }
227
        }
228
229
        return $query;
230
    }
231
232
    /**
233
     * Get item by its id.
234
     *
235
     * @param int $id
236
     *
237
     * @return static
238
     */
239
    public static function getById($id)
240
    {
241
        return static::query()->getById($id);
242
    }
243
244
    /**
245
     * Get list of items.
246
     *
247
     * @param array $params
248
     *
249
     * @return static[]
250
     */
251
    public static function getList($params = [])
252
    {
253
        $query = static::createQueryWithModifiers($params);
254
255
        return $query->getList();
256
    }
257
258
    /**
259
     * Get first item that match $params.
260
     *
261
     * @param array $params
262
     *
263
     * @return static
264
     */
265
    public static function first($params = [])
266
    {
267
        $query = static::createQueryWithModifiers($params);
268
269
        return $query->first();
270
    }
271
272
    /**
273
     * Delete model.
274
     *
275
     * @return bool
276
     */
277
    public function delete()
278
    {
279
        if ($this->onBeforeDelete() === false) {
280
            return false;
281
        }
282
283
        $result = static::$bxObject->delete($this->id);
284
285
        $this->onAfterDelete($result);
286
287
        return $result;
288
    }
289
290
    /**
291
     * Update model.
292
     *
293
     * @param array $fields
294
     *
295
     * @return bool
296
     */
297
    public function update(array $fields = [])
298
    {
299
        $keys = [];
300
        foreach ($fields as $key => $value) {
301
            array_set($this->fields, $key, $value);
302
            $keys[] = $key;
303
        }
304
305
        return $this->save($keys);
306
    }
307
308
    /**
309
     * Save model to database.
310
     *
311
     * @param array $selectedFields save only these fields instead of all.
312
     *
313
     * @return bool
314
     */
315
    public function save($selectedFields = [])
316
    {
317
        $selectedFields = is_array($selectedFields) ? $selectedFields : func_get_args();
318
319
        if ($this->onBeforeSave() === false || $this->onBeforeUpdate() === false) {
320
            return false;
321
        }
322
323
        $fields = $this->normalizeFieldsForSave($selectedFields);
324
        $result = !empty($fields) ? static::$bxObject->update($this->id, $fields) : false;
325
        if ($this instanceof ElementModel) {
326
            $savePropsResult = $this->saveProps($selectedFields);
0 ignored issues
show
Bug introduced by
It seems like you code against a specific sub-type and not the parent class Arrilot\BitrixModels\Models\BaseModel as the method saveProps() does only exist in the following sub-classes of Arrilot\BitrixModels\Models\BaseModel: Arrilot\BitrixModels\Models\ElementModel. Maybe you want to instanceof check for one of these explicitly?

Let’s take a look at an example:

abstract class User
{
    /** @return string */
    abstract public function getPassword();
}

class MyUser extends User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different sub-classes of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the parent class:

    abstract class User
    {
        /** @return string */
        abstract public function getPassword();
    
        /** @return string */
        abstract public function getDisplayName();
    }
    
Loading history...
327
            $result = $result || $savePropsResult;
328
        }
329
330
        $this->onAfterUpdate($result);
331
        $this->onAfterSave($result);
332
333
        return $result;
334
    }
335
336
    /**
337
     * Scope to get only active items.
338
     *
339
     * @param BaseQuery $query
340
     *
341
     * @return BaseQuery
342
     */
343
    public function scopeActive($query)
344
    {
345
        $query->filter['ACTIVE'] = 'Y';
346
347
        return $query;
348
    }
349
350
    /**
351
     * Create an array of fields that will be saved to database.
352
     *
353
     * @param $selectedFields
354
     *
355
     * @return array
356
     */
357
    protected function normalizeFieldsForSave($selectedFields)
358
    {
359
        $fields = [];
360
        if ($this->fields === null) {
361
            return [];
362
        }
363
364
        foreach ($this->fields as $field => $value) {
365
            if (!$this->fieldShouldNotBeSaved($field, $value, $selectedFields)) {
366
                $fields[$field] = $value;
367
            }
368
        }
369
370
        return $fields;
371
    }
372
373
    /**
374
     * Determine whether the field should be stopped from passing to "update".
375
     *
376
     * @param string $field
377
     * @param mixed  $value
378
     * @param array  $selectedFields
379
     *
380
     * @return bool
381
     */
382
    protected function fieldShouldNotBeSaved($field, $value, $selectedFields)
0 ignored issues
show
Unused Code introduced by
The parameter $value 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...
383
    {
384
        $blacklistedFields = [
385
            'ID',
386
            'IBLOCK_ID',
387
            'PROPERTIES',
388
            'GROUPS',
389
            'PROPERTY_VALUES',
390
        ];
391
392
        return (!empty($selectedFields) && !in_array($field, $selectedFields))
393
            || in_array($field, $blacklistedFields)
394
            || (substr($field, 0, 1) === '~')
395
            || (substr($field, 0, 9) === 'PROPERTY_');
396
    }
397
398
    /**
399
     * Instantiate bitrix entity object.
400
     *
401
     * @throws Exception
402
     *
403
     * @return object
404
     */
405
    public static function instantiateObject()
406
    {
407
        if (static::$bxObject) {
408
            return static::$bxObject;
409
        }
410
411
        if (class_exists(static::$objectClass)) {
412
            return static::$bxObject = new static::$objectClass();
413
        }
414
415
        throw new Exception('Object initialization failed');
416
    }
417
418
    /**
419
     * Destroy bitrix entity object.
420
     *
421
     * @return void
422
     */
423
    public static function destroyObject()
424
    {
425
        static::$bxObject = null;
426
    }
427
428
    /**
429
     * Instantiate a query object for the model.
430
     *
431
     * @throws Exception
432
     *
433
     * @return BaseQuery
434
     */
435
    public static function query()
436
    {
437
        throw new Exception('public static function query() is not implemented');
438
    }
439
440
    /**
441
     * Set current model id.
442
     *
443
     * @param $id
444
     */
445
    protected function setId($id)
446
    {
447
        $this->id = $id;
448
        $this->fields['ID'] = $id;
449
    }
450
451
    /**
452
     * Determine if the given relation is loaded.
453
     *
454
     * @param string $key
455
     *
456
     * @return bool
457
     */
458
    public function relationIsLoaded($key)
459
    {
460
        return array_key_exists($key, $this->relations);
461
    }
462
463
    /**
464
     * Get a relationship value from a method.
465
     *
466
     * @param string $method
467
     *
468
     * @return mixed
469
     */
470
    protected function getRelationshipFromMethod($method)
471
    {
472
        $relation = $this->$method();
473
474
        if (!$relation instanceof BaseRelation) {
475
            throw new LogicException('Relationship method must return an object of type Arrilot\BitrixModels\Relations\BaseRelation');
476
        }
477
478
        return $this->relations[$method] = $relation->fetch();
479
    }
480
481
    /**
482
     * Dynamically retrieve fields on the model.
483
     *
484
     * @param string $key
485
     *
486
     * @return mixed
487
     */
488
    public function __get($key)
489
    {
490
        if ($this->relationIsLoaded($key)) {
491
            return $this->relations[$key];
492
        }
493
494
        if (method_exists($this, $key)) {
495
            return $this->getRelationshipFromMethod($key);
496
        }
497
498
        $className = get_class($this);
499
500
        throw new InvalidArgumentException("Invalid property {$className}::{$key}");
501
    }
502
}
503