1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data;
6:
7: use Atk4\Core\ContainerTrait;
8: use Atk4\Core\DiContainerTrait;
9: use Atk4\Core\DynamicMethodTrait;
10: use Atk4\Core\Factory;
11: use Atk4\Core\HookTrait;
12: use Atk4\Core\NameTrait;
13: use Doctrine\DBAL\Platforms;
14: use Doctrine\DBAL\Types\Type;
15:
16: abstract class Persistence
17: {
18: use ContainerTrait {
19: add as private _add;
20: }
21: use DiContainerTrait;
22: use DynamicMethodTrait;
23: use HookTrait;
24: use NameTrait;
25:
26: public const HOOK_AFTER_ADD = self::class . '@afterAdd';
27:
28: public const ID_LOAD_ONE = self::class . '@idLoadOne-qZ5TJwMVJ4LzVhuN';
29: public const ID_LOAD_ANY = self::class . '@idLoadAny-qZ5TJwMVJ4LzVhuN';
30:
31: /** @internal prevent recursion */
32: private bool $typecastSaveSkipNormalize = false;
33:
34: /**
35: * Connects database.
36: *
37: * @param string|array<string, string> $dsn Format as PDO DSN or use "mysql://user:pass@host/db;option=blah",
38: * leaving user and password arguments = null
39: * @param array<string, mixed> $defaults
40: */
41: public static function connect($dsn, string $user = null, string $password = null, array $defaults = []): self
42: {
43: // parse DSN string
44: $dsn = Persistence\Sql\Connection::normalizeDsn($dsn, $user, $password);
45:
46: switch ($dsn['driver']) {
47: case 'pdo_sqlite':
48: case 'pdo_mysql':
49: case 'mysqli':
50: case 'pdo_pgsql':
51: case 'pdo_sqlsrv':
52: case 'pdo_oci':
53: case 'oci8':
54: $persistence = new Persistence\Sql($dsn, $dsn['user'] ?? null, $dsn['password'] ?? null, $defaults);
55:
56: return $persistence;
57: default:
58: throw (new Exception('Unable to determine persistence driver type'))
59: ->addMoreInfo('dsn', $dsn);
60: }
61: }
62:
63: /**
64: * Disconnect from database explicitly.
65: */
66: public function disconnect(): void {}
67:
68: /**
69: * Associate model with the data driver.
70: *
71: * @param array<string, mixed> $defaults
72: */
73: public function add(Model $model, array $defaults = []): void
74: {
75: if ($model->issetPersistence() || $model->persistenceData !== []) {
76: throw new \Error('Persistence::add() cannot be called directly, use Model::setPersistence() instead');
77: }
78:
79: Factory::factory($model, $defaults);
80: $this->initPersistence($model);
81: $model->setPersistence($this);
82:
83: // invokes Model::init()
84: // model is not added to elements as it does not implement TrackableTrait trait
85: $this->_add($model);
86:
87: $this->hook(self::HOOK_AFTER_ADD, [$model]);
88: }
89:
90: /**
91: * Extend this method to enhance model to work with your persistence. Here
92: * you can define additional methods or store additional data. This method
93: * is executed before model's init().
94: */
95: protected function initPersistence(Model $m): void {}
96:
97: /**
98: * Atomic executes operations within one begin/end transaction. Not all
99: * persistencies will support atomic operations, so by default we just
100: * don't do anything.
101: *
102: * @template T
103: *
104: * @param \Closure(): T $fx
105: *
106: * @return T
107: */
108: public function atomic(\Closure $fx)
109: {
110: return $fx();
111: }
112:
113: public function getDatabasePlatform(): Platforms\AbstractPlatform
114: {
115: return new Persistence\GenericPlatform();
116: }
117:
118: /**
119: * Tries to load data record, but will not fail if record can't be loaded.
120: *
121: * @param mixed $id
122: *
123: * @return array<string, mixed>|null
124: */
125: public function tryLoad(Model $model, $id): ?array
126: {
127: throw new Exception('Load is not supported');
128: }
129:
130: /**
131: * Loads a record from model and returns a associative array.
132: *
133: * @param mixed $id
134: *
135: * @return array<string, mixed>
136: */
137: public function load(Model $model, $id): array
138: {
139: $model->assertIsModel();
140:
141: $data = $this->tryLoad($model, $id);
142:
143: if (!$data) {
144: $noId = $id === self::ID_LOAD_ONE || $id === self::ID_LOAD_ANY;
145:
146: throw (new Exception($noId ? 'No record was found' : 'Record with specified ID was not found'))
147: ->addMoreInfo('model', $model)
148: ->addMoreInfo('id', $noId ? null : $id)
149: ->addMoreInfo('scope', $model->scope()->toWords());
150: }
151:
152: return $data;
153: }
154:
155: /**
156: * Inserts record in database and returns new record ID.
157: *
158: * @param array<string, mixed> $data
159: *
160: * @return mixed
161: */
162: public function insert(Model $model, array $data)
163: {
164: $model->assertIsModel();
165:
166: if ($model->idField && array_key_exists($model->idField, $data) && $data[$model->idField] === null) {
167: unset($data[$model->idField]);
168: }
169:
170: $dataRaw = $this->typecastSaveRow($model, $data);
171: unset($data);
172:
173: if (is_object($model->table)) {
174: $innerInsertId = $model->table->insert($this->typecastLoadRow($model->table, $dataRaw));
175: if (!$model->idField) {
176: return false;
177: }
178:
179: $idField = $model->getField($model->idField);
180: $insertId = $this->typecastLoadField(
181: $idField,
182: $idField->getPersistenceName() === $model->table->idField
183: ? $this->typecastSaveField($model->table->getField($model->table->idField), $innerInsertId)
184: : $dataRaw[$idField->getPersistenceName()]
185: );
186:
187: return $insertId;
188: }
189:
190: $idRaw = $this->insertRaw($model, $dataRaw);
191: if (!$model->idField) {
192: return false;
193: }
194:
195: $id = $this->typecastLoadField($model->getField($model->idField), $idRaw);
196:
197: return $id;
198: }
199:
200: /**
201: * @param array<scalar|null> $dataRaw
202: *
203: * @return mixed
204: */
205: protected function insertRaw(Model $model, array $dataRaw)
206: {
207: throw new Exception('Insert is not supported');
208: }
209:
210: /**
211: * Updates record in database.
212: *
213: * @param mixed $id
214: * @param array<string, mixed> $data
215: */
216: public function update(Model $model, $id, array $data): void
217: {
218: $model->assertIsModel();
219:
220: $idRaw = $model->idField ? $this->typecastSaveField($model->getField($model->idField), $id) : null;
221: unset($id);
222: if ($idRaw === null || (array_key_exists($model->idField, $data) && $data[$model->idField] === null)) {
223: throw new Exception('Unable to update record: Model idField is not set');
224: }
225:
226: $dataRaw = $this->typecastSaveRow($model, $data);
227: unset($data);
228:
229: if (count($dataRaw) === 0) {
230: return;
231: }
232:
233: if (is_object($model->table)) {
234: $idPersistenceName = $model->getField($model->idField)->getPersistenceName();
235: $innerId = $this->typecastLoadField($model->table->getField($idPersistenceName), $idRaw);
236: $innerEntity = $model->table->loadBy($idPersistenceName, $innerId);
237:
238: $innerEntity->saveAndUnload($this->typecastLoadRow($model->table, $dataRaw));
239:
240: return;
241: }
242:
243: $this->updateRaw($model, $idRaw, $dataRaw);
244: }
245:
246: /**
247: * @param mixed $idRaw
248: * @param array<scalar|null> $dataRaw
249: */
250: protected function updateRaw(Model $model, $idRaw, array $dataRaw): void
251: {
252: throw new Exception('Update is not supported');
253: }
254:
255: /**
256: * Deletes record from database.
257: *
258: * @param mixed $id
259: */
260: public function delete(Model $model, $id): void
261: {
262: $model->assertIsModel();
263:
264: $idRaw = $model->idField ? $this->typecastSaveField($model->getField($model->idField), $id) : null;
265: unset($id);
266: if ($idRaw === null) {
267: throw new Exception('Unable to delete record: Model idField is not set');
268: }
269:
270: if (is_object($model->table)) {
271: $idPersistenceName = $model->getField($model->idField)->getPersistenceName();
272: $innerId = $this->typecastLoadField($model->table->getField($idPersistenceName), $idRaw);
273: $innerEntity = $model->table->loadBy($idPersistenceName, $innerId);
274:
275: $innerEntity->delete();
276:
277: return;
278: }
279:
280: $this->deleteRaw($model, $idRaw);
281: }
282:
283: /**
284: * @param mixed $idRaw
285: */
286: protected function deleteRaw(Model $model, $idRaw): void
287: {
288: throw new Exception('Delete is not supported');
289: }
290:
291: /**
292: * Will convert one row of data from native PHP types into
293: * persistence types. This will also take care of the "actual"
294: * field keys.
295: *
296: * @param array<string, mixed> $row
297: *
298: * @return array<scalar|Persistence\Sql\Expressionable|null>
299: */
300: public function typecastSaveRow(Model $model, array $row): array
301: {
302: $result = [];
303: foreach ($row as $fieldName => $value) {
304: $field = $model->getField($fieldName);
305:
306: $result[$field->getPersistenceName()] = $this->typecastSaveField($field, $value);
307: }
308:
309: return $result;
310: }
311:
312: /**
313: * Will convert one row of data from Persistence-specific
314: * types to PHP native types.
315: *
316: * NOTE: Please DO NOT perform "actual" field mapping here, because data
317: * may be "aliased" from SQL persistencies or mapped depending on persistence
318: * driver.
319: *
320: * @param array<string, scalar|null> $row
321: *
322: * @return array<string, mixed>
323: */
324: public function typecastLoadRow(Model $model, array $row): array
325: {
326: $result = [];
327: foreach ($row as $fieldName => $value) {
328: $field = $model->getField($fieldName);
329:
330: $result[$fieldName] = $this->typecastLoadField($field, $value);
331: }
332:
333: return $result;
334: }
335:
336: /**
337: * @param mixed $value
338: *
339: * @return ($value is scalar ? scalar : mixed)
340: */
341: private function _typecastPreField(Field $field, $value, bool $fromLoad)
342: {
343: if (is_string($value)) {
344: switch ($field->type) {
345: case 'boolean':
346: case 'integer':
347: $value = preg_replace('~\s+|,~', '', $value);
348:
349: break;
350: case 'float':
351: case 'decimal':
352: case 'atk4_money':
353: $value = preg_replace('~\s+|,(?=.*\.)~', '', $value);
354:
355: break;
356: }
357:
358: switch ($field->type) {
359: case 'boolean':
360: case 'integer':
361: case 'float':
362: case 'decimal':
363: case 'atk4_money':
364: if ($value === '') {
365: $value = null;
366: } elseif (!is_numeric($value)) {
367: throw new Exception('Must be numeric');
368: }
369:
370: break;
371: }
372: } elseif ($value !== null) {
373: switch ($field->type) {
374: case 'string':
375: case 'text':
376: case 'integer':
377: case 'float':
378: case 'decimal':
379: case 'atk4_money':
380: if (is_bool($value)) {
381: throw new Exception('Must not be bool type');
382: } elseif (is_int($value)) {
383: if ($fromLoad) {
384: $value = (string) $value;
385: }
386: } elseif (is_float($value)) {
387: if ($fromLoad) {
388: $value = Persistence\Sql\Expression::castFloatToString($value);
389: }
390: } else {
391: throw new Exception('Must be scalar');
392: }
393:
394: break;
395: }
396: }
397:
398: return $value;
399: }
400:
401: /**
402: * Prepare value of a specific field by converting it to
403: * persistence-friendly format.
404: *
405: * @param mixed $value
406: *
407: * @return scalar|Persistence\Sql\Expressionable|null
408: */
409: public function typecastSaveField(Field $field, $value)
410: {
411: // SQL Expression cannot be converted
412: if ($value instanceof Persistence\Sql\Expressionable) {
413: return $value;
414: }
415:
416: if (!$this->typecastSaveSkipNormalize) {
417: $value = $field->normalize($value);
418: }
419:
420: if ($value === null) {
421: return null;
422: }
423:
424: try {
425: $v = $this->_typecastSaveField($field, $value);
426: if ($v !== null && !is_scalar($v)) { // @phpstan-ignore-line
427: throw new \TypeError('Unexpected non-scalar value');
428: }
429:
430: return $v;
431: } catch (\Exception $e) {
432: if ($e instanceof \ErrorException) {
433: throw $e;
434: }
435:
436: throw (new Exception('Typecast save error', 0, $e))
437: ->addMoreInfo('field', $field->shortName);
438: }
439: }
440:
441: /**
442: * Cast specific field value from the way how it's stored inside
443: * persistence to a PHP format.
444: *
445: * @param scalar|null $value
446: *
447: * @return mixed
448: */
449: public function typecastLoadField(Field $field, $value)
450: {
451: if ($value === null) {
452: return null;
453: } elseif (!is_scalar($value)) { // @phpstan-ignore-line
454: throw new \TypeError('Unexpected non-scalar value');
455: }
456:
457: try {
458: return $this->_typecastLoadField($field, $value);
459: } catch (\Exception $e) {
460: if ($e instanceof \ErrorException) {
461: throw $e;
462: }
463:
464: throw (new Exception('Typecast parse error', 0, $e))
465: ->addMoreInfo('field', $field->shortName);
466: }
467: }
468:
469: /**
470: * This is the actual field typecasting, which you can override in your
471: * persistence to implement necessary typecasting.
472: *
473: * @param mixed $value
474: *
475: * @return scalar|null
476: */
477: protected function _typecastSaveField(Field $field, $value)
478: {
479: $value = $this->_typecastPreField($field, $value, false);
480:
481: if (in_array($field->type, ['json', 'object'], true) && $value === '') { // TODO remove later
482: return null;
483: }
484:
485: // native DBAL DT types have no microseconds support
486: if (in_array($field->type, ['datetime', 'date', 'time'], true)
487: && str_starts_with(get_class(Type::getType($field->type)), 'Doctrine\DBAL\Types\\')) {
488: if ($value === '') {
489: return null;
490: } elseif (!$value instanceof \DateTimeInterface) {
491: throw new Exception('Must be instance of DateTimeInterface');
492: }
493:
494: if ($field->type === 'datetime') {
495: $value = new \DateTime($value->format('Y-m-d H:i:s.u'), $value->getTimezone());
496: $value->setTimezone(new \DateTimeZone('UTC'));
497: }
498:
499: $format = ['date' => 'Y-m-d', 'datetime' => 'Y-m-d H:i:s.u', 'time' => 'H:i:s.u'][$field->type];
500: $value = $value->format($format);
501:
502: return $value;
503: }
504:
505: $res = Type::getType($field->type)->convertToDatabaseValue($value, $this->getDatabasePlatform());
506: if (is_resource($res) && get_resource_type($res) === 'stream') {
507: $res = stream_get_contents($res);
508: }
509:
510: return $res;
511: }
512:
513: /**
514: * This is the actual field typecasting, which you can override in your
515: * persistence to implement necessary typecasting.
516: *
517: * @param scalar $value
518: *
519: * @return mixed
520: */
521: protected function _typecastLoadField(Field $field, $value)
522: {
523: $value = $this->_typecastPreField($field, $value, true);
524:
525: // TODO casting optionally to null should be handled by type itself solely
526: if ($value === '' && in_array($field->type, ['boolean', 'integer', 'float', 'decimal', 'datetime', 'date', 'time', 'json', 'object'], true)) {
527: return null;
528: }
529:
530: // native DBAL DT types have no microseconds support
531: if (in_array($field->type, ['datetime', 'date', 'time'], true)
532: && str_starts_with(get_class(Type::getType($field->type)), 'Doctrine\DBAL\Types\\')) {
533: $format = ['date' => 'Y-m-d', 'datetime' => 'Y-m-d H:i:s', 'time' => 'H:i:s'][$field->type];
534: if (str_contains($value, '.')) { // time possibly with microseconds, otherwise invalid format
535: $format = preg_replace('~(?<=H:i:s)(?![. ]*u)~', '.u', $format);
536: }
537:
538: $valueOrig = $value;
539: $value = \DateTime::createFromFormat('!' . $format, $value, new \DateTimeZone('UTC'));
540: if ($value === false) {
541: throw (new Exception('Incorrectly formatted datetime'))
542: ->addMoreInfo('format', $format)
543: ->addMoreInfo('value', $valueOrig)
544: ->addMoreInfo('field', $field);
545: }
546:
547: if ($field->type === 'datetime') {
548: $value->setTimezone(new \DateTimeZone(date_default_timezone_get()));
549: }
550:
551: return $value;
552: }
553:
554: $res = Type::getType($field->type)->convertToPHPValue($value, $this->getDatabasePlatform());
555: if (is_resource($res) && get_resource_type($res) === 'stream') {
556: $res = stream_get_contents($res);
557: }
558:
559: return $res;
560: }
561: }
562: