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.

108 changes: 108 additions & 0 deletions CHANGELOG-7.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 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

### Relationship joins on the query builder
- New `QueryBasic::joinRelated()`, `leftJoinRelated()` and `rightJoinRelated()`: add a JOIN whose
`ON` condition is derived from a registered `parentTable` relationship, connecting the table to one
already in the query. If they are not directly related, the intermediate tables on the shortest
relationship path are joined automatically; tables already in the query are skipped. The query
keeps its own base table, so this composes with a repository query
- `ActiveRecord::joinWith()` now builds on `joinRelated()` (starting from the model's own table)
- `joinRelated()`/`leftJoinRelated()`/`rightJoinRelated()`/`joinWith()` accept a **model class**
as well as a table name. Passing a class (e.g. `Project::class`) registers that entity's mapper
on demand — reflection only, no DB connection — so its table and `parentTable` relationships
become known even on a request that never touched its repository. To span a hidden intermediate,
name each entity in the path, like Eloquent's `hasManyThrough` through-model:
`Note::joinWith(Task::class, Project::class)`. New helper `ORM::getTableFromClass()` performs the
resolution
- See: `docs/auto-discovering-relationship.md`

## 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

### ORM::getQueryInstance() removed
- `ORM::getQueryInstance(...$tables)` is removed. Build relationship joins with
`Query::getInstance()->table($base)->joinRelated($related)` (or `Model::joinWith(...)` for Active
Record) — it keeps your base table, supports INNER/LEFT/RIGHT, and auto-joins intermediates. The
relationship registry (`addRelationship`/`getRelationship`/`getRelationshipData`) is unchanged

## 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
73 changes: 63 additions & 10 deletions docs/auto-discovering-relationship.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,32 +42,85 @@ relationship registry.

## Generating Queries with Relationships

To generate the SQL query with the relationship, you can use the `ORM` static class:
Once the relationships are registered, use `Query::joinRelated()` to add a
relationship-derived join to a query. It derives the `ON` condition from the
registered `parentTable` relationship instead of writing it by hand:

```php
<?php
$query = ORM::getQueryInstance("table1", "table2");
$query = Query::getInstance()
->table('table2')
->joinRelated('table1');
// SELECT * FROM table2 INNER JOIN table1 ON table1.id = table2.id_table1
```

The command above will return a query object with the appropriate join, equivalent to:
`joinRelated()` appends the join to whichever table is already in the query (the base
table or a previously joined one), so it keeps your own base table. Because of that it
composes on top of an existing query — including one a `Repository` already scoped to
its table, where `$repository->getByQuery($query)` still returns that repository's
entity.

There are `joinRelated()`, `leftJoinRelated()` and `rightJoinRelated()`, mirroring
`join()`/`leftJoin()`/`rightJoin()`, so you can pick the join type.

If the target table is **not directly related** to a table already in the query, the
intermediate tables on the shortest relationship path are joined automatically — you
don't have to remember them (though keep in mind the extra joins have a cost). Tables
already in the query are skipped, so chaining stays consistent:

```php
<?php
$query = Query::getInstance()
->table('table1')
->join('table2', 'table2.id_table1 = table1.id');
// table1 -> table2 -> table4: table2 is joined for you.
$query = Query::getInstance()->table('table1')->joinRelated('table4');
// SELECT * FROM table1
// INNER JOIN table2 ON table1.id = table2.id_table1
// INNER JOIN table4 ON table2.id = table4.id_table2

// Naming the intermediate explicitly gives the same result (table2 is not joined twice):
$query = Query::getInstance()->table('table1')->joinRelated('table2')->joinRelated('table4');
```

## Queries with Multiple Tables
For a `left`/`rightJoinRelated()` that spans intermediates, every hop it adds uses that
join type. `joinRelated()` throws an `InvalidArgumentException` only when no relationship
path connects the tables. For aliased joins, use `join()`/`leftJoin()`/`rightJoin()` with
an explicit `ON`.

Active Record models expose the same through `Model::joinWith(...$tables)`, which starts
from the model's own table and joins the related ones.

### Passing a model class (on-demand mapper registration)

`joinRelated()` (and `joinWith()`) accept a **model class** as well as a table name.
Relationship discovery walks the ORM's registry of mappers, and a mapper is only known
after it has been built. On a request that only touched one entity, the other entities'
mappers are not registered yet, so their relationships are invisible. Passing a class
fixes that: it registers that entity's mapper on demand — this reads the class attributes
(reflection) only and does **not** open a database connection — before resolving the join.

```php
<?php
// 'project' was never referenced on this request, so its mapper is not registered.
$query = Query::getInstance()->table('task')->joinRelated(Project::class);
// Project's mapper is registered on demand, then:
// SELECT * FROM task INNER JOIN project ON project.id = task.project_id
```

You can also create queries with multiple joined tables by passing more table names:
To span a **hidden intermediate**, name each entity in the path — the same way Eloquent's
`hasManyThrough` names its through-model. Auto-discovery can only join an intermediate
whose mapper is registered, so naming it is what makes it known:

```php
<?php
$query = ORM::getQueryInstance("table1", "table2", "table3");
// note -> task -> project. A note has no project_id, so 'task' is the through-entity.
$notes = Note::joinWith(Task::class, Project::class)
->field('note.*')
->where('project.id = :id', ['id' => $projectId]);
// FROM note INNER JOIN task ON task.id = note.task_id
// INNER JOIN project ON project.id = task.project_id
```

The ORM will automatically discover the path to connect these tables if relationships have been defined.
`ORM::getTableFromClass(Model::class)` exposes the same resolution directly, returning the
table name and registering the mapper if needed.

## Manual Relationship Definition

Expand Down
10 changes: 5 additions & 5 deletions docs/comparison-with-other-orms.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ class Post {
public ?string $title;
}

// 2. Auto-generate JOIN query from relationship
$query = ORM::getQueryInstance("users", "posts");
// Automatically generates: JOIN posts ON posts.user_id = users.id
// 2. Build a JOIN query from the relationship
$query = Query::getInstance()->table("users")->joinRelated("posts");
// Automatically generates: FROM users INNER JOIN posts ON users.id = posts.user_id

$results = $userRepo->getByQuery($query, [$postMapper]);
foreach ($results as [$user, $post]) {
Expand Down 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
Loading
Loading