Passed
Push — master ( 6923e5...88cee3 )
by Marwan
02:08
created

Model   A

Complexity

Total Complexity 40

Size/Duplication

Total Lines 366
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 6
Bugs 0 Features 0
Metric Value
eloc 108
c 6
b 0
f 0
dl 0
loc 366
ccs 127
cts 127
cp 1
rs 9.2
wmc 40

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __debugInfo() 0 3 1
A find() 0 34 6
A toArray() 0 10 1
A count() 0 17 4
A first() 0 3 1
A all() 0 29 6
A __call() 0 10 3
A create() 0 6 1
A __toString() 0 3 1
A one() 0 3 1
A toJson() 0 5 1
A last() 0 3 1
A delete() 0 12 1
A destroy() 0 5 2
A update() 0 24 2
A hydrate() 0 8 2
A save() 0 34 6

How to fix   Complexity   

Complex Class

Complex classes like Model 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.

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 Model, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend;
13
14
use MAKS\Velox\Backend\Database;
15
use MAKS\Velox\Helper\Misc;
16
17
/**
18
 * An abstract class that serves as a base model that can be extended to create models.
19
 *
20
 * Example:
21
 * ```
22
 * // Attributes has to match the attribute name (column name) unless otherwise specified.
23
 *
24
 * // creating/manipulating models
25
 * $model = new Model(); // set attributes later via setters or public assignment.
26
 * $model = new Model(['attribute_name' => $value]);
27
 * $model->get('attribute_name');
28
 * $model->set('attribute_name', $value);
29
 * $model->getAttributeName(); // case will be changed to 'snake_case' automatically.
30
 * $model->setAttributeName($value); // case will be changed to 'snake_case' automatically.
31
 * $model->anotherAttribute; // case will be changed to 'snake_case' automatically.
32
 * $model->anotherAttribute = $value; // case will be changed to 'snake_case' automatically.
33
 * $attributes = $model->getAttributes(); // returns all attributes.
34
 * $model->save(); // persists the model in the database.
35
 * $model->update(['attribute_name' => $value]); // updates the model and save changes in the database.
36
 * $model->delete(); // deletes the model from the database.
37
 * Model::create($attributes); // creates a new model instance, call save() on the instance to save it in the database.
38
 * Model::destroy($id); // destroys a model and deletes it from the database.
39
 *
40
 * // fetching models
41
 * $model = Model::first();
42
 * $model = Model::last();
43
 * $model = Model::one();
44
 * $models = Model::all(['name' => 'John'], 'age DESC', $offset, $limit);
45
 * $count = Model::count(); // returns the number of models in the database.
46
 * $model = Model::find($id); // $id is the primary key of the model.
47
 * $models = Model::find('age', 27, 'name', 'John', ...); // or Model::find(['name' => $value]);
48
 * $models = Model::findByName('John'); // fetches using an attribute, case will be changed to 'snake_case' automatically.
49
 * $models = Model::where('name', '=', $name); // fetches using a where clause condition.
50
 * $models = Model::where('name', 'LIKE', 'John%', [['AND', 'age', '>', 27], ...], 'age DESC', $limit, $offset);
51
 * $models = Model::fetch('SELECT * FROM @table WHERE `name` = ?', [$name]); // fetches using raw SQL query.
52
 * ```
53
 *
54
 * @package Velox\Backend
55
 * @since 1.3.0
56
 * @api
57
 *
58
 * @method mixed get*() Getter for model attribute, (`attribute_name` -> `getAttributeName()`).
59
 * @method $this set*() Setter for model attribute, (`attribute_name` -> `setAttributeName($value)`).
60
 * @method static[] findBy*() Finder by model attribute, (`attribute_name` -> `findByAttributeName($value)`).
61
 *
62
 * @property mixed $* Public attribute for model attribute, (`attribute_name` -> `attributeName`).
63
 */
64
abstract class Model extends Model\Base
65
{
66
    /**
67
     * Creates an instance of the model. The model is not saved to the database unless `self::save()` is called.
68
     *
69
     * @param array $attributes The attributes of the model.
70
     *
71
     * @return static
72
     */
73 32
    public static function create(array $attributes): Model
74
    {
75 32
        $item = new static();
76 32
        $item->setAttributes($attributes);
77
78 32
        return $item;
79
    }
80
81
    /**
82
     * Creates/updates a model and saves it in database.
83
     *
84
     * @param array $attributes Model attributes.
85
     *
86
     * @return static
87
     */
88 30
    public function save(array $attributes = []): Model
89
    {
90 30
        $isNew = !$this->get($this->getPrimaryKey());
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Model::get() has too many arguments starting with $this->getPrimaryKey(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

90
        $isNew = !$this->/** @scrutinizer ignore-call */ get($this->getPrimaryKey());

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
91
92 30
        $this->setAttributes($attributes);
93
94 30
        $attributes = $this->getAttributes();
95 30
        $variables  = [];
96
97 30
        foreach ($attributes as $key => $value) {
98 30
            if ($isNew && $key === $this->getPrimaryKey()) {
99 30
                unset($attributes[$key]);
100 30
                continue;
101
            }
102
103 30
            $variables[':' . $key] = $value;
104
        }
105
106 30
        $query = vsprintf('%s INTO `%s` (%s) VALUES(%s);', [
107 30
            $isNew ? 'INSERT' : 'REPLACE',
108 30
            $this->getTable(),
109 30
            implode(', ', array_keys($attributes)),
110 30
            implode(', ', array_keys($variables)),
111
        ]);
112
113 30
        $id = $this->getDatabase()->transactional(function () use ($query, $variables) {
114
            /** @var Database $this */
115 30
            $this->perform($query, $variables);
116 30
            return $this->lastInsertId();
117 30
        });
118
119 30
        $this->set($this->getPrimaryKey(), is_numeric($id) ? (int)$id : $id);
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Model::set() has too many arguments starting with $this->getPrimaryKey(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

119
        $this->/** @scrutinizer ignore-call */ 
120
               set($this->getPrimaryKey(), is_numeric($id) ? (int)$id : $id);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
120
121 30
        return $this;
122
    }
123
124
    /**
125
     * Updates the given attributes of the model and saves them in the database.
126
     *
127
     * @param array $attributes The attributes to update.
128
     *
129
     * @return static
130
     */
131 1
    public function update(array $attributes): Model
132
    {
133 1
        $variables = [];
134
135 1
        foreach ($attributes as $key => $value) {
136 1
            $this->set($key, $value);
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Model::set() has too many arguments starting with $key. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

136
            $this->/** @scrutinizer ignore-call */ 
137
                   set($key, $value);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
137
138 1
            $variables[':' . $key] = $value;
139
        }
140
141 1
        $variables[':id'] = $this->get($this->getPrimaryKey());
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Model::get() has too many arguments starting with $this->getPrimaryKey(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

141
        /** @scrutinizer ignore-call */ 
142
        $variables[':id'] = $this->get($this->getPrimaryKey());

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
142
143 1
        $query = vsprintf('UPDATE `%s` SET %s WHERE `%s` = :id;', [
144 1
            $this->getTable(),
145 1
            implode(', ', array_map(fn ($key) => sprintf('`%s` = :%s', $key, $key), array_keys($attributes))),
146 1
            $this->getPrimaryKey(),
147
        ]);
148
149 1
        $this->getDatabase()->transactional(function () use ($query, $variables) {
150
            /** @var Database $this */
151 1
            $this->perform($query, $variables);
152 1
        });
153
154 1
        return $this;
155
    }
156
157
    /**
158
     * Deletes the model from the database.
159
     *
160
     * @return int The number of affected rows during the SQL operation.
161
     */
162 5
    public function delete(): int
163
    {
164 5
        $query = vsprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1;', [
165 5
            $this->getTable(),
166 5
            $this->getPrimaryKey(),
167
        ]);
168
169 5
        $variables = [':id' => $this->get($this->getPrimaryKey())];
0 ignored issues
show
Unused Code introduced by
The call to MAKS\Velox\Backend\Model::get() has too many arguments starting with $this->getPrimaryKey(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

169
        $variables = [':id' => $this->/** @scrutinizer ignore-call */ get($this->getPrimaryKey())];

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
170
171 5
        return $this->getDatabase()->transactional(function () use ($query, $variables) {
172
            /** @var Database $this */
173 5
            return $this->perform($query, $variables)->rowCount();
174 5
        });
175
    }
176
177
    /**
178
     * Destroys (deletes) a model from the database if it exists.
179
     *
180
     * @param string|int $primaryKey
181
     *
182
     * @return int The number of affected rows during the SQL operation.
183
     */
184 1
    public static function destroy($primaryKey): int
185
    {
186 1
        $model = static::find($primaryKey);
187
188 1
        return $model ? $model->delete() : 0;
189
    }
190
191
    /**
192
     * Hydrates models from an array of model attributes (row).
193
     *
194
     * @param array $data Array of objects data.
195
     *
196
     * @return static[] Array of hydrated objects.
197
     *
198
     * Example:
199
     * - `Model::hydrate([$arrayOfModelAttributes, ...])`
200
     */
201 1
    public static function hydrate(array $models): array
202
    {
203 1
        $objects = [];
204 1
        foreach ($models as $model) {
205 1
            $objects[] = static::create($model);
206
        }
207
208 1
        return $objects;
209
    }
210
211
212
    /**
213
     * Fetches all models.
214
     *
215
     * @param array $conditions Fetch conditions (like: `['id' => $id, ...]`). Conditions are combined by logical `AND`.
216
     * @param string|null $order [optional] SQL order expression (like: `id` or `id ASC`).
217
     * @param int|null $limit [optional] To how many items the result should be limited.
218
     * @param int|null $offset [optional] From which item the result should start.
219
     *
220
     * @return static[]|array
221
     *
222
     * Examples:
223
     * - PHP: `Model::all(['name' => 'Doe', 'job' => 'Developer'],'age DESC', 3, 15)`.
224
     * - SQL: ```SELECT * FROM `users` WHERE `name` = "Doe" AND `job` = `Developer` ORDER BY age DESC LIMIT 3 OFFSET 15```.
225
     */
226 25
    public static function all(?array $conditions = [], ?string $order = null, ?int $limit = null, ?int $offset = null): array
227
    {
228 25
        $query = 'SELECT * FROM @table';
229
230 25
        if (!empty($conditions)) {
231 20
            $sqlConditions = [];
232 20
            foreach ($conditions as $key => $value) {
233 20
                static::assertAttributeExists($key);
234 20
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
235
            }
236
237 20
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions);
238
        }
239
240 25
        if ($order !== null) {
241 7
            $query .= ' ORDER BY ' . $order;
242
        }
243
244 25
        if ($limit !== null) {
245 25
            $query .= ' LIMIT ' . $limit;
246
        }
247
248 25
        if ($offset !== null) {
249 7
            $query .= ' OFFSET ' . $offset;
250
        }
251
252 25
        $query .= ';';
253
254 25
        return static::fetch($query, $conditions);
255
    }
256
257
    /**
258
     * Fetches a single model object.
259
     *
260
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
261
     *
262
     * @return static|null
263
     *
264
     * Examples:
265
     * - Fetching the first items: `Model::one()`.
266
     * - Fetching an item according to a condition: `Model::one(['name' => $name])`.
267
     */
268 20
    public static function one(?array $conditions = []): ?Model
269
    {
270 20
        return static::all($conditions, null, 1, null)[0] ?? null;
271
    }
272
273
    /**
274
     * Fetches the first model object.
275
     *
276
     * @return static|null
277
     *
278
     * Examples: `Model::first()`.
279
     */
280 2
    public static function first(): ?Model
281
    {
282 2
        return static::all(null, static::getPrimaryKey() . ' ASC', 1, 0)[0] ?? null;
283
    }
284
285
    /**
286
     * Fetches the last model object.
287
     *
288
     * @return static|null
289
     *
290
     * Example: `Model::last()`.
291
     */
292 4
    public static function last(): ?Model
293
    {
294 4
        return static::all(null, static::getPrimaryKey() . ' DESC', 1, 0)[0] ?? null;
295
    }
296
297
    /**
298
     * Finds a single or multiple models matching the passed condition.
299
     *
300
     * @param mixed|mixed[] ...$condition Can either be the primary key or a set of condition(s) (like: `id`, or `'name', 'Doe', 'age', 35`, or `['name' => $name]`).
301
     *
302
     * @return static|static[]|null|array Depends on the number of conditions (1 = single, >1 = multiple).
303
     *
304
     * Examples:
305
     * - Find by primary key (ID): `Model::find(1)`.
306
     * - Find by specific value: `Model::find('name', 'Doe', 'age', 35, ...)` or `Model::find(['name' => $name, 'age' => 35], ...)`.
307
     *
308
     */
309 16
    public static function find(...$condition)
310
    {
311
        // formats conditions to be consumed as `$name, $value, $name, $value, ...`
312 16
        $format = function ($array) use (&$format) {
313 16
            $pairs = array_map(function ($key, $value) use (&$format) {
314 16
                if (is_string($key)) {
315 1
                    return [$key, $value];
316
                }
317
318 16
                if (is_array($value)) {
319 1
                    return $format($value);
320
                }
321
322 16
                return [$value];
323 16
            }, array_keys($array), $array);
324
325 16
            return array_values((array)array_merge(...$pairs));
326 16
        };
327
328 16
        $pairs = $format($condition);
329 16
        $count = count($pairs);
330
331 16
        if ($count === 1) {
332 11
            return static::one([static::getPrimaryKey() => current($condition)]);
333
        }
334
335 6
        $conditions = [];
336 6
        for ($i = 0; $i < $count; $i++) {
337 6
            if ($i % 2 === 0) {
338 6
                $conditions[$pairs[$i]] = $pairs[$i + 1];
339
            }
340
        }
341
342 6
        return static::all($conditions);
343
    }
344
345
    /**
346
     * Returns the count of models matching the passed condition (counting is done on the SQL end for better performance).
347
     *
348
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
349
     *
350
     * @return int
351
     */
352 1
    public static function count(?array $conditions = []): int
353
    {
354 1
        $query = 'SELECT COUNT(*) FROM @table';
355
356 1
        if (!empty($conditions)) {
357 1
            $sqlConditions = [];
358 1
            foreach ($conditions as $key => $value) {
359 1
                static::assertAttributeExists($key);
360 1
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
361
            }
362
363 1
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions) . ';';
364
        }
365
366 1
        $data = static::fetch($query, $conditions, true);
367
368 1
        return $data ? $data[0]['COUNT(*)'] ?? 0 : 0;
369
    }
370
371
372
    /**
373
     * Returns array representation of the model. All attributes will be converted to `camelCase` form.
374
     */
375 4
    public function toArray(): array
376
    {
377 4
        $attributes = $this->getAttributes();
378
379 4
        return array_combine(
380 4
            array_map(
381 4
                fn ($key) => Misc::transform($key, 'camel'),
382 4
                array_keys($attributes)
383
            ),
384
            $attributes
385
        );
386
    }
387
388
    /**
389
     * Returns JSON representation of the model. All attributes will be converted to `camelCase` form.
390
     */
391 2
    public function toJson(): string
392
    {
393 2
        return json_encode(
394 2
            $this->toArray(),
395 2
            JSON_UNESCAPED_SLASHES|JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_QUOT
396
        );
397
    }
398
399
400
    /**
401
     * Defines magic getters, setters, and finders for model attributes.
402
     * Examples: `attribute_name` has `getAttributeName()`, `setAttributeName()`, and `findByAttributeName()` methods.
403
     */
404 9
    public function __call(string $method, array $arguments)
405
    {
406 9
        if (preg_match('/^([gs]et|findBy)([a-z0-9]+)$/i', $method, $matches)) {
407 9
            $function  = Misc::transform($matches[1], 'camel');
408 9
            $attribute = Misc::transform($matches[2], 'snake');
409
410 9
            return $this->{$function === 'findBy' ? 'find' : $function}($attribute, ...$arguments);
411
        }
412
413 1
        throw new \Exception(sprintf('Call to undefined method %s::%s()', static::class, $method));
414
    }
415
416
    /**
417
     * Makes the model more friendly presented when exported via `var_dump()`.
418
     */
419 1
    public function __debugInfo()
420
    {
421 1
        return $this->toArray();
422
    }
423
424
    /**
425
     * Makes the object quickly available as a JSON string.
426
     */
427 1
    public function __toString()
428
    {
429 1
        return $this->toJson();
430
    }
431
}
432