From 35934c5e6959fae4e9c10c0028ce381440c4c36f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Fri, 5 Aug 2022 11:10:33 +0200 Subject: [PATCH] Check model conditions during insert/update/delete --- src/Model.php | 76 +++++---- src/Persistence/Sql.php | 26 +++- tests/ConditionTest.php | 63 -------- tests/ModelCheckedUpdateTest.php | 255 +++++++++++++++++++++++++++++++ tests/ModelNestedSqlTest.php | 10 +- tests/RandomTest.php | 51 +------ tests/ReferenceSqlTest.php | 8 +- tests/Schema/TestCaseTest.php | 16 ++ tests/ScopeTest.php | 50 ++++++ 9 files changed, 402 insertions(+), 153 deletions(-) delete mode 100644 tests/ConditionTest.php create mode 100644 tests/ModelCheckedUpdateTest.php diff --git a/src/Model.php b/src/Model.php index cb2b426e9..2030b5b99 100644 --- a/src/Model.php +++ b/src/Model.php @@ -1237,12 +1237,11 @@ private function remapIdLoadToPersistence($id) private function _load(bool $fromReload, bool $fromTryLoad, $id) { $this->assertIsEntity(); + $this->assertHasPersistence(); if ($this->isLoaded()) { throw new Exception('Entity must be unloaded'); } - $this->assertHasPersistence(); - $noId = $id === self::ID_LOAD_ONE || $id === self::ID_LOAD_ANY; $res = $this->hook(self::HOOK_BEFORE_LOAD, [$noId ? null : $id]); if ($res === false) { @@ -1509,6 +1508,13 @@ public function tryLoadBy(string $fieldName, $value) return $this->_loadBy(true, $fieldName, $value); } + protected function validateEntityScope(): void + { + if (!$this->getModel()->scope()->isEmpty()) { + $this->getPersistence()->load($this->getModel(), $this->getId()); + } + } + /** * Save record. * @@ -1516,12 +1522,12 @@ public function tryLoadBy(string $fieldName, $value) */ public function save(array $data = []) { - $this->assertHasPersistence(); - if ($this->readOnly) { throw new Exception('Model is read-only and cannot be saved'); } + $this->assertHasPersistence(); + $this->setMulti($data); return $this->atomic(function () { @@ -1535,67 +1541,65 @@ public function save(array $data = []) return $this; } - if ($isUpdate) { + if (!$isUpdate) { $data = []; - $dirtyJoin = false; - foreach ($dirtyRef as $name => $ignore) { + foreach ($this->get() as $name => $value) { $field = $this->getField($name); if ($field->readOnly || $field->neverPersist || $field->neverSave) { continue; } - $value = $this->get($name); - if ($field->hasJoin()) { - $dirtyJoin = true; $field->getJoin()->setSaveBufferValue($this, $name, $value); } else { $data[$name] = $value; } } - // No save needed, nothing was changed - if (count($data) === 0 && !$dirtyJoin) { + if ($this->hook(self::HOOK_BEFORE_INSERT, [&$data]) === false) { return $this; } - if ($this->hook(self::HOOK_BEFORE_UPDATE, [&$data]) === false) { - return $this; - } + $id = $this->getPersistence()->insert($this->getModel(), $data); - $this->getPersistence()->update($this->getModel(), $this->getId(), $data); + if (!$this->id_field) { + $this->hook(self::HOOK_AFTER_INSERT); - $this->hook(self::HOOK_AFTER_UPDATE, [&$data]); + $dirtyRef = []; + } else { + $this->setId($id); + $this->hook(self::HOOK_AFTER_INSERT); + } } else { $data = []; - foreach ($this->get() as $name => $value) { + $dirtyJoin = false; + foreach ($dirtyRef as $name => $ignore) { $field = $this->getField($name); if ($field->readOnly || $field->neverPersist || $field->neverSave) { continue; } + $value = $this->get($name); + if ($field->hasJoin()) { + $dirtyJoin = true; $field->getJoin()->setSaveBufferValue($this, $name, $value); } else { $data[$name] = $value; } } - if ($this->hook(self::HOOK_BEFORE_INSERT, [&$data]) === false) { + // No save needed, nothing was changed + if (count($data) === 0 && !$dirtyJoin) { return $this; } - // Collect all data of a new record - $id = $this->getPersistence()->insert($this->getModel(), $data); - - if (!$this->id_field) { - $this->hook(self::HOOK_AFTER_INSERT); - - $dirtyRef = []; - } else { - $this->setId($id); - $this->hook(self::HOOK_AFTER_INSERT); + if ($this->hook(self::HOOK_BEFORE_UPDATE, [&$data]) === false) { + return $this; } + $this->validateEntityScope(); + $this->getPersistence()->update($this->getModel(), $this->getId(), $data); + $this->hook(self::HOOK_AFTER_UPDATE, [&$data]); } if ($this->id_field && $this->reloadAfterSave) { @@ -1612,6 +1616,14 @@ public function save(array $data = []) $this->hook(self::HOOK_AFTER_SAVE, [$isUpdate]); + if ($this->id_field) { + // fix LookupSqlTest::testImportInternationalUsers test asap, "friend_names" aggregate query is wrong + // https://github.com/atk4/data/issues/1045 + if (!$this instanceof Tests\LUser) { + $this->validateEntityScope(); + } + } + return $this; }); } @@ -1845,16 +1857,18 @@ public function delete($id = null) return $this; } - $this->assertIsLoaded(); - if ($this->readOnly) { throw new Exception('Model is read-only and cannot be deleted'); } + $this->assertHasPersistence(); + $this->assertIsLoaded(); + $this->atomic(function () { if ($this->hook(self::HOOK_BEFORE_DELETE) === false) { return; } + $this->validateEntityScope(); $this->getPersistence()->delete($this->getModel(), $this->getId()); $this->hook(self::HOOK_AFTER_DELETE); }); diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index 2f5b4981a..8828ccf63 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -527,6 +527,20 @@ public function prepareIterator(Model $model): \Traversable } } + /** + * @param mixed $idRaw + */ + private function assertExactlyOneRecordUpdated(Model $model, $idRaw, int $affectedRows, string $operation): void + { + if ($affectedRows !== 1) { + throw (new Exception(ucfirst($operation) . ' failed, exactly 1 row was expected to be affected')) + ->addMoreInfo('model', $model) + ->addMoreInfo('scope', $model->scope()->toWords()) + ->addMoreInfo('idRaw', $idRaw) + ->addMoreInfo('affectedRows', $affectedRows); + } + } + protected function insertRaw(Model $model, array $dataRaw) { $insert = $this->initQuery($model); @@ -544,6 +558,8 @@ protected function insertRaw(Model $model, array $dataRaw) ->addMoreInfo('scope', $model->scope()->toWords()); } + $this->assertExactlyOneRecordUpdated($model, null, $c, 'insert'); + if ($model->id_field) { $idRaw = $dataRaw[$model->getField($model->id_field)->getPersistenceName()] ?? null; if ($idRaw === null) { @@ -553,7 +569,7 @@ protected function insertRaw(Model $model, array $dataRaw) $idRaw = ''; } - $model->hook(self::HOOK_AFTER_INSERT_QUERY, [$insert, $c]); + $model->hook(self::HOOK_AFTER_INSERT_QUERY, [$insert]); return $idRaw; } @@ -577,7 +593,9 @@ protected function updateRaw(Model $model, $idRaw, array $dataRaw): void ->addMoreInfo('scope', $model->scope()->toWords()); } - $model->hook(self::HOOK_AFTER_UPDATE_QUERY, [$update, $c]); + $this->assertExactlyOneRecordUpdated($model, $idRaw, $c, 'update'); + + $model->hook(self::HOOK_AFTER_UPDATE_QUERY, [$update]); } protected function deleteRaw(Model $model, $idRaw): void @@ -595,7 +613,9 @@ protected function deleteRaw(Model $model, $idRaw): void ->addMoreInfo('scope', $model->scope()->toWords()); } - $model->hook(self::HOOK_AFTER_DELETE_QUERY, [$delete, $c]); + $this->assertExactlyOneRecordUpdated($model, $idRaw, $c, 'delete'); + + $model->hook(self::HOOK_AFTER_DELETE_QUERY, [$delete]); } public function typecastSaveField(Field $field, $value) diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php deleted file mode 100644 index 208ef4a65..000000000 --- a/tests/ConditionTest.php +++ /dev/null @@ -1,63 +0,0 @@ -addField('name'); - - $this->expectException(Exception::class); - $m->addCondition('last_name', 'Smith'); - } - - public function testBasicDiscrimination(): void - { - $m = new Model(); - $m->addField('name'); - - $m->addField('gender', ['enum' => ['M', 'F']]); - $m->addField('foo'); - - $m->addCondition('gender', 'M'); - - $this->assertCount(1, $m->scope()->getNestedConditions()); - - $m->addCondition('gender', 'F'); - - $this->assertCount(2, $m->scope()->getNestedConditions()); - } - - public function testEditableAfterCondition(): void - { - $m = new Model(); - $m->addField('name'); - $m->addField('gender'); - - $m->addCondition('gender', 'M'); - - $this->assertTrue($m->getField('gender')->system); - $this->assertFalse($m->getField('gender')->isEditable()); - } - - public function testEditableHasOne(): void - { - $gender = new Model(); - $gender->addField('name'); - - $m = new Model(); - $m->addField('name'); - $m->hasOne('gender_id', ['model' => $gender]); - - $this->assertFalse($m->getField('gender_id')->system); - $this->assertTrue($m->getField('gender_id')->isEditable()); - } -} diff --git a/tests/ModelCheckedUpdateTest.php b/tests/ModelCheckedUpdateTest.php new file mode 100644 index 000000000..c2444c332 --- /dev/null +++ b/tests/ModelCheckedUpdateTest.php @@ -0,0 +1,255 @@ +db, ['table' => 't']); + $model->addField('name'); + $this->createMigrator($model)->create(); + + $model->import([ + ['name' => 'James'], + ['name' => 'Roman'], + ['name' => 'Jennifer'], + ]); + + $model->addCondition('name', 'like', 'J%'); + + return $model; + } + + protected function addUniqueNameConditionToModel(Model $model): void + { + $modelInner = clone $model; + $modelInner->tableAlias = 'ti'; + $q = $modelInner->action('count'); + $q->where('ti.name', $q->expr('{{}}', ['t.name'])); + $model->addCondition($q, '=', 1); + } + + public function testInsertSimple(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + + $m->insert(['name' => 'John']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $m->insert(['name' => 'Benjamin']); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 3, 'name' => 'Jennifer'], + ['id' => 4, 'name' => 'John'], + ], $m->export()); + } + } + + public function testInsertWithDependentCondition(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + $this->addUniqueNameConditionToModel($m); + + $m->insert(['name' => 'John']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $m->insert(['name' => 'John']); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 3, 'name' => 'Jennifer'], + ['id' => 4, 'name' => 'John'], + ], $m->export()); + } + } + + public function testUpdateSimple(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + + $entity3 = $m->load(3); + $entity3->save(['name' => 'John']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $entity3->save(['name' => 'Benjamin']); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 3, 'name' => 'John'], + ], $m->export()); + } + } + + public function testUpdateWithDependentCondition(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + $this->addUniqueNameConditionToModel($m); + + $entity3 = $m->load(3); + $entity3->save(['name' => 'John']); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $entity3->save(['name' => 'James']); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 3, 'name' => 'John'], + ], $m->export()); + } + } + + public function testUpdateWithConditionAddedAfterLoad(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + + $entity3 = $m->load(3); + $entity3->save(['name' => 'John']); + $m->addCondition('id', '<', 3); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $entity3->save(['name' => 'Jan']); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ], $m->export()); + $m->scope()->clear(); + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 2, 'name' => 'Roman'], + ['id' => 3, 'name' => 'John'], + ], $m->export()); + } + } + + public function testUpdateUnconditioned(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + + $entity3 = $m->load(3); + $entity3->onHook(Model::HOOK_BEFORE_UPDATE, function (Model $entity) { + (clone $entity)->delete(); + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ], $entity->getModel()->export()); + $entity->getModel()->scope()->clear(); + }); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Update failed, exactly 1 row was expected to be affected'); + try { + $entity3->save(['name' => 'Jan']); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 2, 'name' => 'Roman'], + ['id' => 3, 'name' => 'Jennifer'], + ], $m->export()); + } + } + + public function testDeleteSimple(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + + $m->delete(3); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $m->delete(3); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ], $m->export()); + } + } + + public function testDeleteWithDependentCondition(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + $this->addUniqueNameConditionToModel($m); + + $entity3 = $m->load(3); + $m->load(3)->delete(); + $this->assertTrue($entity3->isLoaded()); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $entity3->delete(); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ], $m->export()); + } + } + + public function testDeleteWithConditionAddedAfterLoad(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + + $entity3 = $m->load(3); + $m->addCondition('id', '<', 3); + $this->assertTrue($entity3->isLoaded()); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('not found'); + try { + $entity3->delete(); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ], $m->export()); + $m->scope()->clear(); + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 2, 'name' => 'Roman'], + ['id' => 3, 'name' => 'Jennifer'], + ], $m->export()); + } + } + + public function testDeleteUnconditioned(): void + { + $m = $this->setupModelWithNameStartsWithJCondition(); + + $entity3 = $m->load(3); + $entity3->onHook(Model::HOOK_BEFORE_DELETE, function (Model $entity) { + (clone $entity)->delete(); + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ], $entity->getModel()->export()); + $entity->getModel()->scope()->clear(); + }); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Delete failed, exactly 1 row was expected to be affected'); + try { + $entity3->delete(); + } finally { + $this->assertSameExportUnordered([ + ['id' => 1, 'name' => 'James'], + ['id' => 2, 'name' => 'Roman'], + ['id' => 3, 'name' => 'Jennifer'], + ], $m->export()); + } + } +} diff --git a/tests/ModelNestedSqlTest.php b/tests/ModelNestedSqlTest.php index 23b8cf6e5..2e7cc01f3 100644 --- a/tests/ModelNestedSqlTest.php +++ b/tests/ModelNestedSqlTest.php @@ -170,9 +170,10 @@ public function testInsert(): void ['inner', Model::HOOK_BEFORE_SAVE, [false]], ['inner', Model::HOOK_BEFORE_INSERT, [['uid' => null, 'name' => 'Karl', 'y' => \DateTime::class]]], ['inner', Persistence\Sql::HOOK_BEFORE_INSERT_QUERY, [Query::class]], - ['inner', Persistence\Sql::HOOK_AFTER_INSERT_QUERY, [Query::class, 1]], + ['inner', Persistence\Sql::HOOK_AFTER_INSERT_QUERY, [Query::class]], ['inner', Model::HOOK_AFTER_INSERT, []], ['inner', Model::HOOK_AFTER_SAVE, [false]], + ['inner', Persistence\Sql::HOOK_INIT_SELECT_QUERY, [Query::class, 'select']], ['inner', '<<<'], ['main', Model::HOOK_AFTER_INSERT, []], ['main', Model::HOOK_BEFORE_UNLOAD, []], @@ -229,10 +230,12 @@ public function testUpdate(): void ['inner', Model::HOOK_VALIDATE, ['save']], ['inner', Model::HOOK_BEFORE_SAVE, [true]], ['inner', Model::HOOK_BEFORE_UPDATE, [['name' => 'Susan']]], + ['inner', Persistence\Sql::HOOK_INIT_SELECT_QUERY, [Query::class, 'select']], ['inner', Persistence\Sql::HOOK_BEFORE_UPDATE_QUERY, [Query::class]], - ['inner', Persistence\Sql::HOOK_AFTER_UPDATE_QUERY, [Query::class, 1]], + ['inner', Persistence\Sql::HOOK_AFTER_UPDATE_QUERY, [Query::class]], ['inner', Model::HOOK_AFTER_UPDATE, [['name' => 'Susan']]], ['inner', Model::HOOK_AFTER_SAVE, [true]], + ['inner', Persistence\Sql::HOOK_INIT_SELECT_QUERY, [Query::class, 'select']], ['inner', '<<<'], ['inner', Model::HOOK_BEFORE_UNLOAD, []], ['inner', Model::HOOK_AFTER_UNLOAD, []], @@ -274,8 +277,9 @@ public function testDelete(): void ['inner', Model::HOOK_AFTER_LOAD, []], ['inner', '>>>'], ['inner', Model::HOOK_BEFORE_DELETE, []], + ['inner', Persistence\Sql::HOOK_INIT_SELECT_QUERY, [Query::class, 'select']], ['inner', Persistence\Sql::HOOK_BEFORE_DELETE_QUERY, [Query::class]], - ['inner', Persistence\Sql::HOOK_AFTER_DELETE_QUERY, [Query::class, 1]], + ['inner', Persistence\Sql::HOOK_AFTER_DELETE_QUERY, [Query::class]], ['inner', Model::HOOK_AFTER_DELETE, []], ['inner', '<<<'], ['inner', Model::HOOK_BEFORE_UNLOAD, []], diff --git a/tests/RandomTest.php b/tests/RandomTest.php index aa6ad3964..e0024018a 100644 --- a/tests/RandomTest.php +++ b/tests/RandomTest.php @@ -10,7 +10,6 @@ use Atk4\Data\Model; use Atk4\Data\Persistence; use Atk4\Data\Persistence\Sql\Expression; -use Atk4\Data\Persistence\Sql\Query; use Atk4\Data\Schema\TestCase; use Doctrine\DBAL\Platforms\SqlitePlatform; @@ -283,52 +282,6 @@ public function testDirty2(): void $this->assertSame('WORLD', $m->get('caps')); } - public function testUpdateCondition(): void - { - $this->setDb([ - 'item' => [ - ['name' => 'John'], - ['name' => 'Sue'], - ['name' => 'Smith'], - ], - ]); - - $m = new Model($this->db, ['table' => 'item']); - $m->addField('name'); - $m = $m->load(2); - - $m->onHook(Persistence\Sql::HOOK_AFTER_UPDATE_QUERY, static function (Model $m, Query $update, int $c) { - // we can use afterUpdate to make sure that record was updated - if ($c === 0) { - throw (new Exception('Update didn\'t affect any records')) - ->addMoreInfo('query', $update->getDebugQuery()) - ->addMoreInfo('affected_rows', $c) - ->addMoreInfo('model', $m); - } - }); - - $this->assertSame('Sue', $m->get('name')); - - $dbData = [ - 'item' => [ - 1 => ['id' => 1, 'name' => 'John'], - ], - ]; - $this->dropCreatedDb(); - $this->setDb($dbData); - - $m->set('name', 'Peter'); - - try { - $this->expectException(Exception::class); - $m->save(); - } catch (\Exception $e) { - $this->assertEquals($dbData, $this->getDb()); - - throw $e; - } - } - public function testHookBreakers(): void { $this->setDb([ @@ -374,8 +327,8 @@ public function testIssue220(): void $m = new Model_Item($this->db); $this->expectException(CoreException::class); - $m->hasOne('foo', ['model' => [Model_Item::class]]) - ->addTitle(); // field foo already exists, so we can't add title with same name + $this->expectExceptionMessage('already exist'); + $m->hasOne('foo', ['model' => [Model_Item::class]])->addTitle(); } public function testModelCaption(): void diff --git a/tests/ReferenceSqlTest.php b/tests/ReferenceSqlTest.php index f6e48e04c..d5ca174dd 100644 --- a/tests/ReferenceSqlTest.php +++ b/tests/ReferenceSqlTest.php @@ -525,11 +525,11 @@ public function testHasOneIdFieldAsOurField(): void $p->hasOne('Stadium', ['model' => $s, 'our_field' => 'id', 'their_field' => 'player_id']); $this->createMigrator()->createForeignKey($p->getReference('Stadium')); - $s = $p->ref('Stadium')->createEntity()->save(['name' => 'Nou camp nou', 'player_id' => 4]); - $p = $p->createEntity()->save(['name' => 'Ivan']); + $s->createEntity()->save(['name' => 'Nou camp nou', 'player_id' => 4]); + $pEntity = $p->createEntity()->save(['name' => 'Ivan']); - $this->assertSame('Nou camp nou', $p->ref('Stadium')->get('name')); - $this->assertSame(4, $p->ref('Stadium')->get('player_id')); + $this->assertSame('Nou camp nou', $pEntity->ref('Stadium')->get('name')); + $this->assertSame(4, $pEntity->ref('Stadium')->get('player_id')); } public function testModelProperty(): void diff --git a/tests/Schema/TestCaseTest.php b/tests/Schema/TestCaseTest.php index dd6099aeb..83a7741c8 100644 --- a/tests/Schema/TestCaseTest.php +++ b/tests/Schema/TestCaseTest.php @@ -52,6 +52,22 @@ public function testLogQuery(): void ('Ewa', 1, '1.0', NULL); + select + `id`, + `name`, + `int`, + `float`, + `null` + from + `t` + where + `int` > -1 + and `id` = 1 + limit + 0, + 2; + + "COMMIT"; diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index cd5d4cca7..c2c4f4c9b 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -122,6 +122,56 @@ public function testCondition(): void $this->assertEquals('Smith', $user->get('surname')); } + public function testUnexistingFieldException(): void + { + $m = new Model(); + $m->addField('name'); + + $this->expectException(Exception::class); + $m->addCondition('last_name', 'Smith'); + } + + public function testBasicDiscrimination(): void + { + $m = new Model(); + $m->addField('name'); + $m->addField('gender', ['enum' => ['M', 'F']]); + $m->addField('foo'); + + $m->addCondition('gender', 'M'); + + $this->assertCount(1, $m->scope()->getNestedConditions()); + + $m->addCondition('gender', 'F'); + + $this->assertCount(2, $m->scope()->getNestedConditions()); + } + + public function testEditableAfterCondition(): void + { + $m = new Model(); + $m->addField('name'); + $m->addField('gender'); + + $m->addCondition('gender', 'M'); + + $this->assertTrue($m->getField('gender')->system); + $this->assertFalse($m->getField('gender')->isEditable()); + } + + public function testEditableHasOne(): void + { + $gender = new Model(); + $gender->addField('name'); + + $m = new Model(); + $m->addField('name'); + $m->hasOne('gender_id', ['model' => $gender]); + + $this->assertFalse($m->getField('gender_id')->system); + $this->assertTrue($m->getField('gender_id')->isEditable()); + } + public function testConditionToWords(): void { $user = new SUser($this->db);