From 277c60707e5ae024842fda501abb08bd4fa20d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Sun, 16 Jan 2022 13:32:32 +0100 Subject: [PATCH] Add support for Model nesting for Array persistence (#961) --- src/Persistence/Array_.php | 76 ++++-- .../Array_/Action/RenameColumnIterator.php | 9 +- src/Persistence/Array_/Db/Table.php | 6 +- src/Persistence/Array_/Join.php | 10 +- src/Persistence/Sql/Join.php | 7 +- src/Persistence/Static_.php | 2 +- src/Reference/HasOne.php | 2 +- src/Schema/TestCase.php | 29 +-- tests/ModelNestedArrayTest.php | 234 ++++++++++++++++++ ...lNestedTest.php => ModelNestedSqlTest.php} | 18 +- tests/Persistence/ArrayTest.php | 5 +- 11 files changed, 352 insertions(+), 46 deletions(-) create mode 100644 tests/ModelNestedArrayTest.php rename tests/{ModelNestedTest.php => ModelNestedSqlTest.php} (96%) diff --git a/src/Persistence/Array_.php b/src/Persistence/Array_.php index cda1734a7..697fe8f07 100644 --- a/src/Persistence/Array_.php +++ b/src/Persistence/Array_.php @@ -97,7 +97,9 @@ private function seedDataAndGetTable(Model $model): Table */ public function getRawDataByTable(Model $model, string $table): array { - $this->seedData($model); + if (!is_object($model->table)) { + $this->seedData($model); + } $rows = []; foreach ($this->data[$table]->getRows() as $row) { @@ -182,18 +184,34 @@ public function add(Model $model, array $defaults = []): void } } - $this->seedData($model); + if (!is_object($model->table)) { + $this->seedData($model); + } } - private function filterRowDataOnlyModelFields(Model $model, array $rowData): array + private function getPersistenceNameToNameMap(Model $model): array { - return array_intersect_key($rowData, array_map(fn (Field $f) => $f->short_name, $model->getFields())); + return array_flip(array_map(fn (Field $f) => $f->getPersistenceName(), $model->getFields())); } - public function tryLoad(Model $model, $id): ?array + private function filterRowDataOnlyModelFields(Model $model, array $rowDataRaw): array { - $table = $this->seedDataAndGetTable($model); + return array_intersect_key($rowDataRaw, $this->getPersistenceNameToNameMap($model)); + } + private function remapLoadRow(Model $model, array $row): array + { + $rowRemapped = []; + $map = $this->getPersistenceNameToNameMap($model); + foreach ($row as $k => $v) { + $rowRemapped[$map[$k]] = $v; + } + + return $rowRemapped; + } + + public function tryLoad(Model $model, $id): ?array + { if ($id === self::ID_LOAD_ONE || $id === self::ID_LOAD_ANY) { $action = $this->action($model, 'select'); $action->generator->rewind(); // TODO needed for some reasons! @@ -214,12 +232,30 @@ public function tryLoad(Model $model, $id): ?array return $row; } - $row = $table->getRowById($model, $id); - if ($row === null) { - return null; + if (is_object($model->table)) { + $action = $this->action($model, 'select'); + $condition = new Model\Scope\Condition('', $id); + $condition->key = $model->getField($model->id_field); + $condition->setOwner($model->createEntity()); // TODO needed for typecasting to apply + $action->filter($condition); + $action->generator->rewind(); // TODO needed for some reasons! + + $rowData = $action->getRow(); + if ($rowData === null) { + return null; + } + } else { + $table = $this->seedDataAndGetTable($model); + + $row = $table->getRowById($model, $id); + if ($row === null) { + return null; + } + + $rowData = $this->remapLoadRow($model, $this->filterRowDataOnlyModelFields($model, $row->getData())); } - return $this->typecastLoadRow($model, $this->filterRowDataOnlyModelFields($model, $row->getData())); + return $this->typecastLoadRow($model, $rowData); } protected function insertRaw(Model $model, array $dataRaw) @@ -319,11 +355,21 @@ public function export(Model $model, array $fields = null, bool $typecast = true */ public function initAction(Model $model, array $fields = null): Action { - $table = $this->seedDataAndGetTable($model); + if (is_object($model->table)) { + $tableAction = $this->action($model->table, 'select'); - $rows = []; - foreach ($table->getRows() as $row) { - $rows[$row->getValue($model->id_field)] = $this->filterRowDataOnlyModelFields($model, $row->getData()); + $rows = $tableAction->getRows(); + } else { + $table = $this->seedDataAndGetTable($model); + + $rows = []; + foreach ($table->getRows() as $row) { + $rows[$row->getValue($model->getField($model->id_field)->getPersistenceName())] = $row->getData(); + } + } + + foreach ($rows as $rowIndex => $row) { + $rows[$rowIndex] = $this->remapLoadRow($model, $this->filterRowDataOnlyModelFields($model, $row)); } if ($fields !== null) { @@ -374,8 +420,6 @@ protected function applyScope(Model $model, Action $action): void */ public function action(Model $model, string $type, array $args = []) { - $args = (array) $args; - switch ($type) { case 'select': $action = $this->initAction($model, $args[0] ?? null); diff --git a/src/Persistence/Array_/Action/RenameColumnIterator.php b/src/Persistence/Array_/Action/RenameColumnIterator.php index d42ec2591..604e5be52 100644 --- a/src/Persistence/Array_/Action/RenameColumnIterator.php +++ b/src/Persistence/Array_/Action/RenameColumnIterator.php @@ -4,6 +4,8 @@ namespace Atk4\Data\Persistence\Array_\Action; +use Atk4\Data\Exception; + /** * @internal * @@ -32,7 +34,12 @@ public function current(): array $row = parent::current(); $keys = array_keys($row); - $keys[array_search($this->origName, $keys, true)] = $this->newName; + $index = array_search($this->origName, $keys, true); + if ($index === false) { + throw (new Exception('Column not found')) + ->addMoreInfo('orig_name', $this->origName); + } + $keys[$index] = $this->newName; return array_combine($keys, $row); } diff --git a/src/Persistence/Array_/Db/Table.php b/src/Persistence/Array_/Db/Table.php index 8bf0632d7..49107073c 100644 --- a/src/Persistence/Array_/Db/Table.php +++ b/src/Persistence/Array_/Db/Table.php @@ -181,12 +181,12 @@ protected function beforeValuesSet(Row $childRow, $newRowData): void /** * TODO rewrite with hash index support. * - * @param mixed $id + * @param mixed $idRaw */ - public function getRowById(\Atk4\Data\Model $model, $id): ?Row + public function getRowById(\Atk4\Data\Model $model, $idRaw): ?Row { foreach ($this->getRows() as $row) { - if ($row->getValue($model->id_field) === $id) { + if ($row->getValue($model->getField($model->id_field)->getPersistenceName()) === $idRaw) { return $row; } } diff --git a/src/Persistence/Array_/Join.php b/src/Persistence/Array_/Join.php index 4d2fcd3db..900d1b201 100644 --- a/src/Persistence/Array_/Join.php +++ b/src/Persistence/Array_/Join.php @@ -38,9 +38,17 @@ protected function makeFakeModelWithForeignTable(): Model $this->getOwner()->assertIsModel(); $modelCloned = clone $this->getOwner(); + foreach ($modelCloned->getFields() as $field) { + if ($field->hasJoin() && $field->getJoin()->foreign_table === $this->foreign_table) { + \Closure::bind(fn () => $field->joinName = null, null, \Atk4\Data\Field::class)(); + } else { + $modelCloned->removeField($field->short_name); + } + } + $modelCloned->addField($this->id_field, ['type' => 'integer']); $modelCloned->table = $this->foreign_table; - // @TODO hooks will be fixed on a cloned model, Join should be replaced later by supporting unioned table as a table model + // @TODO hooks will be fixed on a cloned model, foreign_table string name should be replaced with object model return $modelCloned; } diff --git a/src/Persistence/Sql/Join.php b/src/Persistence/Sql/Join.php index fdb066f42..8a0ed7e53 100644 --- a/src/Persistence/Sql/Join.php +++ b/src/Persistence/Sql/Join.php @@ -168,7 +168,12 @@ public function afterInsert(Model $entity): void $query->setMulti($model->persistence->typecastSaveRow($model, $this->getAndUnsetSaveBuffer($entity))); $query->set($this->foreign_field, $this->hasJoin() ? $this->getJoin()->getId($entity) : $entity->getId()); $query->mode('insert')->execute(); // TODO IMPORTANT migrate to Model insert - $this->setId($entity, $model->persistence->lastInsertId($model)); + $modelForLastInsertId = $model; + while (is_object($modelForLastInsertId->table)) { + $modelForLastInsertId = $modelForLastInsertId->table; + } + // assumes same ID field across all nested models (not needed once migrated to Model insert) + $this->setId($entity, $model->persistence->lastInsertId($modelForLastInsertId)); } public function beforeUpdate(Model $entity, array &$data): void diff --git a/src/Persistence/Static_.php b/src/Persistence/Static_.php index 1ab963d96..8f4916fde 100644 --- a/src/Persistence/Static_.php +++ b/src/Persistence/Static_.php @@ -136,7 +136,7 @@ public function add(Model $model, array $defaults = []): void $hadData = true; if (!isset($this->data[$model->table])) { $hadData = false; - $this->data[$model->table] = true; // @phpstan-ignore-line + $this->data[$model->table] = true; } try { parent::add($model, $defaults); diff --git a/src/Reference/HasOne.php b/src/Reference/HasOne.php index 6f0c97ec6..c4aa870d0 100644 --- a/src/Reference/HasOne.php +++ b/src/Reference/HasOne.php @@ -41,7 +41,7 @@ protected function init(): void $v = $this->{$fieldPropRefl->getName()}; $vDefault = \PHP_MAJOR_VERSION < 8 ? $fieldPropRefl->getDeclaringClass()->getDefaultProperties()[$fieldPropRefl->getName()] - : $fieldPropRefl->getDefaultValue(); + : (null ?? $fieldPropRefl->getDefaultValue()); // @phpstan-ignore-line for PHP 7.x if ($v !== $vDefault) { $fieldSeed[$fieldPropRefl->getName()] = $v; } diff --git a/src/Schema/TestCase.php b/src/Schema/TestCase.php index fab30ef3f..21ff2fca2 100644 --- a/src/Schema/TestCase.php +++ b/src/Schema/TestCase.php @@ -319,32 +319,29 @@ public function getDb(array $tableNames = null, bool $noId = false): array $tableNames = array_values($tableNames); } - $ret = []; - + $resAll = []; foreach ($tableNames as $table) { - $data2 = []; - - $s = $this->db->dsql(); - $data = $s->table($table)->getRows(); + $query = $this->db->dsql(); + $rows = $query->table($table)->getRows(); - foreach ($data as &$row) { - foreach ($row as &$val) { - if (is_int($val)) { - $val = (int) $val; - } + $res = []; + $idColumnName = null; + foreach ($rows as $row) { + if ($idColumnName === null) { + $idColumnName = isset($row['_id']) ? '_id' : 'id'; } if ($noId) { - unset($row['id']); - $data2[] = $row; + unset($row[$idColumnName]); + $res[] = $row; } else { - $data2[$row['id']] = $row; + $res[$row[$idColumnName]] = $row; } } - $ret[$table] = $data2; + $resAll[$table] = $res; } - return $ret; + return $resAll; } } diff --git a/tests/ModelNestedArrayTest.php b/tests/ModelNestedArrayTest.php new file mode 100644 index 000000000..301bc3283 --- /dev/null +++ b/tests/ModelNestedArrayTest.php @@ -0,0 +1,234 @@ +db->connection->connection()->close(); + + $this->db = new Persistence\Array_([ + 'user' => [ + 1 => ['name' => 'John', '_birthday' => '1980-02-01'], + ['name' => 'Sue', '_birthday' => '2005-04-03'], + ['name' => 'Veronica', '_birthday' => '2005-04-03'], + ], + ]); + } + + protected function createTestModel(): Model + { + $mWithLoggingClass = get_class(new class() extends Model { + /** @var \WeakReference */ + protected $testCaseWeakRef; + /** @var string */ + protected $testModelAlias; + + /** + * @param mixed $v + * + * @return mixed + */ + protected function convertValueToLog($v) + { + if (is_array($v)) { + return array_map(fn ($v) => $this->convertValueToLog($v), $v); + } elseif (is_scalar($v) || $v === null) { + return $v; + } elseif ($v instanceof self) { + return $this->testModelAlias; + } + + return get_debug_type($v); + } + + public function hook(string $spot, array $args = [], HookBreaker &$brokenBy = null) + { + if (!str_starts_with($spot, '__atk__method__') && $spot !== Model::HOOK_NORMALIZE) { + $this->testCaseWeakRef->get()->hookLog[] = [$this->convertValueToLog($this), $spot, $this->convertValueToLog($args)]; + } + + return parent::hook($spot, $args, $brokenBy); + } + + public function atomic(\Closure $fx) + { + $this->testCaseWeakRef->get()->hookLog[] = [$this->convertValueToLog($this), '>>>']; + + $res = parent::atomic($fx); + + $this->testCaseWeakRef->get()->hookLog[] = [$this->convertValueToLog($this), '<<<']; + + return $res; + } + }); + + $mInner = new $mWithLoggingClass($this->db, [ + 'testCaseWeakRef' => \WeakReference::create($this), + 'testModelAlias' => 'inner', + 'table' => 'user', + 'id_field' => '_id', + ]); + $mInner->removeField('_id'); + $mInner->id_field = 'uid'; + $mInner->addField('uid', ['actual' => '_id', 'type' => 'integer']); + $mInner->addField('name'); + $mInner->addField('y', ['actual' => '_birthday', 'type' => 'date']); + $mInner->addCondition('uid', '!=', 3); + + $m = new $mWithLoggingClass($this->db, [ + 'testCaseWeakRef' => \WeakReference::create($this), + 'testModelAlias' => 'main', + 'table' => $mInner, + ]); + $m->removeField('id'); + $m->id_field = 'birthday'; + $m->addField('name'); + $m->addField('birthday', ['actual' => 'y', 'type' => 'date']); + + return $m; + } + + public function testSelectExport(): void + { + $m = $this->createTestModel(); + + $this->assertSameExportUnordered([ + 1 => ['name' => 'John', 'birthday' => new \DateTime('1980-2-1')], + ['name' => 'Sue', 'birthday' => new \DateTime('2005-4-3')], + ], $m->export()); + + $this->assertSame([ + ], $this->hookLog); + } + + public function testInsert(): void + { + $m = $this->createTestModel(); + + $entity = $m->createEntity() + ->setMulti([ + 'name' => 'Karl', + 'birthday' => new \DateTime('2000-6-1'), + ])->save(); + + $this->assertSame([ + ['main', '>>>'], + ['main', Model::HOOK_VALIDATE, ['save']], + ['main', Model::HOOK_BEFORE_SAVE, [false]], + ['main', Model::HOOK_BEFORE_INSERT, [['name' => 'Karl', 'birthday' => \DateTime::class]]], + ['inner', '>>>'], + ['inner', Model::HOOK_VALIDATE, ['save']], + ['inner', Model::HOOK_BEFORE_SAVE, [false]], + ['inner', Model::HOOK_BEFORE_INSERT, [['uid' => null, 'name' => 'Karl', 'y' => \DateTime::class]]], + ['inner', Model::HOOK_AFTER_INSERT, []], + ['inner', Model::HOOK_AFTER_SAVE, [false]], + ['inner', '<<<'], + ['main', Model::HOOK_AFTER_INSERT, []], + ['main', Model::HOOK_BEFORE_UNLOAD, []], + ['main', Model::HOOK_AFTER_UNLOAD, []], + ['main', Model::HOOK_BEFORE_LOAD, [\DateTime::class]], + ['main', Model::HOOK_AFTER_LOAD, []], + ['main', Model::HOOK_AFTER_SAVE, [false]], + ['main', '<<<'], + ], $this->hookLog); + + $this->assertSame(4, $m->table->loadBy('name', 'Karl')->getId()); + $this->assertSameExportUnordered([[new \DateTime('2000-6-1')]], [[$entity->getId()]]); + + $this->assertSameExportUnordered([ + 1 => ['name' => 'John', 'birthday' => new \DateTime('1980-2-1')], + ['name' => 'Sue', 'birthday' => new \DateTime('2005-4-3')], + 4 => ['name' => 'Karl', 'birthday' => new \DateTime('2000-6-1')], + ], $m->export()); + } + + public function testUpdate(): void + { + $m = $this->createTestModel(); + + $m->load(new \DateTime('2005-4-3')) + ->setMulti([ + 'name' => 'Sue', // no change + ])->save() + ->setMulti([ + 'name' => 'Susan', + ])->save(); + + $this->assertSame([ + ['main', Model::HOOK_BEFORE_LOAD, [\DateTime::class]], + ['main', Model::HOOK_AFTER_LOAD, []], + + ['main', '>>>'], + ['main', Model::HOOK_VALIDATE, ['save']], + ['main', Model::HOOK_BEFORE_SAVE, [true]], + ['main', '<<<'], + + ['main', '>>>'], + ['main', Model::HOOK_VALIDATE, ['save']], + ['main', Model::HOOK_BEFORE_SAVE, [true]], + ['main', Model::HOOK_BEFORE_UPDATE, [['name' => 'Susan']]], + ['inner', Model::HOOK_BEFORE_LOAD, [null]], + ['inner', Model::HOOK_AFTER_LOAD, []], + ['inner', '>>>'], + ['inner', Model::HOOK_VALIDATE, ['save']], + ['inner', Model::HOOK_BEFORE_SAVE, [true]], + ['inner', Model::HOOK_BEFORE_UPDATE, [['name' => 'Susan']]], + ['inner', Model::HOOK_AFTER_UPDATE, [['name' => 'Susan']]], + ['inner', Model::HOOK_AFTER_SAVE, [true]], + ['inner', '<<<'], + ['main', Model::HOOK_AFTER_UPDATE, [['name' => 'Susan']]], + ['main', Model::HOOK_AFTER_SAVE, [true]], + ['main', '<<<'], + ], $this->hookLog); + + $this->assertSameExportUnordered([ + 1 => ['name' => 'John', 'birthday' => new \DateTime('1980-2-1')], + ['name' => 'Susan', 'birthday' => new \DateTime('2005-4-3')], + ], $m->export()); + } + + public function testDelete(): void + { + $m = $this->createTestModel(); + + $m->delete(new \DateTime('2005-4-3')); + + $this->assertSame([ + ['main', Model::HOOK_BEFORE_LOAD, [\DateTime::class]], + ['main', Model::HOOK_AFTER_LOAD, []], + + ['main', '>>>'], + ['main', Model::HOOK_BEFORE_DELETE, []], + ['inner', Model::HOOK_BEFORE_LOAD, [null]], + ['inner', Model::HOOK_AFTER_LOAD, []], + ['inner', '>>>'], + ['inner', Model::HOOK_BEFORE_DELETE, []], + ['inner', Model::HOOK_AFTER_DELETE, []], + ['inner', '<<<'], + ['inner', Model::HOOK_BEFORE_UNLOAD, []], + ['inner', Model::HOOK_AFTER_UNLOAD, []], + ['main', Model::HOOK_AFTER_DELETE, []], + ['main', '<<<'], + ['main', Model::HOOK_BEFORE_UNLOAD, []], + ['main', Model::HOOK_AFTER_UNLOAD, []], + ], $this->hookLog); + + $this->assertSameExportUnordered([ + 1 => ['name' => 'John', 'birthday' => new \DateTime('1980-2-1')], + ], $m->export()); + } +} diff --git a/tests/ModelNestedTest.php b/tests/ModelNestedSqlTest.php similarity index 96% rename from tests/ModelNestedTest.php rename to tests/ModelNestedSqlTest.php index bb7b2ddf2..3fb2f592f 100644 --- a/tests/ModelNestedTest.php +++ b/tests/ModelNestedSqlTest.php @@ -12,8 +12,11 @@ use Atk4\Data\Schema\TestCase; use Doctrine\DBAL\Result as DbalResult; -class ModelNestedTest extends TestCase +class ModelNestedSqlTest extends TestCase { + /** @var array */ + public $hookLog = []; + protected function setUp(): void { parent::setUp(); @@ -27,13 +30,10 @@ protected function setUp(): void ]); } - /** @var array */ - public $hookLog = []; - protected function createTestModel(): Model { $mWithLoggingClass = get_class(new class() extends Model { - /** @var \WeakReference */ + /** @var \WeakReference */ protected $testCaseWeakRef; /** @var string */ protected $testModelAlias; @@ -205,6 +205,9 @@ public function testUpdate(): void $m = $this->createTestModel(); $m->load(new \DateTime('2005-4-3')) + ->setMulti([ + 'name' => 'Sue', // no change + ])->save() ->setMulti([ 'name' => 'Susan', ])->save(); @@ -215,6 +218,11 @@ public function testUpdate(): void ['main', Persistence\Sql::HOOK_INIT_SELECT_QUERY, [Query::class, 'select']], ['main', Model::HOOK_AFTER_LOAD, []], + ['main', '>>>'], + ['main', Model::HOOK_VALIDATE, ['save']], + ['main', Model::HOOK_BEFORE_SAVE, [true]], + ['main', '<<<'], + ['main', '>>>'], ['main', Model::HOOK_VALIDATE, ['save']], ['main', Model::HOOK_BEFORE_SAVE, [true]], diff --git a/tests/Persistence/ArrayTest.php b/tests/Persistence/ArrayTest.php index 334506f5e..eeb336a08 100644 --- a/tests/Persistence/ArrayTest.php +++ b/tests/Persistence/ArrayTest.php @@ -583,7 +583,10 @@ public function testOrder(): void // order by one field descending $p = new Persistence\Array_($dbData); - $m = new Model($p); + $m = new Model($p, ['id_field' => 'myid']); + $m->id_field = 'id'; + $m->removeField('myid'); + $m->addField('id'); $m->getField('id')->actual = 'myid'; $m->addField('f1'); $m->addField('f2');