Skip to content
Open
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
539 changes: 539 additions & 0 deletions CHANGELOG-6.0.md

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions CHANGELOG-7.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Changelog - Version 7.0

## Overview

Version 7.0 rewires the entire observer system on top of the low-level `DatabaseExecutor` observer
introduced by `byjg/anydataset-db` 6.0. The global `ORMSubject` singleton is removed: observer
dispatch now happens at the database-connection level, which makes observation complete (every
write built by MicroOrm notifies, not only `save()` and `deleteByQuery()`), faster (zero cost when
no observers are registered), and multi-connection safe.

`ObserverProcessorInterface` implementations require **no code change**.

## New Features

### Observers driven by the executor (ObserverBridge)
- All observer dispatch flows through `ObserverBridge`, a `DatabaseEventObserverInterface`
implementation attached (one per `DatabaseExecutor`) on demand
- Writes now notify regardless of how they are executed: `save()`, `delete()`, `deleteByQuery()`,
`bulkExecute()` (after commit; nothing on rollback), `buildAndExecute()`, and built queries
executed directly on the executor
- `save()` keeps its exact previous semantics: Insert/Update observers still receive the entity
rehydrated with generated keys (deferred dispatch)
- See: `docs/observers.md`

### OrmSqlStatement
- Every query builder `build()` now returns `OrmSqlStatement` (extends
`ByJG\AnyDataset\Db\SqlStatement`), stamped with the ORM event (`Insert`, `Update`, `Delete`,
`SoftDelete`) and the affected table for write builders
- `ObserverData::getStatement()` exposes it to observers (SQL text + params)

### SoftDelete observer event
- New `ObserverEvent::SoftDelete` case: `delete()` on a mapper with soft delete enabled now fires
an event (previously it fired nothing). The row still exists, with `deleted_at` set

### Before-execute hook with veto
- New `StatementHookInterface`: a processor implementing it is called before the SQL of an observed
table executes, with full access to SQL and params. Throwing aborts the write

### Raw low-level observers through the Repository
- `Repository::addObserver()` also accepts a raw
`ByJG\AnyDataset\Db\Interfaces\DatabaseEventObserverInterface`, attached directly to the
repository executors (receives `BEFORE/AFTER_QUERY` and `BEFORE/AFTER_EXECUTE` for every
statement, including reads and raw SQL)
- New `Repository::removeObserver()` for both observer kinds

## Breaking Changes

### ORMSubject removed
- `ORMSubject` (global singleton) no longer exists. `Repository::addObserver()` keeps working with
the same signature for `ObserverProcessorInterface`
- Remove any `ORMSubject::getInstance()->clearObservers()` calls (typically in test suites) —
bridges die with their executors, there is no global state to clear

### Observer scope: global → per executor
- Observers now fire for writes flowing through the `DatabaseExecutor` instance(s) of the
repository where they were registered. Two repositories over different executors no longer
cross-notify, even on the same database. Register the observer on each connection if needed
- `addDbDriverForWrite()` re-attaches the repository observers to the new write executor

### More events than before
- Soft deletes (`SoftDelete`), `bulkExecute()` writes, `buildAndExecute()` and direct executions of
built statements now notify. Review observers that assumed only `save()`/`deleteByQuery()` fired

### Event payload for non-save() writes
- Events from writes without an entity in scope (bulk, direct executions) carry the SQL parameters
array in `getData()`/`getOldData()` instead of entity instances. Use `getStatement()` for the SQL

### Repository protected helpers
- `insert()`, `insertWithAutoInc()`, `insertWithKeyGen()` and `update()` now receive the pre-built
`OrmSqlStatement` instead of the query builder. Subclasses overriding them must adopt the new
signatures

### ObserverEvent enum
- New `SoftDelete` case: exhaustive `match` expressions over `ObserverEvent` in userland need a new
arm

## Migration from 6.x

| 6.x | 7.0 |
|----------------------------------------------------------|----------------------------------------------------------|
| `ObserverProcessorInterface` implementations | No change |
| `$repository->addObserver($processor)` | No change |
| `ORMSubject::getInstance()->clearObservers()` | Remove the call |
| Observer watching writes made through another connection | Register the observer on a repository of each connection |
| `Repository` subclass overriding `insert()`/`update()` | Update to the new `OrmSqlStatement` signatures |
| `match` over `ObserverEvent` | Add the `SoftDelete` arm |
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"php": ">=8.3 <8.6",
"ext-json": "*",
"ext-pdo": "*",
"byjg/anydataset-db": "^6.0"
"byjg/anydataset-db": "^7.0"
},
"require-dev": {
"phpunit/phpunit": "^10.5|^11.5",
Expand Down
4 changes: 2 additions & 2 deletions docs/comparison-with-other-orms.md
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,7 @@ class UserStatusChangedObserver implements ObserverProcessorInterface
}

// Register the observer
ORMSubject::getInstance()->registerObserver(new UserStatusChangedObserver());
$repository->addObserver(new UserStatusChangedObserver());

// Now whenever a user's status changes, your domain event fires!
$user = $repository->get(1);
Expand Down Expand Up @@ -1081,7 +1081,7 @@ class Order
}

// 2. Register domain event handlers (observers)
ORMSubject::getInstance()->registerObserver(new class implements ObserverProcessorInterface {
$repository->addObserver(new class implements ObserverProcessorInterface {
public function getObservedTable(): string { return 'orders'; }

public function process(ObserverData $data): void
Expand Down
79 changes: 70 additions & 9 deletions docs/observers.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,45 @@ sidebar_position: 12

# Observers

An observer is a class will be called after an insert, update or delete record in the DB.
An observer is a class that is called when a record is inserted, updated or deleted in the DB.

Since version 7.0 the observers are dispatched by the low-level
[anydataset-db executor observer](https://github.com/byjg/php-anydataset-db):
every `Repository` attaches a bridge to its `DatabaseExecutor`, and any write statement built by
MicroOrm that flows through that executor notifies the observers watching its table — no matter if
the write came from a `Repository` method, `buildAndExecute()`, `bulkExecute()` or a built query
executed directly on the executor.

```mermaid
flowchart TD
A[MyRepository] --> |1. addObserver| B[Subject]
C[WatchedRepository] --> |2. Notify Update| B
A[MyRepository] --> |1. addObserver| B[ObserverBridge attached to the DatabaseExecutor]
C[Any write through the same executor] --> |2. BEFORE/AFTER_EXECUTE| B
B --> |3. Execute Callback| A
```

## Example

```php
<?php
// This observer will be called after insert, update or delete a record on the table 'triggerTable'
$myRepository->addObserver(new class($this->infoMapper->getTable()) implements ObserverProcessorInterface {
// This observer will be called after insert, update or delete a record on the table 'triggerTable'
$myRepository->addObserver(new class('triggerTable') implements ObserverProcessorInterface {
private $table;

public function __construct($table)
{
$this->table = $table;
}

public function process(ObserverData $observerData)
public function process(ObserverData $observerData): void
{
// Do something here
}

public function onError(Throwable $exception, ObserverData $observerData): void
{
// Called when process() throws. The write is never aborted by process().
}

public function getObservedTable(): string
{
return $this->table;
Expand All @@ -39,11 +51,60 @@ $myRepository->addObserver(new class($this->infoMapper->getTable()) implements O
```

The `ObserverData` class contains the following properties:

- `getTable()`: The table name that was affected
- `getEvent()`: The event that was triggered. Can be 'insert', 'update' or 'delete'
- `getEvent()`: The `ObserverEvent` that was triggered: `Insert`, `Update`, `Delete` or `SoftDelete`
- `getData()`: The data that was inserted or updated. It is null in case of delete.
For writes issued outside `Repository::save()` (bulk, direct executions) the entity is not
available and the SQL parameters array is provided instead.
- `getOldData()`: The data before update. In case of insert comes null, and in case of delete comes with the param filters.
- `getRepository()`: The repository is listening to the event (the same as $myRepository)
- `getRepository()`: The repository listening to the event (the same as `$myRepository`)
- `getStatement()`: The `OrmSqlStatement` that triggered the event, with the SQL text (`getSql()`) and
parameters (`getParams()`)

## Which writes fire events

| Write | Event | data / oldData |
|-------|-------|----------------|
| `save()` insert | `Insert` | hydrated entity (with generated keys) / null |
| `save()` update | `Update` | entity / previous entity |
| `delete()` / `deleteByQuery()` | `Delete` | null / where params |
| `delete()` with soft delete enabled | `SoftDelete` | null / where params (the row still exists, with `deleted_at` set) |
| `bulkExecute()` | one event per write statement, after commit (nothing on rollback) | params |
| `buildAndExecute()` or a built query executed directly on the executor | per builder | params |

*Note*: Raw SQL strings executed on the executor (not built by a MicroOrm query builder) do not fire
entity events. Attach a raw `DatabaseEventObserverInterface` (below) if you need to see everything.

## Observer scope

Observers are scoped to the `DatabaseExecutor` instance(s) of the repository where
`addObserver()` was called. Writes through another executor — even on the same database — do not
notify. If you need to observe more than one connection, register the observer on a repository of
each one.

## Before hook (veto)

If the processor also implements `StatementHookInterface`, it is called right before the SQL of an
observed table executes:

```php
<?php
class MyObserver implements ObserverProcessorInterface, StatementHookInterface
{
public function beforeStatement(OrmSqlStatement $statement, Repository $repository): void
{
// Full access to $statement->getSql() and $statement->getParams().
// Throwing here ABORTS the write.
}

// ... process(), onError(), getObservedTable()
}
```

*Note*: The observer will not be called if the insert, update or delete is called using the DBDriver object.
## Low-level observers

`Repository::addObserver()` also accepts a raw
`ByJG\AnyDataset\Db\Interfaces\DatabaseEventObserverInterface`. It is attached directly to the
repository executors and receives the anydataset-db events (`BEFORE_QUERY`, `AFTER_QUERY`,
`BEFORE_EXECUTE`, `AFTER_EXECUTE`) for every statement, including reads and raw SQL.
5 changes: 3 additions & 2 deletions docs/query-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,16 @@ $query = Query::getInstance()
$sqlStatement = $query->build();

// Use the same statement with different parameters
$executor = DatabaseExecutor::using($dbDriver);
$params1 = ['min_age' => 18];
$iterator1 = $dbDriver->getIterator($sqlStatement->withParams($params1));
$iterator1 = $executor->getIterator($sqlStatement->withParams($params1));
foreach ($iterator1 as $row) {
echo "Adult user: {$row['name']}\n";
}

// Reuse with different parameters
$params2 = ['min_age' => 21];
$iterator2 = $dbDriver->getIterator($sqlStatement->withParams($params2));
$iterator2 = $executor->getIterator($sqlStatement->withParams($params2));
foreach ($iterator2 as $row) {
echo "Legal age user: {$row['name']}\n";
}
Expand Down
18 changes: 9 additions & 9 deletions docs/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,25 +80,25 @@ The `getMapper` method returns the Mapper object that defines the relationship b
$mapper = $repository->getMapper();
```

### getDbDriver
### getExecutor

The `getDbDriver` method returns the database driver used for read operations. It allows you to use SQL commands
The `getExecutor` method returns the `DatabaseExecutor` used for read operations. It allows you to use SQL commands
directly.
To find more about the database driver, please refer to the [AnyDataset documentation](https://github.com/byjg/anydataset)
To find more about the executor, please refer to the [AnyDataset documentation](https://github.com/byjg/anydataset)
and the [Query](querying-the-database.md).

```php
$dbDriver = $repository->getDbDriver();
$iterator = $dbDriver->getIterator('select * from mytable');
$executor = $repository->getExecutor();
$iterator = $executor->getIterator('select * from mytable');
```

### getDbDriverWrite
### getExecutorWrite

The `getDbDriverWrite` method returns the database driver used for write operations. If no separate write driver was
configured, it returns the same driver as `getDbDriver()`.
The `getExecutorWrite` method returns the `DatabaseExecutor` used for write operations. If no separate write executor
was configured, it returns the same executor as `getExecutor()`.

```php
$dbDriverWrite = $repository->getDbDriverWrite();
$executorWrite = $repository->getExecutorWrite();
```

## Repository Query Methods
Expand Down
6 changes: 3 additions & 3 deletions src/DeleteQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface;
use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface;
use ByJG\AnyDataset\Db\SqlStatement;
use ByJG\MicroOrm\Enum\ObserverEvent;
use ByJG\MicroOrm\Exception\InvalidArgumentException;
use ByJG\MicroOrm\Interface\QueryBuilderInterface;
use Override;
Expand All @@ -17,7 +17,7 @@ public static function getInstance(): DeleteQuery
}

#[Override]
public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement
public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement
{
$whereStr = $this->getWhere();
if (is_null($whereStr)) {
Expand All @@ -31,7 +31,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp

$sql = ORMHelper::processLiteral($sql, $params);

return new SqlStatement($sql, $params);
return new OrmSqlStatement($sql, $params, ObserverEvent::Delete, $this->table);
}

#[Override]
Expand Down
1 change: 1 addition & 0 deletions src/Enum/ObserverEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ enum ObserverEvent: string
case Insert = 'insert';
case Update = 'update';
case Delete = 'delete';
case SoftDelete = 'soft_delete';
}
8 changes: 4 additions & 4 deletions src/InsertBulkQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface;
use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface;
use ByJG\AnyDataset\Db\SqlStatement;
use ByJG\MicroOrm\Enum\ObserverEvent;
use ByJG\MicroOrm\Exception\OrmInvalidFieldsException;
use ByJG\MicroOrm\Interface\QueryBuilderInterface;
use ByJG\MicroOrm\Literal\Literal;
Expand Down Expand Up @@ -62,11 +62,11 @@ public function values(array $values, bool $allowNonMatchFields = true): static

/**
* @param DbDriverInterface|SqlDialectInterface|null $dbDriverOrHelper
* @return SqlStatement
* @return OrmSqlStatement
* @throws OrmInvalidFieldsException
*/
#[Override]
public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement
public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement
{
if (empty($this->fields)) {
throw new OrmInvalidFieldsException('You must specify the fields for insert');
Expand Down Expand Up @@ -124,7 +124,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp
);

$sql = ORMHelper::processLiteral($sql, $params);
return new SqlStatement($sql, $params);
return new OrmSqlStatement($sql, $params, ObserverEvent::Insert, $this->table);
}

#[Override]
Expand Down
8 changes: 4 additions & 4 deletions src/InsertQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface;
use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface;
use ByJG\AnyDataset\Db\SqlStatement;
use ByJG\MicroOrm\Enum\ObserverEvent;
use ByJG\MicroOrm\Exception\InvalidArgumentException;
use ByJG\MicroOrm\Exception\OrmInvalidFieldsException;
use ByJG\MicroOrm\Interface\QueryBuilderInterface;
Expand Down Expand Up @@ -69,11 +69,11 @@ public function defineFields(array $fields): static

/**
* @param DbDriverInterface|SqlDialectInterface|null $dbDriverOrHelper
* @return SqlStatement
* @return OrmSqlStatement
* @throws OrmInvalidFieldsException
*/
#[Override]
public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement
public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement
{
if (empty($this->values)) {
throw new OrmInvalidFieldsException('You must specify the fields for insert');
Expand Down Expand Up @@ -101,7 +101,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp

$params = $this->values;
$sql = ORMHelper::processLiteral($sql, $params);
return new SqlStatement($sql, $params);
return new OrmSqlStatement($sql, $params, ObserverEvent::Insert, $this->table);
}

/**
Expand Down
Loading
Loading