Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 158 |
|
0.00% |
0 / 21 |
CRAP | |
0.00% |
0 / 1 |
MySQLModel | |
0.00% |
0 / 158 |
|
0.00% |
0 / 21 |
2862 | |
0.00% |
0 / 1 |
primaryField | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
0 | |||
formatFields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
redactFields | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
invoke | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
processRecords | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
formatRecord | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
redactRecord | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
create | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
find | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
update | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
delete | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
clone | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
all | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
search | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
get | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
count | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
sum | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
avg | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
min | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
max | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
20 | |||
paginate | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace Projom\Storage\Model; |
6 | |
7 | use Projom\Storage\Query\MySQLQuery; |
8 | use Projom\Storage\SQL\Util\Aggregate; |
9 | use Projom\Storage\SQL\Util\Operator; |
10 | use 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 | */ |
28 | trait 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 | } |