Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
MySQLModel
0.00% covered (danger)
0.00%
0 / 158
0.00% covered (danger)
0.00%
0 / 21
2862
0.00% covered (danger)
0.00%
0 / 1
 primaryField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0
 formatFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 redactFields
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 invoke
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 processRecords
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 formatRecord
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 redactRecord
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 create
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 find
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 update
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 delete
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 clone
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 all
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 search
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 get
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 count
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 sum
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 avg
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 min
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 max
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 paginate
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace Projom\Storage\Model;
6
7use Projom\Storage\Query\MySQLQuery;
8use Projom\Storage\SQL\Util\Aggregate;
9use Projom\Storage\SQL\Util\Operator;
10use Projom\Storage\Util;
11
12/**
13 * MySQLModel provides a set of methods to interact with a database table.
14 * 
15 * How to use:
16 * * Extend this class to create a query-able "model/repository" for that table.
17 * * The extended class name should be the same as the table name.
18 *
19 * Mandatory abstract methods to implement: 
20 * * primaryField(): string 'FieldID'
21 *
22 * Optional methods to implement for additional processing:
23 *  * formatFields(): array [ 'Field' => 'string', 'AnotherField' => 'int', ... ]
24 *  * redactFields(): array [ 'Field', 'AnotherField' ]
25 * 
26 * The value of all redacted fields will be replaced with the string "\_\_REDACTED\_\_".
27 */
28trait MySQLModel
29{
30    private $table = null;
31    private $primaryField = null;
32    private $formatFields = [];
33    private $redactedFields = [];
34
35    abstract public function primaryField(): string;
36
37    public function formatFields(): array
38    {
39        return [];
40    }
41
42    public function redactFields(): array
43    {
44        return [];
45    }
46
47    private function invoke()
48    {
49        $calledClass = get_class($this);
50        $class = basename(str_replace('\\', DIRECTORY_SEPARATOR, $calledClass));
51        $this->table = $class;
52        $this->primaryField = $this->primaryField();
53        $this->formatFields = $this->formatFields();
54        $this->redactedFields = $this->redactFields();
55
56        if (!$this->table)
57            throw new \Exception('Table name not set', 400);
58        if (!$this->primaryField)
59            throw new \Exception('Primary field not set', 400);
60    }
61
62    private function processRecords(array $records): array
63    {
64        $records = Util::rekey($records, $this->primaryField);
65
66        $processedRecords = [];
67        foreach ($records as $key => $record) {
68            $record = $this->formatRecord($record);
69            $record = $this->redactRecord($record);
70            $processedRecords[$key] = $record;
71        }
72
73        return $processedRecords;
74    }
75
76    private function formatRecord(array $record): array
77    {
78        if (!$this->formatFields)
79            return $record;
80
81        foreach ($this->formatFields as $field => $type) {
82            if (!array_key_exists($field, $record))
83                continue;
84            $value = $record[$field];
85            $record[$field] = Util::format($value, $type);
86        }
87
88        return $record;
89    }
90
91    private function redactRecord(array $record): array
92    {
93        if (!$this->redactedFields)
94            return $record;
95
96        foreach ($this->redactedFields as $field) {
97            if (!array_key_exists($field, $record))
98                throw new \Exception("Field: {$field}, could not be redacted. Not found in record", 400);
99            $record[$field] = '__REDACTED__';
100        }
101
102        return $record;
103    }
104
105    /**
106     * Create a record.
107     * 
108     * * Example use: $user->create(['Name' => 'John'])
109     */
110    public function create(array $record): int|string
111    {
112        $this->invoke();
113        $primaryID = MySQLQuery::query($this->table)->insert($record);
114        return $primaryID;
115    }
116
117    /**
118     * Find a record by its primary id.
119     * 
120     * * Example use: $user->find($userID = 3)
121     */
122    public function find(string|int $primaryID): null|array|object
123    {
124        $this->invoke();
125
126        $records = MySQLQuery::query($this->table)->fetch($this->primaryField, $primaryID);
127        if (!$records)
128            return null;
129
130        $records = $this->processRecords($records);
131
132        return array_pop($records);
133    }
134
135    /**
136     * Update a record by its primary id.
137     * 
138     * * Example use: $user->update($userID = 3, ['Name' => 'A new name'])
139     */
140    public function update(string|int $primaryID, array $data): void
141    {
142        $this->invoke();
143        MySQLQuery::query($this->table)->filterOn($this->primaryField, $primaryID)->update($data);
144    }
145
146    /**
147     * Delete a record by its primary id.
148     * 
149     * * Example use: $user->delete($userID = 3)
150     */
151    public function delete(string|int $primaryID): void
152    {
153        $this->invoke();
154        MySQLQuery::query($this->table)->filterOn($this->primaryField, $primaryID)->delete();
155    }
156
157    /**
158     * Clone a record.
159     * 
160     * @param array $newRecord used to write new values to fields from the cloned record.
161     * 
162     * * Example use: $user->clone($userID = 3)
163     * * Example use: $user->clone($userID = 3, ['Name' => 'New Name'])
164     */
165    public function clone(string|int $primaryID, array $newRecord = []): array|object
166    {
167        $this->invoke();
168
169        $records = MySQLQuery::query($this->table)->fetch($this->primaryField, $primaryID);
170        if (!$records)
171            return throw new \Exception('Record to clone not found', 400);
172
173        $record = array_pop($records);
174        unset($record[$this->primaryField]);
175
176        // Merge new record with existing record. 
177        $record = $newRecord + $record;
178        $clonePrimaryID = MySQLQuery::query($this->table)->insert($record);
179
180        $clonedRecords = MySQLQuery::query($this->table)->fetch($this->primaryField, $clonePrimaryID);
181        $clonedRecords = $this->processRecords($clonedRecords);
182        return array_pop($clonedRecords);
183    }
184
185    /**
186     * Get all records.
187     * 
188     * * Example use: $user->all()
189     * * Example use: $user->all($filters = ['Active' => 0])
190     */
191    public function all(array $filters = []): null|array
192    {
193        $this->invoke();
194
195        $query = MySQLQuery::query($this->table);
196        if ($filters)
197            $query->filterOnFields($filters);
198
199        $records = $query->select();
200        if (!$records)
201            return null;
202
203        $records = $this->processRecords($records);
204
205        return $records;
206    }
207
208    /**
209     * Search for records filtering on field like %value%.
210     * 
211     * * Example use: $user->search('Name', 'John')
212     */
213    public function search(string $field, string $value): null|array
214    {
215        $this->invoke();
216
217        $records = MySQLQuery::query($this->table)->filterOn($field, "%$value%", Operator::LIKE)->select();
218        if (!$records)
219            return null;
220
221        $records = $this->processRecords($records);
222
223        return $records;
224    }
225
226    /**
227     * Get a record by filtering on field with value.
228     * 
229     * * Example use: $user->get('Email', '[email protected]')
230     */
231    public function get(string $field, mixed $value): null|array|object
232    {
233        $this->invoke();
234
235        $records = MySQLQuery::query($this->table)->fetch($field, $value);
236        if (!$records)
237            return null;
238
239        $records = $this->processRecords($records);
240
241        if (count($records) === 1)
242            return array_pop($records);
243
244        return $records;
245    }
246
247    /**
248     * Count records.
249     * 
250     * * Example use: $user->count()
251     * * Example use: $user->count(filters: ['Active' => 0])
252     * * Example use: $user->count('UserID', groupFields: ['Active'])
253     */
254    public function count(string $countField = '*',  array $filters = [], array $groupByFields = []): null|array
255    {
256        $this->invoke();
257
258        $query = MySQLQuery::query($this->table);
259
260        if ($filters)
261            $query->filterOnFields($filters);
262
263        $aggregate = Aggregate::COUNT->buildSQL($countField, 'count');
264        $fields = [$aggregate];
265
266        if ($groupByFields) {
267            $query->groupOn(...$groupByFields);
268            $fields = Util::merge($fields, $groupByFields);
269        }
270
271        $records = $query->select(...$fields);
272        if (!$records)
273            return null;
274
275        return $records;
276    }
277
278    /**
279     * Summarize records.
280     * 
281     * * Example use: Invoice::sum('Amount')
282     * * Example use: Invoice::sum('Amount', ['Paid' => 0, 'Due' => '2024-07-25'])
283     * * Example use: Invoice::sum('Amount', groupFields: ['Paid'])
284     */
285    public function sum(string $sumField, array $filters = [], array $groupByFields = []): null|array
286    {
287        $this->invoke();
288
289        $query = MySQLQuery::query($this->table);
290
291        if ($filters)
292            $query->filterOnFields($filters);
293
294        $aggregate = Aggregate::SUM->buildSQL($sumField, 'sum');
295        $fields = [$aggregate];
296
297        if ($groupByFields) {
298            $query->groupOn(...$groupByFields);
299            $fields = Util::merge($fields, $groupByFields);
300        }
301
302        $records = $query->select(...$fields);
303        if (!$records)
304            return null;
305
306        return $records;
307    }
308
309    /**
310     * Average records.
311     * 
312     * * Example use: Invoice::avg('Amount')
313     * * Example use: Invoice::avg('Amount', ['Paid' => 0, 'Due' => '2024-07-25'])
314     * * Example use: Invoice::avg('Amount', groupFields: ['Paid'])
315     */
316    public function avg(string $averageField, array $filters = [], array $groupByFields = []): null|array
317    {
318        $this->invoke();
319
320        $query = MySQLQuery::query($this->table);
321
322        if ($filters)
323            $query->filterOnFields($filters);
324
325        $aggregate = Aggregate::AVG->buildSQL($averageField, 'avg');
326        $fields = [$aggregate];
327
328        if ($groupByFields) {
329            $query->groupOn(...$groupByFields);
330            $fields = Util::merge($fields, $groupByFields);
331        }
332
333        $records = $query->select(...$fields);
334        if (!$records)
335            return null;
336
337        return $records;
338    }
339
340    /**
341     * Minimum of records.
342     * 
343     * * Example use: Invoice::min('Amount')
344     * * Example use: Invoice::min('Amount', ['Paid' => 0, 'Due' => '2024-07-25'])
345     * * Example use: Invoice::min('Amount', groupFields: ['Paid'])
346     */
347    public function min(string $minField, array $filters = [], array $groupByFields = []): null|array
348    {
349        $this->invoke();
350
351        $query = MySQLQuery::query($this->table);
352
353        if ($filters)
354            $query->filterOnFields($filters);
355
356        $aggregate = Aggregate::MIN->buildSQL($minField, 'min');
357        $fields = [$aggregate];
358
359        if ($groupByFields) {
360            $query->groupOn(...$groupByFields);
361            $fields = Util::merge($fields, $groupByFields);
362        }
363
364        $records = $query->select(...$fields);
365        if (!$records)
366            return null;
367
368        return $records;
369    }
370
371    /**
372     * Maximum of records.
373     * 
374     * * Example use: Invoice::max('Amount')
375     * * Example use: Invoice::max('Amount', ['Paid' => 0, 'Due' => '2024-07-25'])
376     * * Example use: Invoice::max('Amount', groupFields: ['Paid'])
377     */
378    public function max(string $maxField, array $filters = [], array $groupByFields = []): null|array
379    {
380        $this->invoke();
381
382        $query = MySQLQuery::query($this->table);
383
384        if ($filters)
385            $query->filterOnFields($filters);
386
387        $aggregate = Aggregate::MAX->buildSQL($maxField, 'max');
388        $fields = [$aggregate];
389
390        if ($groupByFields) {
391            $query->groupOn(...$groupByFields);
392            $fields = Util::merge($fields, $groupByFields);
393        }
394
395        $records = $query->select(...$fields);
396        if (!$records)
397            return null;
398
399        return $records;
400    }
401
402    /**
403     * Paginate records.
404     * 
405     * * Example use: $user->paginate(1, 10)
406     * * Example use: $user->paginate(1, 10, ['Name' => 'John'])
407     */
408    public function paginate(int $page, int $pageSize, array $filters = []): null|array
409    {
410        $this->invoke();
411
412        $query = MySQLQuery::query($this->table);
413
414        if ($filters)
415            $query->filterOnFields($filters);
416
417        $offset = ($page - 1) * $pageSize;
418        $query->offset($offset)->limit($pageSize);
419
420        $records = $query->select();
421        if (!$records)
422            return null;
423
424        $records = $this->processRecords($records);
425
426        return $records;
427    }
428}