1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Persistence;
6:
7: use Atk4\Data\Exception;
8: use Atk4\Data\Field;
9: use Atk4\Data\Model;
10: use Atk4\Data\Persistence;
11: use Atk4\Data\Persistence\Array_\Action;
12: use Atk4\Data\Persistence\Array_\Action\RenameColumnIterator;
13: use Atk4\Data\Persistence\Array_\Db\Row;
14: use Atk4\Data\Persistence\Array_\Db\Table;
15:
16: /**
17: * Implements persistence driver that can save data into array and load
18: * from array. This basic driver only offers the load/save support based
19: * around ID, you can't use conditions, order or limit.
20: */
21: class Array_ extends Persistence
22: {
23: /** @var array<string, array<int|string, mixed>> */
24: private $seedData;
25:
26: /** @var array<string, Table> */
27: private $data;
28:
29: /** @var array<string, int> */
30: protected $maxSeenIdByTable = [];
31:
32: /** @var array<string, int|string> */
33: protected $lastInsertIdByTable = [];
34:
35: /** @var string */
36: protected $lastInsertIdTable;
37:
38: /**
39: * @param array<int|string, mixed> $data
40: */
41: public function __construct(array $data = [])
42: {
43: $this->seedData = $data;
44:
45: // if there is no model table specified, then create fake one named 'data'
46: // and put all persistence data in there 1/2
47: if (count($this->seedData) > 0 && !isset($this->seedData['data'])) {
48: $rowSample = reset($this->seedData);
49: if (is_array($rowSample) && $rowSample !== [] && !is_array(reset($rowSample))) {
50: $this->seedData = ['data' => $this->seedData];
51: }
52: }
53: }
54:
55: private function seedData(Model $model): void
56: {
57: $tableName = $model->table;
58: if (isset($this->data[$tableName])) {
59: return;
60: }
61:
62: $this->data[$tableName] = new Table($tableName);
63:
64: if (isset($this->seedData[$tableName])) {
65: $rows = $this->seedData[$tableName];
66: unset($this->seedData[$tableName]);
67:
68: foreach ($rows as $id => $row) {
69: $this->saveRow($model, $row, $id);
70: }
71: }
72:
73: // for array persistence join which accept table directly (without model initialization)
74: foreach ($model->getFields() as $field) {
75: if ($field->hasJoin()) {
76: $join = $field->getJoin();
77: $joinTableName = \Closure::bind(static function () use ($join) {
78: return $join->foreignTable;
79: }, null, Array_\Join::class)();
80: if (isset($this->seedData[$joinTableName])) {
81: $dummyJoinModel = new Model($this, ['table' => $joinTableName]);
82: $dummyJoinModel->setPersistence($this);
83: }
84: }
85: }
86: }
87:
88: private function seedDataAndGetTable(Model $model): Table
89: {
90: $this->seedData($model);
91:
92: return $this->data[$model->table];
93: }
94:
95: /**
96: * @return array<mixed, array<string, mixed>>
97: *
98: * @deprecated TODO temporary for these:
99: * - https://github.com/atk4/data/blob/90ab68ac063b8fc2c72dcd66115f1bd3f70a3a92/src/Reference/ContainsOne.php#L119
100: * - https://github.com/atk4/data/blob/90ab68ac063b8fc2c72dcd66115f1bd3f70a3a92/src/Reference/ContainsMany.php#L66
101: * remove once fixed/no longer needed
102: */
103: public function getRawDataByTable(Model $model, string $table): array
104: {
105: $model->assertIsModel();
106:
107: if (!is_object($model->table)) {
108: $this->seedData($model);
109: }
110:
111: $rows = [];
112: foreach ($this->data[$table]->getRows() as $row) {
113: $rows[$row->getValue($model->idField)] = $row->getData();
114: }
115:
116: return $rows;
117: }
118:
119: /**
120: * @param int|string|null $idFromRow
121: * @param int|string $id
122: */
123: private function assertNoIdMismatch(Model $model, $idFromRow, $id): void
124: {
125: if ($idFromRow !== null && !$model->getField($model->idField)->compare($idFromRow, $id)) {
126: throw (new Exception('Row contains ID column, but it does not match the row ID'))
127: ->addMoreInfo('idFromKey', $id)
128: ->addMoreInfo('idFromData', $idFromRow);
129: }
130: }
131:
132: /**
133: * @param array<string, mixed> $rowData
134: * @param mixed $id
135: */
136: private function saveRow(Model $model, array $rowData, $id): void
137: {
138: if ($model->idField) {
139: $idField = $model->getField($model->idField);
140: $id = $idField->normalize($id);
141: $idColumnName = $idField->getPersistenceName();
142: if (array_key_exists($idColumnName, $rowData)) {
143: $this->assertNoIdMismatch($model, $rowData[$idColumnName], $id);
144: unset($rowData[$idColumnName]);
145: }
146:
147: $rowData = [$idColumnName => $id] + $rowData;
148: }
149:
150: if ($id > ($this->maxSeenIdByTable[$model->table] ?? 0)) {
151: $this->maxSeenIdByTable[$model->table] = $id;
152: }
153:
154: $table = $this->data[$model->table];
155:
156: $row = $table->getRowById($model, $id);
157: if ($row !== null) {
158: foreach (array_keys($rowData) as $columnName) {
159: if (!$table->hasColumnName($columnName)) {
160: $table->addColumnName($columnName);
161: }
162: }
163: $row->updateValues($rowData);
164: } else {
165: $row = $table->addRow(Row::class, $rowData);
166: }
167: }
168:
169: #[\Override]
170: public function add(Model $model, array $defaults = []): void
171: {
172: $defaults = array_merge([
173: '_defaultSeedJoin' => [Array_\Join::class],
174: ], $defaults);
175:
176: parent::add($model, $defaults);
177:
178: // if there is no model table specified, then create fake one named 'data'
179: // and put all persistence data in there 2/2
180: if (!$model->table) {
181: $model->table = 'data';
182: }
183:
184: if (!is_object($model->table)) {
185: $this->seedData($model);
186: }
187: }
188:
189: /**
190: * @return array<string, string>
191: */
192: private function getPersistenceNameToNameMap(Model $model): array
193: {
194: return array_flip(array_map(static fn (Field $f) => $f->getPersistenceName(), $model->getFields()));
195: }
196:
197: /**
198: * @param array<string, mixed> $rowDataRaw
199: *
200: * @return array<string, mixed>
201: */
202: private function filterRowDataOnlyModelFields(Model $model, array $rowDataRaw): array
203: {
204: return array_intersect_key($rowDataRaw, $this->getPersistenceNameToNameMap($model));
205: }
206:
207: /**
208: * @param array<string, mixed> $row
209: *
210: * @return array<string, mixed>
211: */
212: private function remapLoadRow(Model $model, array $row): array
213: {
214: $rowRemapped = [];
215: $map = $this->getPersistenceNameToNameMap($model);
216: foreach ($row as $k => $v) {
217: $rowRemapped[$map[$k]] = $v;
218: }
219:
220: return $rowRemapped;
221: }
222:
223: #[\Override]
224: public function tryLoad(Model $model, $id): ?array
225: {
226: $model->assertIsModel();
227:
228: if ($id === self::ID_LOAD_ONE || $id === self::ID_LOAD_ANY) {
229: $action = $this->action($model, 'select');
230:
231: $action->limit($id === self::ID_LOAD_ANY ? 1 : 2);
232:
233: $rowsRaw = $action->getRows();
234: if (count($rowsRaw) === 0) {
235: return null;
236: } elseif (count($rowsRaw) !== 1) {
237: throw (new Exception('Ambiguous conditions, more than one record can be loaded'))
238: ->addMoreInfo('model', $model)
239: ->addMoreInfo('id', null);
240: }
241:
242: $idRaw = reset($rowsRaw)[$model->idField];
243:
244: $row = $this->tryLoad($model, $idRaw);
245:
246: return $row;
247: }
248:
249: if (is_object($model->table)) {
250: $action = $this->action($model, 'select');
251: $condition = new Model\Scope\Condition('', $id);
252: $condition->key = $model->getField($model->idField);
253: $condition->setOwner($model->createEntity()); // TODO needed for typecasting to apply
254: $action->filter($condition);
255:
256: $rowData = $action->getRow();
257: if ($rowData === null) {
258: return null;
259: }
260: } else {
261: $table = $this->seedDataAndGetTable($model);
262:
263: $row = $table->getRowById($model, $id);
264: if ($row === null) {
265: return null;
266: }
267:
268: $rowData = $this->remapLoadRow($model, $this->filterRowDataOnlyModelFields($model, $row->getData()));
269: }
270:
271: return $this->typecastLoadRow($model, $rowData);
272: }
273:
274: #[\Override]
275: protected function insertRaw(Model $model, array $dataRaw)
276: {
277: $this->seedData($model);
278:
279: $idRaw = $dataRaw[$model->idField] ?? $this->generateNewId($model);
280:
281: $this->saveRow($model, $dataRaw, $idRaw);
282:
283: return $idRaw;
284: }
285:
286: #[\Override]
287: protected function updateRaw(Model $model, $idRaw, array $dataRaw): void
288: {
289: $table = $this->seedDataAndGetTable($model);
290:
291: $this->saveRow($model, array_merge($this->filterRowDataOnlyModelFields($model, $table->getRowById($model, $idRaw)->getData()), $dataRaw), $idRaw);
292: }
293:
294: #[\Override]
295: protected function deleteRaw(Model $model, $idRaw): void
296: {
297: $table = $this->seedDataAndGetTable($model);
298:
299: $table->deleteRow($table->getRowById($model, $idRaw));
300: }
301:
302: /**
303: * Generates new record ID.
304: *
305: * @return string
306: */
307: public function generateNewId(Model $model)
308: {
309: $this->seedData($model);
310:
311: $type = $model->idField ? $model->getField($model->idField)->type : 'integer';
312:
313: switch ($type) {
314: case 'integer':
315: $nextId = ($this->maxSeenIdByTable[$model->table] ?? 0) + 1;
316: $this->maxSeenIdByTable[$model->table] = $nextId;
317:
318: break;
319: case 'string':
320: $nextId = uniqid();
321:
322: break;
323: default:
324: throw (new Exception('Unsupported id field type. Array supports type=integer or type=string only'))
325: ->addMoreInfo('type', $type);
326: }
327:
328: $this->lastInsertIdByTable[$model->table] = $nextId;
329: $this->lastInsertIdTable = $model->table;
330:
331: return $nextId;
332: }
333:
334: /**
335: * Last ID inserted.
336: *
337: * @return mixed
338: */
339: public function lastInsertId(Model $model = null)
340: {
341: if ($model) {
342: return $this->lastInsertIdByTable[$model->table] ?? null;
343: }
344:
345: return $this->lastInsertIdByTable[$this->lastInsertIdTable] ?? null;
346: }
347:
348: /**
349: * @return \Traversable<array<string, mixed>>
350: */
351: public function prepareIterator(Model $model): \Traversable
352: {
353: return $model->action('select')->generator; // @phpstan-ignore-line
354: }
355:
356: /**
357: * Export all DataSet.
358: *
359: * @param array<int, string>|null $fields
360: *
361: * @return array<int, array<string, mixed>>
362: */
363: public function export(Model $model, array $fields = null, bool $typecast = true): array
364: {
365: $data = $model->action('select', [$fields])->getRows();
366:
367: if ($typecast) {
368: $data = array_map(function (array $row) use ($model) {
369: return $this->typecastLoadRow($model, $row);
370: }, $data);
371: }
372:
373: return $data;
374: }
375:
376: /**
377: * Typecast data and return Action of data array.
378: *
379: * @param array<int, string>|null $fields
380: */
381: public function initAction(Model $model, array $fields = null): Action
382: {
383: if (is_object($model->table)) {
384: $tableAction = $this->action($model->table, 'select');
385:
386: $rows = $tableAction->getRows();
387: } else {
388: $table = $this->seedDataAndGetTable($model);
389:
390: $rows = [];
391: foreach ($table->getRows() as $row) {
392: $rows[$row->getValue($model->getField($model->idField)->getPersistenceName())] = $row->getData();
393: }
394: }
395:
396: foreach ($rows as $rowIndex => $row) {
397: $rows[$rowIndex] = $this->remapLoadRow($model, $this->filterRowDataOnlyModelFields($model, $row));
398: }
399:
400: if ($fields !== null) {
401: $rows = array_map(static function (array $row) use ($fields) {
402: return array_intersect_key($row, array_flip($fields));
403: }, $rows);
404: }
405:
406: return new Action($rows);
407: }
408:
409: /**
410: * Will set limit defined inside $model onto Action.
411: */
412: protected function setLimitOrder(Model $model, Action $action): void
413: {
414: // first order by
415: if (count($model->order) > 0) {
416: $action->order($model->order);
417: }
418:
419: // then set limit
420: if ($model->limit[0] !== null || $model->limit[1] !== 0) {
421: $action->limit($model->limit[0] ?? \PHP_INT_MAX, $model->limit[1]);
422: }
423: }
424:
425: /**
426: * Will apply conditions defined inside $model onto Action.
427: */
428: protected function applyScope(Model $model, Action $action): void
429: {
430: $scope = $model->getModel(true)->scope();
431:
432: // add entity ID to scope to allow easy traversal
433: if ($model->isEntity() && $model->idField && $model->getId() !== null) {
434: $scope = new Model\Scope([$scope]);
435: $scope->addCondition($model->getField($model->idField), $model->getId());
436: }
437:
438: $action->filter($scope);
439: }
440:
441: /**
442: * Various actions possible here, mostly for compatibility with SQLs.
443: *
444: * @param array<mixed> $args
445: *
446: * @return Action
447: */
448: public function action(Model $model, string $type, array $args = [])
449: {
450: switch ($type) {
451: case 'select':
452: $action = $this->initAction($model, $args[0] ?? null);
453: $this->applyScope($model, $action);
454: $this->setLimitOrder($model, $action);
455:
456: return $action;
457: case 'count':
458: $action = $this->initAction($model, $args[0] ?? null);
459: $this->applyScope($model, $action);
460: $this->setLimitOrder($model, $action);
461:
462: return $action->count();
463: case 'exists':
464: $action = $this->initAction($model, $args[0] ?? null);
465: $this->applyScope($model, $action);
466:
467: return $action->exists();
468: case 'field':
469: if (!isset($args[0])) {
470: throw (new Exception('This action requires one argument with field name'))
471: ->addMoreInfo('action', $type);
472: }
473:
474: $field = is_string($args[0]) ? $args[0] : $args[0][0];
475:
476: $action = $this->initAction($model, [$field]);
477: $this->applyScope($model, $action);
478: $this->setLimitOrder($model, $action);
479:
480: if (isset($args['alias'])) {
481: $action->generator = new RenameColumnIterator($action->generator, $field, $args['alias']);
482: }
483:
484: return $action;
485: case 'fx':
486: case 'fx0':
487: if (!isset($args[0]) || !isset($args[1])) {
488: throw (new Exception('fx action needs 2 arguments, eg: [\'sum\', \'amount\']'))
489: ->addMoreInfo('action', $type);
490: }
491:
492: [$fx, $field] = $args;
493:
494: $action = $this->initAction($model, [$field]);
495: $this->applyScope($model, $action);
496: $this->setLimitOrder($model, $action);
497:
498: return $action->aggregate($fx, $field, $type === 'fx0');
499: default:
500: throw (new Exception('Unsupported action mode'))
501: ->addMoreInfo('type', $type);
502: }
503: }
504: }
505: