Model   A
last analyzed

Complexity

Total Complexity 40

Size/Duplication

Total Lines 369
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 7
Bugs 0 Features 0
Metric Value
eloc 110
c 7
b 0
f 0
dl 0
loc 369
ccs 124
cts 124
cp 1
rs 9.2
wmc 40

17 Methods

Rating   Name   Duplication   Size   Complexity  
A __debugInfo() 0 3 1
A toArray() 0 10 1
A count() 0 17 4
A first() 0 3 1
A all() 0 29 6
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 hydrate() 0 8 2
A find() 0 34 6
A __call() 0 12 3
A update() 0 24 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\Exception;
15
use MAKS\Velox\Backend\Database;
16
use MAKS\Velox\Backend\Model\Element;
17
use MAKS\Velox\Helper\Misc;
18
19
/**
20
 * An abstract class that serves as a base model that can be extended to create models.
21
 *
22
 * Example:
23
 * ```
24
 * // Attributes has to match the attribute name (column name) unless otherwise specified.
25
 *
26
 * // creating/manipulating models
27
 * $model = new Model(); // set attributes later via setters or public assignment.
28
 * $model = new Model(['attribute_name' => $value]);
29
 * $model->get('attribute_name');
30
 * $model->set('attribute_name', $value);
31
 * $model->getAttributeName(); // case will be changed to 'snake_case' automatically.
32
 * $model->setAttributeName($value); // case will be changed to 'snake_case' automatically.
33
 * $model->anotherAttribute; // case will be changed to 'snake_case' automatically.
34
 * $model->anotherAttribute = $value; // case will be changed to 'snake_case' automatically.
35
 * $attributes = $model->getAttributes(); // returns all attributes.
36
 * $model->save(); // persists the model in the database.
37
 * $model->update(['attribute_name' => $value]); // updates the model and save changes in the database.
38
 * $model->delete(); // deletes the model from the database.
39
 * Model::create($attributes); // creates a new model instance, call save() on the instance to save it in the database.
40
 * Model::destroy($id); // destroys a model and deletes it from the database.
41
 *
42
 * // fetching models
43
 * $model = Model::first();
44
 * $model = Model::last();
45
 * $model = Model::one();
46
 * $models = Model::all(['name' => 'John'], 'age DESC', $offset, $limit);
47
 * $count = Model::count(); // returns the number of models in the database.
48
 * $model = Model::find($id); // $id is the primary key of the model.
49
 * $models = Model::find('age', 27, 'name', 'John', ...); // or Model::find(['name' => $value]);
50
 * $models = Model::findByName('John'); // fetches using an attribute, case will be changed to 'snake_case' automatically.
51
 * $models = Model::where('name', '=', $name); // fetches using a where clause condition.
52
 * $models = Model::where('name', 'LIKE', 'John%', [['AND', 'age', '>', 27], ...], 'age DESC', $limit, $offset);
53
 * $models = Model::fetch('SELECT * FROM @table WHERE `name` = ?', [$name]); // fetches using raw SQL query.
54
 * ```
55
 *
56
 * @package Velox\Backend\Model
57
 * @since 1.3.0
58
 * @api
59
 *
60
 * @method mixed getSomeAttribute() Getter for model attribute, (`attribute_name` -> `getAttributeName()`).
61
 * @method $this setSomeAttribute($value) Setter for model attribute, (`attribute_name` -> `setAttributeName($value)`).
62
 * @method static[] findBySomeAttribute($value) Finder by model attribute, (`attribute_name` -> `findByAttributeName($value)`).
63
 *
64
 * @property mixed $attributeName* Public property for model attribute, (`attribute_name` -> `attributeName`).
65
 */
66
abstract class Model extends Element
67
{
68
    /**
69
     * Creates an instance of the model. The model is not saved to the database unless `self::save()` is called.
70
     *
71
     * @param array $attributes The attributes of the model.
72
     *
73
     * @return static
74
     */
75 32
    public static function create(array $attributes): Model
76
    {
77 32
        $item = new static();
78 32
        $item->setAttributes($attributes);
79
80 32
        return $item;
81
    }
82
83
    /**
84
     * Creates/updates a model and saves it in database.
85
     *
86
     * @param array $attributes Model attributes.
87
     *
88
     * @return static
89
     */
90 30
    public function save(array $attributes = []): Model
91
    {
92 30
        $isNew = !$this->get($this->getPrimaryKey());
93
94 30
        $this->setAttributes($attributes);
95
96 30
        $attributes = $this->getAttributes();
97 30
        $variables  = [];
98
99 30
        foreach ($attributes as $key => $value) {
100 30
            if ($isNew && $key === $this->getPrimaryKey()) {
101 30
                unset($attributes[$key]);
102 30
                continue;
103
            }
104
105 30
            $variables[':' . $key] = $value;
106
        }
107
108 30
        $query = vsprintf('%s INTO `%s` (%s) VALUES(%s);', [
109 30
            $isNew ? 'INSERT' : 'REPLACE',
110 30
            $this->getTable(),
111 30
            implode(', ', array_keys($attributes)),
112 30
            implode(', ', array_keys($variables)),
113
        ]);
114
115 30
        $id = $this->getDatabase()->transactional(function () use ($query, $variables) {
116
            /** @var Database $this */
117 30
            $this->perform($query, $variables);
118 30
            return $this->lastInsertId();
119
        });
120
121 30
        $this->set($this->getPrimaryKey(), is_numeric($id) ? (int)$id : $id);
122
123 30
        return $this;
124
    }
125
126
    /**
127
     * Updates the given attributes of the model and saves them in the database.
128
     *
129
     * @param array $attributes The attributes to update.
130
     *
131
     * @return static
132
     */
133 1
    public function update(array $attributes): Model
134
    {
135 1
        $variables = [];
136
137 1
        foreach ($attributes as $key => $value) {
138 1
            $this->set($key, $value);
139
140 1
            $variables[':' . $key] = $value;
141
        }
142
143 1
        $variables[':id'] = $this->get($this->getPrimaryKey());
144
145 1
        $query = vsprintf('UPDATE `%s` SET %s WHERE `%s` = :id;', [
146 1
            $this->getTable(),
147 1
            implode(', ', array_map(fn ($key) => sprintf('`%s` = :%s', $key, $key), array_keys($attributes))),
148 1
            $this->getPrimaryKey(),
149
        ]);
150
151 1
        $this->getDatabase()->transactional(function () use ($query, $variables) {
152
            /** @var Database $this */
153 1
            $this->perform($query, $variables);
154
        });
155
156 1
        return $this;
157
    }
158
159
    /**
160
     * Deletes the model from the database.
161
     *
162
     * @return int The number of affected rows during the SQL operation.
163
     */
164 5
    public function delete(): int
165
    {
166 5
        $query = vsprintf('DELETE FROM `%s` WHERE `%s` = :id LIMIT 1;', [
167 5
            $this->getTable(),
168 5
            $this->getPrimaryKey(),
169
        ]);
170
171 5
        $variables = [':id' => $this->get($this->getPrimaryKey())];
172
173 5
        return $this->getDatabase()->transactional(function () use ($query, $variables) {
174
            /** @var Database $this */
175 5
            return $this->perform($query, $variables)->rowCount();
176
        });
177
    }
178
179
    /**
180
     * Destroys (deletes) a model from the database if it exists.
181
     *
182
     * @param string|int $primaryKey
183
     *
184
     * @return int The number of affected rows during the SQL operation.
185
     */
186 1
    public static function destroy($primaryKey): int
187
    {
188 1
        $model = static::find($primaryKey);
189
190 1
        return $model ? $model->delete() : 0;
191
    }
192
193
    /**
194
     * Hydrates models from an array of model attributes (row).
195
     *
196
     * @param array $data Array of objects data.
197
     *
198
     * @return static[] Array of hydrated objects.
199
     *
200
     * Example:
201
     * - `Model::hydrate([$arrayOfModelAttributes, ...])`
202
     */
203 1
    public static function hydrate(array $models): array
204
    {
205 1
        $objects = [];
206 1
        foreach ($models as $model) {
207 1
            $objects[] = static::create($model);
208
        }
209
210 1
        return $objects;
211
    }
212
213
214
    /**
215
     * Fetches all models.
216
     *
217
     * @param array $conditions Fetch conditions (like: `['id' => $id, ...]`). Conditions are combined by logical `AND`.
218
     * @param string|null $order [optional] SQL order expression (like: `id` or `id ASC`).
219
     * @param int|null $limit [optional] To how many items the result should be limited.
220
     * @param int|null $offset [optional] From which item the result should start.
221
     *
222
     * @return static[]|array
223
     *
224
     * Examples:
225
     * - PHP: `Model::all(['name' => 'Doe', 'job' => 'Developer'],'age DESC', 3, 15)`.
226
     * - SQL: ```SELECT * FROM `users` WHERE `name` = "Doe" AND `job` = `Developer` ORDER BY age DESC LIMIT 3 OFFSET 15```.
227
     */
228 25
    public static function all(?array $conditions = [], ?string $order = null, ?int $limit = null, ?int $offset = null): array
229
    {
230 25
        $query = 'SELECT * FROM @table';
231
232 25
        if (!empty($conditions)) {
233 20
            $sqlConditions = [];
234 20
            foreach ($conditions as $key => $value) {
235 20
                static::assertAttributeExists($key);
236 20
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
237
            }
238
239 20
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions);
240
        }
241
242 25
        if ($order !== null) {
243 7
            $query .= ' ORDER BY ' . $order;
244
        }
245
246 25
        if ($limit !== null) {
247 25
            $query .= ' LIMIT ' . $limit;
248
        }
249
250 25
        if ($offset !== null) {
251 7
            $query .= ' OFFSET ' . $offset;
252
        }
253
254 25
        $query .= ';';
255
256 25
        return static::fetch($query, $conditions);
257
    }
258
259
    /**
260
     * Fetches a single model object.
261
     *
262
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
263
     *
264
     * @return static|null
265
     *
266
     * Examples:
267
     * - Fetching the first items: `Model::one()`.
268
     * - Fetching an item according to a condition: `Model::one(['name' => $name])`.
269
     */
270 20
    public static function one(?array $conditions = []): ?Model
271
    {
272 20
        return static::all($conditions, null, 1, null)[0] ?? null;
273
    }
274
275
    /**
276
     * Fetches the first model object.
277
     *
278
     * @return static|null
279
     *
280
     * Examples: `Model::first()`.
281
     */
282 2
    public static function first(): ?Model
283
    {
284 2
        return static::all(null, static::getPrimaryKey() . ' ASC', 1, 0)[0] ?? null;
285
    }
286
287
    /**
288
     * Fetches the last model object.
289
     *
290
     * @return static|null
291
     *
292
     * Example: `Model::last()`.
293
     */
294 4
    public static function last(): ?Model
295
    {
296 4
        return static::all(null, static::getPrimaryKey() . ' DESC', 1, 0)[0] ?? null;
297
    }
298
299
    /**
300
     * Finds a single or multiple models matching the passed condition.
301
     *
302
     * @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]`).
303
     *
304
     * @return static|static[]|null|array Depends on the number of conditions (1 = single, >1 = multiple).
305
     *
306
     * Examples:
307
     * - Find by primary key (ID): `Model::find(1)`.
308
     * - Find by specific value: `Model::find('name', 'Doe', 'age', 35, ...)` or `Model::find(['name' => $name, 'age' => 35], ...)`.
309
     *
310
     */
311 16
    public static function find(...$condition)
312
    {
313
        // formats conditions to be consumed as `$name, $value, $name, $value, ...`
314 16
        $format = function ($array) use (&$format) {
315 16
            $pairs = array_map(function ($key, $value) use (&$format) {
316 16
                if (is_string($key)) {
317 1
                    return [$key, $value];
318
                }
319
320 16
                if (is_array($value)) {
321 1
                    return $format($value);
322
                }
323
324 16
                return [$value];
325 16
            }, array_keys($array), $array);
326
327 16
            return array_values((array)array_merge(...$pairs));
328
        };
329
330 16
        $pairs = $format($condition);
331 16
        $count = count($pairs);
332
333 16
        if ($count === 1) {
334 11
            return static::one([static::getPrimaryKey() => current($condition)]);
335
        }
336
337 6
        $conditions = [];
338 6
        for ($i = 0; $i < $count; $i++) {
339 6
            if ($i % 2 === 0) {
340 6
                $conditions[$pairs[$i]] = $pairs[$i + 1];
341
            }
342
        }
343
344 6
        return static::all($conditions);
345
    }
346
347
    /**
348
     * Returns the count of models matching the passed condition (counting is done on the SQL end for better performance).
349
     *
350
     * @param array $conditions [optional] Query conditions (like: `['id' => 1, ...]`). Conditions are combined by logical `AND`.
351
     *
352
     * @return int
353
     */
354 1
    public static function count(?array $conditions = []): int
355
    {
356 1
        $query = 'SELECT COUNT(*) FROM @table';
357
358 1
        if (!empty($conditions)) {
359 1
            $sqlConditions = [];
360 1
            foreach ($conditions as $key => $value) {
361 1
                static::assertAttributeExists($key);
362 1
                $sqlConditions[] = sprintf('`%s` = :%s', $key, $key);
363
            }
364
365 1
            $query .= ' WHERE ' . implode(' AND ', $sqlConditions) . ';';
366
        }
367
368 1
        $data = static::fetch($query, $conditions, true);
369
370 1
        return $data ? $data[0]['COUNT(*)'] ?? 0 : 0;
371
    }
372
373
374
    /**
375
     * Returns array representation of the model. All attributes will be converted to `camelCase` form.
376
     */
377 4
    public function toArray(): array
378
    {
379 4
        $attributes = $this->getAttributes();
380
381 4
        return array_combine(
382 4
            array_map(
383 4
                fn ($key) => Misc::transform($key, 'camel'),
384 4
                array_keys($attributes)
385
            ),
386
            $attributes
387
        );
388
    }
389
390
    /**
391
     * Returns JSON representation of the model. All attributes will be converted to `camelCase` form.
392
     */
393 2
    public function toJson(): string
394
    {
395 2
        return json_encode(
396 2
            $this->toArray(),
397 2
            JSON_UNESCAPED_SLASHES|JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_QUOT
398
        );
399
    }
400
401
402
    /**
403
     * Defines magic getters, setters, and finders for model attributes.
404
     * Examples: `attribute_name` has `getAttributeName()`, `setAttributeName()`, and `findByAttributeName()` methods.
405
     */
406 9
    public function __call(string $method, array $arguments)
407
    {
408 9
        if (preg_match('/^([gs]et|findBy)([a-z0-9]+)$/i', $method, $matches)) {
409 9
            $function  = Misc::transform($matches[1], 'camel');
410 9
            $attribute = Misc::transform($matches[2], 'snake');
411
412 9
            return $this->{$function === 'findBy' ? 'find' : $function}($attribute, ...$arguments);
413
        }
414
415 1
        Exception::throw(
416
            'UndefinedMethodException:BadMethodCallException',
417 1
            sprintf('Call to undefined method %s::%s', static::class, $method)
418
        );
419
    }
420
421
    /**
422
     * Makes the model more friendly presented when exported via `var_dump()`.
423
     */
424 1
    public function __debugInfo()
425
    {
426 1
        return $this->toArray();
427
    }
428
429
    /**
430
     * Makes the object quickly available as a JSON string.
431
     */
432 1
    public function __toString()
433
    {
434 1
        return $this->toJson();
435
    }
436
}
437