1 | <?php |
||
2 | |||
3 | /** |
||
4 | * Bluz Framework Component |
||
5 | * |
||
6 | * @copyright Bluz PHP Team |
||
7 | * @link https://github.com/bluzphp/framework |
||
8 | */ |
||
9 | |||
10 | declare(strict_types=1); |
||
11 | |||
12 | namespace Bluz\Db; |
||
13 | |||
14 | use Bluz\Common\Instance; |
||
15 | use Bluz\Db\Exception\DbException; |
||
16 | use Bluz\Db\Exception\InvalidPrimaryKeyException; |
||
17 | use Bluz\Db\Traits\TableRelations; |
||
18 | use Bluz\Proxy\Cache; |
||
19 | use Bluz\Proxy\Db as DbProxy; |
||
20 | use InvalidArgumentException; |
||
21 | |||
22 | /** |
||
23 | * Table |
||
24 | * |
||
25 | * Example of Users\Table |
||
26 | * <code> |
||
27 | * namespace Application\Users; |
||
28 | * class Table extends \Bluz\Db\Table |
||
29 | * { |
||
30 | * protected $table = 'users'; |
||
31 | * protected $primary = ['id']; |
||
32 | * } |
||
33 | * |
||
34 | * $userRows = \Application\Users\Table::find(1,2,3,4,5); |
||
35 | * foreach ($userRows as $userRow) { |
||
36 | * $userRow -> description = 'In first 5'; |
||
37 | * $userRow -> save(); |
||
38 | * } |
||
39 | * </code> |
||
40 | * |
||
41 | * @package Bluz\Db |
||
42 | * @author Anton Shevchuk |
||
43 | * @link https://github.com/bluzphp/framework/wiki/Db-Table |
||
44 | */ |
||
45 | abstract class Table implements TableInterface |
||
46 | { |
||
47 | use Instance; |
||
48 | use TableRelations; |
||
49 | |||
50 | /** |
||
51 | * @var string the table name |
||
52 | */ |
||
53 | protected $name; |
||
54 | |||
55 | /** |
||
56 | * @var string the model name |
||
57 | */ |
||
58 | protected $model; |
||
59 | |||
60 | /** |
||
61 | * @var array table meta |
||
62 | */ |
||
63 | protected $meta = []; |
||
64 | |||
65 | /** |
||
66 | * @var string default SQL query for select |
||
67 | */ |
||
68 | protected $select = ''; |
||
69 | |||
70 | /** |
||
71 | * @var array the primary key column or columns (only as array). |
||
72 | */ |
||
73 | protected $primary; |
||
74 | |||
75 | /** |
||
76 | * @var string the sequence name, required for PostgreSQL |
||
77 | */ |
||
78 | protected $sequence; |
||
79 | |||
80 | /** |
||
81 | * @var string row class name |
||
82 | */ |
||
83 | protected $rowClass; |
||
84 | |||
85 | /** |
||
86 | * Create and initialize Table instance |
||
87 | */ |
||
88 | 2 | private function __construct() |
|
89 | { |
||
90 | 2 | $tableClass = static::class; |
|
91 | 2 | $namespace = class_namespace($tableClass); |
|
92 | |||
93 | // autodetect model name |
||
94 | 2 | if (!$this->model) { |
|
95 | 2 | $this->model = substr($namespace, strrpos($namespace, '\\') + 1); |
|
96 | } |
||
97 | |||
98 | // autodetect table name - camelCase to uppercase |
||
99 | 2 | if (!$this->name) { |
|
100 | $table = preg_replace('/(?<=\\w)(?=[A-Z])/', '_$1', $this->model); |
||
101 | $this->name = strtolower($table); |
||
102 | } |
||
103 | |||
104 | // autodetect row class |
||
105 | 2 | if (!$this->rowClass) { |
|
106 | 1 | $this->rowClass = $namespace . '\\Row'; |
|
107 | } |
||
108 | |||
109 | // setup default select query |
||
110 | 2 | if (empty($this->select)) { |
|
111 | 2 | $this->select = 'SELECT ' . DbProxy::quoteIdentifier($this->name) . '.* ' . |
|
112 | 2 | 'FROM ' . DbProxy::quoteIdentifier($this->name); |
|
113 | } |
||
114 | |||
115 | 2 | Relations::addClassMap($this->model, $tableClass); |
|
116 | |||
117 | 2 | $this->init(); |
|
118 | 2 | } |
|
119 | |||
120 | /** |
||
121 | * Initialization hook. |
||
122 | * Subclasses may override this method |
||
123 | * |
||
124 | * @return void |
||
125 | */ |
||
126 | 2 | public function init(): void |
|
127 | { |
||
128 | 2 | } |
|
129 | |||
130 | /** |
||
131 | * Get primary key(s) |
||
132 | * |
||
133 | * @return array |
||
134 | * @throws InvalidPrimaryKeyException if primary key was not set or has wrong format |
||
135 | */ |
||
136 | 39 | public function getPrimaryKey(): array |
|
137 | { |
||
138 | 39 | if (!is_array($this->primary)) { |
|
0 ignored issues
–
show
introduced
by
![]() |
|||
139 | 1 | throw new InvalidPrimaryKeyException('The primary key must be set as an array'); |
|
140 | } |
||
141 | 38 | return $this->primary; |
|
142 | } |
||
143 | |||
144 | /** |
||
145 | * Get table name |
||
146 | * |
||
147 | * @return string |
||
148 | */ |
||
149 | 3 | public function getName(): string |
|
150 | { |
||
151 | 3 | return $this->name; |
|
152 | } |
||
153 | |||
154 | /** |
||
155 | * Get model name |
||
156 | * |
||
157 | * @return string |
||
158 | */ |
||
159 | 2 | public function getModel(): string |
|
160 | { |
||
161 | 2 | return $this->model; |
|
162 | } |
||
163 | |||
164 | /** |
||
165 | * Return information about table columns |
||
166 | * |
||
167 | * @return array |
||
168 | */ |
||
169 | 8 | public static function getMeta(): array |
|
170 | { |
||
171 | 8 | $self = static::getInstance(); |
|
172 | 8 | if (empty($self->meta)) { |
|
173 | 1 | $cacheKey = "db.table.{$self->name}"; |
|
174 | 1 | $meta = Cache::get($cacheKey); |
|
175 | 1 | if (!$meta) { |
|
176 | 1 | $schema = DbProxy::getOption('connect', 'name'); |
|
177 | |||
178 | 1 | $meta = DbProxy::fetchUniqueGroup( |
|
179 | 1 | ' |
|
180 | SELECT |
||
181 | COLUMN_NAME AS `name`, |
||
182 | DATA_TYPE AS `type`, |
||
183 | COLUMN_DEFAULT AS `default`, |
||
184 | COLUMN_KEY AS `key` |
||
185 | FROM INFORMATION_SCHEMA.COLUMNS |
||
186 | WHERE TABLE_SCHEMA = ? |
||
187 | AND TABLE_NAME = ?', |
||
188 | 1 | [$schema, $self->getName()] |
|
189 | ); |
||
190 | 1 | Cache::set($cacheKey, $meta, Cache::TTL_NO_EXPIRY, ['system', 'db']); |
|
191 | } |
||
192 | 1 | $self->meta = $meta; |
|
193 | } |
||
194 | 8 | return $self->meta; |
|
195 | } |
||
196 | |||
197 | /** |
||
198 | * Return names of table columns |
||
199 | * |
||
200 | * @return array |
||
201 | */ |
||
202 | 7 | public static function getColumns(): array |
|
203 | { |
||
204 | 7 | $self = static::getInstance(); |
|
205 | 7 | return array_keys($self::getMeta()); |
|
206 | } |
||
207 | |||
208 | /** |
||
209 | * Filter columns for insert/update queries by table columns definition |
||
210 | * |
||
211 | * @param array $data |
||
212 | * |
||
213 | * @return array |
||
214 | */ |
||
215 | 6 | public static function filterColumns(array $data): array |
|
216 | { |
||
217 | 6 | return array_intersect_key($data, array_flip(static::getColumns())); |
|
218 | } |
||
219 | |||
220 | /** |
||
221 | * Fetching rows by SQL query |
||
222 | * |
||
223 | * @param string $sql SQL query with placeholders |
||
224 | * @param array $params Params for query placeholders |
||
225 | * |
||
226 | * @return RowInterface[] of rows results in FETCH_CLASS mode |
||
227 | */ |
||
228 | 10 | protected static function fetch(string $sql, array $params = []): array |
|
229 | { |
||
230 | 10 | $self = static::getInstance(); |
|
231 | 10 | return DbProxy::fetchObjects($sql, $params, $self->rowClass); |
|
232 | } |
||
233 | |||
234 | /** |
||
235 | * {@inheritdoc} |
||
236 | * |
||
237 | * @throws DbException |
||
238 | * @throws InvalidPrimaryKeyException if wrong count of values passed |
||
239 | * @throws InvalidArgumentException |
||
240 | */ |
||
241 | 10 | public static function find(...$keys): array |
|
242 | { |
||
243 | 10 | $keyNames = array_values(static::getInstance()->getPrimaryKey()); |
|
244 | 10 | $whereList = []; |
|
245 | |||
246 | 10 | foreach ($keys as $keyValues) { |
|
247 | 10 | $keyValues = (array)$keyValues; |
|
248 | 10 | if (count($keyValues) !== count($keyNames)) { |
|
249 | 2 | throw new InvalidPrimaryKeyException( |
|
250 | "Invalid columns for the primary key.\n" . |
||
251 | 2 | "Please check " . static::class . " initialization or usage.\n" . |
|
252 | 2 | "Settings described at https://github.com/bluzphp/framework/wiki/Db-Table" |
|
253 | ); |
||
254 | } |
||
255 | |||
256 | 8 | if (array_keys($keyValues)[0] === 0) { |
|
257 | // for numerical array |
||
258 | 7 | $whereList[] = array_combine($keyNames, $keyValues); |
|
259 | } else { |
||
260 | // for assoc array |
||
261 | 1 | $whereList[] = $keyValues; |
|
262 | } |
||
263 | } |
||
264 | 8 | return static::findWhere(...$whereList); |
|
265 | } |
||
266 | |||
267 | /** |
||
268 | * {@inheritdoc} |
||
269 | * |
||
270 | * @throws InvalidArgumentException |
||
271 | * @throws Exception\DbException |
||
272 | */ |
||
273 | 10 | public static function findWhere(...$where): array |
|
274 | { |
||
275 | 10 | $self = static::getInstance(); |
|
276 | |||
277 | 10 | $whereParams = []; |
|
278 | |||
279 | 10 | if (count($where) === 2 && is_string($where[0])) { |
|
280 | $whereClause = $where[0]; |
||
281 | $whereParams = (array)$where[1]; |
||
282 | 10 | } elseif (count($where)) { |
|
283 | 10 | $whereOrTerms = []; |
|
284 | 10 | foreach ($where as $keyValueSets) { |
|
285 | 10 | $whereAndTerms = []; |
|
286 | 10 | foreach ($keyValueSets as $keyName => $keyValue) { |
|
287 | 10 | if (is_array($keyValue)) { |
|
288 | $keyValue = array_map( |
||
289 | ['DbProxy', 'quote'], |
||
290 | $keyValue |
||
291 | ); |
||
292 | $keyValue = implode(',', $keyValue); |
||
293 | $whereAndTerms[] = $self->name . '.' . $keyName . ' IN (' . $keyValue . ')'; |
||
294 | 10 | } elseif (null === $keyValue) { |
|
295 | $whereAndTerms[] = $self->name . '.' . $keyName . ' IS NULL'; |
||
296 | } elseif ( |
||
297 | 10 | is_string($keyValue) |
|
298 | 10 | && ('%' === substr($keyValue, 0, 1) || '%' === substr($keyValue, -1, 1)) |
|
299 | ) { |
||
300 | $whereAndTerms[] = $self->name . '.' . $keyName . ' LIKE ?'; |
||
301 | $whereParams[] = $keyValue; |
||
302 | } else { |
||
303 | 10 | $whereAndTerms[] = $self->name . '.' . $keyName . ' = ?'; |
|
304 | 10 | $whereParams[] = $keyValue; |
|
305 | } |
||
306 | 10 | if (!is_scalar($keyValue) && !is_null($keyValue)) { |
|
307 | throw new InvalidArgumentException( |
||
308 | "Wrong arguments of method 'findWhere'.\n" . |
||
309 | 'Please use syntax described at https://github.com/bluzphp/framework/wiki/Db-Table' |
||
310 | ); |
||
311 | } |
||
312 | } |
||
313 | 10 | $whereOrTerms[] = '(' . implode(' AND ', $whereAndTerms) . ')'; |
|
314 | } |
||
315 | 10 | $whereClause = '(' . implode(' OR ', $whereOrTerms) . ')'; |
|
316 | } else { |
||
317 | throw new DbException( |
||
318 | "Method `Table::findWhere()` can't return all records from table" |
||
319 | ); |
||
320 | } |
||
321 | |||
322 | 10 | return self::fetch($self->select . ' WHERE ' . $whereClause, $whereParams); |
|
323 | } |
||
324 | |||
325 | /** |
||
326 | * {@inheritdoc} |
||
327 | * |
||
328 | * @throws DbException |
||
329 | * @throws InvalidArgumentException |
||
330 | * @throws InvalidPrimaryKeyException |
||
331 | */ |
||
332 | 8 | public static function findRow($primaryKey): ?RowInterface |
|
333 | { |
||
334 | 8 | $result = static::find($primaryKey); |
|
335 | 8 | return current($result) ?: null; |
|
336 | } |
||
337 | |||
338 | /** |
||
339 | * {@inheritdoc} |
||
340 | * |
||
341 | * @throws DbException |
||
342 | * @throws InvalidArgumentException |
||
343 | */ |
||
344 | 2 | public static function findRowWhere(array $whereList): ?RowInterface |
|
345 | { |
||
346 | 2 | $result = static::findWhere($whereList); |
|
347 | 2 | return current($result) ?: null; |
|
348 | } |
||
349 | |||
350 | /** |
||
351 | * Prepare array for WHERE or SET statements |
||
352 | * |
||
353 | * @param array $where |
||
354 | * |
||
355 | * @return array |
||
356 | */ |
||
357 | 6 | private static function prepareStatement(array $where): array |
|
358 | { |
||
359 | 6 | $keys = array_keys($where); |
|
360 | 6 | foreach ($keys as &$key) { |
|
361 | 6 | $key = DbProxy::quoteIdentifier($key) . ' = ?'; |
|
362 | } |
||
363 | 6 | return $keys; |
|
364 | } |
||
365 | |||
366 | /** |
||
367 | * Prepare Db\Query\Select for current table: |
||
368 | * - predefine "select" section as "*" from current table |
||
369 | * - predefine "from" section as current table name and first letter as alias |
||
370 | * - predefine fetch type |
||
371 | * |
||
372 | * <code> |
||
373 | * // use default select "*" |
||
374 | * $select = Users\Table::select(); |
||
375 | * $arrUsers = $select->where('u.id = ?', $id) |
||
376 | * ->execute(); |
||
377 | * |
||
378 | * // setup custom select "u.id, u.login" |
||
379 | * $select = Users\Table::select(); |
||
380 | * $arrUsers = $select->select('u.id, u.login') |
||
381 | * ->where('u.id = ?', $id) |
||
382 | * ->execute(); |
||
383 | * </code> |
||
384 | * |
||
385 | * @return Query\Select |
||
386 | */ |
||
387 | 2 | public static function select(): Query\Select |
|
388 | { |
||
389 | 2 | $self = static::getInstance(); |
|
390 | |||
391 | 2 | $select = new Query\Select(); |
|
392 | 2 | $select->select(DbProxy::quoteIdentifier($self->name) . '.*') |
|
393 | 2 | ->from($self->name, $self->name) |
|
394 | 2 | ->setFetchType($self->rowClass); |
|
395 | |||
396 | 2 | return $select; |
|
397 | } |
||
398 | |||
399 | /** |
||
400 | * {@inheritdoc} |
||
401 | */ |
||
402 | 2 | public static function create(array $data = []): RowInterface |
|
403 | { |
||
404 | 2 | $rowClass = static::getInstance()->rowClass; |
|
405 | /** @var Row $row */ |
||
406 | 2 | $row = new $rowClass($data); |
|
407 | 2 | $row->setTable(static::getInstance()); |
|
408 | 2 | return $row; |
|
409 | } |
||
410 | |||
411 | /** |
||
412 | * {@inheritdoc} |
||
413 | * |
||
414 | * @throws Exception\DbException |
||
415 | */ |
||
416 | 2 | public static function insert(array $data) |
|
417 | { |
||
418 | 2 | $self = static::getInstance(); |
|
419 | |||
420 | 2 | $data = static::filterColumns($data); |
|
421 | |||
422 | 2 | if (!count($data)) { |
|
423 | throw new DbException( |
||
424 | "Invalid field names of table `{$self->name}`. Please check use of `insert()` method" |
||
425 | ); |
||
426 | } |
||
427 | |||
428 | 2 | $table = DbProxy::quoteIdentifier($self->name); |
|
429 | |||
430 | 2 | $sql = "INSERT INTO $table SET " . implode(',', self::prepareStatement($data)); |
|
431 | 2 | $result = DbProxy::query($sql, array_values($data)); |
|
432 | 2 | if (!$result) { |
|
433 | return null; |
||
434 | } |
||
435 | |||
436 | /** |
||
437 | * If a sequence name was not specified for the name parameter, PDO::lastInsertId() |
||
438 | * returns a string representing the row ID of the last row that was inserted into the database. |
||
439 | * |
||
440 | * If a sequence name was specified for the name parameter, PDO::lastInsertId() |
||
441 | * returns a string representing the last value retrieved from the specified sequence object. |
||
442 | * |
||
443 | * If the PDO driver does not support this capability, PDO::lastInsertId() triggers an IM001 SQLSTATE. |
||
444 | */ |
||
445 | 2 | return DbProxy::handler()->lastInsertId($self->sequence); |
|
446 | } |
||
447 | |||
448 | /** |
||
449 | * {@inheritdoc} |
||
450 | * |
||
451 | * @throws Exception\DbException |
||
452 | */ |
||
453 | 2 | public static function update(array $data, array $where): int |
|
454 | { |
||
455 | 2 | $self = static::getInstance(); |
|
456 | |||
457 | 2 | $data = static::filterColumns($data); |
|
458 | |||
459 | 2 | if (!count($data)) { |
|
460 | throw new DbException( |
||
461 | "Invalid field names of table `{$self->name}`. Please check use of `update()` method" |
||
462 | ); |
||
463 | } |
||
464 | |||
465 | 2 | $where = static::filterColumns($where); |
|
466 | |||
467 | 2 | if (!count($where)) { |
|
468 | throw new DbException( |
||
469 | "Method `Table::update()` can't update all records in the table `{$self->name}`,\n" . |
||
470 | "please use `Db::query()` instead (of cause if you know what are you doing)" |
||
471 | ); |
||
472 | } |
||
473 | |||
474 | 2 | $table = DbProxy::quoteIdentifier($self->name); |
|
475 | |||
476 | 2 | $sql = "UPDATE $table SET " . implode(',', self::prepareStatement($data)) |
|
477 | 2 | . " WHERE " . implode(' AND ', self::prepareStatement($where)); |
|
478 | |||
479 | 2 | return DbProxy::query($sql, array_merge(array_values($data), array_values($where))); |
|
480 | } |
||
481 | |||
482 | /** |
||
483 | * {@inheritdoc} |
||
484 | * |
||
485 | * @throws DbException |
||
486 | */ |
||
487 | 2 | public static function delete(array $where): int |
|
488 | { |
||
489 | 2 | $self = static::getInstance(); |
|
490 | |||
491 | 2 | if (!count($where)) { |
|
492 | throw new DbException( |
||
493 | "Method `Table::delete()` can't delete all records in the table `{$self->name}`,\n" . |
||
494 | "please use `Db::query()` instead (of cause if you know what are you doing)" |
||
495 | ); |
||
496 | } |
||
497 | |||
498 | |||
499 | 2 | $where = static::filterColumns($where); |
|
500 | |||
501 | 2 | if (!count($where)) { |
|
502 | throw new DbException( |
||
503 | "Invalid field names of table `{$self->name}`. Please check use of `delete()` method" |
||
504 | ); |
||
505 | } |
||
506 | |||
507 | 2 | $table = DbProxy::quoteIdentifier($self->name); |
|
508 | |||
509 | 2 | $sql = "DELETE FROM $table WHERE " . implode(' AND ', self::prepareStatement($where)); |
|
510 | 2 | return DbProxy::query($sql, array_values($where)); |
|
511 | } |
||
512 | } |
||
513 |