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() ); }