Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG-7.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/active-record.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions docs/controlling-the-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php
use ByJG\MicroOrm\Attributes\TableAttribute;
use ByJG\MicroOrm\Interface\EntityValidatorInterface;
use InvalidArgumentException;
use Override;

class ProductValidator implements EntityValidatorInterface
{
#[Override]
public function validate(mixed $instance, bool $isInsert): void
{
if (trim((string)$instance->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
<?php
$product = Product::new(['name' => '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
<?php
$repository = new Repository($executor, Product::class);

$product = new Product();
$product->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.

1 change: 1 addition & 0 deletions docs/model-attribute.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions docs/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/updating-the-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion src/Attributes/TableAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -15,20 +16,23 @@ 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;
$this->tableAlias = $tableAlias;
$this->primaryKeySeedFunction = $primaryKeySeedFunction;
$this->beforeInsert = $beforeInsert;
$this->beforeUpdate = $beforeUpdate;
$this->validator = $validator;
}

public function getTableName(): string
Expand All @@ -55,4 +59,9 @@ public function getBeforeUpdate(): string|EntityProcessorInterface|null
{
return $this->beforeUpdate;
}

public function getValidator(): string|EntityValidatorInterface|null
{
return $this->validator;
}
}
5 changes: 3 additions & 2 deletions src/Attributes/TableUuidPKAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions src/Interface/EntityValidatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace ByJG\MicroOrm\Interface;

/**
* Interface for validators that check an entity before it is saved.
*/
interface EntityValidatorInterface
{
/**
* Validate an entity before it is saved. It receives the instance itself, before any
* field is mapped to its column or converted for the database, and rejects it by
* throwing an exception, which stops the save.
*
* @param mixed $instance The entity being saved
* @param bool $isInsert Whether the save inserts a new row or updates an existing one
* @return void
*/
public function validate(mixed $instance, bool $isInsert): void;
}
17 changes: 17 additions & 0 deletions src/Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use ByJG\MicroOrm\Exception\InvalidArgumentException;
use ByJG\MicroOrm\Exception\OrmModelInvalidException;
use ByJG\MicroOrm\Interface\EntityProcessorInterface;
use ByJG\MicroOrm\Interface\EntityValidatorInterface;
use ByJG\MicroOrm\Interface\MapperFunctionInterface;
use ByJG\MicroOrm\Literal\LiteralInterface;
use ByJG\MicroOrm\PropertyHandler\MapFromDbToInstanceHandler;
Expand All @@ -28,6 +29,7 @@ class Mapper
private bool $softDelete = false;
private string|EntityProcessorInterface|null $beforeInsert = null;
private string|EntityProcessorInterface|null $beforeUpdate = null;
private string|EntityValidatorInterface|null $validator = null;

/**
* @var FieldMapping[]
Expand Down Expand Up @@ -101,6 +103,10 @@ protected function processAttribute(string $entity): void
if ($beforeUpdate !== null && $beforeUpdate !== '') {
$this->withBeforeUpdate($beforeUpdate);
}
$validator = $tableAttribute->getValidator();
if ($validator !== null && $validator !== '') {
$this->withValidator($validator);
}
ORM::addMapper($this);

$this->primaryKey = [];
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -400,4 +412,9 @@ public function getBeforeUpdate(): mixed
{
return $this->beforeUpdate;
}

public function getValidator(): EntityValidatorInterface|string|null
{
return $this->validator;
}
}
37 changes: 37 additions & 0 deletions src/Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,6 +67,8 @@ class Repository
*/
protected EntityProcessorInterface|null $beforeInsert = null;

protected EntityValidatorInterface|null $validator = null;

/**
* @var ObserverProcessorInterface[]
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}
}
Loading
Loading