diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index db4bccf..f75758c 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -87,6 +87,14 @@ no observers are registered), and multi-connection safe. the filter. Done by `byjg/serializer` 7.0; tested in `RepositoryBackedEnumTest` - See: `docs/getting-started-model.md` +### Entity validator +- `EntityValidatorInterface::validate(mixed $instance, bool $isInsert)` checks an entity before it is saved and + rejects it by throwing an exception. Unlike `beforeInsert`/`beforeUpdate`, which receive the record already + mapped to columns and converted for the database, it receives the instance itself +- Set with `TableAttribute(validator: ...)`, `TableUuidPKAttribute(validator: ...)` or + `Repository::setValidator()`; it runs on every `save()`, in the Repository and the ActiveRecord patterns +- See: `docs/controlling-the-data.md` + ## Breaking Changes ### Fixed: a primary key set on the instance is kept on insert diff --git a/docs/active-record.md b/docs/active-record.md index f02db90..4f10d88 100644 --- a/docs/active-record.md +++ b/docs/active-record.md @@ -223,6 +223,25 @@ $myClassList = MyClass::query($query); $tableName = MyClass::tableName(); ``` +### Validate Before Saving + +Declare a validator in the table attribute, and `save()` checks the instance before it writes it. The call does +not change; an invalid instance throws the validator's exception and nothing is written: + +```php +#[TableAttribute(tableName: 'my_table', validator: MyClassValidator::class)] +class MyClass +{ + use ActiveRecord; + // ... +} + +$myClass = MyClass::new(['someProperty' => -1]); +$myClass->save(); // MyClassValidator::validate($myClass, true) throws: no INSERT runs +``` + +See [Validating the entity before it is saved](controlling-the-data.md#validating-the-entity-before-it-is-saved). + ## Custom Mapper Configuration By default, the Active Record uses the class attributes to discover the mapper configuration. diff --git a/docs/controlling-the-data.md b/docs/controlling-the-data.md index 72d1983..0f4b8f1 100644 --- a/docs/controlling-the-data.md +++ b/docs/controlling-the-data.md @@ -217,3 +217,76 @@ class MyModel This approach allows you to associate processors directly with your model class, making the relationship more explicit and self-contained. +## Validating the entity before it is saved + +A processor works on the record as it is written to the table: the fields are already mapped to their columns and +converted by the mapper functions (a UUID is already binary, for example). To check the entity itself, use a +validator. It receives the instance before anything is mapped or converted, on every `save()`, and rejects it by +throwing an exception, which stops the save before any SQL is run: + +```php +getName()) === '') { + throw new InvalidArgumentException('The name is required'); + } + } +} + +#[TableAttribute(tableName: 'product', validator: ProductValidator::class)] +class Product +{ + // Model properties and methods +} +``` + +`$isInsert` tells a new record from an update. The validator is set like a processor: in the `TableAttribute` (or +`TableUuidPKAttribute`), as a class name or an instance, or with `$repository->setValidator()`. + +### Saving with a validator + +Nothing changes in the code that saves: you pass the model to `save()` as always, and `save()` calls the validator +first. A valid instance is saved; an invalid one throws the validator's exception, and no SQL is run, so a new +record is not inserted and an existing one keeps its stored values. + +With the ActiveRecord pattern: + +```php + 'Keyboard', 'price' => 49.90]); +$product->save(); // validate($product, true) passes, then the INSERT runs + +$product->setName(''); +try { + $product->save(); // validate($product, false) throws: no UPDATE runs +} catch (InvalidArgumentException $ex) { + echo $ex->getMessage(); // "The name is required"; the stored name is still 'Keyboard' +} +``` + +With the Repository pattern, the repository takes the validator from the model's attribute: + +```php +setName(''); +$repository->save($product); // throws InvalidArgumentException: no INSERT runs +``` + +For a model without the attribute, set it on the repository: `$repository->setValidator(new ProductValidator())`. + +The validator runs on every `save()`, whoever calls it: a controller, a command line script, a queue consumer. +Queries built and executed directly (`UpdateQuery`, `InsertBulkQuery`, `InsertQuery`, raw SQL with the +`DatabaseExecutor`) do not go through `save()`, so they are not validated. + diff --git a/docs/model-attribute.md b/docs/model-attribute.md index 15915e5..4eb6bae 100644 --- a/docs/model-attribute.md +++ b/docs/model-attribute.md @@ -83,6 +83,7 @@ The `TableAttribute` has the following parameters: | **tableAlias** | The alias of the table in the database. | No | | **beforeInsert** | A processor that is called before inserting a record into the database. Can be a string class name or an instance of EntityProcessorInterface. | No | | **beforeUpdate** | A processor that is called before updating a record in the database. Can be a string class name or an instance of EntityProcessorInterface. | No | +| **validator** | A validator that checks the instance before it is inserted or updated. Can be a string class name or an instance of EntityValidatorInterface. Also accepted by `TableUuidPKAttribute`. | No | ## Field Attributes parameters diff --git a/docs/repository.md b/docs/repository.md index 2cbf312..ffe670b 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -324,6 +324,17 @@ class BeforeUpdateProcessor implements EntityProcessorInterface $repository->setBeforeUpdate(new BeforeUpdateProcessor()); ``` +### setValidator + +Set a validator to be called on every `save()`, before the processors. It receives the instance, before any field +is mapped or converted, and whether the save is an insert; it rejects the instance by throwing an exception. It +accepts an implementation of `EntityValidatorInterface` or the name of a class implementing it. See +[Validating the entity before it is saved](controlling-the-data.md#validating-the-entity-before-it-is-saved). + +```php +$repository->setValidator(new ProductValidator()); +``` + ## Observers ### addObserver diff --git a/docs/updating-the-database.md b/docs/updating-the-database.md index 08c879a..bc5edee 100644 --- a/docs/updating-the-database.md +++ b/docs/updating-the-database.md @@ -31,6 +31,15 @@ $users->name = "New name"; $repository->save($users); ``` +## Validation before saving + +When the model declares a validator, `save()` checks the instance before the insert or the update, and an invalid +one throws without writing anything. See +[Validating the entity before it is saved](controlling-the-data.md#validating-the-entity-before-it-is-saved). + +The queries in the next sections (`UpdateQuery`, `InsertQuery`, `InsertSelectQuery`, `InsertBulkQuery`) write to +the table directly. They do not go through `save()`, so the model's validator and processors are not called. + ## Using the UpdateQuery for Multiple Records UpdateQuery allows you to update multiple records simultaneously with a single query. This is more efficient than diff --git a/src/Attributes/TableAttribute.php b/src/Attributes/TableAttribute.php index d02fff9..eed57eb 100644 --- a/src/Attributes/TableAttribute.php +++ b/src/Attributes/TableAttribute.php @@ -4,6 +4,7 @@ use Attribute; use ByJG\MicroOrm\Interface\EntityProcessorInterface; +use ByJG\MicroOrm\Interface\EntityValidatorInterface; use ByJG\MicroOrm\Interface\MapperFunctionInterface; #[Attribute(Attribute::TARGET_CLASS)] @@ -15,13 +16,15 @@ class TableAttribute private ?string $tableAlias; private string|EntityProcessorInterface|null $beforeInsert = null; private string|EntityProcessorInterface|null $beforeUpdate = null; + private string|EntityValidatorInterface|null $validator = null; public function __construct( string $tableName, string|MapperFunctionInterface|null $primaryKeySeedFunction = null, ?string $tableAlias = null, string|EntityProcessorInterface|null $beforeInsert = null, - string|EntityProcessorInterface|null $beforeUpdate = null + string|EntityProcessorInterface|null $beforeUpdate = null, + string|EntityValidatorInterface|null $validator = null ) { $this->tableName = $tableName; @@ -29,6 +32,7 @@ public function __construct( $this->primaryKeySeedFunction = $primaryKeySeedFunction; $this->beforeInsert = $beforeInsert; $this->beforeUpdate = $beforeUpdate; + $this->validator = $validator; } public function getTableName(): string @@ -55,4 +59,9 @@ public function getBeforeUpdate(): string|EntityProcessorInterface|null { return $this->beforeUpdate; } + + public function getValidator(): string|EntityValidatorInterface|null + { + return $this->validator; + } } diff --git a/src/Attributes/TableUuidPKAttribute.php b/src/Attributes/TableUuidPKAttribute.php index 8806000..3bce948 100644 --- a/src/Attributes/TableUuidPKAttribute.php +++ b/src/Attributes/TableUuidPKAttribute.php @@ -4,6 +4,7 @@ use Attribute; use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Interface\EntityValidatorInterface; use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\MicroOrm\Literal\HexUuidLiteral; use Override; @@ -15,9 +16,9 @@ #[Attribute(Attribute::TARGET_CLASS)] class TableUuidPKAttribute extends TableAttribute implements MapperFunctionInterface { - public function __construct(string $tableName) + public function __construct(string $tableName, string|EntityValidatorInterface|null $validator = null) { - parent::__construct($tableName, primaryKeySeedFunction: $this); + parent::__construct($tableName, primaryKeySeedFunction: $this, validator: $validator); } #[Override] diff --git a/src/Interface/EntityValidatorInterface.php b/src/Interface/EntityValidatorInterface.php new file mode 100644 index 0000000..a005b64 --- /dev/null +++ b/src/Interface/EntityValidatorInterface.php @@ -0,0 +1,20 @@ +withBeforeUpdate($beforeUpdate); } + $validator = $tableAttribute->getValidator(); + if ($validator !== null && $validator !== '') { + $this->withValidator($validator); + } ORM::addMapper($this); $this->primaryKey = []; @@ -151,6 +157,12 @@ public function withBeforeUpdate(EntityProcessorInterface|string $processor): st return $this; } + public function withValidator(EntityValidatorInterface|string $validator): static + { + $this->validator = $validator; + return $this; + } + public function fixFieldName(?string $field): ?string { if (is_null($field)) { @@ -400,4 +412,9 @@ public function getBeforeUpdate(): mixed { return $this->beforeUpdate; } + + public function getValidator(): EntityValidatorInterface|string|null + { + return $this->validator; + } } diff --git a/src/Repository.php b/src/Repository.php index f733684..b1142c9 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -22,6 +22,7 @@ use ByJG\MicroOrm\Exception\RepositoryReadOnlyException; use ByJG\MicroOrm\Exception\UpdateConstraintException; use ByJG\MicroOrm\Interface\EntityProcessorInterface; +use ByJG\MicroOrm\Interface\EntityValidatorInterface; use ByJG\MicroOrm\Interface\ObserverProcessorInterface; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\MicroOrm\Interface\UpdateConstraintInterface; @@ -66,6 +67,8 @@ class Repository */ protected EntityProcessorInterface|null $beforeInsert = null; + protected EntityValidatorInterface|null $validator = null; + /** * @var ObserverProcessorInterface[] */ @@ -100,6 +103,10 @@ public function __construct(DatabaseExecutor $executor, string|Mapper $mapperOrE if (!empty($this->mapper->getBeforeUpdate())) { $this->setBeforeUpdate($this->mapper->getBeforeUpdate()); } + $validator = $this->mapper->getValidator(); + if (!empty($validator)) { + $this->setValidator($validator); + } } public function addDbDriverForWrite(DatabaseExecutor $executor): void @@ -762,6 +769,11 @@ protected function saveUpdatableInternal(mixed $instance): array } $isInsert = empty($oldInstance); + // The validator sees the instance as it is, before the processors and the mapper functions + if (!empty($this->validator)) { + $this->validator->validate($instance, $isInsert); + } + // Execute Before Statements if ($isInsert) { if (!empty($this->beforeInsert)) { @@ -951,4 +963,29 @@ public function setBeforeInsert(EntityProcessorInterface|string $processor): voi } $this->beforeInsert = $processor; } + + /** + * Sets a validator to be executed before inserting or updating an entity + * + * @param EntityValidatorInterface|string $validator The validator, or the name of its class + * @return void + * @throws InvalidArgumentException + */ + public function setValidator(EntityValidatorInterface|string $validator): void + { + if (is_string($validator)) { + if (!class_exists($validator)) { + throw new InvalidArgumentException("The class '$validator' does not exist"); + } + + $interfaces = class_implements($validator); + if ($interfaces === false || !in_array(EntityValidatorInterface::class, $interfaces)) { + throw new InvalidArgumentException("The class '$validator' must implement EntityValidatorInterface"); + } + + /** @var EntityValidatorInterface $validator */ + $validator = new $validator(); + } + $this->validator = $validator; + } } diff --git a/tests/EntityValidatorTest.php b/tests/EntityValidatorTest.php new file mode 100644 index 0000000..417b2b5 --- /dev/null +++ b/tests/EntityValidatorTest.php @@ -0,0 +1,134 @@ +executor = DatabaseExecutor::using(Factory::getDbInstance(self::URI)); + $this->executor->execute('create table users (id integer primary key autoincrement, name varchar(45), createdate datetime)'); + $this->executor->execute('create table uuidkey (id binary(16) primary key, name varchar(45), ref binary(16))'); + + ORM::defaultDbDriver($this->executor); + ActiveRecordWithValidator::reset(); + RecordingValidator::$calls = []; + } + + #[Override] + public function tearDown(): void + { + ORM::resetMemory(); + unlink((new Uri(self::URI))->getPath()); + } + + public function testTheValidatorReceivesTheInstanceOnInsertAndOnUpdate(): void + { + $model = ActiveRecordWithValidator::new(['name' => 'John']); + $model->save(); + + $model->name = 'John Doe'; + $model->save(); + + $this->assertCount(2, RecordingValidator::$calls); + $this->assertSame($model, RecordingValidator::$calls[0]['instance']); + $this->assertTrue(RecordingValidator::$calls[0]['isInsert']); + $this->assertSame($model, RecordingValidator::$calls[1]['instance']); + $this->assertFalse(RecordingValidator::$calls[1]['isInsert']); + $this->assertSame('John Doe', ActiveRecordWithValidator::get($model->getId())->name); + } + + public function testAnInvalidInstanceIsNotInserted(): void + { + try { + ActiveRecordWithValidator::new(['name' => ''])->save(); + $this->fail('The empty name was saved'); + } catch (InvalidArgumentException $ex) { + $this->assertSame('The name is empty', $ex->getMessage()); + } + + $this->assertSame(0, $this->executor->getScalar('select count(*) from users')); + } + + public function testAnInvalidInstanceIsNotUpdated(): void + { + $model = ActiveRecordWithValidator::new(['name' => 'John']); + $model->save(); + + $model->name = ''; + try { + $model->save(); + $this->fail('The empty name was saved'); + } catch (InvalidArgumentException) { + } + + $this->assertSame('John', ActiveRecordWithValidator::get($model->getId())->name); + } + + public function testTheValidatorSeesTheValuesOfTheModelNotOfTheDatabase(): void + { + $repository = new Repository($this->executor, UuidKeyWithValidator::class); + + $model = new UuidKeyWithValidator(); + $model->name = 'John'; + $repository->save($model); + + $saved = $repository->get($model->id); + $saved->name = 'John Doe'; + $repository->save($saved); + + // On insert the key is still empty: it is generated after the validation. + $this->assertNull(RecordingValidator::$calls[0]['id']); + // On update the key is the UUID string of the model, not the binary value written to the column. + $this->assertMatchesRegularExpression('/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', RecordingValidator::$calls[1]['id']); + } + + public function testAValidatorCanBeSetOnTheRepository(): void + { + $repository = new Repository($this->executor, ModelWithProcessors::class); + $repository->setValidator(new class implements EntityValidatorInterface { + public function validate(mixed $instance, bool $isInsert): void + { + throw new InvalidArgumentException('Rejected'); + } + }); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Rejected'); + $model = new ModelWithProcessors(); + $model->name = 'John'; + $repository->save($model); + } + + public function testAClassThatIsNotAValidatorIsRefused(): void + { + $repository = new Repository($this->executor, ModelWithProcessors::class); + + $this->expectException(OrmInvalidArgumentException::class); + $this->expectExceptionMessage('must implement EntityValidatorInterface'); + $repository->setValidator(TestInsertProcessor::class); + } +} diff --git a/tests/Model/ActiveRecordWithValidator.php b/tests/Model/ActiveRecordWithValidator.php new file mode 100644 index 0000000..920af22 --- /dev/null +++ b/tests/Model/ActiveRecordWithValidator.php @@ -0,0 +1,32 @@ +id; + } + + public function setId($id) + { + $this->id = $id; + } +} diff --git a/tests/Model/RecordingValidator.php b/tests/Model/RecordingValidator.php new file mode 100644 index 0000000..1ac26c5 --- /dev/null +++ b/tests/Model/RecordingValidator.php @@ -0,0 +1,25 @@ + */ + public static array $calls = []; + + #[\Override] + public function validate(mixed $instance, bool $isInsert): void + { + self::$calls[] = ['instance' => $instance, 'isInsert' => $isInsert, 'id' => $instance->getId()]; + + if ($instance->name === '') { + throw new InvalidArgumentException('The name is empty'); + } + } +} diff --git a/tests/Model/UuidKeyWithValidator.php b/tests/Model/UuidKeyWithValidator.php new file mode 100644 index 0000000..db97139 --- /dev/null +++ b/tests/Model/UuidKeyWithValidator.php @@ -0,0 +1,23 @@ +id; + } +}