From 59af8b1740ff839b1ef84c02613e5099c4e073bb Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 12 Jul 2026 11:56:39 -0400 Subject: [PATCH 1/5] Rewire observers on top of the anydataset-db 7.0 DatabaseExecutor observer - Remove the global ORMSubject singleton; observer dispatch now flows through ObserverBridge, a DatabaseEventObserverInterface implementation attached per DatabaseExecutor - Query builders return OrmSqlStatement (extends SqlStatement) stamped with the ORM event and affected table - All writes now notify: save(), delete(), deleteByQuery(), bulkExecute() (after commit), buildAndExecute() and built statements executed directly on the executor - save() keeps its previous semantics via deferred dispatch: observers receive the entity rehydrated with generated keys - New ObserverEvent::SoftDelete case: soft deletes now fire an event - New StatementHookInterface: before-execute hook with veto (throwing aborts the write) - Repository::addObserver() also accepts raw DatabaseEventObserverInterface; new Repository::removeObserver(); addDbDriverForWrite() re-attaches observers to the new write executor - ObserverData::getStatement() exposes the SQL statement to observers - Require byjg/anydataset-db ^7.0 and migrate tests/docs off the driver query methods removed in that release --- CHANGELOG-6.0.md | 539 +++++++++++++++++++++++ CHANGELOG-7.0.md | 86 ++++ composer.json | 2 +- docs/comparison-with-other-orms.md | 4 +- docs/observers.md | 79 +++- docs/query-build.md | 5 +- docs/repository.md | 18 +- src/DeleteQuery.php | 6 +- src/Enum/ObserverEvent.php | 1 + src/InsertBulkQuery.php | 8 +- src/InsertQuery.php | 8 +- src/InsertSelectQuery.php | 7 +- src/Interface/StatementHookInterface.php | 18 + src/ORMSubject.php | 70 --- src/ObserverBridge.php | 181 ++++++++ src/ObserverData.php | 16 +- src/OrmEventContext.php | 84 ++++ src/OrmSqlStatement.php | 61 +++ src/Query.php | 2 +- src/QueryBasic.php | 2 +- src/QueryRaw.php | 2 +- src/Recursive.php | 2 +- src/Repository.php | 157 +++++-- src/Union.php | 2 +- src/Updatable.php | 10 + src/UpdateQuery.php | 7 +- tests/ConnectionUtil.php | 3 +- tests/DeleteQueryTest.php | 15 +- tests/InsertQueryTest.php | 17 +- tests/ObserverBridgeTest.php | 370 ++++++++++++++++ tests/QueryTest.php | 85 ++-- tests/RecursiveTest.php | 4 +- tests/RepositoryTest.php | 8 +- tests/TransactionManagerTest.php | 44 +- tests/UpdateQueryTest.php | 53 ++- tests/WhereTraitTest.php | 8 +- 36 files changed, 1726 insertions(+), 258 deletions(-) create mode 100644 CHANGELOG-6.0.md create mode 100644 CHANGELOG-7.0.md create mode 100644 src/Interface/StatementHookInterface.php delete mode 100644 src/ORMSubject.php create mode 100644 src/ObserverBridge.php create mode 100644 src/OrmEventContext.php create mode 100644 src/OrmSqlStatement.php create mode 100644 tests/ObserverBridgeTest.php diff --git a/CHANGELOG-6.0.md b/CHANGELOG-6.0.md new file mode 100644 index 0000000..e8572f3 --- /dev/null +++ b/CHANGELOG-6.0.md @@ -0,0 +1,539 @@ +# Changelog - Version 6.0 + +## Overview + +Version 6.0 is a major release that introduces significant improvements to type safety, adds new features for enhanced ORM functionality, and removes deprecated methods from version 5.x. This release requires PHP 8.3+ and includes breaking changes that require code updates when upgrading from 5.x. + +## New Features + +### ActiveRecordQuery - Fluent Query API +- Introduced `ActiveRecordQuery` for a fluent and chainable query API +- Provides an intuitive way to build complex queries with method chaining +- Enhances the Active Record pattern implementation +- See: `docs/active-record.md` for examples + +### UUID Support Enhancements +- Added comprehensive UUID field support beyond just primary keys +- Introduced `FormatSelectUuidMapper` and `FormatUpdateUuidMapper` (renamed from Binary variants) +- Added `HexUuidMapperFunctionTest` for validation of UUID handling +- Full support for UUIDs in regular fields with automatic formatting +- See: `docs/uuid-support.md` + +### Varchar Primary Keys +- Added support for non-integer (varchar) primary keys +- Introduced `Product` and `ActiveRecordProduct` models showcasing custom primary key handling +- Enhanced primary key validation and handling across the ORM +- Updated tests and examples with seed functions for varchar PKs + +### HAVING Clause Support +- Added native support for HAVING clause in query building +- Allows for aggregate function filtering in SQL queries +- Integrates seamlessly with the existing query API + +### Entity Lifecycle Hooks +- Added `beforeUpdate` and `beforeInsert` hooks to the `TableAttribute` +- Introduced `EntityProcessorInterface` for custom entity processing +- Allows for custom validation and data transformation before persistence +- Can be defined at both table attribute and mapper levels +- Methods: `withBeforeInsert()` and `withBeforeUpdate()` in Mapper + +### Mapper Functions (Enhanced) +- Introduced `MapperFunctionInterface` as the standard interface for field transformations +- Added comprehensive mapper functions: + - `StandardMapper` - basic field mapping + - `ReadOnlyMapper` - read-only fields + - `NowUtcMapper` - automatic UTC timestamp + - `FormatUpdateUuidMapper` - UUID formatting on update + - `FormatSelectUuidMapper` - UUID formatting on select +- Simplified and more consistent API for field transformations +- See: `docs/mapper-functions.md` + +### Enhanced Field Mapping +- Added `MapperTest` to validate field mapping behavior +- Support for field overwriting, aliases, and non-existent properties +- Added `getPropertyName()` method to Mapper for reverse field-to-property lookup +- Improved handling of array-to-object casting in Mapper + +### Observer Events +- Introduced `ObserverEvent` enum with Insert, Update, and Delete events +- Provides type-safe event handling for database operations +- See: `docs/observers.md` + +### Update Constraints +- Added `UpdateConstraintInterface` for controlling update operations +- Introduced `CustomConstraint` for custom update validation +- Added `RequireChangedValuesConstraint` to ensure values have changed before update +- New exception: `RequireChangedValuesConstraintException` +- See: `docs/update-constraints.md` + +### Enhanced Primary Key Handling +- Improved validation with `MissingPrimaryKeyException` +- More robust primary key validation in `Repository::getPrimaryKeys()` +- Better error messages for missing or invalid primary keys +- Enhanced support for composite primary keys + +### Iterator Support +- Added `buildAndExecuteIterator()` to `QueryBuilderInterface` +- Implemented `getIterator()` and enhanced `getByQuery()` in Repository +- Allows for efficient memory usage with large result sets + +### Serialization Optimization +- Applied `withStopAtFirstLevel()` in Serialize calls across multiple classes +- Prevents unnecessary deep parsing and improves performance +- Optimizes data transfer between layers + +### Documentation Improvements +- Added comprehensive `docs/architecture-layers.md` explaining Infrastructure vs Domain layers +- Added `docs/comparison-with-other-orms.md` comparing with Eloquent and Doctrine +- Added `docs/common-traits.md` for timestamp field helpers +- Added `docs/mapper-functions.md` for field transformation documentation +- Added `docs/query-build.md` for SQL query building +- Expanded and improved all existing documentation files +- Better examples and code samples throughout + +## Bug Fixes + +- Fixed `Repository::setBeforeUpdate()` and `Repository::setBeforeInsert()` implementations +- Improved primary key handling edge cases +- Fixed namespace references for `byjg/anydataset-db` compatibility +- Resolved Psalm static analysis issues +- Fixed test compatibility with updated dependencies +- Corrected return type for `Repository::insert()` to `?int` +- Improved transaction handling in Repository +- Fixed UUID formatting consistency issues + +## Breaking Changes + +| Before (5.x) | After (6.0) | Description | +|--------------|-------------|-------------| +| `php: >=8.1 <8.4` | `php: >=8.3 <8.6` | PHP 8.3+ is now required | +| `byjg/anydataset-db: ^5.0` | `byjg/anydataset-db: ^6.0` | Updated to version 6.x of anydataset-db | +| `phpunit/phpunit: ^9.6` | `phpunit/phpunit: ^10.5\|^11.5` | Updated to PHPUnit 10.5+ or 11.5+ | +| `byjg/cache-engine: ^5.0` | `byjg/cache-engine: ^6.0` | Updated to version 6.x | +| `DbFunctionsInterface` | `DatabaseExecutor` | Interface renamed for clarity | +| `DbDriverInterface` parameter | `Repository->getExecutor()` | Access database executor through repository | +| `SqlObject` | `SqlStatement` | Class renamed across entire codebase | +| `UniqueIdGeneratorInterface` | `MapperFunctionInterface` | Interface renamed for consistency | +| `SelectBinaryUuidMapper` | `FormatSelectUuidMapper` | Mapper class renamed | +| `UpdateBinaryUuidMapper` | `FormatUpdateUuidMapper` | Mapper class renamed | +| `Mapper::addFieldMap()` | `Mapper::addFieldMapping()` | Deprecated method removed | +| `MapperClosure` | Mapper Functions (e.g., `StandardMapper`) | Deprecated class removed | +| `AllowOnlyNewValuesConstraintException` | Removed/replaced | Exception removed | +| `Literal` class usage | `LiteralInterface` | Refactored to use interface | +| Nullable types (inconsistent) | Strict nullable types (`?Type`) | Improved type safety across all methods | +| `withPrimaryKeySeedFunction(callable)` | `withPrimaryKeySeedFunction(string\|MapperFunctionInterface)` | Type signature changed | + +### Removed Deprecated Features + +The following deprecated features from 5.x have been removed: + +1. **`Mapper::addFieldMap()`** - Use `Mapper::addFieldMapping()` instead +2. **`MapperClosure`** - Use the new Mapper Functions classes (`StandardMapper`, `ReadOnlyMapper`, etc.) +3. **Old closure-based field transformations** - Use `MapperFunctionInterface` implementations +4. **Direct `DbDriverInterface` access** - Use `Repository->getExecutor()` instead + +## Upgrade Path from 5.x to 6.0 + +### Step 1: Update Dependencies + +Update your `composer.json`: + +```json +{ + "require": { + "php": ">=8.3", + "byjg/micro-orm": "^6.0" + } +} +``` + +Run: +```bash +composer update byjg/micro-orm +``` + +### Step 2: Update PHP Version + +Ensure your project is running PHP 8.3 or higher. Version 6.0 takes advantage of PHP 8.3+ features including: +- Typed class constants +- Enhanced type system +- Improved readonly properties +- Better attribute handling + +### Step 3: Replace Deprecated Classes and Methods + +#### Replace `SqlObject` with `SqlStatement` + +**Before:** +```php +use ByJG\AnyDataset\Db\SqlObject; + +$sql = new SqlObject('SELECT * FROM users'); +``` + +**After:** +```php +use ByJG\AnyDataset\Db\SqlStatement; + +$sql = new SqlStatement('SELECT * FROM users'); +``` + +#### Replace `DbFunctionsInterface` with `DatabaseExecutor` + +**Before:** +```php +public function __construct(DbFunctionsInterface $dbFunctions) +{ + $this->dbFunctions = $dbFunctions; +} +``` + +**After:** +```php +public function __construct(DatabaseExecutor $executor) +{ + $this->executor = $executor; +} +``` + +Or, if you were accessing it through Repository: + +**Before:** +```php +$driver = $repository->getDbDriver(); +``` + +**After:** +```php +$executor = $repository->getExecutor(); +``` + +#### Replace `UniqueIdGeneratorInterface` with `MapperFunctionInterface` + +**Before:** +```php +use ByJG\MicroOrm\Interface\UniqueIdGeneratorInterface; + +class MyGenerator implements UniqueIdGeneratorInterface +{ + // ... +} +``` + +**After:** +```php +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class MyGenerator implements MapperFunctionInterface +{ + public function update(mixed $value, object $instance, string $field): mixed + { + // your logic + } + + public function select(mixed $value, object $instance, string $field): mixed + { + // your logic + } +} +``` + +#### Replace UUID Mapper Classes + +**Before:** +```php +use ByJG\MicroOrm\MapperFunctions\SelectBinaryUuidMapper; +use ByJG\MicroOrm\MapperFunctions\UpdateBinaryUuidMapper; + +$field->withUpdateFunction(new UpdateBinaryUuidMapper()); +$field->withSelectFunction(new SelectBinaryUuidMapper()); +``` + +**After:** +```php +use ByJG\MicroOrm\MapperFunctions\FormatUpdateUuidMapper; +use ByJG\MicroOrm\MapperFunctions\FormatSelectUuidMapper; + +$field->withUpdateFunction(new FormatUpdateUuidMapper()); +$field->withSelectFunction(new FormatSelectUuidMapper()); +``` + +#### Replace `Mapper::addFieldMap()` with `addFieldMapping()` + +**Before:** +```php +$mapper->addFieldMap( + 'propertyName', + 'field_name', + fn($value) => strtoupper($value), // update function + fn($value) => strtolower($value) // select function +); +``` + +**After:** +```php +use ByJG\MicroOrm\FieldMapping; + +$fieldMapping = FieldMapping::create('propertyName') + ->withFieldName('field_name') + ->withUpdateFunction(new class implements MapperFunctionInterface { + public function update(mixed $value, object $instance, string $field): mixed { + return strtoupper($value); + } + public function select(mixed $value, object $instance, string $field): mixed { + return strtolower($value); + } + }); + +$mapper->addFieldMapping($fieldMapping); +``` + +Or use one of the built-in mapper functions: + +```php +use ByJG\MicroOrm\FieldMapping; +use ByJG\MicroOrm\MapperFunctions\StandardMapper; + +$fieldMapping = FieldMapping::create('propertyName') + ->withFieldName('field_name') + ->withUpdateFunction(new StandardMapper()) + ->withSelectFunction(new StandardMapper()); + +$mapper->addFieldMapping($fieldMapping); +``` + +#### Replace `MapperClosure` with Mapper Functions + +**Before:** +```php +use ByJG\MicroOrm\MapperClosure; + +$closure = MapperClosure::forUpdate(fn($value) => json_encode($value)); +``` + +**After:** +```php +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class JsonEncodeMapper implements MapperFunctionInterface +{ + public function update(mixed $value, object $instance, string $field): mixed + { + return json_encode($value); + } + + public function select(mixed $value, object $instance, string $field): mixed + { + return $value; + } +} + +$mapper = new JsonEncodeMapper(); +``` + +### Step 4: Update Type Declarations + +Review your code for nullable type declarations. Version 6.0 enforces strict nullable types: + +**Before:** +```php +public function myMethod(string $param) // might accept null in some contexts +``` + +**After:** +```php +public function myMethod(?string $param) // explicitly nullable +``` + +### Step 5: Use `LiteralInterface` Instead of Direct `Literal` Usage + +**Before:** +```php +use ByJG\AnyDataset\Db\Literal; + +$value = new Literal('NOW()'); +``` + +**After:** +```php +use ByJG\AnyDataset\Db\LiteralInterface; +use ByJG\AnyDataset\Db\Literal; + +function processValue(LiteralInterface $value) +{ + // Type hint uses interface +} + +$value = new Literal('NOW()'); // Implementation is still Literal +processValue($value); +``` + +### Step 6: Update Primary Key Seed Functions + +If you were using callable for primary key seed functions: + +**Before:** +```php +$mapper->withPrimaryKeySeedFunction(function() { + return generateCustomId(); +}); +``` + +**After:** +```php +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class CustomIdGenerator implements MapperFunctionInterface +{ + public function update(mixed $value, object $instance, string $field): mixed + { + return generateCustomId(); + } + + public function select(mixed $value, object $instance, string $field): mixed + { + return $value; + } +} + +$mapper->withPrimaryKeySeedFunction(new CustomIdGenerator()); +// Or use a string reference to a static class method +$mapper->withPrimaryKeySeedFunction('MyClass::generateId'); +``` + +### Step 7: Handle New Exceptions + +Add handling for new exceptions introduced in 6.0: + +```php +use ByJG\MicroOrm\Exception\MissingPrimaryKeyException; +use ByJG\MicroOrm\Exception\RequireChangedValuesConstraintException; + +try { + $repository->save($entity); +} catch (MissingPrimaryKeyException $e) { + // Handle missing primary key +} catch (RequireChangedValuesConstraintException $e) { + // Handle unchanged values when using RequireChangedValuesConstraint +} +``` + +### Step 8: Update Tests + +- Update PHPUnit to version 10.5+ or 11.5+ +- Review test setup to use `ConnectionUtil` for database connections +- Replace all `SqlObject` references with `SqlStatement` +- Update any custom test assertions for type changes + +### Step 9: Leverage New Features (Optional) + +Take advantage of new features introduced in 6.0: + +#### Use Entity Lifecycle Hooks + +```php +use ByJG\MicroOrm\Attributes\TableAttribute; + +#[TableAttribute( + tableName: 'users', + beforeInsert: MyValidator::class . '::validateBeforeInsert', + beforeUpdate: MyValidator::class . '::validateBeforeUpdate' +)] +class User +{ + // ... +} +``` + +#### Use ActiveRecordQuery for Fluent API + +```php +$products = Product::query() + ->where('category = :cat', ['cat' => 'electronics']) + ->orderBy(['price' => 'DESC']) + ->limit(10) + ->get(); +``` + +#### Use HAVING Clause + +```php +$query = Query::getInstance() + ->fields(['category', 'COUNT(*) as total']) + ->groupBy(['category']) + ->having('COUNT(*) > :min', ['min' => 5]); +``` + +#### Add Update Constraints + +```php +use ByJG\MicroOrm\Constraint\RequireChangedValuesConstraint; + +$repository->setUpdateConstraint(new RequireChangedValuesConstraint()); +``` + +### Step 10: Run Tests and Verify + +After making all changes: + +```bash +# Update dependencies +composer update + +# Run static analysis +vendor/bin/psalm + +# Run tests +vendor/bin/phpunit +``` + +## Additional Notes + +### Performance Improvements + +- Serialization is now optimized with `withStopAtFirstLevel()` +- Iterator support allows efficient handling of large datasets +- Better query caching through refined cache key generation + +### Code Quality + +- Full PHP 8.3+ type safety +- Added `#[Override]` attributes for better IDE support +- Comprehensive Psalm static analysis compliance +- Improved code documentation and examples + +### Testing + +- Expanded test coverage for all new features +- Better edge case handling in tests +- Centralized database connection logic in tests +- Support for Docker-based testing with MySQL + +## Migration Checklist + +- [ ] Update PHP to 8.3 or higher +- [ ] Update `composer.json` to require `byjg/micro-orm: ^6.0` +- [ ] Run `composer update` +- [ ] Replace all `SqlObject` with `SqlStatement` +- [ ] Replace `DbFunctionsInterface` with `DatabaseExecutor` +- [ ] Replace `UniqueIdGeneratorInterface` with `MapperFunctionInterface` +- [ ] Replace UUID mapper classes (`SelectBinaryUuidMapper` → `FormatSelectUuidMapper`, etc.) +- [ ] Replace `Mapper::addFieldMap()` with `Mapper::addFieldMapping()` +- [ ] Remove usage of deprecated `MapperClosure` class +- [ ] Update type hints to use `LiteralInterface` where applicable +- [ ] Update primary key seed functions to new signature +- [ ] Add exception handling for `MissingPrimaryKeyException` and `RequireChangedValuesConstraintException` +- [ ] Update PHPUnit to 10.5+ or 11.5+ +- [ ] Run Psalm and fix any type issues +- [ ] Run test suite and verify all tests pass +- [ ] Review and update documentation/comments + +## Support + +For issues, questions, or contributions: +- GitHub Issues: https://github.com/byjg/php-micro-orm/issues +- Documentation: See the `docs/` folder for detailed guides + +## Credits + +This release includes contributions focused on improving type safety, developer experience, and ORM functionality. Special attention was given to maintaining clean architecture patterns and comprehensive documentation. diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md new file mode 100644 index 0000000..f387d9a --- /dev/null +++ b/CHANGELOG-7.0.md @@ -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 | diff --git a/composer.json b/composer.json index 9bfd58a..311b063 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md index aa52c74..62a69a5 100644 --- a/docs/comparison-with-other-orms.md +++ b/docs/comparison-with-other-orms.md @@ -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); @@ -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 diff --git a/docs/observers.md b/docs/observers.md index a923b89..bd8518b 100644 --- a/docs/observers.md +++ b/docs/observers.md @@ -4,12 +4,19 @@ 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 ``` @@ -17,8 +24,8 @@ flowchart TD ```php 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) @@ -26,11 +33,16 @@ $myRepository->addObserver(new class($this->infoMapper->getTable()) implements O $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; @@ -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 +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. diff --git a/docs/query-build.md b/docs/query-build.md index 7179e62..a10f308 100644 --- a/docs/query-build.md +++ b/docs/query-build.md @@ -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"; } diff --git a/docs/repository.md b/docs/repository.md index f05e764..4e001fc 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -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 diff --git a/src/DeleteQuery.php b/src/DeleteQuery.php index c95be54..fcfb040 100644 --- a/src/DeleteQuery.php +++ b/src/DeleteQuery.php @@ -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; @@ -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)) { @@ -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] diff --git a/src/Enum/ObserverEvent.php b/src/Enum/ObserverEvent.php index 7fbd6bc..4804a72 100644 --- a/src/Enum/ObserverEvent.php +++ b/src/Enum/ObserverEvent.php @@ -7,4 +7,5 @@ enum ObserverEvent: string case Insert = 'insert'; case Update = 'update'; case Delete = 'delete'; + case SoftDelete = 'soft_delete'; } \ No newline at end of file diff --git a/src/InsertBulkQuery.php b/src/InsertBulkQuery.php index 1df6dbc..dc8c604 100644 --- a/src/InsertBulkQuery.php +++ b/src/InsertBulkQuery.php @@ -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; @@ -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'); @@ -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] diff --git a/src/InsertQuery.php b/src/InsertQuery.php index 1a34e02..26a74e6 100644 --- a/src/InsertQuery.php +++ b/src/InsertQuery.php @@ -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; @@ -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'); @@ -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); } /** diff --git a/src/InsertSelectQuery.php b/src/InsertSelectQuery.php index e540074..d1d532a 100644 --- a/src/InsertSelectQuery.php +++ b/src/InsertSelectQuery.php @@ -5,6 +5,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 InvalidArgumentException; @@ -53,11 +54,11 @@ public function fromSqlStatement(SqlStatement $sqlStatement): 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'); @@ -98,7 +99,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp throw new OrmInvalidFieldsException('Query or SqlStatement must be set'); } - return new SqlStatement($sql . $fromObj->getSql(), $fromObj->getParams()); + return new OrmSqlStatement($sql . $fromObj->getSql(), $fromObj->getParams(), ObserverEvent::Insert, $this->table); } #[Override] diff --git a/src/Interface/StatementHookInterface.php b/src/Interface/StatementHookInterface.php new file mode 100644 index 0000000..7cb9d14 --- /dev/null +++ b/src/Interface/StatementHookInterface.php @@ -0,0 +1,18 @@ + - */ - protected array $observers = []; - - public function addObserver(ObserverProcessorInterface $observerProcessor, Repository $repoObserverIn): void - { - $repoObserverIn->getExecutor()->getDriver()->log("Observer: entity " . $repoObserverIn->getMapper()->getTable() . ", listening for {$observerProcessor->getObservedTable()}"); - if (!isset($this->observers[$observerProcessor->getObservedTable()])) { - $this->observers[$observerProcessor->getObservedTable()] = []; - } - /** @var ObserverProcessorInternal $observer */ - foreach ($this->observers[$observerProcessor->getObservedTable()] as $observer) { - if (get_class($observer->getObservedProcessor()) === get_class($observerProcessor) && get_class($observer->getRepository()) === get_class($repoObserverIn)) { - throw new InvalidArgumentException("Observer already exists"); - } - } - $this->observers[$observerProcessor->getObservedTable()][] = new ObserverProcessorInternal($observerProcessor, $repoObserverIn); - } - - public function notify(string $entitySource, ObserverEvent $event, mixed $data, mixed $oldData = null): void - { - if (!isset($this->observers[$entitySource])) { - return; - } - foreach ($this->observers[$entitySource] as $observer) { - $observer->log("Observer: notifying " . $observer->getMapper()->getTable() . ", changes in $entitySource"); - - $observerData = new ObserverData($entitySource, $event, $data, $oldData, $observer->getRepository()); - - try { - $observer->getObservedProcessor()->process($observerData); - } catch (Throwable $e) { - $observer->getObservedProcessor()->onError($e, $observerData); - } - } - } - - public function clearObservers(): void - { - $this->observers = []; - } -} \ No newline at end of file diff --git a/src/ObserverBridge.php b/src/ObserverBridge.php new file mode 100644 index 0000000..ea50be8 --- /dev/null +++ b/src/ObserverBridge.php @@ -0,0 +1,181 @@ +|null + */ + private static ?WeakMap $registry = null; + + /** + * @var array keyed by observed table + */ + private array $processors = []; + + private function __construct() + { + } + + /** + * Get the bridge for the executor, creating and attaching it on demand. + */ + public static function for(DatabaseExecutor $executor): ObserverBridge + { + $bridge = self::find($executor); + if (is_null($bridge)) { + if (self::$registry === null) { + /** @var WeakMap $registry */ + $registry = new WeakMap(); + self::$registry = $registry; + } + $bridge = new ObserverBridge(); + self::$registry[$executor] = $bridge; + $executor->addObserver($bridge); + } + return $bridge; + } + + /** + * Get the bridge for the executor, or null if none was created. + */ + public static function find(DatabaseExecutor $executor): ?ObserverBridge + { + if (self::$registry === null || !isset(self::$registry[$executor])) { + return null; + } + $bridge = self::$registry[$executor]; + return $bridge instanceof ObserverBridge ? $bridge : null; + } + + public function addProcessor(ObserverProcessorInterface $processor, Repository $repository): void + { + $repository->getExecutor()->getDriver()->log("Observer: entity " . $repository->getMapper()->getTable() . ", listening for {$processor->getObservedTable()}"); + $table = $processor->getObservedTable(); + foreach ($this->processors[$table] ?? [] as $observer) { + if (get_class($observer->getObservedProcessor()) === get_class($processor) && get_class($observer->getRepository()) === get_class($repository)) { + throw new InvalidArgumentException("Observer already exists"); + } + } + $this->processors[$table][] = new ObserverProcessorInternal($processor, $repository); + } + + public function hasProcessor(ObserverProcessorInterface $processor): bool + { + foreach ($this->processors as $observers) { + foreach ($observers as $observer) { + if ($observer->getObservedProcessor() === $processor) { + return true; + } + } + } + return false; + } + + public function removeProcessor(ObserverProcessorInterface $processor): void + { + foreach ($this->processors as $table => $observers) { + $this->processors[$table] = array_values( + array_filter($observers, fn($observer) => $observer->getObservedProcessor() !== $processor) + ); + if (empty($this->processors[$table])) { + unset($this->processors[$table]); + } + } + } + + /** + * @return DatabaseEventTypeEnum[] + */ + #[Override] + public function subscribedEvents(): array + { + return [DatabaseEventTypeEnum::BEFORE_EXECUTE, DatabaseEventTypeEnum::AFTER_EXECUTE]; + } + + #[Override] + public function handleEvent(DatabaseEvent $event): void + { + $statement = $event->getStatement(); + if (!$statement instanceof OrmSqlStatement || !$statement->hasOrmEvent()) { + return; + } + $table = $statement->getOrmTable(); + if ($table === null || !isset($this->processors[$table])) { + return; + } + + if ($event->getType() === DatabaseEventTypeEnum::BEFORE_EXECUTE) { + foreach ($this->processors[$table] as $observer) { + $processor = $observer->getObservedProcessor(); + if ($processor instanceof StatementHookInterface) { + // exceptions propagate on purpose: throwing here vetoes the write + $processor->beforeStatement($statement, $observer->getRepository()); + } + } + return; + } + + // AFTER_EXECUTE + $context = $statement->getOrmContext(); + $context->markExecuted(); + if ($context->isDeferred()) { + // Repository::save() flushes after the entity is rehydrated + $context->addPendingBridge($this); + return; + } + $this->notifyStatement($statement); + } + + /** + * Translate the statement into ObserverData and notify the processors + * watching its table. Exceptions thrown by process() are routed to + * onError() and never abort the SQL flow. + */ + public function notifyStatement(OrmSqlStatement $statement): void + { + $table = $statement->getOrmTable(); + $event = $statement->getOrmEvent(); + if ($table === null || $event === null || !isset($this->processors[$table])) { + return; + } + + $context = $statement->getOrmContext(); + $isDeleteEvent = $event === ObserverEvent::Delete || $event === ObserverEvent::SoftDelete; + $data = $context->getEntity() ?? ($isDeleteEvent ? null : $statement->getParams()); + $oldData = $context->getOldEntity() ?? ($isDeleteEvent ? $statement->getParams() : null); + + foreach ($this->processors[$table] as $observer) { + $observer->log("Observer: notifying " . $observer->getMapper()->getTable() . ", changes in $table"); + + $observerData = new ObserverData($table, $event, $data, $oldData, $observer->getRepository(), $statement); + + try { + $observer->getObservedProcessor()->process($observerData); + } catch (Throwable $e) { + $observer->getObservedProcessor()->onError($e, $observerData); + } + } + } +} diff --git a/src/ObserverData.php b/src/ObserverData.php index f7879ca..58bece8 100644 --- a/src/ObserverData.php +++ b/src/ObserverData.php @@ -21,13 +21,17 @@ class ObserverData // The repository is listening to the event (the same as $myRepository) protected Repository $repository; - public function __construct(string $table, ObserverEvent $event, mixed $data, mixed $oldData, Repository $repository) + // The SQL statement that triggered the event (null when not available) + protected ?OrmSqlStatement $statement; + + public function __construct(string $table, ObserverEvent $event, mixed $data, mixed $oldData, Repository $repository, ?OrmSqlStatement $statement = null) { $this->table = $table; $this->event = $event; $this->data = $data; $this->oldData = $oldData; $this->repository = $repository; + $this->statement = $statement; } /** @@ -69,4 +73,14 @@ public function getRepository(): Repository { return $this->repository; } + + /** + * The SQL statement (with SQL text and params) that triggered the event. + * + * @return OrmSqlStatement|null + */ + public function getStatement(): ?OrmSqlStatement + { + return $this->statement; + } } \ No newline at end of file diff --git a/src/OrmEventContext.php b/src/OrmEventContext.php new file mode 100644 index 0000000..424ff02 --- /dev/null +++ b/src/OrmEventContext.php @@ -0,0 +1,84 @@ +entity = $entity; + $this->oldEntity = $oldEntity; + } + + public function getEntity(): mixed + { + return $this->entity; + } + + public function getOldEntity(): mixed + { + return $this->oldEntity; + } + + /** + * Postpone observer notification: at AFTER_EXECUTE the bridge parks itself + * here instead of dispatching, and the caller flushes via drainPendingBridges() + * once the entity is fully hydrated (e.g. generated keys copied back). + */ + public function defer(): void + { + $this->deferred = true; + } + + public function isDeferred(): bool + { + return $this->deferred; + } + + public function markExecuted(): void + { + $this->executed = true; + } + + public function isExecuted(): bool + { + return $this->executed; + } + + public function addPendingBridge(ObserverBridge $bridge): void + { + if (!in_array($bridge, $this->pendingBridges, true)) { + $this->pendingBridges[] = $bridge; + } + } + + /** + * @return ObserverBridge[] + */ + public function drainPendingBridges(): array + { + $bridges = $this->pendingBridges; + $this->pendingBridges = []; + return $bridges; + } +} diff --git a/src/OrmSqlStatement.php b/src/OrmSqlStatement.php new file mode 100644 index 0000000..bb6ecbb --- /dev/null +++ b/src/OrmSqlStatement.php @@ -0,0 +1,61 @@ +ormEvent = $ormEvent; + $this->ormTable = $ormTable; + } + + public function withOrmEvent(ObserverEvent $ormEvent, string $ormTable): static + { + $statement = clone $this; + $statement->ormEvent = $ormEvent; + $statement->ormTable = $ormTable; + return $statement; + } + + public function getOrmEvent(): ?ObserverEvent + { + return $this->ormEvent; + } + + public function getOrmTable(): ?string + { + return $this->ormTable; + } + + public function hasOrmEvent(): bool + { + return $this->ormEvent !== null && $this->ormTable !== null; + } + + /** + * Lazily created. Clones made by the executor share the same context by + * reference, but only if it exists before the clone — callers that need to + * read the context after execution must call this before handing the + * statement to the executor. + */ + public function getOrmContext(): OrmEventContext + { + return $this->ormContext ??= new OrmEventContext(); + } +} diff --git a/src/Query.php b/src/Query.php index 7d4f539..6b718cf 100644 --- a/src/Query.php +++ b/src/Query.php @@ -96,7 +96,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params); } protected function addOrderBy(): string diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 7a6265d..56da25b 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -335,7 +335,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params); } #[Override] diff --git a/src/QueryRaw.php b/src/QueryRaw.php index ba7c27e..9c6f649 100644 --- a/src/QueryRaw.php +++ b/src/QueryRaw.php @@ -24,7 +24,7 @@ public static function getInstance(string $sql, array $parameters = []): QueryRa #[Override] public function build(?DbDriverInterface $dbDriver = null): SqlStatement { - return new SqlStatement($this->sql, $this->parameters); + return new OrmSqlStatement($this->sql, $this->parameters); } #[Override] diff --git a/src/Recursive.php b/src/Recursive.php index 5a5b95d..ae84b25 100644 --- a/src/Recursive.php +++ b/src/Recursive.php @@ -60,7 +60,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $sql .= " UNION ALL "; $sql .= $this->getRecursion(); $sql .= ") "; - return new SqlStatement($sql); + return new OrmSqlStatement($sql); } protected function getBase(): string diff --git a/src/Repository.php b/src/Repository.php index 7b6f4e7..af9a62d 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -9,6 +9,7 @@ use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Exception\DbDriverNotConnected; +use ByJG\AnyDataset\Db\Interfaces\DatabaseEventObserverInterface; use ByJG\AnyDataset\Db\IsolationLevelEnum; use ByJG\AnyDataset\Db\IteratorFilterSqlFormatter; use ByJG\AnyDataset\Db\SqlStatement; @@ -64,6 +65,16 @@ class Repository */ protected EntityProcessorInterface|null $beforeInsert = null; + /** + * @var ObserverProcessorInterface[] + */ + protected array $observerProcessors = []; + + /** + * @var DatabaseEventObserverInterface[] + */ + protected array $rawObservers = []; + /** * Repository constructor. * @param DatabaseExecutor $executor @@ -93,6 +104,20 @@ public function __construct(DatabaseExecutor $executor, string|Mapper $mapperOrE public function addDbDriverForWrite(DatabaseExecutor $executor): void { $this->dbDriverWrite = $executor; + if ($executor === $this->dbDriver) { + return; + } + foreach ($this->rawObservers as $observer) { + $executor->addObserver($observer); + } + if (!empty($this->observerProcessors)) { + $bridge = ObserverBridge::for($executor); + foreach ($this->observerProcessors as $processor) { + if (!$bridge->hasProcessor($processor)) { + $bridge->addProcessor($processor, $this); + } + } + } } public function setRepositoryReadOnly(): void @@ -248,7 +273,10 @@ public function delete(array|string|int|LiteralInterface $pkId): bool ->table($this->mapper->getTable()) ->set('deleted_at', new Literal($this->getExecutorWrite()->getHelper()->sqlDate('Y-m-d H:i:s'))) ->where($filterList, $filterKeys); - $this->update($updatable); + // the SQL is an UPDATE, but the caller intent is a delete + $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()) + ->withOrmEvent(ObserverEvent::SoftDelete, $this->mapper->getTable()); + $this->getExecutorWrite()->execute($sqlStatement); return true; } @@ -285,6 +313,7 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $selectSql = null; $selectParams = []; $bigParams = []; + $ormStatements = []; foreach ($queries as $i => $query) { if (!($query instanceof QueryBuilderInterface) && !($query instanceof Updatable)) { @@ -304,6 +333,12 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel continue; } + // The writes are concatenated into a single raw SQL string, so the executor + // observers cannot map them; keep the built statements to notify after commit + if ($sqlStatement instanceof OrmSqlStatement && $sqlStatement->hasOrmEvent()) { + $ormStatements[] = $sqlStatement; + } + // For write statements, avoid parameter name collisions by uniquifying named params if ($params !== null) { foreach ($params as $key => $value) { @@ -342,6 +377,13 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $dbDriver->commitTransaction(); + $bridge = ObserverBridge::find($this->getExecutor()); + if (!is_null($bridge)) { + foreach ($ormStatements as $ormStatement) { + $bridge->notifyStatement($ormStatement); + } + } + return $it; } catch (Exception $ex) { $dbDriver->rollbackTransaction(); @@ -363,8 +405,6 @@ public function deleteByQuery(DeleteQuery $updatable): bool $this->getExecutorWrite()->execute($sqlStatement); - ORMSubject::getInstance()->notify($this->mapper->getTable(), ObserverEvent::Delete, null, $sqlStatement->getParams()); - return true; } @@ -576,6 +616,8 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda // Build the updatable without executing [$updatable, $array, $fieldToProperty, $isInsert, $oldInstance, $pkList] = $this->saveUpdatableInternal($instance); + $sqlStatement = null; + // Execute the Insert or Update if ($isInsert) { $keyGen = $this->getMapper()->generateKey($this->getExecutorWrite(), $instance) ?? []; @@ -587,7 +629,8 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda $array[$pkList[$position]] = $value; $updatable->set($this->mapper->getPrimaryKey()[$position++], $value); } - $keyReturned = $this->insert($updatable, $keyGen); + $sqlStatement = $this->prepareForDeferredNotify($updatable, $instance, $oldInstance); + $keyReturned = $this->insert($sqlStatement, $keyGen); if (count($pkList) == 1 && !empty($keyReturned)) { $array[$pkList[0]] = $keyReturned; } @@ -609,19 +652,35 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda $constraint->check($oldInstance, $instance); } } - $this->update($updatable); + $sqlStatement = $this->prepareForDeferredNotify($updatable, $instance, $oldInstance); + $this->update($sqlStatement); } - - ORMSubject::getInstance()->notify( - $this->mapper->getTable(), - $isInsert ? ObserverEvent::Insert : ObserverEvent::Update, - $instance, $oldInstance - ); + // Notify only now, so the observers receive the instance rehydrated + // with generated keys and computed fields + if (!is_null($sqlStatement)) { + foreach ($sqlStatement->getOrmContext()->drainPendingBridges() as $bridge) { + $bridge->notifyStatement($sqlStatement); + } + } return $instance; } + /** + * Build the statement and mark it for deferred observer notification: + * the bridges seeing AFTER_EXECUTE park themselves in the statement context + * and save() flushes them after the entity is rehydrated. + */ + private function prepareForDeferredNotify(Updatable $updatable, mixed $instance, mixed $oldInstance): OrmSqlStatement + { + $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); + $context = $sqlStatement->getOrmContext(); + $context->setEntities($instance, $oldInstance); + $context->defer(); + return $sqlStatement; + } + /** * Build and return the updatable (InsertQuery or UpdateQuery) without executing it. * This method mirrors the preparatory stage of save() and can be used to inspect or @@ -722,69 +781,107 @@ protected function saveUpdatableInternal(mixed $instance): array } /** + * Add an observer to this repository's executors. + * + * An ObserverProcessorInterface receives entity-level events (Insert, Update, + * Delete, SoftDelete) for its observed table, no matter which repository or + * direct executor call issued the write, as long as it flows through the + * same DatabaseExecutor instance(s) used by this repository. + * + * A raw DatabaseEventObserverInterface is attached directly to the executors + * and receives the low-level anydataset-db events (BEFORE/AFTER QUERY/EXECUTE). + * * @throws InvalidArgumentException */ - public function addObserver(ObserverProcessorInterface $observerProcessor): void + public function addObserver(ObserverProcessorInterface|DatabaseEventObserverInterface $observer): void + { + if ($observer instanceof ObserverProcessorInterface) { + ObserverBridge::for($this->getExecutor())->addProcessor($observer, $this); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + ObserverBridge::for($this->dbDriverWrite)->addProcessor($observer, $this); + } + $this->observerProcessors[] = $observer; + return; + } + + $this->rawObservers[] = $observer; + $this->getExecutor()->addObserver($observer); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + $this->dbDriverWrite->addObserver($observer); + } + } + + public function removeObserver(ObserverProcessorInterface|DatabaseEventObserverInterface $observer): void { - ORMSubject::getInstance()->addObserver($observerProcessor, $this); + if ($observer instanceof ObserverProcessorInterface) { + $this->observerProcessors = array_values( + array_filter($this->observerProcessors, fn($item) => $item !== $observer) + ); + ObserverBridge::find($this->getExecutor())?->removeProcessor($observer); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + ObserverBridge::find($this->dbDriverWrite)?->removeProcessor($observer); + } + return; + } + + $this->rawObservers = array_values( + array_filter($this->rawObservers, fn($item) => $item !== $observer) + ); + $this->getExecutor()->removeObserver($observer); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + $this->dbDriverWrite->removeObserver($observer); + } } /** - * @param InsertQuery $updatable + * @param OrmSqlStatement $sqlStatement * @param mixed $keyGen * @return int|null * @throws DatabaseException * @throws DbDriverNotConnected - * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insert(InsertQuery $updatable, mixed $keyGen): ?int + protected function insert(OrmSqlStatement $sqlStatement, mixed $keyGen): ?int { if (empty($keyGen)) { - return $this->insertWithAutoinc($updatable); + return $this->insertWithAutoinc($sqlStatement); } else { - $this->insertWithKeyGen($updatable); + $this->insertWithKeyGen($sqlStatement); return null; } } /** - * @param InsertQuery $updatable + * @param OrmSqlStatement $sqlStatement * @return int - * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insertWithAutoInc(InsertQuery $updatable): int + protected function insertWithAutoInc(OrmSqlStatement $sqlStatement): int { $dbFunctions = $this->getExecutorWrite()->getHelper(); - $sqlStatement = $updatable->build($dbFunctions); return $dbFunctions->executeAndGetInsertedId($this->getExecutorWrite(), $sqlStatement); } /** - * @param InsertQuery $updatable + * @param OrmSqlStatement $sqlStatement * @return void * @throws DatabaseException * @throws DbDriverNotConnected - * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insertWithKeyGen(InsertQuery $updatable): void + protected function insertWithKeyGen(OrmSqlStatement $sqlStatement): void { - $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); $this->getExecutorWrite()->execute($sqlStatement); } /** - * @param UpdateQuery $updatable + * @param OrmSqlStatement $sqlStatement * @throws DatabaseException * @throws DbDriverNotConnected - * @throws InvalidArgumentException * @throws RepositoryReadOnlyException */ - protected function update(UpdateQuery $updatable): void + protected function update(OrmSqlStatement $sqlStatement): void { - $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); $this->getExecutorWrite()->execute($sqlStatement); } diff --git a/src/Union.php b/src/Union.php index 369f397..9315b1f 100644 --- a/src/Union.php +++ b/src/Union.php @@ -109,7 +109,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $unionQuery = trim($unionQuery . " " . substr($build->getSql(), $pos + 8)); } - return new SqlStatement($unionQuery, $params); + return new OrmSqlStatement($unionQuery, $params); } diff --git a/src/Updatable.php b/src/Updatable.php index f86aace..99fe5f6 100644 --- a/src/Updatable.php +++ b/src/Updatable.php @@ -3,6 +3,8 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; +use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface; use ByJG\MicroOrm\Interface\UpdateBuilderInterface; use Override; @@ -26,6 +28,14 @@ public function table(string $table): static return $this; } + public function getTable(): string + { + return $this->table; + } + + #[Override] + abstract public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement; + #[Override] public function buildAndExecute(DatabaseExecutor $executor, $params = []): bool { diff --git a/src/UpdateQuery.php b/src/UpdateQuery.php index 1eeae7c..1c96510 100644 --- a/src/UpdateQuery.php +++ b/src/UpdateQuery.php @@ -5,6 +5,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 ByJG\MicroOrm\Literal\Literal; @@ -103,11 +104,11 @@ public function join(QueryBasic|string $table, string $joinCondition, ?string $a /** * @param DbDriverInterface|SqlDialectInterface|null $dbDriverOrHelper - * @return SqlStatement + * @return OrmSqlStatement * @throws InvalidArgumentException */ #[Override] - public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement + public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement { if (empty($this->set)) { throw new InvalidArgumentException('You must specify the fields for update'); @@ -163,7 +164,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp $params = array_merge($params, $whereStr[1]); $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params, ObserverEvent::Update, $this->table); } #[Override] diff --git a/tests/ConnectionUtil.php b/tests/ConnectionUtil.php index 0480200..5c74ceb 100644 --- a/tests/ConnectionUtil.php +++ b/tests/ConnectionUtil.php @@ -2,6 +2,7 @@ namespace Tests; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use ByJG\AnyDataset\Db\Factory; use ByJG\Util\Uri; @@ -11,7 +12,7 @@ class ConnectionUtil public static function getConnection(string $database): DbDriverInterface { $dbDriver = Factory::getDbInstance(ConnectionUtil::getUri()); - $dbDriver->execute("create database if not exists $database;"); + DatabaseExecutor::using($dbDriver)->execute("create database if not exists $database;"); return Factory::getDbInstance(ConnectionUtil::getUri($database)); } diff --git a/tests/DeleteQueryTest.php b/tests/DeleteQueryTest.php index 6c242e5..f2e3db7 100644 --- a/tests/DeleteQueryTest.php +++ b/tests/DeleteQueryTest.php @@ -2,7 +2,8 @@ namespace Tests; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Updatable; @@ -35,7 +36,7 @@ public function testDelete() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement('DELETE FROM test WHERE fld1 = :id', ['id' => 10]), + new OrmSqlStatement('DELETE FROM test WHERE fld1 = :id', ['id' => 10], ObserverEvent::Delete, 'test'), $sqlStatement ); } @@ -54,12 +55,12 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -67,7 +68,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld2 = :teste', ['teste' => 10]), + new OrmSqlStatement('SELECT * FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -75,7 +76,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), + new OrmSqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -83,7 +84,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } diff --git a/tests/InsertQueryTest.php b/tests/InsertQueryTest.php index b4ce21d..b869a86 100644 --- a/tests/InsertQueryTest.php +++ b/tests/InsertQueryTest.php @@ -3,7 +3,8 @@ namespace Tests; use ByJG\AnyDataset\Db\SqlDialect\SqliteDialect; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\InsertQuery; use Override; use PHPUnit\Framework\TestCase; @@ -37,13 +38,13 @@ public function testInsert() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement('INSERT INTO test( fld1, fld2, fld3 ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C']), + new OrmSqlStatement('INSERT INTO test( fld1, fld2, fld3 ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], ObserverEvent::Insert, 'test'), $sqlStatement ); $sqlStatement = $this->object->build(new SqliteDialect()); $this->assertEquals( - new SqlStatement('INSERT INTO `test`( `fld1`, `fld2`, `fld3` ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C']), + new OrmSqlStatement('INSERT INTO `test`( `fld1`, `fld2`, `fld3` ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], ObserverEvent::Insert, 'test'), $sqlStatement ); } @@ -52,7 +53,7 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -61,7 +62,7 @@ public function testQueryUpdatable() $this->object->set('fld3', 'C'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->convert()->build() ); @@ -69,7 +70,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -77,7 +78,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -85,7 +86,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } diff --git a/tests/ObserverBridgeTest.php b/tests/ObserverBridgeTest.php new file mode 100644 index 0000000..4dda2e8 --- /dev/null +++ b/tests/ObserverBridgeTest.php @@ -0,0 +1,370 @@ +received[] = $observerData; + } + + #[Override] + public function onError(Throwable $exception, ObserverData $observerData): void + { + $this->errors[] = $exception; + } + + #[Override] + public function getObservedTable(): string + { + return $this->table; + } +} + +/** + * A processor that also hooks BEFORE_EXECUTE and can veto the write. + */ +class VetoObserver extends RecordingObserver implements StatementHookInterface +{ + /** @var OrmSqlStatement[] */ + public array $beforeStatements = []; + + public bool $veto = false; + + #[Override] + public function beforeStatement(OrmSqlStatement $statement, Repository $repository): void + { + $this->beforeStatements[] = $statement; + if ($this->veto) { + throw new Exception("Vetoed by observer"); + } + } +} + +/** + * Raw anydataset-db observer, attached through Repository::addObserver(). + */ +class RawRecordingObserver implements DatabaseEventObserverInterface +{ + /** @var DatabaseEvent[] */ + public array $events = []; + + #[Override] + public function subscribedEvents(): array + { + return [DatabaseEventTypeEnum::BEFORE_EXECUTE, DatabaseEventTypeEnum::AFTER_EXECUTE]; + } + + #[Override] + public function handleEvent(DatabaseEvent $event): void + { + $this->events[] = $event; + } +} + +class ObserverBridgeTest extends TestCase +{ + protected Repository $userRepository; + protected Mapper $userMapper; + protected DatabaseExecutor $executor; + + #[Override] + public function setUp(): void + { + $dbDriver = ConnectionUtil::getConnection("testmicroorm_observer"); + $this->executor = DatabaseExecutor::using($dbDriver); + $this->userMapper = new Mapper(Users::class, 'users', 'Id'); + $this->userRepository = new Repository($this->executor, $this->userMapper); + + $this->executor->execute('create table users ( + id integer primary key auto_increment, + name varchar(45), + createdate datetime);' + ); + $this->executor->execute("insert into users (name, createdate) values ('John Doe', '2015-05-02')"); + $this->executor->execute("insert into users (name, createdate) values ('Jane Doe', '2017-01-04')"); + + $this->executor->execute('create table info ( + id integer primary key auto_increment, + iduser INTEGER, + property decimal(10, 2), + registration_id VARCHAR(255) not null, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at datetime);' + ); + $this->executor->execute("insert into info (iduser, property, registration_id) values (1, 30.4, 'REG001')"); + } + + #[Override] + public function tearDown(): void + { + $this->executor->execute('drop table if exists users;'); + $this->executor->execute('drop table if exists info;'); + ORM::resetMemory(); + } + + public function testSoftDeleteFiresSoftDeleteEvent() + { + $infoRepository = new Repository($this->executor, ModelWithAttributes::class); + + $observer = new RecordingObserver('info'); + $infoRepository->addObserver($observer); + + $infoRepository->delete(1); + + $this->assertCount(1, $observer->received); + $event = $observer->received[0]; + $this->assertEquals(ObserverEvent::SoftDelete, $event->getEvent()); + $this->assertEquals('info', $event->getTable()); + $this->assertNull($event->getData()); + $this->assertEquals(['pkid' => 1], $event->getOldData()); + + // soft delete: the row still exists, with deleted_at set + $deletedAt = $this->executor->getScalar('select deleted_at from info where id = 1'); + $this->assertNotNull($deletedAt); + } + + public function testDirectExecutorWriteFiresObserver() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + // a built statement executed directly on the executor, bypassing the repository + $insert = InsertQuery::getInstance('users', ['name' => 'Direct', 'createdate' => '2024-01-01'])->build(); + $this->executor->execute($insert); + + $update = UpdateQuery::getInstance() + ->table('users') + ->set('name', 'DirectChanged') + ->where('name = :name', ['name' => 'Direct']) + ->build(); + $this->executor->execute($update); + + $delete = DeleteQuery::getInstance() + ->table('users') + ->where('name = :name', ['name' => 'DirectChanged']) + ->build(); + $this->executor->execute($delete); + + $this->assertCount(3, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + // no entity is available outside save(): the params are provided instead + $this->assertEquals(['name' => 'Direct', 'createdate' => '2024-01-01'], $observer->received[0]->getData()); + $this->assertEquals(ObserverEvent::Update, $observer->received[1]->getEvent()); + $this->assertEquals(ObserverEvent::Delete, $observer->received[2]->getEvent()); + $this->assertNull($observer->received[2]->getData()); + $this->assertEquals(['name' => 'DirectChanged'], $observer->received[2]->getOldData()); + } + + public function testBulkExecuteFiresObserversAfterCommit() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + $this->userRepository->bulkExecute([ + InsertQuery::getInstance('users', ['name' => 'Bulk1', 'createdate' => '2024-01-01']), + UpdateQuery::getInstance() + ->table('users') + ->set('name', 'Bulk2') + ->where('name = :name', ['name' => 'Bulk1']), + ]); + + $this->assertCount(2, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + $this->assertEquals(ObserverEvent::Update, $observer->received[1]->getEvent()); + } + + public function testBulkExecuteRollbackFiresNothing() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + $exceptionThrown = false; + try { + $this->userRepository->bulkExecute([ + InsertQuery::getInstance('users', ['name' => 'BulkFail', 'createdate' => '2024-01-01']), + InsertQuery::getInstance('users', ['nonexistent_column' => 'x']), + ]); + } catch (Exception $ex) { + $exceptionThrown = true; + } + + $this->assertTrue($exceptionThrown); + $this->assertCount(0, $observer->received); + $this->assertEquals(0, $this->executor->getScalar("select count(*) from users where name = 'BulkFail'")); + } + + public function testObserverScopingIsPerExecutor() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + // another executor over the same database: the observer must NOT fire + $otherExecutor = DatabaseExecutor::using(ConnectionUtil::getConnection("testmicroorm_observer")); + $otherRepository = new Repository($otherExecutor, $this->userMapper); + $users = new Users(); + $users->setName('OtherConnection'); + $users->setCreatedate('2024-01-01'); + $otherRepository->save($users); + + $this->assertCount(0, $observer->received); + + // a repository sharing the same executor: the observer fires + $sharedRepository = new Repository($this->executor, $this->userMapper); + $users = new Users(); + $users->setName('SharedConnection'); + $users->setCreatedate('2024-01-01'); + $sharedRepository->save($users); + + $this->assertCount(1, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + } + + public function testStatementHookReceivesSqlAndCanVeto() + { + $observer = new VetoObserver('users'); + $this->userRepository->addObserver($observer); + + // hook receives the statement before it executes + $users = new Users(); + $users->setName('HookTest'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertCount(1, $observer->beforeStatements); + $this->assertStringContainsString('INSERT INTO `users`', $observer->beforeStatements[0]->getSql()); + $this->assertCount(1, $observer->received); + + // a throwing hook vetoes the write: the row must not be persisted and process() must not fire + $observer->veto = true; + $users = new Users(); + $users->setName('Vetoed'); + $users->setCreatedate('2024-01-01'); + + $exceptionThrown = false; + try { + $this->userRepository->save($users); + } catch (Exception $ex) { + $exceptionThrown = true; + $this->assertEquals("Vetoed by observer", $ex->getMessage()); + } + + $this->assertTrue($exceptionThrown); + $this->assertEquals(0, $this->executor->getScalar("select count(*) from users where name = 'Vetoed'")); + $this->assertCount(1, $observer->received); + } + + public function testObserverDataCarriesStatement() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + $users = new Users(); + $users->setName('WithStatement'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertCount(1, $observer->received); + $statement = $observer->received[0]->getStatement(); + $this->assertInstanceOf(OrmSqlStatement::class, $statement); + $this->assertStringContainsString('INSERT INTO `users`', $statement->getSql()); + $this->assertEquals('WithStatement', $statement->getParams()['name']); + // deferred dispatch: the entity is rehydrated before the observer runs + $this->assertNotEmpty($observer->received[0]->getData()->getId()); + } + + public function testRawObserverPassthrough() + { + $rawObserver = new RawRecordingObserver(); + $this->userRepository->addObserver($rawObserver); + + $users = new Users(); + $users->setName('RawObserved'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertNotEmpty($rawObserver->events); + $types = array_map(fn($event) => $event->getType(), $rawObserver->events); + $this->assertContains(DatabaseEventTypeEnum::BEFORE_EXECUTE, $types); + $this->assertContains(DatabaseEventTypeEnum::AFTER_EXECUTE, $types); + } + + public function testAddDbDriverForWriteAttachesObservers() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + // writes switch to a new executor after the observer was registered + $writeExecutor = DatabaseExecutor::using(ConnectionUtil::getConnection("testmicroorm_observer")); + $this->userRepository->addDbDriverForWrite($writeExecutor); + + $users = new Users(); + $users->setName('WriteExecutor'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertCount(1, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + } + + public function testOnErrorStillSwallowsProcessExceptions() + { + $observer = new class('users') extends RecordingObserver { + #[Override] + public function process(ObserverData $observerData): void + { + parent::process($observerData); + throw new Exception("Observer failure"); + } + }; + $this->userRepository->addObserver($observer); + + $users = new Users(); + $users->setName('ErrorSwallowed'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + // the write succeeded despite the observer exception, routed to onError() + $this->assertEquals(1, $this->executor->getScalar("select count(*) from users where name = 'ErrorSwallowed'")); + $this->assertCount(1, $observer->errors); + $this->assertEquals("Observer failure", $observer->errors[0]->getMessage()); + } +} diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 74757d2..f52ee7f 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -4,7 +4,8 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Literal\Literal; @@ -42,7 +43,7 @@ public function testQueryBasic() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->build() ); @@ -52,7 +53,7 @@ public function testQueryBasic() ->fields(['fld2', 'fld3']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->build() ); @@ -60,7 +61,7 @@ public function testQueryBasic() ->orderBy(['fld1']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test ORDER BY fld1'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test ORDER BY fld1'), $this->object->build() ); @@ -68,7 +69,7 @@ public function testQueryBasic() ->groupBy(['fld1', 'fld2', 'fld3']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test GROUP BY fld1, fld2, fld3 ORDER BY fld1'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test GROUP BY fld1, fld2, fld3 ORDER BY fld1'), $this->object->build() ); @@ -76,7 +77,7 @@ public function testQueryBasic() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), $this->object->build() ); @@ -84,7 +85,7 @@ public function testQueryBasic() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), $this->object->build() ); @@ -92,7 +93,7 @@ public function testQueryBasic() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), $this->object->build() ); @@ -100,7 +101,7 @@ public function testQueryBasic() ->having('count(fld1) > 1'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), $this->object->build() ); @@ -108,7 +109,7 @@ public function testQueryBasic() $iteratorFilter->and('fld4', Relation::EQUAL, 40); $this->object->where($iteratorFilter); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 AND fld4 = :fld4 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40, 'fld4' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 AND fld4 = :fld4 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40, 'fld4' => 40]), $this->object->build() ); } @@ -126,7 +127,7 @@ public function testQueryWhereIteratorFilter() $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld1 = :fld1 and fld2 = :fld2 or fld1 = :fld10 ', ['fld1' => 10, 'fld2' => '20', 'fld10' => '30']), + new OrmSqlStatement('SELECT * FROM test WHERE fld1 = :fld1 and fld2 = :fld2 or fld1 = :fld10 ', ['fld1' => 10, 'fld2' => '20', 'fld10' => '30']), $this->object->build() ); } @@ -156,14 +157,14 @@ public function testConvertQueryToQueryBasic() $expectedSql = 'WITH RECURSIVE table6() AS (SELECT UNION ALL SELECT FROM table6 WHERE ) SELECT fld1, fld2, fld3 FROM test INNER JOIN table2 ON table2.id = test.id LEFT JOIN table3 ON table3.id = test.id RIGHT JOIN table4 ON table4.id = test.id CROSS JOIN table5 as table5.id = test.id WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1'; $this->assertEquals( - new SqlStatement($expectedSql, ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement($expectedSql, ['teste' => 10, 'teste2' => 40]), $query->build() ); $queryBasic = $query->getQueryBasic(); $expectedSql2 = 'WITH RECURSIVE table6() AS (SELECT UNION ALL SELECT FROM table6 WHERE ) SELECT fld1, fld2, fld3 FROM test INNER JOIN table2 ON table2.id = test.id LEFT JOIN table3 ON table3.id = test.id RIGHT JOIN table4 ON table4.id = test.id CROSS JOIN table5 as table5.id = test.id WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1'; $this->assertEquals( - new SqlStatement($expectedSql2, ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement($expectedSql2, ['teste' => 10, 'teste2' => 40]), $queryBasic->build() ); } @@ -177,7 +178,7 @@ public function testLiteral() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE field = ABC', []), + new OrmSqlStatement('SELECT * FROM test WHERE field = ABC', []), $result ); } @@ -192,7 +193,7 @@ public function testLiteral2() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE field = ABC AND other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM test WHERE field = ABC AND other = :other', ['other' => 'test']), $result ); } @@ -202,7 +203,7 @@ public function testTableAlias() $this->object->table('test', "t"); $this->object->where("1 = 1"); $this->assertEquals( - new SqlStatement('SELECT * FROM test as t WHERE 1 = 1', []), + new OrmSqlStatement('SELECT * FROM test as t WHERE 1 = 1', []), $this->object->build() ); } @@ -218,7 +219,7 @@ public function testJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo INNER JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo INNER JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -234,7 +235,7 @@ public function testJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo INNER JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo INNER JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -250,7 +251,7 @@ public function testLeftJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo LEFT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo LEFT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -266,7 +267,7 @@ public function testLeftJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo LEFT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo LEFT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -282,7 +283,7 @@ public function testRightJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo RIGHT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo RIGHT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -298,7 +299,7 @@ public function testRightJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo RIGHT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo RIGHT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -314,7 +315,7 @@ public function testCrossJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo CROSS JOIN bar WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo CROSS JOIN bar WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -330,7 +331,7 @@ public function testCrossJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo CROSS JOIN bar as b WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo CROSS JOIN bar as b WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -355,7 +356,7 @@ public function testSubQueryTable() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq WHERE sq.date < :date', ['date' => '2020-06-01']), + new OrmSqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq WHERE sq.date < :date', ['date' => '2020-06-01']), $result ); @@ -413,7 +414,7 @@ public function testSubQueryTableWithFilter() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest WHERE date > :test GROUP BY id) as sq WHERE sq.date < :date', [ + new OrmSqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest WHERE date > :test GROUP BY id) as sq WHERE sq.date < :date', [ 'test' => '2020-06-01', 'date' => '2020-06-28', ]), @@ -442,7 +443,7 @@ public function testSubQueryJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM test INNER JOIN (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq ON test.id = sq.id WHERE test.date < :date', ['date' => '2020-06-28']), + new OrmSqlStatement('SELECT * FROM test INNER JOIN (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq ON test.id = sq.id WHERE test.date < :date', ['date' => '2020-06-28']), $result ); @@ -531,7 +532,7 @@ public function testSubQueryField() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT test.id, test.name, test.date, (SELECT max(date) as date FROM subtest) as subdate FROM test WHERE test.date < :date', ['date' => '2020-06-28']), + new OrmSqlStatement('SELECT test.id, test.name, test.date, (SELECT max(date) as date FROM subtest) as subdate FROM test WHERE test.date < :date', ['date' => '2020-06-28']), $result ); @@ -544,7 +545,7 @@ public function testSoftDeleteQuery() ->table('info') ->where('iduser = :id', ['id' => 3]) ->orderBy(['property']); - $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); @@ -552,11 +553,11 @@ public function testSoftDeleteQuery() ->table('info') ->where('iduser = :id', ['id' => 3]) ->orderBy(['property']); - $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id AND info.deleted_at is null ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("SELECT * FROM info WHERE iduser = :id AND info.deleted_at is null ORDER BY property", ["id" => 3]), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); } public function testSoftDeleteUpdate() @@ -566,7 +567,7 @@ public function testSoftDeleteUpdate() ->table('info') ->set('property', 'value') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3], ObserverEvent::Update, 'info'), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); @@ -574,11 +575,11 @@ public function testSoftDeleteUpdate() ->table('info') ->set('property', 'value') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id AND info.deleted_at is null", ["property" => "value", "id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("UPDATE info SET property = :property WHERE iduser = :id AND info.deleted_at is null", ["property" => "value", "id" => 3], ObserverEvent::Update, 'info'), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3], ObserverEvent::Update, 'info'), $query->build()); } public function testSoftDeleteDelete() @@ -587,18 +588,18 @@ public function testSoftDeleteDelete() $query = DeleteQuery::getInstance() ->table('info') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3], ObserverEvent::Delete, 'info'), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); $query = DeleteQuery::getInstance() ->table('info') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id AND info.deleted_at is null", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("DELETE FROM info WHERE iduser = :id AND info.deleted_at is null", ["id" => 3], ObserverEvent::Delete, 'info'), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3], ObserverEvent::Delete, 'info'), $query->build()); } public function testQueryBasicDistinct() @@ -609,7 +610,7 @@ public function testQueryBasicDistinct() ->fields(['fld1', 'fld2']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2 FROM test'), $queryBasic->build() ); @@ -620,7 +621,7 @@ public function testQueryBasicDistinct() ->distinct(); $this->assertEquals( - new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $queryBasic->build() ); } @@ -633,7 +634,7 @@ public function testQueryDistinct() ->fields(['fld1', 'fld2']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2 FROM test'), $query->build() ); @@ -644,14 +645,14 @@ public function testQueryDistinct() ->distinct(); $this->assertEquals( - new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $query->build() ); // Test getQueryBasic preserves distinct $queryBasic = $query->getQueryBasic(); $this->assertEquals( - new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $queryBasic->build() ); } diff --git a/tests/RecursiveTest.php b/tests/RecursiveTest.php index daee710..6d69305 100644 --- a/tests/RecursiveTest.php +++ b/tests/RecursiveTest.php @@ -2,7 +2,7 @@ namespace Tests; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Recursive; use PHPUnit\Framework\TestCase; @@ -26,7 +26,7 @@ public function testRecursive() $expected = $expected . "SELECT start, end FROM test"; $this->assertEquals( - new SqlStatement($expected), + new OrmSqlStatement($expected), $query->build() ); diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 0932e5b..cd21933 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -5,7 +5,7 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DatabaseExecutor; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\Cache\Psr16\ArrayCacheEngine; use ByJG\MicroOrm\CacheQueryResult; use ByJG\MicroOrm\Constraint\CustomConstraint; @@ -28,7 +28,6 @@ use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\ObserverData; use ByJG\MicroOrm\ORM; -use ByJG\MicroOrm\ORMSubject; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\Union; @@ -80,7 +79,6 @@ public function setUp(): void $this->infoMapper->addFieldMapping(FieldMapping::create('registrationId')->withFieldName('registration_id')); $this->repository = new Repository($executor, $this->userMapper); - ORMSubject::getInstance()->clearObservers(); $executor->execute('create table users ( id integer primary key auto_increment, @@ -374,7 +372,7 @@ public function testInsertFromObject() $sqlStatement = $insertQuery->build(); $this->assertEquals( - new SqlStatement("INSERT INTO users( name, createdate ) values ( X'6565', :createdate ) ", ["createdate" => "2015-08-09"]), + new OrmSqlStatement("INSERT INTO users( name, createdate ) values ( X'6565', :createdate ) ", ["createdate" => "2015-08-09"], ObserverEvent::Insert, 'users'), $sqlStatement ); @@ -614,7 +612,7 @@ public function testUpdateObject() $sqlStatement = $updateQuery->build(); $this->assertEquals( - new SqlStatement("UPDATE users SET name = X'6565' , createdate = :createdate WHERE id = :pkid", ["createdate" => "2020-01-02", "pkid" => 1]), + new OrmSqlStatement("UPDATE users SET name = X'6565' , createdate = :createdate WHERE id = :pkid", ["createdate" => "2020-01-02", "pkid" => 1], ObserverEvent::Update, 'users'), $sqlStatement ); diff --git a/tests/TransactionManagerTest.php b/tests/TransactionManagerTest.php index 06beb1c..ead73bb 100644 --- a/tests/TransactionManagerTest.php +++ b/tests/TransactionManagerTest.php @@ -37,12 +37,12 @@ public function tearDown(): void $this->object->destroy(); $this->object = null; - $dbDriver = ConnectionUtil::getConnection("a"); - $dbDriver->execute('drop table if exists users;'); - $dbDriver->execute('drop table if exists users1;'); + $executor = DatabaseExecutor::using(ConnectionUtil::getConnection("a")); + $executor->execute('drop table if exists users;'); + $executor->execute('drop table if exists users1;'); - $dbDriver = ConnectionUtil::getConnection("b"); - $dbDriver->execute('drop table if exists users2;'); + $executor = DatabaseExecutor::using(ConnectionUtil::getConnection("b")); + $executor->execute('drop table if exists users2;'); } public function testAddConnectionError() @@ -171,18 +171,20 @@ public function testTransaction() { $dbDrive1 = ConnectionUtil::getConnection("a"); $dbDrive2 = ConnectionUtil::getConnection("b"); + $executor1 = DatabaseExecutor::using($dbDrive1); + $executor2 = DatabaseExecutor::using($dbDrive2); - $dbDrive1->execute('create table users1 ( + $executor1->execute('create table users1 ( id integer primary key auto_increment, name varchar(45));' ); - $dbDrive2->execute('create table users2 ( + $executor2->execute('create table users2 ( id integer primary key auto_increment, name varchar(45));' ); - $this->assertEquals(0, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(0, $dbDrive2->getScalar("select count(*) from users2")); + $this->assertEquals(0, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(0, $executor2->getScalar("select count(*) from users2")); // Create Transaction Manager @@ -191,27 +193,27 @@ public function testTransaction() // Initialize Transaction $this->object->beginTransaction(); - $dbDrive1->execute("insert into users1 (name) values ('John1')"); - $dbDrive2->execute("insert into users2 (name) values ('John2')"); - $this->assertEquals(1, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(1, $dbDrive2->getScalar("select count(*) from users2")); + $executor1->execute("insert into users1 (name) values ('John1')"); + $executor2->execute("insert into users2 (name) values ('John2')"); + $this->assertEquals(1, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(1, $executor2->getScalar("select count(*) from users2")); $this->object->rollbackTransaction(); // After rollback, no records should be added. - $this->assertEquals(0, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(0, $dbDrive2->getScalar("select count(*) from users2")); + $this->assertEquals(0, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(0, $executor2->getScalar("select count(*) from users2")); // Initialize a new Transaction $this->object->beginTransaction(); - $dbDrive1->execute("insert into users1 (name) values ('John1')"); - $dbDrive2->execute("insert into users2 (name) values ('John2')"); - $this->assertEquals(1, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(1, $dbDrive2->getScalar("select count(*) from users2")); + $executor1->execute("insert into users1 (name) values ('John1')"); + $executor2->execute("insert into users2 (name) values ('John2')"); + $this->assertEquals(1, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(1, $executor2->getScalar("select count(*) from users2")); $this->object->commitTransaction(); // After commit, records should be added. - $this->assertEquals(1, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(1, $dbDrive2->getScalar("select count(*) from users2")); + $this->assertEquals(1, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(1, $executor2->getScalar("select count(*) from users2")); } } diff --git a/tests/UpdateQueryTest.php b/tests/UpdateQueryTest.php index b9e2d80..c1d4639 100644 --- a/tests/UpdateQueryTest.php +++ b/tests/UpdateQueryTest.php @@ -7,7 +7,8 @@ use ByJG\AnyDataset\Db\SqlDialect\SqliteDialect; use ByJG\AnyDataset\Db\PdoMysql; use ByJG\AnyDataset\Db\PdoObj; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\UpdateQuery; @@ -46,18 +47,20 @@ public function testUpdate() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE test SET fld1 = :fld1 , fld2 = :fld2 , fld3 = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); $sqlStatement = $this->object->build(new SqliteDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -80,7 +83,7 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -91,7 +94,7 @@ public function testQueryUpdatable() ->set('fld3', 'C'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->convert()->build() ); @@ -99,7 +102,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -107,7 +110,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -115,7 +118,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } @@ -139,9 +142,10 @@ public function testUpdateJoinMySQl() $sqlStatement = $this->object->build(new MysqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` INNER JOIN `table2` ON table2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -158,9 +162,10 @@ public function testUpdateJoinMySQlAlias() $sqlObject = $this->object->build(new MysqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` INNER JOIN `table2` AS t2 ON t2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlObject ); @@ -189,9 +194,10 @@ public function __construct() $sqlObject = $this->object->build($dbDriver); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` INNER JOIN (SELECT * FROM table2 WHERE id = 10) AS t2 ON t2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlObject ); @@ -208,9 +214,10 @@ public function testUpdateJoinPostgres() $sqlStatement = $this->object->build(new PgsqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE "test" SET "fld1" = :fld1 , "fld2" = :fld2 , "fld3" = :fld3 FROM "table2" ON table2.id = test.id WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -224,9 +231,10 @@ public function testSetLiteral() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE test SET counter = counter + 1 WHERE id = :id', - ['id' => 10] + ['id' => 10], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -234,9 +242,10 @@ public function testSetLiteral() // Test with database helper $sqlStatement = $this->object->build(new MysqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` SET `counter` = counter + 1 WHERE id = :id', - ['id' => 10] + ['id' => 10], + ObserverEvent::Update, 'test' ), $sqlStatement ); diff --git a/tests/WhereTraitTest.php b/tests/WhereTraitTest.php index 2409c87..36f9cc5 100644 --- a/tests/WhereTraitTest.php +++ b/tests/WhereTraitTest.php @@ -2,7 +2,7 @@ namespace Tests; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\QueryBasic; use PHPUnit\Framework\TestCase; @@ -15,7 +15,7 @@ public function testWhereIsNull() ->whereIsNull('test.field'); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE test.field IS NULL', []), + new OrmSqlStatement('SELECT * FROM test WHERE test.field IS NULL', []), $query->build() ); } @@ -27,7 +27,7 @@ public function testWhereIsNotNull() ->whereIsNotNull('test.field'); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE test.field IS NOT NULL', []), + new OrmSqlStatement('SELECT * FROM test WHERE test.field IS NOT NULL', []), $query->build() ); } @@ -62,7 +62,7 @@ public function testWhereInEmpty() ->whereIn('test.field', []); $this->assertEquals( - new SqlStatement('SELECT * FROM test', []), + new OrmSqlStatement('SELECT * FROM test', []), $query->build() ); } From ec6680d3009ec8265a23bb79fe0ecda4e3136d6d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 18 Jul 2026 18:38:21 -0400 Subject: [PATCH 2/5] FieldUuidAttribute: accept parentTable to declare FK relationships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldUuidAttribute exposed only primaryKey/fieldName/fieldAlias/syncWithDb and hard-coded the UUID select/update mappers, so a binary UUID foreign key could not register a relationship via ORM::addRelationship — unlike a plain FieldAttribute. Add the parentTable pass-through (updateFunction/selectFunction stay fixed to the UUID mappers). The join SQL built from the relationship is dialect-agnostic (QueryBasic::getJoin concatenates a standard INNER JOIN ... ON), so this works across MySQL, Postgres, Sqlite and SqlServer. insertFunction is intentionally not exposed (the UUID conversion for writes is handled by the update function on the value-prepare path). --- src/Attributes/FieldUuidAttribute.php | 13 +++++++++++-- tests/Model/Class5.php | 16 ++++++++++++++++ tests/ORMTest.php | 22 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/Model/Class5.php diff --git a/src/Attributes/FieldUuidAttribute.php b/src/Attributes/FieldUuidAttribute.php index f4d1c1b..91f6b16 100644 --- a/src/Attributes/FieldUuidAttribute.php +++ b/src/Attributes/FieldUuidAttribute.php @@ -13,9 +13,18 @@ public function __construct( ?bool $primaryKey = null, ?string $fieldName = null, ?string $fieldAlias = null, - ?bool $syncWithDb = null + ?bool $syncWithDb = null, + ?string $parentTable = null ) { - parent::__construct($primaryKey, $fieldName, $fieldAlias, $syncWithDb, FormatUpdateUuidMapper::class, FormatSelectUuidMapper::class); + parent::__construct( + primaryKey: $primaryKey, + fieldName: $fieldName, + fieldAlias: $fieldAlias, + syncWithDb: $syncWithDb, + updateFunction: FormatUpdateUuidMapper::class, + selectFunction: FormatSelectUuidMapper::class, + parentTable: $parentTable + ); } } diff --git a/tests/Model/Class5.php b/tests/Model/Class5.php new file mode 100644 index 0000000..777a4a4 --- /dev/null +++ b/tests/Model/Class5.php @@ -0,0 +1,16 @@ +assertEquals("SELECT * FROM table1 WHERE field1 = 'testando' AND field2 = 'Joana D''Arc'", $sql); $this->assertEquals([], $params); } + + public function testFieldUuidAttributeRegistersParentTableRelationship() + { + // table1 (mapper1) is already registered in setUp. Building Class5's mapper + // must register the FK relationship declared via FieldUuidAttribute(parentTable:). + new Mapper(Class5::class); + + $this->assertEquals(['table1,table5'], ORM::getRelationship('table1', 'table5')); + + $data = ORM::getRelationshipData('table1', 'table5'); + $this->assertCount(1, $data); + $this->assertEquals('table1', $data[0]['parent']); + $this->assertEquals('table5', $data[0]['child']); + $this->assertEquals('id', $data[0]['pk']); + $this->assertEquals('id_table1', $data[0]['fk']); + + // And the dynamic query builder joins them without hand-written SQL. + $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $this->assertStringContainsString('INNER JOIN table5', $sql); + $this->assertStringContainsString('table1.id = table5.id_table1', $sql); + } } From db747aa3ca03092d59b71c94a62722758aca512f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 18 Jul 2026 19:00:41 -0400 Subject: [PATCH 3/5] ORM: resolve incomplete relationships when the parent registers later getRelationship() retried incomplete relationships (parent primary key still '?') with an inverted condition: it skipped exactly the case where the parent mapper had since been registered, so a child mapper created before its parent kept a '?' primary key and produced a broken join (parent.? = child.fk). Flip the guard so the retry runs once the parent is available and the real primary key is filled in. This matters when relationships are declared via FieldAttribute(parentTable:) and mappers register in dependency-injection order rather than parent-first. --- src/ORM.php | 5 +++-- tests/ORMTest.php | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/ORM.php b/src/ORM.php index 2973e55..520b7af 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -59,9 +59,10 @@ public static function addRelationship(string|Mapper $parent, string|Mapper $chi public static function getRelationship(string ...$tables): array { - // First time we try to fix the incomplete relationships + // Retry incomplete relationships whose parent mapper is now registered, + // so the parent primary key ('?') can finally be resolved. foreach (static::$incompleteRelationships as $relationship) { - if (isset(static::$mapper[$relationship["parent"]])) { + if (!isset(static::$mapper[$relationship["parent"]])) { continue; } static::addRelationship($relationship["parent"], $relationship["child"], $relationship["fk"]); diff --git a/tests/ORMTest.php b/tests/ORMTest.php index 624e903..dcdb1f5 100644 --- a/tests/ORMTest.php +++ b/tests/ORMTest.php @@ -172,6 +172,21 @@ public function testProcessLiteralString() $this->assertEquals([], $params); } + public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() + { + // Register the CHILD (table5, FK id_table1 -> table1) BEFORE its parent. + // At this point table1's primary key is unknown, so the relationship is + // stored as incomplete ('?'). Registering the parent afterwards must let + // getRelationship back-fill the real primary key. + ORM::resetMemory(); + new Mapper(Class5::class); + new Mapper(Class1::class, 'table1', 'id'); + + $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $this->assertStringContainsString('table1.id = table5.id_table1', $sql); + $this->assertStringNotContainsString('table1.? =', $sql); + } + public function testFieldUuidAttributeRegistersParentTableRelationship() { // table1 (mapper1) is already registered in setUp. Building Class5's mapper From 8da3229082a3ac26914082fde3670caf617c12bb Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 19 Jul 2026 11:29:09 -0300 Subject: [PATCH 4/5] QueryBasic: add auto-discovering joinRelated; joinWith on it; remove ORM::getQueryInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit joinRelated/leftJoinRelated/rightJoinRelated derive a JOIN's ON condition from a registered parentTable relationship and connect a 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 it composes with a repository query. A shared addRelatedJoin() helper walks the path and delegates to join()/leftJoin()/rightJoin(). ActiveRecord::joinWith() now builds on joinRelated() (starting from the model's own table) instead of ORM::getQueryInstance. ORM::getQueryInstance() is removed — joinRelated/joinWith replace it, keeping the base table, supporting INNER/LEFT/RIGHT, and auto-joining intermediates without the malformed double-join it produced. The relationship registry (addRelationship/getRelationship/ getRelationshipData) is unchanged. Docs (auto-discovering-relationship, comparison-with-other-orms) and CHANGELOG-7.0 updated. --- CHANGELOG-7.0.md | 15 ++++ docs/auto-discovering-relationship.md | 47 ++++++++---- docs/comparison-with-other-orms.md | 6 +- src/ORM.php | 35 --------- src/QueryBasic.php | 100 ++++++++++++++++++++++++++ src/Trait/ActiveRecord.php | 13 +++- tests/ORMTest.php | 98 +++++++++++++++++-------- 7 files changed, 230 insertions(+), 84 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index f387d9a..491f437 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -43,6 +43,15 @@ no observers are registered), and multi-connection safe. 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) +- See: `docs/auto-discovering-relationship.md` + ## Breaking Changes ### ORMSubject removed @@ -74,6 +83,12 @@ no observers are registered), and multi-connection safe. - 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 | diff --git a/docs/auto-discovering-relationship.md b/docs/auto-discovering-relationship.md index 3100f35..7fad453 100644 --- a/docs/auto-discovering-relationship.md +++ b/docs/auto-discovering-relationship.md @@ -42,32 +42,51 @@ relationship registry. ## Generating Queries with Relationships -To generate the SQL query with the relationship, you can use the `ORM` static class: - -```php -table('table1') - ->join('table2', 'table2.id_table1 = table1.id'); + ->table('table2') + ->joinRelated('table1'); +// SELECT * FROM table2 INNER JOIN table1 ON table1.id = table2.id_table1 ``` -## Queries with Multiple Tables +`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. -You can also create queries with multiple joined tables by passing more table names: +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 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'); ``` -The ORM will automatically discover the path to connect these tables if relationships have been defined. +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. ## Manual Relationship Definition diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md index 62a69a5..7cb25fb 100644 --- a/docs/comparison-with-other-orms.md +++ b/docs/comparison-with-other-orms.md @@ -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]) { diff --git a/src/ORM.php b/src/ORM.php index 520b7af..2230194 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -126,41 +126,6 @@ public static function getMapper(string $tableName): ?Mapper return static::$mapper[$tableName] ?? null; } - public static function getQueryInstance(string ...$tables): Query - { - $query = new Query(); - - $relationships = static::getRelationshipData(...$tables); - - if (empty($relationships)) { - if (count($tables) === 1) { - $query->table($tables[0]); - return $query; - } else { - throw new InvalidArgumentException("No relationship found between the tables"); - } - } - - $first = true; - foreach ($relationships as $relationship) { - $parent = $relationship["parent"]; - $child = $relationship["child"]; - $foreignKey = $relationship["fk"]; - $primaryKey = $relationship["pk"]; - - $parentAlis = static::$mapper[$parent]->getTableAlias(); - $childAlias = static::$mapper[$child]->getTableAlias(); - - if ($first) { - $query->table($parent, $parentAlis); - $first = false; - } - $query->join($child, "{$parentAlis}.{$primaryKey} = {$childAlias}.{$foreignKey}", $childAlias); - } - - return $query; - } - private static function getNormalizedKey(string $table1, string $table2): string { return strcmp($table1, $table2) < 0 ? "$table1,$table2" : "$table2,$table1"; diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 56da25b..2b4b33e 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -167,6 +167,106 @@ public function crossJoin(QueryBasic|string $table, ?string $alias = null): stat return $this; } + /** + * Add an INNER JOIN to $table using the registered parentTable relationships, + * deriving the ON condition instead of writing it by hand. $table is connected to + * a table already in the query (base or a previous join); if they are not directly + * related, the intermediate tables on the shortest relationship path are joined too + * — so you don't have to remember them — while tables already in the query are + * skipped. The query keeps its own base table, so this composes on top of an + * existing query, including one a repository has scoped to its table. + * + * Example: + * $query->table('task')->joinRelated('project'); + * // INNER JOIN project ON project.id = task.project_id + * + * $query->table('project')->joinRelated('note'); + * // auto-joins task in between: INNER JOIN task ON … INNER JOIN note ON … + * + * For aliased joins, use join()/leftJoin()/rightJoin() with an explicit ON. + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + public function joinRelated(string $table): static + { + return $this->addRelatedJoin($table, 'INNER'); + } + + /** + * Like joinRelated(), but every hop it adds is a LEFT JOIN. + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + public function leftJoinRelated(string $table): static + { + return $this->addRelatedJoin($table, 'LEFT'); + } + + /** + * Like joinRelated(), but every hop it adds is a RIGHT JOIN. + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + public function rightJoinRelated(string $table): static + { + return $this->addRelatedJoin($table, 'RIGHT'); + } + + /** + * Walk the relationship path from a table already in the query to $table and join + * each hop's not-yet-present table, delegating to join()/leftJoin()/rightJoin(). + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + private function addRelatedJoin(string $table, string $type): static + { + foreach ($this->relatedTables() as $present) { + $path = ORM::getRelationshipData($present, $table); + if (empty($path)) { + continue; + } + + foreach ($path as $rel) { + $current = $this->relatedTables(); + $parentPresent = in_array($rel['parent'], $current, true); + if ($parentPresent && in_array($rel['child'], $current, true)) { + continue; // both tables already in the query + } + $newTable = $parentPresent ? $rel['child'] : $rel['parent']; + $on = "{$rel['parent']}.{$rel['pk']} = {$rel['child']}.{$rel['fk']}"; + match ($type) { + 'LEFT' => $this->leftJoin($newTable, $on), + 'RIGHT' => $this->rightJoin($newTable, $on), + default => $this->join($newTable, $on), + }; + } + + return $this; + } + + throw new InvalidArgumentException("No relationship registered between '$table' and the query tables."); + } + + /** + * Table names already present in the query (base + joined), used to resolve a + * relationship for joinRelated(). + * + * @return string[] + */ + private function relatedTables(): array + { + $tables = []; + if (is_string($this->table) && $this->table !== '') { + $tables[] = $this->table; + } + foreach ($this->join as $item) { + if (is_string($item['table'])) { + $tables[] = $item['table']; + } + } + return $tables; + } + public function withRecursive(Recursive $recursive): static { $this->recursive = $recursive; diff --git a/src/Trait/ActiveRecord.php b/src/Trait/ActiveRecord.php index 0a3d411..a25c150 100644 --- a/src/Trait/ActiveRecord.php +++ b/src/Trait/ActiveRecord.php @@ -120,11 +120,20 @@ public static function all(int $page = 0, int $limit = 50): array return self::$repository->getByFilter(page: $page, limit: $limit); } + /** + * Start a query on this model's table, optionally joining related tables via + * their registered parentTable relationships. Built on Query::joinRelated(), so + * the model's own table stays the base and each joined table must be directly + * related to a table already in the query (chain for multi-hop). + */ public static function joinWith(string ...$tables): Query { self::initialize(); - $tables[] = self::$repository->getMapper()->getTable(); - return ORM::getQueryInstance(...$tables); + $query = Query::getInstance()->table(self::$repository->getMapper()->getTable()); + foreach ($tables as $table) { + $query->joinRelated($table); + } + return $query; } public function toArray(bool $includeNullValue = false): array diff --git a/tests/ORMTest.php b/tests/ORMTest.php index dcdb1f5..6a537e6 100644 --- a/tests/ORMTest.php +++ b/tests/ORMTest.php @@ -2,12 +2,14 @@ namespace Tests; +use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\FieldMapping; use ByJG\MicroOrm\Literal\HexUuidLiteral; use ByJG\MicroOrm\Literal\Literal; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\ORM; use ByJG\MicroOrm\ORMHelper; +use ByJG\MicroOrm\Query; use Override; use PHPUnit\Framework\TestCase; use Tests\Model\Class1; @@ -105,33 +107,9 @@ public function testSanityCheckData() $this->assertEquals([$table1Table3, $table1Table2], ORM::getRelationshipData('table3', 'table2')); } - public function testGetQuery() - { - $query = ORM::getQueryInstance('table1'); - $this->assertEquals("SELECT * FROM table1", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table2', 'table1'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table4', 'table2', 'table1'); - $this->assertEquals("SELECT * FROM table2 INNER JOIN table4 ON table2.id = table4.id_table2 INNER JOIN table2 ON table1.id = table2.id_table1 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table4', 'table1', 'table2'); - $this->assertEquals("SELECT * FROM table2 INNER JOIN table4 ON table2.id = table4.id_table2 INNER JOIN table2 ON table1.id = table2.id_table1 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table2', 'table1', 'table4'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table1', 'table4'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table2', 'table3'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 INNER JOIN table3 ON table1.id = table3.id_table1", $query->build()->getSql()); - } - public function testProcessLiteral() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new Literal('upper(field1)')]); $this->assertEquals("SELECT * FROM table1 WHERE field1 = upper(field1)", $query->build()->getSql()); @@ -139,14 +117,14 @@ public function testProcessLiteral() public function testProcessHexUuidLiteral() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new HexUuidLiteral(hex2bin('01010101010101010101010101010101'))]); $this->assertEquals("SELECT * FROM table1 WHERE field1 = X'01010101010101010101010101010101'", $query->build()->getSql()); } public function testProcessLiteralUnsafe() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new Literal(10)]); $sqlStatement = $query->build(); @@ -159,7 +137,7 @@ public function testProcessLiteralUnsafe() public function testProcessLiteralString() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new Literal("'testando'")]); $query->where('field2 = :value2', ['value2' => new Literal("'Joana D''Arc'")]); @@ -172,6 +150,66 @@ public function testProcessLiteralString() $this->assertEquals([], $params); } + public function testJoinRelatedDerivesOnFromRelationshipKeepingBaseTable() + { + // table3 has a FK id_table1 -> table1. Base stays table3; project is added. + $sql = Query::getInstance()->table('table3')->joinRelated('table1')->build()->getSql(); + $this->assertStringContainsString('FROM table3 INNER JOIN table1 ON table1.id = table3.id_table1', $sql); + } + + public function testLeftJoinRelated() + { + $sql = Query::getInstance()->table('table3')->leftJoinRelated('table1')->build()->getSql(); + $this->assertStringContainsString('LEFT JOIN table1 ON table1.id = table3.id_table1', $sql); + } + + public function testRightJoinRelated() + { + $sql = Query::getInstance()->table('table3')->rightJoinRelated('table1')->build()->getSql(); + $this->assertStringContainsString('RIGHT JOIN table1 ON table1.id = table3.id_table1', $sql); + } + + public function testJoinRelatedAutoDiscoversIntermediateTables() + { + // table1 is not directly related to table4 (table1 -> table2 -> table4). The + // intermediate table2 is joined automatically, and table4's soft-delete filter + // is kept. The base table (table1) is preserved. + $sql = Query::getInstance()->table('table1')->joinRelated('table4')->build()->getSql(); + $this->assertEquals( + "SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 " + . "INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", + $sql + ); + + // table2 -> table3 auto-discovers table1 sitting between them. + $sql = Query::getInstance()->table('table2')->joinRelated('table3')->build()->getSql(); + $this->assertEquals( + "SELECT * FROM table2 INNER JOIN table1 ON table1.id = table2.id_table1 " + . "INNER JOIN table3 ON table1.id = table3.id_table1", + $sql + ); + } + + public function testJoinRelatedSkipsTablesAlreadyInTheQuery() + { + // Naming the intermediate explicitly must not join table2 twice. + $sql = Query::getInstance()->table('table1') + ->joinRelated('table2') + ->joinRelated('table4') + ->build()->getSql(); + $this->assertEquals( + "SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 " + . "INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", + $sql + ); + } + + public function testJoinRelatedThrowsWhenNoRelationshipPathExists() + { + $this->expectException(InvalidArgumentException::class); + Query::getInstance()->table('table1')->joinRelated('unrelated_table'); + } + public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() { // Register the CHILD (table5, FK id_table1 -> table1) BEFORE its parent. @@ -182,7 +220,7 @@ public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() new Mapper(Class5::class); new Mapper(Class1::class, 'table1', 'id'); - $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $sql = Query::getInstance()->table('table1')->joinRelated('table5')->build()->getSql(); $this->assertStringContainsString('table1.id = table5.id_table1', $sql); $this->assertStringNotContainsString('table1.? =', $sql); } @@ -203,7 +241,7 @@ public function testFieldUuidAttributeRegistersParentTableRelationship() $this->assertEquals('id_table1', $data[0]['fk']); // And the dynamic query builder joins them without hand-written SQL. - $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $sql = Query::getInstance()->table('table1')->joinRelated('table5')->build()->getSql(); $this->assertStringContainsString('INNER JOIN table5', $sql); $this->assertStringContainsString('table1.id = table5.id_table1', $sql); } From b5b96cbb9f62c38aaf0f4062fe0f691497744efa Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 21 Jul 2026 23:06:38 -0300 Subject: [PATCH 5/5] joinRelated/joinWith accept a model class (on-demand mapper registration) Passing a class-string to joinRelated/leftJoinRelated/rightJoinRelated/joinWith now resolves it to its table via new ORM::getTableFromClass(), registering the entity's mapper on demand. Registration is reflection-only (no DB connection), so relationship auto-discovery works on a request that never touched that entity's repository. Name each entity in a multi-hop path (like hasManyThrough's through-model) to make the intermediate mapper known. Backward compatible: a table-name string does not class_exists, so it is used verbatim. --- CHANGELOG-7.0.md | 7 ++++++ docs/auto-discovering-relationship.md | 34 +++++++++++++++++++++++++++ src/ORM.php | 22 +++++++++++++++++ src/QueryBasic.php | 16 +++++++++++++ src/Trait/ActiveRecord.php | 13 ++++++---- tests/ORMTest.php | 29 +++++++++++++++++++++++ 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 491f437..691c5c7 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -50,6 +50,13 @@ no observers are registered), and multi-connection safe. 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 diff --git a/docs/auto-discovering-relationship.md b/docs/auto-discovering-relationship.md index 7fad453..c0a99f0 100644 --- a/docs/auto-discovering-relationship.md +++ b/docs/auto-discovering-relationship.md @@ -88,6 +88,40 @@ 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 +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 +``` + +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 + 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 +``` + +`ORM::getTableFromClass(Model::class)` exposes the same resolution directly, returning the +table name and registering the mapper if needed. + ## Manual Relationship Definition If you need to define relationships manually (without using attributes), you can use the `addRelationship` method: diff --git a/src/ORM.php b/src/ORM.php index 2230194..7968e14 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -126,6 +126,28 @@ public static function getMapper(string $tableName): ?Mapper return static::$mapper[$tableName] ?? null; } + /** + * Resolve a model class to its table name, registering its mapper on demand. + * + * Building the Mapper only reads the class attributes (reflection) — it does not + * open a database connection — so calling this at any point simply makes the + * entity's table and its parentTable relationships known to the ORM. This is what + * lets joinRelated()/joinWith() take a model class and discover the relationship + * graph on the current request, without every mapper being pre-registered. + * + * @param class-string $class + */ + public static function getTableFromClass(string $class): string + { + foreach (static::$mapper as $mapper) { + if ($mapper->getEntity() === $class) { + return $mapper->getTable(); + } + } + + return (new Mapper($class))->getTable(); + } + private static function getNormalizedKey(string $table1, string $table2): string { return strcmp($table1, $table2) < 0 ? "$table1,$table2" : "$table2,$table1"; diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 2b4b33e..03d0dc1 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -176,6 +176,13 @@ public function crossJoin(QueryBasic|string $table, ?string $alias = null): stat * skipped. The query keeps its own base table, so this composes on top of an * existing query, including one a repository has scoped to its table. * + * $table may be a table name or a model class. 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 are known even on a + * request that never touched its repository. To join across a hidden intermediate, + * name each entity in the path (like Eloquent's hasManyThrough through-model): + * joinRelated(Task::class)->joinRelated(Project::class). + * * Example: * $query->table('task')->joinRelated('project'); * // INNER JOIN project ON project.id = task.project_id @@ -185,6 +192,7 @@ public function crossJoin(QueryBasic|string $table, ?string $alias = null): stat * * For aliased joins, use join()/leftJoin()/rightJoin() with an explicit ON. * + * @param string $table Table name or model class-string. * @throws InvalidArgumentException When no relationship path connects $table to the query. */ public function joinRelated(string $table): static @@ -220,6 +228,14 @@ public function rightJoinRelated(string $table): static */ private function addRelatedJoin(string $table, string $type): static { + // Accept either a table name or a model class. A class is resolved to its + // table and registered on demand (reflection only, no DB connection), so the + // intermediate/target entities of the join become known even on a request that + // never touched their repositories. + if (class_exists($table)) { + $table = ORM::getTableFromClass($table); + } + foreach ($this->relatedTables() as $present) { $path = ORM::getRelationshipData($present, $table); if (empty($path)) { diff --git a/src/Trait/ActiveRecord.php b/src/Trait/ActiveRecord.php index a25c150..e5f1728 100644 --- a/src/Trait/ActiveRecord.php +++ b/src/Trait/ActiveRecord.php @@ -121,10 +121,15 @@ public static function all(int $page = 0, int $limit = 50): array } /** - * Start a query on this model's table, optionally joining related tables via - * their registered parentTable relationships. Built on Query::joinRelated(), so - * the model's own table stays the base and each joined table must be directly - * related to a table already in the query (chain for multi-hop). + * Start a query on this model's table, optionally joining related tables via their + * registered parentTable relationships. Built on Query::joinRelated(), so the + * model's own table stays the base. Each argument may be a table name or a model + * class; passing a class registers that entity's mapper on demand (reflection only, + * no DB connection). Name each entity in a multi-hop path — including the + * intermediate — so its mapper is known, e.g.: + * + * Note::joinWith(Task::class, Project::class) + * // FROM note JOIN task ON … JOIN project ON … (note has no project_id) */ public static function joinWith(string ...$tables): Query { diff --git a/tests/ORMTest.php b/tests/ORMTest.php index 6a537e6..ddcd086 100644 --- a/tests/ORMTest.php +++ b/tests/ORMTest.php @@ -225,6 +225,35 @@ public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() $this->assertStringNotContainsString('table1.? =', $sql); } + public function testGetTableFromClassRegistersMapperOnDemand() + { + // Only table1 is known; Class5 (table5) has never been registered. + ORM::resetMemory(); + new Mapper(Class1::class, 'table1', 'id'); + $this->assertNull(ORM::getMapper('table5')); + + // Resolving the class registers its mapper (reflection only) and returns its table. + $this->assertEquals('table5', ORM::getTableFromClass(Class5::class)); + $this->assertNotNull(ORM::getMapper('table5')); + + // Idempotent: a second call reuses the already-registered mapper. + $this->assertSame(ORM::getMapper('table5'), ORM::getMapper('table5')); + $this->assertEquals('table5', ORM::getTableFromClass(Class5::class)); + } + + public function testJoinRelatedAcceptsModelClassRegisteringMapperOnDemand() + { + // table5's mapper is not pre-registered; passing the class must register it on + // demand and join it, just like passing the 'table5' string would after setup. + ORM::resetMemory(); + new Mapper(Class1::class, 'table1', 'id'); + $this->assertNull(ORM::getMapper('table5')); + + $sql = Query::getInstance()->table('table1')->joinRelated(Class5::class)->build()->getSql(); + $this->assertStringContainsString('INNER JOIN table5 ON table1.id = table5.id_table1', $sql); + $this->assertNotNull(ORM::getMapper('table5')); + } + public function testFieldUuidAttributeRegistersParentTableRelationship() { // table1 (mapper1) is already registered in setUp. Building Class5's mapper