From 59af8b1740ff839b1ef84c02613e5099c4e073bb Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 12 Jul 2026 11:56:39 -0400 Subject: [PATCH 01/18] Rewire observers on top of the anydataset-db 7.0 DatabaseExecutor observer - Remove the global ORMSubject singleton; observer dispatch now flows through ObserverBridge, a DatabaseEventObserverInterface implementation attached per DatabaseExecutor - Query builders return OrmSqlStatement (extends SqlStatement) stamped with the ORM event and affected table - All writes now notify: save(), delete(), deleteByQuery(), bulkExecute() (after commit), buildAndExecute() and built statements executed directly on the executor - save() keeps its previous semantics via deferred dispatch: observers receive the entity rehydrated with generated keys - New ObserverEvent::SoftDelete case: soft deletes now fire an event - New StatementHookInterface: before-execute hook with veto (throwing aborts the write) - Repository::addObserver() also accepts raw DatabaseEventObserverInterface; new Repository::removeObserver(); addDbDriverForWrite() re-attaches observers to the new write executor - ObserverData::getStatement() exposes the SQL statement to observers - Require byjg/anydataset-db ^7.0 and migrate tests/docs off the driver query methods removed in that release --- CHANGELOG-6.0.md | 539 +++++++++++++++++++++++ CHANGELOG-7.0.md | 86 ++++ composer.json | 2 +- docs/comparison-with-other-orms.md | 4 +- docs/observers.md | 79 +++- docs/query-build.md | 5 +- docs/repository.md | 18 +- src/DeleteQuery.php | 6 +- src/Enum/ObserverEvent.php | 1 + src/InsertBulkQuery.php | 8 +- src/InsertQuery.php | 8 +- src/InsertSelectQuery.php | 7 +- src/Interface/StatementHookInterface.php | 18 + src/ORMSubject.php | 70 --- src/ObserverBridge.php | 181 ++++++++ src/ObserverData.php | 16 +- src/OrmEventContext.php | 84 ++++ src/OrmSqlStatement.php | 61 +++ src/Query.php | 2 +- src/QueryBasic.php | 2 +- src/QueryRaw.php | 2 +- src/Recursive.php | 2 +- src/Repository.php | 157 +++++-- src/Union.php | 2 +- src/Updatable.php | 10 + src/UpdateQuery.php | 7 +- tests/ConnectionUtil.php | 3 +- tests/DeleteQueryTest.php | 15 +- tests/InsertQueryTest.php | 17 +- tests/ObserverBridgeTest.php | 370 ++++++++++++++++ tests/QueryTest.php | 85 ++-- tests/RecursiveTest.php | 4 +- tests/RepositoryTest.php | 8 +- tests/TransactionManagerTest.php | 44 +- tests/UpdateQueryTest.php | 53 ++- tests/WhereTraitTest.php | 8 +- 36 files changed, 1726 insertions(+), 258 deletions(-) create mode 100644 CHANGELOG-6.0.md create mode 100644 CHANGELOG-7.0.md create mode 100644 src/Interface/StatementHookInterface.php delete mode 100644 src/ORMSubject.php create mode 100644 src/ObserverBridge.php create mode 100644 src/OrmEventContext.php create mode 100644 src/OrmSqlStatement.php create mode 100644 tests/ObserverBridgeTest.php diff --git a/CHANGELOG-6.0.md b/CHANGELOG-6.0.md new file mode 100644 index 0000000..e8572f3 --- /dev/null +++ b/CHANGELOG-6.0.md @@ -0,0 +1,539 @@ +# Changelog - Version 6.0 + +## Overview + +Version 6.0 is a major release that introduces significant improvements to type safety, adds new features for enhanced ORM functionality, and removes deprecated methods from version 5.x. This release requires PHP 8.3+ and includes breaking changes that require code updates when upgrading from 5.x. + +## New Features + +### ActiveRecordQuery - Fluent Query API +- Introduced `ActiveRecordQuery` for a fluent and chainable query API +- Provides an intuitive way to build complex queries with method chaining +- Enhances the Active Record pattern implementation +- See: `docs/active-record.md` for examples + +### UUID Support Enhancements +- Added comprehensive UUID field support beyond just primary keys +- Introduced `FormatSelectUuidMapper` and `FormatUpdateUuidMapper` (renamed from Binary variants) +- Added `HexUuidMapperFunctionTest` for validation of UUID handling +- Full support for UUIDs in regular fields with automatic formatting +- See: `docs/uuid-support.md` + +### Varchar Primary Keys +- Added support for non-integer (varchar) primary keys +- Introduced `Product` and `ActiveRecordProduct` models showcasing custom primary key handling +- Enhanced primary key validation and handling across the ORM +- Updated tests and examples with seed functions for varchar PKs + +### HAVING Clause Support +- Added native support for HAVING clause in query building +- Allows for aggregate function filtering in SQL queries +- Integrates seamlessly with the existing query API + +### Entity Lifecycle Hooks +- Added `beforeUpdate` and `beforeInsert` hooks to the `TableAttribute` +- Introduced `EntityProcessorInterface` for custom entity processing +- Allows for custom validation and data transformation before persistence +- Can be defined at both table attribute and mapper levels +- Methods: `withBeforeInsert()` and `withBeforeUpdate()` in Mapper + +### Mapper Functions (Enhanced) +- Introduced `MapperFunctionInterface` as the standard interface for field transformations +- Added comprehensive mapper functions: + - `StandardMapper` - basic field mapping + - `ReadOnlyMapper` - read-only fields + - `NowUtcMapper` - automatic UTC timestamp + - `FormatUpdateUuidMapper` - UUID formatting on update + - `FormatSelectUuidMapper` - UUID formatting on select +- Simplified and more consistent API for field transformations +- See: `docs/mapper-functions.md` + +### Enhanced Field Mapping +- Added `MapperTest` to validate field mapping behavior +- Support for field overwriting, aliases, and non-existent properties +- Added `getPropertyName()` method to Mapper for reverse field-to-property lookup +- Improved handling of array-to-object casting in Mapper + +### Observer Events +- Introduced `ObserverEvent` enum with Insert, Update, and Delete events +- Provides type-safe event handling for database operations +- See: `docs/observers.md` + +### Update Constraints +- Added `UpdateConstraintInterface` for controlling update operations +- Introduced `CustomConstraint` for custom update validation +- Added `RequireChangedValuesConstraint` to ensure values have changed before update +- New exception: `RequireChangedValuesConstraintException` +- See: `docs/update-constraints.md` + +### Enhanced Primary Key Handling +- Improved validation with `MissingPrimaryKeyException` +- More robust primary key validation in `Repository::getPrimaryKeys()` +- Better error messages for missing or invalid primary keys +- Enhanced support for composite primary keys + +### Iterator Support +- Added `buildAndExecuteIterator()` to `QueryBuilderInterface` +- Implemented `getIterator()` and enhanced `getByQuery()` in Repository +- Allows for efficient memory usage with large result sets + +### Serialization Optimization +- Applied `withStopAtFirstLevel()` in Serialize calls across multiple classes +- Prevents unnecessary deep parsing and improves performance +- Optimizes data transfer between layers + +### Documentation Improvements +- Added comprehensive `docs/architecture-layers.md` explaining Infrastructure vs Domain layers +- Added `docs/comparison-with-other-orms.md` comparing with Eloquent and Doctrine +- Added `docs/common-traits.md` for timestamp field helpers +- Added `docs/mapper-functions.md` for field transformation documentation +- Added `docs/query-build.md` for SQL query building +- Expanded and improved all existing documentation files +- Better examples and code samples throughout + +## Bug Fixes + +- Fixed `Repository::setBeforeUpdate()` and `Repository::setBeforeInsert()` implementations +- Improved primary key handling edge cases +- Fixed namespace references for `byjg/anydataset-db` compatibility +- Resolved Psalm static analysis issues +- Fixed test compatibility with updated dependencies +- Corrected return type for `Repository::insert()` to `?int` +- Improved transaction handling in Repository +- Fixed UUID formatting consistency issues + +## Breaking Changes + +| Before (5.x) | After (6.0) | Description | +|--------------|-------------|-------------| +| `php: >=8.1 <8.4` | `php: >=8.3 <8.6` | PHP 8.3+ is now required | +| `byjg/anydataset-db: ^5.0` | `byjg/anydataset-db: ^6.0` | Updated to version 6.x of anydataset-db | +| `phpunit/phpunit: ^9.6` | `phpunit/phpunit: ^10.5\|^11.5` | Updated to PHPUnit 10.5+ or 11.5+ | +| `byjg/cache-engine: ^5.0` | `byjg/cache-engine: ^6.0` | Updated to version 6.x | +| `DbFunctionsInterface` | `DatabaseExecutor` | Interface renamed for clarity | +| `DbDriverInterface` parameter | `Repository->getExecutor()` | Access database executor through repository | +| `SqlObject` | `SqlStatement` | Class renamed across entire codebase | +| `UniqueIdGeneratorInterface` | `MapperFunctionInterface` | Interface renamed for consistency | +| `SelectBinaryUuidMapper` | `FormatSelectUuidMapper` | Mapper class renamed | +| `UpdateBinaryUuidMapper` | `FormatUpdateUuidMapper` | Mapper class renamed | +| `Mapper::addFieldMap()` | `Mapper::addFieldMapping()` | Deprecated method removed | +| `MapperClosure` | Mapper Functions (e.g., `StandardMapper`) | Deprecated class removed | +| `AllowOnlyNewValuesConstraintException` | Removed/replaced | Exception removed | +| `Literal` class usage | `LiteralInterface` | Refactored to use interface | +| Nullable types (inconsistent) | Strict nullable types (`?Type`) | Improved type safety across all methods | +| `withPrimaryKeySeedFunction(callable)` | `withPrimaryKeySeedFunction(string\|MapperFunctionInterface)` | Type signature changed | + +### Removed Deprecated Features + +The following deprecated features from 5.x have been removed: + +1. **`Mapper::addFieldMap()`** - Use `Mapper::addFieldMapping()` instead +2. **`MapperClosure`** - Use the new Mapper Functions classes (`StandardMapper`, `ReadOnlyMapper`, etc.) +3. **Old closure-based field transformations** - Use `MapperFunctionInterface` implementations +4. **Direct `DbDriverInterface` access** - Use `Repository->getExecutor()` instead + +## Upgrade Path from 5.x to 6.0 + +### Step 1: Update Dependencies + +Update your `composer.json`: + +```json +{ + "require": { + "php": ">=8.3", + "byjg/micro-orm": "^6.0" + } +} +``` + +Run: +```bash +composer update byjg/micro-orm +``` + +### Step 2: Update PHP Version + +Ensure your project is running PHP 8.3 or higher. Version 6.0 takes advantage of PHP 8.3+ features including: +- Typed class constants +- Enhanced type system +- Improved readonly properties +- Better attribute handling + +### Step 3: Replace Deprecated Classes and Methods + +#### Replace `SqlObject` with `SqlStatement` + +**Before:** +```php +use ByJG\AnyDataset\Db\SqlObject; + +$sql = new SqlObject('SELECT * FROM users'); +``` + +**After:** +```php +use ByJG\AnyDataset\Db\SqlStatement; + +$sql = new SqlStatement('SELECT * FROM users'); +``` + +#### Replace `DbFunctionsInterface` with `DatabaseExecutor` + +**Before:** +```php +public function __construct(DbFunctionsInterface $dbFunctions) +{ + $this->dbFunctions = $dbFunctions; +} +``` + +**After:** +```php +public function __construct(DatabaseExecutor $executor) +{ + $this->executor = $executor; +} +``` + +Or, if you were accessing it through Repository: + +**Before:** +```php +$driver = $repository->getDbDriver(); +``` + +**After:** +```php +$executor = $repository->getExecutor(); +``` + +#### Replace `UniqueIdGeneratorInterface` with `MapperFunctionInterface` + +**Before:** +```php +use ByJG\MicroOrm\Interface\UniqueIdGeneratorInterface; + +class MyGenerator implements UniqueIdGeneratorInterface +{ + // ... +} +``` + +**After:** +```php +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class MyGenerator implements MapperFunctionInterface +{ + public function update(mixed $value, object $instance, string $field): mixed + { + // your logic + } + + public function select(mixed $value, object $instance, string $field): mixed + { + // your logic + } +} +``` + +#### Replace UUID Mapper Classes + +**Before:** +```php +use ByJG\MicroOrm\MapperFunctions\SelectBinaryUuidMapper; +use ByJG\MicroOrm\MapperFunctions\UpdateBinaryUuidMapper; + +$field->withUpdateFunction(new UpdateBinaryUuidMapper()); +$field->withSelectFunction(new SelectBinaryUuidMapper()); +``` + +**After:** +```php +use ByJG\MicroOrm\MapperFunctions\FormatUpdateUuidMapper; +use ByJG\MicroOrm\MapperFunctions\FormatSelectUuidMapper; + +$field->withUpdateFunction(new FormatUpdateUuidMapper()); +$field->withSelectFunction(new FormatSelectUuidMapper()); +``` + +#### Replace `Mapper::addFieldMap()` with `addFieldMapping()` + +**Before:** +```php +$mapper->addFieldMap( + 'propertyName', + 'field_name', + fn($value) => strtoupper($value), // update function + fn($value) => strtolower($value) // select function +); +``` + +**After:** +```php +use ByJG\MicroOrm\FieldMapping; + +$fieldMapping = FieldMapping::create('propertyName') + ->withFieldName('field_name') + ->withUpdateFunction(new class implements MapperFunctionInterface { + public function update(mixed $value, object $instance, string $field): mixed { + return strtoupper($value); + } + public function select(mixed $value, object $instance, string $field): mixed { + return strtolower($value); + } + }); + +$mapper->addFieldMapping($fieldMapping); +``` + +Or use one of the built-in mapper functions: + +```php +use ByJG\MicroOrm\FieldMapping; +use ByJG\MicroOrm\MapperFunctions\StandardMapper; + +$fieldMapping = FieldMapping::create('propertyName') + ->withFieldName('field_name') + ->withUpdateFunction(new StandardMapper()) + ->withSelectFunction(new StandardMapper()); + +$mapper->addFieldMapping($fieldMapping); +``` + +#### Replace `MapperClosure` with Mapper Functions + +**Before:** +```php +use ByJG\MicroOrm\MapperClosure; + +$closure = MapperClosure::forUpdate(fn($value) => json_encode($value)); +``` + +**After:** +```php +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class JsonEncodeMapper implements MapperFunctionInterface +{ + public function update(mixed $value, object $instance, string $field): mixed + { + return json_encode($value); + } + + public function select(mixed $value, object $instance, string $field): mixed + { + return $value; + } +} + +$mapper = new JsonEncodeMapper(); +``` + +### Step 4: Update Type Declarations + +Review your code for nullable type declarations. Version 6.0 enforces strict nullable types: + +**Before:** +```php +public function myMethod(string $param) // might accept null in some contexts +``` + +**After:** +```php +public function myMethod(?string $param) // explicitly nullable +``` + +### Step 5: Use `LiteralInterface` Instead of Direct `Literal` Usage + +**Before:** +```php +use ByJG\AnyDataset\Db\Literal; + +$value = new Literal('NOW()'); +``` + +**After:** +```php +use ByJG\AnyDataset\Db\LiteralInterface; +use ByJG\AnyDataset\Db\Literal; + +function processValue(LiteralInterface $value) +{ + // Type hint uses interface +} + +$value = new Literal('NOW()'); // Implementation is still Literal +processValue($value); +``` + +### Step 6: Update Primary Key Seed Functions + +If you were using callable for primary key seed functions: + +**Before:** +```php +$mapper->withPrimaryKeySeedFunction(function() { + return generateCustomId(); +}); +``` + +**After:** +```php +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class CustomIdGenerator implements MapperFunctionInterface +{ + public function update(mixed $value, object $instance, string $field): mixed + { + return generateCustomId(); + } + + public function select(mixed $value, object $instance, string $field): mixed + { + return $value; + } +} + +$mapper->withPrimaryKeySeedFunction(new CustomIdGenerator()); +// Or use a string reference to a static class method +$mapper->withPrimaryKeySeedFunction('MyClass::generateId'); +``` + +### Step 7: Handle New Exceptions + +Add handling for new exceptions introduced in 6.0: + +```php +use ByJG\MicroOrm\Exception\MissingPrimaryKeyException; +use ByJG\MicroOrm\Exception\RequireChangedValuesConstraintException; + +try { + $repository->save($entity); +} catch (MissingPrimaryKeyException $e) { + // Handle missing primary key +} catch (RequireChangedValuesConstraintException $e) { + // Handle unchanged values when using RequireChangedValuesConstraint +} +``` + +### Step 8: Update Tests + +- Update PHPUnit to version 10.5+ or 11.5+ +- Review test setup to use `ConnectionUtil` for database connections +- Replace all `SqlObject` references with `SqlStatement` +- Update any custom test assertions for type changes + +### Step 9: Leverage New Features (Optional) + +Take advantage of new features introduced in 6.0: + +#### Use Entity Lifecycle Hooks + +```php +use ByJG\MicroOrm\Attributes\TableAttribute; + +#[TableAttribute( + tableName: 'users', + beforeInsert: MyValidator::class . '::validateBeforeInsert', + beforeUpdate: MyValidator::class . '::validateBeforeUpdate' +)] +class User +{ + // ... +} +``` + +#### Use ActiveRecordQuery for Fluent API + +```php +$products = Product::query() + ->where('category = :cat', ['cat' => 'electronics']) + ->orderBy(['price' => 'DESC']) + ->limit(10) + ->get(); +``` + +#### Use HAVING Clause + +```php +$query = Query::getInstance() + ->fields(['category', 'COUNT(*) as total']) + ->groupBy(['category']) + ->having('COUNT(*) > :min', ['min' => 5]); +``` + +#### Add Update Constraints + +```php +use ByJG\MicroOrm\Constraint\RequireChangedValuesConstraint; + +$repository->setUpdateConstraint(new RequireChangedValuesConstraint()); +``` + +### Step 10: Run Tests and Verify + +After making all changes: + +```bash +# Update dependencies +composer update + +# Run static analysis +vendor/bin/psalm + +# Run tests +vendor/bin/phpunit +``` + +## Additional Notes + +### Performance Improvements + +- Serialization is now optimized with `withStopAtFirstLevel()` +- Iterator support allows efficient handling of large datasets +- Better query caching through refined cache key generation + +### Code Quality + +- Full PHP 8.3+ type safety +- Added `#[Override]` attributes for better IDE support +- Comprehensive Psalm static analysis compliance +- Improved code documentation and examples + +### Testing + +- Expanded test coverage for all new features +- Better edge case handling in tests +- Centralized database connection logic in tests +- Support for Docker-based testing with MySQL + +## Migration Checklist + +- [ ] Update PHP to 8.3 or higher +- [ ] Update `composer.json` to require `byjg/micro-orm: ^6.0` +- [ ] Run `composer update` +- [ ] Replace all `SqlObject` with `SqlStatement` +- [ ] Replace `DbFunctionsInterface` with `DatabaseExecutor` +- [ ] Replace `UniqueIdGeneratorInterface` with `MapperFunctionInterface` +- [ ] Replace UUID mapper classes (`SelectBinaryUuidMapper` → `FormatSelectUuidMapper`, etc.) +- [ ] Replace `Mapper::addFieldMap()` with `Mapper::addFieldMapping()` +- [ ] Remove usage of deprecated `MapperClosure` class +- [ ] Update type hints to use `LiteralInterface` where applicable +- [ ] Update primary key seed functions to new signature +- [ ] Add exception handling for `MissingPrimaryKeyException` and `RequireChangedValuesConstraintException` +- [ ] Update PHPUnit to 10.5+ or 11.5+ +- [ ] Run Psalm and fix any type issues +- [ ] Run test suite and verify all tests pass +- [ ] Review and update documentation/comments + +## Support + +For issues, questions, or contributions: +- GitHub Issues: https://github.com/byjg/php-micro-orm/issues +- Documentation: See the `docs/` folder for detailed guides + +## Credits + +This release includes contributions focused on improving type safety, developer experience, and ORM functionality. Special attention was given to maintaining clean architecture patterns and comprehensive documentation. diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md new file mode 100644 index 0000000..f387d9a --- /dev/null +++ b/CHANGELOG-7.0.md @@ -0,0 +1,86 @@ +# Changelog - Version 7.0 + +## Overview + +Version 7.0 rewires the entire observer system on top of the low-level `DatabaseExecutor` observer +introduced by `byjg/anydataset-db` 6.0. The global `ORMSubject` singleton is removed: observer +dispatch now happens at the database-connection level, which makes observation complete (every +write built by MicroOrm notifies, not only `save()` and `deleteByQuery()`), faster (zero cost when +no observers are registered), and multi-connection safe. + +`ObserverProcessorInterface` implementations require **no code change**. + +## New Features + +### Observers driven by the executor (ObserverBridge) +- All observer dispatch flows through `ObserverBridge`, a `DatabaseEventObserverInterface` + implementation attached (one per `DatabaseExecutor`) on demand +- Writes now notify regardless of how they are executed: `save()`, `delete()`, `deleteByQuery()`, + `bulkExecute()` (after commit; nothing on rollback), `buildAndExecute()`, and built queries + executed directly on the executor +- `save()` keeps its exact previous semantics: Insert/Update observers still receive the entity + rehydrated with generated keys (deferred dispatch) +- See: `docs/observers.md` + +### OrmSqlStatement +- Every query builder `build()` now returns `OrmSqlStatement` (extends + `ByJG\AnyDataset\Db\SqlStatement`), stamped with the ORM event (`Insert`, `Update`, `Delete`, + `SoftDelete`) and the affected table for write builders +- `ObserverData::getStatement()` exposes it to observers (SQL text + params) + +### SoftDelete observer event +- New `ObserverEvent::SoftDelete` case: `delete()` on a mapper with soft delete enabled now fires + an event (previously it fired nothing). The row still exists, with `deleted_at` set + +### Before-execute hook with veto +- New `StatementHookInterface`: a processor implementing it is called before the SQL of an observed + table executes, with full access to SQL and params. Throwing aborts the write + +### Raw low-level observers through the Repository +- `Repository::addObserver()` also accepts a raw + `ByJG\AnyDataset\Db\Interfaces\DatabaseEventObserverInterface`, attached directly to the + repository executors (receives `BEFORE/AFTER_QUERY` and `BEFORE/AFTER_EXECUTE` for every + statement, including reads and raw SQL) +- New `Repository::removeObserver()` for both observer kinds + +## Breaking Changes + +### ORMSubject removed +- `ORMSubject` (global singleton) no longer exists. `Repository::addObserver()` keeps working with + the same signature for `ObserverProcessorInterface` +- Remove any `ORMSubject::getInstance()->clearObservers()` calls (typically in test suites) — + bridges die with their executors, there is no global state to clear + +### Observer scope: global → per executor +- Observers now fire for writes flowing through the `DatabaseExecutor` instance(s) of the + repository where they were registered. Two repositories over different executors no longer + cross-notify, even on the same database. Register the observer on each connection if needed +- `addDbDriverForWrite()` re-attaches the repository observers to the new write executor + +### More events than before +- Soft deletes (`SoftDelete`), `bulkExecute()` writes, `buildAndExecute()` and direct executions of + built statements now notify. Review observers that assumed only `save()`/`deleteByQuery()` fired + +### Event payload for non-save() writes +- Events from writes without an entity in scope (bulk, direct executions) carry the SQL parameters + array in `getData()`/`getOldData()` instead of entity instances. Use `getStatement()` for the SQL + +### Repository protected helpers +- `insert()`, `insertWithAutoInc()`, `insertWithKeyGen()` and `update()` now receive the pre-built + `OrmSqlStatement` instead of the query builder. Subclasses overriding them must adopt the new + signatures + +### ObserverEvent enum +- New `SoftDelete` case: exhaustive `match` expressions over `ObserverEvent` in userland need a new + arm + +## Migration from 6.x + +| 6.x | 7.0 | +|----------------------------------------------------------|----------------------------------------------------------| +| `ObserverProcessorInterface` implementations | No change | +| `$repository->addObserver($processor)` | No change | +| `ORMSubject::getInstance()->clearObservers()` | Remove the call | +| Observer watching writes made through another connection | Register the observer on a repository of each connection | +| `Repository` subclass overriding `insert()`/`update()` | Update to the new `OrmSqlStatement` signatures | +| `match` over `ObserverEvent` | Add the `SoftDelete` arm | diff --git a/composer.json b/composer.json index 9bfd58a..311b063 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,7 @@ "php": ">=8.3 <8.6", "ext-json": "*", "ext-pdo": "*", - "byjg/anydataset-db": "^6.0" + "byjg/anydataset-db": "^7.0" }, "require-dev": { "phpunit/phpunit": "^10.5|^11.5", diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md index aa52c74..62a69a5 100644 --- a/docs/comparison-with-other-orms.md +++ b/docs/comparison-with-other-orms.md @@ -928,7 +928,7 @@ class UserStatusChangedObserver implements ObserverProcessorInterface } // Register the observer -ORMSubject::getInstance()->registerObserver(new UserStatusChangedObserver()); +$repository->addObserver(new UserStatusChangedObserver()); // Now whenever a user's status changes, your domain event fires! $user = $repository->get(1); @@ -1081,7 +1081,7 @@ class Order } // 2. Register domain event handlers (observers) -ORMSubject::getInstance()->registerObserver(new class implements ObserverProcessorInterface { +$repository->addObserver(new class implements ObserverProcessorInterface { public function getObservedTable(): string { return 'orders'; } public function process(ObserverData $data): void diff --git a/docs/observers.md b/docs/observers.md index a923b89..bd8518b 100644 --- a/docs/observers.md +++ b/docs/observers.md @@ -4,12 +4,19 @@ sidebar_position: 12 # Observers -An observer is a class will be called after an insert, update or delete record in the DB. +An observer is a class that is called when a record is inserted, updated or deleted in the DB. + +Since version 7.0 the observers are dispatched by the low-level +[anydataset-db executor observer](https://github.com/byjg/php-anydataset-db): +every `Repository` attaches a bridge to its `DatabaseExecutor`, and any write statement built by +MicroOrm that flows through that executor notifies the observers watching its table — no matter if +the write came from a `Repository` method, `buildAndExecute()`, `bulkExecute()` or a built query +executed directly on the executor. ```mermaid flowchart TD - A[MyRepository] --> |1. addObserver| B[Subject] - C[WatchedRepository] --> |2. Notify Update| B + A[MyRepository] --> |1. addObserver| B[ObserverBridge attached to the DatabaseExecutor] + C[Any write through the same executor] --> |2. BEFORE/AFTER_EXECUTE| B B --> |3. Execute Callback| A ``` @@ -17,8 +24,8 @@ flowchart TD ```php addObserver(new class($this->infoMapper->getTable()) implements ObserverProcessorInterface { +// This observer will be called after insert, update or delete a record on the table 'triggerTable' +$myRepository->addObserver(new class('triggerTable') implements ObserverProcessorInterface { private $table; public function __construct($table) @@ -26,11 +33,16 @@ $myRepository->addObserver(new class($this->infoMapper->getTable()) implements O $this->table = $table; } - public function process(ObserverData $observerData) + public function process(ObserverData $observerData): void { // Do something here } + public function onError(Throwable $exception, ObserverData $observerData): void + { + // Called when process() throws. The write is never aborted by process(). + } + public function getObservedTable(): string { return $this->table; @@ -39,11 +51,60 @@ $myRepository->addObserver(new class($this->infoMapper->getTable()) implements O ``` The `ObserverData` class contains the following properties: + - `getTable()`: The table name that was affected -- `getEvent()`: The event that was triggered. Can be 'insert', 'update' or 'delete' +- `getEvent()`: The `ObserverEvent` that was triggered: `Insert`, `Update`, `Delete` or `SoftDelete` - `getData()`: The data that was inserted or updated. It is null in case of delete. + For writes issued outside `Repository::save()` (bulk, direct executions) the entity is not + available and the SQL parameters array is provided instead. - `getOldData()`: The data before update. In case of insert comes null, and in case of delete comes with the param filters. -- `getRepository()`: The repository is listening to the event (the same as $myRepository) +- `getRepository()`: The repository listening to the event (the same as `$myRepository`) +- `getStatement()`: The `OrmSqlStatement` that triggered the event, with the SQL text (`getSql()`) and + parameters (`getParams()`) + +## Which writes fire events + +| Write | Event | data / oldData | +|-------|-------|----------------| +| `save()` insert | `Insert` | hydrated entity (with generated keys) / null | +| `save()` update | `Update` | entity / previous entity | +| `delete()` / `deleteByQuery()` | `Delete` | null / where params | +| `delete()` with soft delete enabled | `SoftDelete` | null / where params (the row still exists, with `deleted_at` set) | +| `bulkExecute()` | one event per write statement, after commit (nothing on rollback) | params | +| `buildAndExecute()` or a built query executed directly on the executor | per builder | params | + +*Note*: Raw SQL strings executed on the executor (not built by a MicroOrm query builder) do not fire +entity events. Attach a raw `DatabaseEventObserverInterface` (below) if you need to see everything. + +## Observer scope + +Observers are scoped to the `DatabaseExecutor` instance(s) of the repository where +`addObserver()` was called. Writes through another executor — even on the same database — do not +notify. If you need to observe more than one connection, register the observer on a repository of +each one. + +## Before hook (veto) + +If the processor also implements `StatementHookInterface`, it is called right before the SQL of an +observed table executes: + +```php +getSql() and $statement->getParams(). + // Throwing here ABORTS the write. + } + + // ... process(), onError(), getObservedTable() +} +``` -*Note*: The observer will not be called if the insert, update or delete is called using the DBDriver object. +## Low-level observers +`Repository::addObserver()` also accepts a raw +`ByJG\AnyDataset\Db\Interfaces\DatabaseEventObserverInterface`. It is attached directly to the +repository executors and receives the anydataset-db events (`BEFORE_QUERY`, `AFTER_QUERY`, +`BEFORE_EXECUTE`, `AFTER_EXECUTE`) for every statement, including reads and raw SQL. diff --git a/docs/query-build.md b/docs/query-build.md index 7179e62..a10f308 100644 --- a/docs/query-build.md +++ b/docs/query-build.md @@ -166,15 +166,16 @@ $query = Query::getInstance() $sqlStatement = $query->build(); // Use the same statement with different parameters +$executor = DatabaseExecutor::using($dbDriver); $params1 = ['min_age' => 18]; -$iterator1 = $dbDriver->getIterator($sqlStatement->withParams($params1)); +$iterator1 = $executor->getIterator($sqlStatement->withParams($params1)); foreach ($iterator1 as $row) { echo "Adult user: {$row['name']}\n"; } // Reuse with different parameters $params2 = ['min_age' => 21]; -$iterator2 = $dbDriver->getIterator($sqlStatement->withParams($params2)); +$iterator2 = $executor->getIterator($sqlStatement->withParams($params2)); foreach ($iterator2 as $row) { echo "Legal age user: {$row['name']}\n"; } diff --git a/docs/repository.md b/docs/repository.md index f05e764..4e001fc 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -80,25 +80,25 @@ The `getMapper` method returns the Mapper object that defines the relationship b $mapper = $repository->getMapper(); ``` -### getDbDriver +### getExecutor -The `getDbDriver` method returns the database driver used for read operations. It allows you to use SQL commands +The `getExecutor` method returns the `DatabaseExecutor` used for read operations. It allows you to use SQL commands directly. -To find more about the database driver, please refer to the [AnyDataset documentation](https://github.com/byjg/anydataset) +To find more about the executor, please refer to the [AnyDataset documentation](https://github.com/byjg/anydataset) and the [Query](querying-the-database.md). ```php -$dbDriver = $repository->getDbDriver(); -$iterator = $dbDriver->getIterator('select * from mytable'); +$executor = $repository->getExecutor(); +$iterator = $executor->getIterator('select * from mytable'); ``` -### getDbDriverWrite +### getExecutorWrite -The `getDbDriverWrite` method returns the database driver used for write operations. If no separate write driver was -configured, it returns the same driver as `getDbDriver()`. +The `getExecutorWrite` method returns the `DatabaseExecutor` used for write operations. If no separate write executor +was configured, it returns the same executor as `getExecutor()`. ```php -$dbDriverWrite = $repository->getDbDriverWrite(); +$executorWrite = $repository->getExecutorWrite(); ``` ## Repository Query Methods diff --git a/src/DeleteQuery.php b/src/DeleteQuery.php index c95be54..fcfb040 100644 --- a/src/DeleteQuery.php +++ b/src/DeleteQuery.php @@ -4,7 +4,7 @@ use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use Override; @@ -17,7 +17,7 @@ public static function getInstance(): DeleteQuery } #[Override] - public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement + public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement { $whereStr = $this->getWhere(); if (is_null($whereStr)) { @@ -31,7 +31,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params, ObserverEvent::Delete, $this->table); } #[Override] diff --git a/src/Enum/ObserverEvent.php b/src/Enum/ObserverEvent.php index 7fbd6bc..4804a72 100644 --- a/src/Enum/ObserverEvent.php +++ b/src/Enum/ObserverEvent.php @@ -7,4 +7,5 @@ enum ObserverEvent: string case Insert = 'insert'; case Update = 'update'; case Delete = 'delete'; + case SoftDelete = 'soft_delete'; } \ No newline at end of file diff --git a/src/InsertBulkQuery.php b/src/InsertBulkQuery.php index 1df6dbc..dc8c604 100644 --- a/src/InsertBulkQuery.php +++ b/src/InsertBulkQuery.php @@ -4,7 +4,7 @@ use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\MicroOrm\Literal\Literal; @@ -62,11 +62,11 @@ public function values(array $values, bool $allowNonMatchFields = true): static /** * @param DbDriverInterface|SqlDialectInterface|null $dbDriverOrHelper - * @return SqlStatement + * @return OrmSqlStatement * @throws OrmInvalidFieldsException */ #[Override] - public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement + public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement { if (empty($this->fields)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); @@ -124,7 +124,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp ); $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params, ObserverEvent::Insert, $this->table); } #[Override] diff --git a/src/InsertQuery.php b/src/InsertQuery.php index 1a34e02..26a74e6 100644 --- a/src/InsertQuery.php +++ b/src/InsertQuery.php @@ -4,7 +4,7 @@ use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; @@ -69,11 +69,11 @@ public function defineFields(array $fields): static /** * @param DbDriverInterface|SqlDialectInterface|null $dbDriverOrHelper - * @return SqlStatement + * @return OrmSqlStatement * @throws OrmInvalidFieldsException */ #[Override] - public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement + public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement { if (empty($this->values)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); @@ -101,7 +101,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp $params = $this->values; $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params, ObserverEvent::Insert, $this->table); } /** diff --git a/src/InsertSelectQuery.php b/src/InsertSelectQuery.php index e540074..d1d532a 100644 --- a/src/InsertSelectQuery.php +++ b/src/InsertSelectQuery.php @@ -5,6 +5,7 @@ use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface; use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use InvalidArgumentException; @@ -53,11 +54,11 @@ public function fromSqlStatement(SqlStatement $sqlStatement): static /** * @param DbDriverInterface|SqlDialectInterface|null $dbDriverOrHelper - * @return SqlStatement + * @return OrmSqlStatement * @throws OrmInvalidFieldsException */ #[Override] - public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement + public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement { if (empty($this->fields)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); @@ -98,7 +99,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp throw new OrmInvalidFieldsException('Query or SqlStatement must be set'); } - return new SqlStatement($sql . $fromObj->getSql(), $fromObj->getParams()); + return new OrmSqlStatement($sql . $fromObj->getSql(), $fromObj->getParams(), ObserverEvent::Insert, $this->table); } #[Override] diff --git a/src/Interface/StatementHookInterface.php b/src/Interface/StatementHookInterface.php new file mode 100644 index 0000000..7cb9d14 --- /dev/null +++ b/src/Interface/StatementHookInterface.php @@ -0,0 +1,18 @@ + - */ - protected array $observers = []; - - public function addObserver(ObserverProcessorInterface $observerProcessor, Repository $repoObserverIn): void - { - $repoObserverIn->getExecutor()->getDriver()->log("Observer: entity " . $repoObserverIn->getMapper()->getTable() . ", listening for {$observerProcessor->getObservedTable()}"); - if (!isset($this->observers[$observerProcessor->getObservedTable()])) { - $this->observers[$observerProcessor->getObservedTable()] = []; - } - /** @var ObserverProcessorInternal $observer */ - foreach ($this->observers[$observerProcessor->getObservedTable()] as $observer) { - if (get_class($observer->getObservedProcessor()) === get_class($observerProcessor) && get_class($observer->getRepository()) === get_class($repoObserverIn)) { - throw new InvalidArgumentException("Observer already exists"); - } - } - $this->observers[$observerProcessor->getObservedTable()][] = new ObserverProcessorInternal($observerProcessor, $repoObserverIn); - } - - public function notify(string $entitySource, ObserverEvent $event, mixed $data, mixed $oldData = null): void - { - if (!isset($this->observers[$entitySource])) { - return; - } - foreach ($this->observers[$entitySource] as $observer) { - $observer->log("Observer: notifying " . $observer->getMapper()->getTable() . ", changes in $entitySource"); - - $observerData = new ObserverData($entitySource, $event, $data, $oldData, $observer->getRepository()); - - try { - $observer->getObservedProcessor()->process($observerData); - } catch (Throwable $e) { - $observer->getObservedProcessor()->onError($e, $observerData); - } - } - } - - public function clearObservers(): void - { - $this->observers = []; - } -} \ No newline at end of file diff --git a/src/ObserverBridge.php b/src/ObserverBridge.php new file mode 100644 index 0000000..ea50be8 --- /dev/null +++ b/src/ObserverBridge.php @@ -0,0 +1,181 @@ +|null + */ + private static ?WeakMap $registry = null; + + /** + * @var array keyed by observed table + */ + private array $processors = []; + + private function __construct() + { + } + + /** + * Get the bridge for the executor, creating and attaching it on demand. + */ + public static function for(DatabaseExecutor $executor): ObserverBridge + { + $bridge = self::find($executor); + if (is_null($bridge)) { + if (self::$registry === null) { + /** @var WeakMap $registry */ + $registry = new WeakMap(); + self::$registry = $registry; + } + $bridge = new ObserverBridge(); + self::$registry[$executor] = $bridge; + $executor->addObserver($bridge); + } + return $bridge; + } + + /** + * Get the bridge for the executor, or null if none was created. + */ + public static function find(DatabaseExecutor $executor): ?ObserverBridge + { + if (self::$registry === null || !isset(self::$registry[$executor])) { + return null; + } + $bridge = self::$registry[$executor]; + return $bridge instanceof ObserverBridge ? $bridge : null; + } + + public function addProcessor(ObserverProcessorInterface $processor, Repository $repository): void + { + $repository->getExecutor()->getDriver()->log("Observer: entity " . $repository->getMapper()->getTable() . ", listening for {$processor->getObservedTable()}"); + $table = $processor->getObservedTable(); + foreach ($this->processors[$table] ?? [] as $observer) { + if (get_class($observer->getObservedProcessor()) === get_class($processor) && get_class($observer->getRepository()) === get_class($repository)) { + throw new InvalidArgumentException("Observer already exists"); + } + } + $this->processors[$table][] = new ObserverProcessorInternal($processor, $repository); + } + + public function hasProcessor(ObserverProcessorInterface $processor): bool + { + foreach ($this->processors as $observers) { + foreach ($observers as $observer) { + if ($observer->getObservedProcessor() === $processor) { + return true; + } + } + } + return false; + } + + public function removeProcessor(ObserverProcessorInterface $processor): void + { + foreach ($this->processors as $table => $observers) { + $this->processors[$table] = array_values( + array_filter($observers, fn($observer) => $observer->getObservedProcessor() !== $processor) + ); + if (empty($this->processors[$table])) { + unset($this->processors[$table]); + } + } + } + + /** + * @return DatabaseEventTypeEnum[] + */ + #[Override] + public function subscribedEvents(): array + { + return [DatabaseEventTypeEnum::BEFORE_EXECUTE, DatabaseEventTypeEnum::AFTER_EXECUTE]; + } + + #[Override] + public function handleEvent(DatabaseEvent $event): void + { + $statement = $event->getStatement(); + if (!$statement instanceof OrmSqlStatement || !$statement->hasOrmEvent()) { + return; + } + $table = $statement->getOrmTable(); + if ($table === null || !isset($this->processors[$table])) { + return; + } + + if ($event->getType() === DatabaseEventTypeEnum::BEFORE_EXECUTE) { + foreach ($this->processors[$table] as $observer) { + $processor = $observer->getObservedProcessor(); + if ($processor instanceof StatementHookInterface) { + // exceptions propagate on purpose: throwing here vetoes the write + $processor->beforeStatement($statement, $observer->getRepository()); + } + } + return; + } + + // AFTER_EXECUTE + $context = $statement->getOrmContext(); + $context->markExecuted(); + if ($context->isDeferred()) { + // Repository::save() flushes after the entity is rehydrated + $context->addPendingBridge($this); + return; + } + $this->notifyStatement($statement); + } + + /** + * Translate the statement into ObserverData and notify the processors + * watching its table. Exceptions thrown by process() are routed to + * onError() and never abort the SQL flow. + */ + public function notifyStatement(OrmSqlStatement $statement): void + { + $table = $statement->getOrmTable(); + $event = $statement->getOrmEvent(); + if ($table === null || $event === null || !isset($this->processors[$table])) { + return; + } + + $context = $statement->getOrmContext(); + $isDeleteEvent = $event === ObserverEvent::Delete || $event === ObserverEvent::SoftDelete; + $data = $context->getEntity() ?? ($isDeleteEvent ? null : $statement->getParams()); + $oldData = $context->getOldEntity() ?? ($isDeleteEvent ? $statement->getParams() : null); + + foreach ($this->processors[$table] as $observer) { + $observer->log("Observer: notifying " . $observer->getMapper()->getTable() . ", changes in $table"); + + $observerData = new ObserverData($table, $event, $data, $oldData, $observer->getRepository(), $statement); + + try { + $observer->getObservedProcessor()->process($observerData); + } catch (Throwable $e) { + $observer->getObservedProcessor()->onError($e, $observerData); + } + } + } +} diff --git a/src/ObserverData.php b/src/ObserverData.php index f7879ca..58bece8 100644 --- a/src/ObserverData.php +++ b/src/ObserverData.php @@ -21,13 +21,17 @@ class ObserverData // The repository is listening to the event (the same as $myRepository) protected Repository $repository; - public function __construct(string $table, ObserverEvent $event, mixed $data, mixed $oldData, Repository $repository) + // The SQL statement that triggered the event (null when not available) + protected ?OrmSqlStatement $statement; + + public function __construct(string $table, ObserverEvent $event, mixed $data, mixed $oldData, Repository $repository, ?OrmSqlStatement $statement = null) { $this->table = $table; $this->event = $event; $this->data = $data; $this->oldData = $oldData; $this->repository = $repository; + $this->statement = $statement; } /** @@ -69,4 +73,14 @@ public function getRepository(): Repository { return $this->repository; } + + /** + * The SQL statement (with SQL text and params) that triggered the event. + * + * @return OrmSqlStatement|null + */ + public function getStatement(): ?OrmSqlStatement + { + return $this->statement; + } } \ No newline at end of file diff --git a/src/OrmEventContext.php b/src/OrmEventContext.php new file mode 100644 index 0000000..424ff02 --- /dev/null +++ b/src/OrmEventContext.php @@ -0,0 +1,84 @@ +entity = $entity; + $this->oldEntity = $oldEntity; + } + + public function getEntity(): mixed + { + return $this->entity; + } + + public function getOldEntity(): mixed + { + return $this->oldEntity; + } + + /** + * Postpone observer notification: at AFTER_EXECUTE the bridge parks itself + * here instead of dispatching, and the caller flushes via drainPendingBridges() + * once the entity is fully hydrated (e.g. generated keys copied back). + */ + public function defer(): void + { + $this->deferred = true; + } + + public function isDeferred(): bool + { + return $this->deferred; + } + + public function markExecuted(): void + { + $this->executed = true; + } + + public function isExecuted(): bool + { + return $this->executed; + } + + public function addPendingBridge(ObserverBridge $bridge): void + { + if (!in_array($bridge, $this->pendingBridges, true)) { + $this->pendingBridges[] = $bridge; + } + } + + /** + * @return ObserverBridge[] + */ + public function drainPendingBridges(): array + { + $bridges = $this->pendingBridges; + $this->pendingBridges = []; + return $bridges; + } +} diff --git a/src/OrmSqlStatement.php b/src/OrmSqlStatement.php new file mode 100644 index 0000000..bb6ecbb --- /dev/null +++ b/src/OrmSqlStatement.php @@ -0,0 +1,61 @@ +ormEvent = $ormEvent; + $this->ormTable = $ormTable; + } + + public function withOrmEvent(ObserverEvent $ormEvent, string $ormTable): static + { + $statement = clone $this; + $statement->ormEvent = $ormEvent; + $statement->ormTable = $ormTable; + return $statement; + } + + public function getOrmEvent(): ?ObserverEvent + { + return $this->ormEvent; + } + + public function getOrmTable(): ?string + { + return $this->ormTable; + } + + public function hasOrmEvent(): bool + { + return $this->ormEvent !== null && $this->ormTable !== null; + } + + /** + * Lazily created. Clones made by the executor share the same context by + * reference, but only if it exists before the clone — callers that need to + * read the context after execution must call this before handing the + * statement to the executor. + */ + public function getOrmContext(): OrmEventContext + { + return $this->ormContext ??= new OrmEventContext(); + } +} diff --git a/src/Query.php b/src/Query.php index 7d4f539..6b718cf 100644 --- a/src/Query.php +++ b/src/Query.php @@ -96,7 +96,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params); } protected function addOrderBy(): string diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 7a6265d..56da25b 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -335,7 +335,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params); } #[Override] diff --git a/src/QueryRaw.php b/src/QueryRaw.php index ba7c27e..9c6f649 100644 --- a/src/QueryRaw.php +++ b/src/QueryRaw.php @@ -24,7 +24,7 @@ public static function getInstance(string $sql, array $parameters = []): QueryRa #[Override] public function build(?DbDriverInterface $dbDriver = null): SqlStatement { - return new SqlStatement($this->sql, $this->parameters); + return new OrmSqlStatement($this->sql, $this->parameters); } #[Override] diff --git a/src/Recursive.php b/src/Recursive.php index 5a5b95d..ae84b25 100644 --- a/src/Recursive.php +++ b/src/Recursive.php @@ -60,7 +60,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $sql .= " UNION ALL "; $sql .= $this->getRecursion(); $sql .= ") "; - return new SqlStatement($sql); + return new OrmSqlStatement($sql); } protected function getBase(): string diff --git a/src/Repository.php b/src/Repository.php index 7b6f4e7..af9a62d 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -9,6 +9,7 @@ use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Exception\DbDriverNotConnected; +use ByJG\AnyDataset\Db\Interfaces\DatabaseEventObserverInterface; use ByJG\AnyDataset\Db\IsolationLevelEnum; use ByJG\AnyDataset\Db\IteratorFilterSqlFormatter; use ByJG\AnyDataset\Db\SqlStatement; @@ -64,6 +65,16 @@ class Repository */ protected EntityProcessorInterface|null $beforeInsert = null; + /** + * @var ObserverProcessorInterface[] + */ + protected array $observerProcessors = []; + + /** + * @var DatabaseEventObserverInterface[] + */ + protected array $rawObservers = []; + /** * Repository constructor. * @param DatabaseExecutor $executor @@ -93,6 +104,20 @@ public function __construct(DatabaseExecutor $executor, string|Mapper $mapperOrE public function addDbDriverForWrite(DatabaseExecutor $executor): void { $this->dbDriverWrite = $executor; + if ($executor === $this->dbDriver) { + return; + } + foreach ($this->rawObservers as $observer) { + $executor->addObserver($observer); + } + if (!empty($this->observerProcessors)) { + $bridge = ObserverBridge::for($executor); + foreach ($this->observerProcessors as $processor) { + if (!$bridge->hasProcessor($processor)) { + $bridge->addProcessor($processor, $this); + } + } + } } public function setRepositoryReadOnly(): void @@ -248,7 +273,10 @@ public function delete(array|string|int|LiteralInterface $pkId): bool ->table($this->mapper->getTable()) ->set('deleted_at', new Literal($this->getExecutorWrite()->getHelper()->sqlDate('Y-m-d H:i:s'))) ->where($filterList, $filterKeys); - $this->update($updatable); + // the SQL is an UPDATE, but the caller intent is a delete + $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()) + ->withOrmEvent(ObserverEvent::SoftDelete, $this->mapper->getTable()); + $this->getExecutorWrite()->execute($sqlStatement); return true; } @@ -285,6 +313,7 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $selectSql = null; $selectParams = []; $bigParams = []; + $ormStatements = []; foreach ($queries as $i => $query) { if (!($query instanceof QueryBuilderInterface) && !($query instanceof Updatable)) { @@ -304,6 +333,12 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel continue; } + // The writes are concatenated into a single raw SQL string, so the executor + // observers cannot map them; keep the built statements to notify after commit + if ($sqlStatement instanceof OrmSqlStatement && $sqlStatement->hasOrmEvent()) { + $ormStatements[] = $sqlStatement; + } + // For write statements, avoid parameter name collisions by uniquifying named params if ($params !== null) { foreach ($params as $key => $value) { @@ -342,6 +377,13 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel $dbDriver->commitTransaction(); + $bridge = ObserverBridge::find($this->getExecutor()); + if (!is_null($bridge)) { + foreach ($ormStatements as $ormStatement) { + $bridge->notifyStatement($ormStatement); + } + } + return $it; } catch (Exception $ex) { $dbDriver->rollbackTransaction(); @@ -363,8 +405,6 @@ public function deleteByQuery(DeleteQuery $updatable): bool $this->getExecutorWrite()->execute($sqlStatement); - ORMSubject::getInstance()->notify($this->mapper->getTable(), ObserverEvent::Delete, null, $sqlStatement->getParams()); - return true; } @@ -576,6 +616,8 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda // Build the updatable without executing [$updatable, $array, $fieldToProperty, $isInsert, $oldInstance, $pkList] = $this->saveUpdatableInternal($instance); + $sqlStatement = null; + // Execute the Insert or Update if ($isInsert) { $keyGen = $this->getMapper()->generateKey($this->getExecutorWrite(), $instance) ?? []; @@ -587,7 +629,8 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda $array[$pkList[$position]] = $value; $updatable->set($this->mapper->getPrimaryKey()[$position++], $value); } - $keyReturned = $this->insert($updatable, $keyGen); + $sqlStatement = $this->prepareForDeferredNotify($updatable, $instance, $oldInstance); + $keyReturned = $this->insert($sqlStatement, $keyGen); if (count($pkList) == 1 && !empty($keyReturned)) { $array[$pkList[0]] = $keyReturned; } @@ -609,19 +652,35 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda $constraint->check($oldInstance, $instance); } } - $this->update($updatable); + $sqlStatement = $this->prepareForDeferredNotify($updatable, $instance, $oldInstance); + $this->update($sqlStatement); } - - ORMSubject::getInstance()->notify( - $this->mapper->getTable(), - $isInsert ? ObserverEvent::Insert : ObserverEvent::Update, - $instance, $oldInstance - ); + // Notify only now, so the observers receive the instance rehydrated + // with generated keys and computed fields + if (!is_null($sqlStatement)) { + foreach ($sqlStatement->getOrmContext()->drainPendingBridges() as $bridge) { + $bridge->notifyStatement($sqlStatement); + } + } return $instance; } + /** + * Build the statement and mark it for deferred observer notification: + * the bridges seeing AFTER_EXECUTE park themselves in the statement context + * and save() flushes them after the entity is rehydrated. + */ + private function prepareForDeferredNotify(Updatable $updatable, mixed $instance, mixed $oldInstance): OrmSqlStatement + { + $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); + $context = $sqlStatement->getOrmContext(); + $context->setEntities($instance, $oldInstance); + $context->defer(); + return $sqlStatement; + } + /** * Build and return the updatable (InsertQuery or UpdateQuery) without executing it. * This method mirrors the preparatory stage of save() and can be used to inspect or @@ -722,69 +781,107 @@ protected function saveUpdatableInternal(mixed $instance): array } /** + * Add an observer to this repository's executors. + * + * An ObserverProcessorInterface receives entity-level events (Insert, Update, + * Delete, SoftDelete) for its observed table, no matter which repository or + * direct executor call issued the write, as long as it flows through the + * same DatabaseExecutor instance(s) used by this repository. + * + * A raw DatabaseEventObserverInterface is attached directly to the executors + * and receives the low-level anydataset-db events (BEFORE/AFTER QUERY/EXECUTE). + * * @throws InvalidArgumentException */ - public function addObserver(ObserverProcessorInterface $observerProcessor): void + public function addObserver(ObserverProcessorInterface|DatabaseEventObserverInterface $observer): void + { + if ($observer instanceof ObserverProcessorInterface) { + ObserverBridge::for($this->getExecutor())->addProcessor($observer, $this); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + ObserverBridge::for($this->dbDriverWrite)->addProcessor($observer, $this); + } + $this->observerProcessors[] = $observer; + return; + } + + $this->rawObservers[] = $observer; + $this->getExecutor()->addObserver($observer); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + $this->dbDriverWrite->addObserver($observer); + } + } + + public function removeObserver(ObserverProcessorInterface|DatabaseEventObserverInterface $observer): void { - ORMSubject::getInstance()->addObserver($observerProcessor, $this); + if ($observer instanceof ObserverProcessorInterface) { + $this->observerProcessors = array_values( + array_filter($this->observerProcessors, fn($item) => $item !== $observer) + ); + ObserverBridge::find($this->getExecutor())?->removeProcessor($observer); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + ObserverBridge::find($this->dbDriverWrite)?->removeProcessor($observer); + } + return; + } + + $this->rawObservers = array_values( + array_filter($this->rawObservers, fn($item) => $item !== $observer) + ); + $this->getExecutor()->removeObserver($observer); + if (!is_null($this->dbDriverWrite) && $this->dbDriverWrite !== $this->dbDriver) { + $this->dbDriverWrite->removeObserver($observer); + } } /** - * @param InsertQuery $updatable + * @param OrmSqlStatement $sqlStatement * @param mixed $keyGen * @return int|null * @throws DatabaseException * @throws DbDriverNotConnected - * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insert(InsertQuery $updatable, mixed $keyGen): ?int + protected function insert(OrmSqlStatement $sqlStatement, mixed $keyGen): ?int { if (empty($keyGen)) { - return $this->insertWithAutoinc($updatable); + return $this->insertWithAutoinc($sqlStatement); } else { - $this->insertWithKeyGen($updatable); + $this->insertWithKeyGen($sqlStatement); return null; } } /** - * @param InsertQuery $updatable + * @param OrmSqlStatement $sqlStatement * @return int - * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insertWithAutoInc(InsertQuery $updatable): int + protected function insertWithAutoInc(OrmSqlStatement $sqlStatement): int { $dbFunctions = $this->getExecutorWrite()->getHelper(); - $sqlStatement = $updatable->build($dbFunctions); return $dbFunctions->executeAndGetInsertedId($this->getExecutorWrite(), $sqlStatement); } /** - * @param InsertQuery $updatable + * @param OrmSqlStatement $sqlStatement * @return void * @throws DatabaseException * @throws DbDriverNotConnected - * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insertWithKeyGen(InsertQuery $updatable): void + protected function insertWithKeyGen(OrmSqlStatement $sqlStatement): void { - $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); $this->getExecutorWrite()->execute($sqlStatement); } /** - * @param UpdateQuery $updatable + * @param OrmSqlStatement $sqlStatement * @throws DatabaseException * @throws DbDriverNotConnected - * @throws InvalidArgumentException * @throws RepositoryReadOnlyException */ - protected function update(UpdateQuery $updatable): void + protected function update(OrmSqlStatement $sqlStatement): void { - $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); $this->getExecutorWrite()->execute($sqlStatement); } diff --git a/src/Union.php b/src/Union.php index 369f397..9315b1f 100644 --- a/src/Union.php +++ b/src/Union.php @@ -109,7 +109,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlStatement $unionQuery = trim($unionQuery . " " . substr($build->getSql(), $pos + 8)); } - return new SqlStatement($unionQuery, $params); + return new OrmSqlStatement($unionQuery, $params); } diff --git a/src/Updatable.php b/src/Updatable.php index f86aace..99fe5f6 100644 --- a/src/Updatable.php +++ b/src/Updatable.php @@ -3,6 +3,8 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; +use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface; use ByJG\MicroOrm\Interface\UpdateBuilderInterface; use Override; @@ -26,6 +28,14 @@ public function table(string $table): static return $this; } + public function getTable(): string + { + return $this->table; + } + + #[Override] + abstract public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement; + #[Override] public function buildAndExecute(DatabaseExecutor $executor, $params = []): bool { diff --git a/src/UpdateQuery.php b/src/UpdateQuery.php index 1eeae7c..1c96510 100644 --- a/src/UpdateQuery.php +++ b/src/UpdateQuery.php @@ -5,6 +5,7 @@ use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use ByJG\AnyDataset\Db\Interfaces\SqlDialectInterface; use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\MicroOrm\Literal\Literal; @@ -103,11 +104,11 @@ public function join(QueryBasic|string $table, string $joinCondition, ?string $a /** * @param DbDriverInterface|SqlDialectInterface|null $dbDriverOrHelper - * @return SqlStatement + * @return OrmSqlStatement * @throws InvalidArgumentException */ #[Override] - public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement + public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelper = null): OrmSqlStatement { if (empty($this->set)) { throw new InvalidArgumentException('You must specify the fields for update'); @@ -163,7 +164,7 @@ public function build(SqlDialectInterface|DbDriverInterface|null $dbDriverOrHelp $params = array_merge($params, $whereStr[1]); $sql = ORMHelper::processLiteral($sql, $params); - return new SqlStatement($sql, $params); + return new OrmSqlStatement($sql, $params, ObserverEvent::Update, $this->table); } #[Override] diff --git a/tests/ConnectionUtil.php b/tests/ConnectionUtil.php index 0480200..5c74ceb 100644 --- a/tests/ConnectionUtil.php +++ b/tests/ConnectionUtil.php @@ -2,6 +2,7 @@ namespace Tests; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use ByJG\AnyDataset\Db\Factory; use ByJG\Util\Uri; @@ -11,7 +12,7 @@ class ConnectionUtil public static function getConnection(string $database): DbDriverInterface { $dbDriver = Factory::getDbInstance(ConnectionUtil::getUri()); - $dbDriver->execute("create database if not exists $database;"); + DatabaseExecutor::using($dbDriver)->execute("create database if not exists $database;"); return Factory::getDbInstance(ConnectionUtil::getUri($database)); } diff --git a/tests/DeleteQueryTest.php b/tests/DeleteQueryTest.php index 6c242e5..f2e3db7 100644 --- a/tests/DeleteQueryTest.php +++ b/tests/DeleteQueryTest.php @@ -2,7 +2,8 @@ namespace Tests; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Updatable; @@ -35,7 +36,7 @@ public function testDelete() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement('DELETE FROM test WHERE fld1 = :id', ['id' => 10]), + new OrmSqlStatement('DELETE FROM test WHERE fld1 = :id', ['id' => 10], ObserverEvent::Delete, 'test'), $sqlStatement ); } @@ -54,12 +55,12 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -67,7 +68,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld2 = :teste', ['teste' => 10]), + new OrmSqlStatement('SELECT * FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -75,7 +76,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), + new OrmSqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -83,7 +84,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } diff --git a/tests/InsertQueryTest.php b/tests/InsertQueryTest.php index b4ce21d..b869a86 100644 --- a/tests/InsertQueryTest.php +++ b/tests/InsertQueryTest.php @@ -3,7 +3,8 @@ namespace Tests; use ByJG\AnyDataset\Db\SqlDialect\SqliteDialect; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\InsertQuery; use Override; use PHPUnit\Framework\TestCase; @@ -37,13 +38,13 @@ public function testInsert() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement('INSERT INTO test( fld1, fld2, fld3 ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C']), + new OrmSqlStatement('INSERT INTO test( fld1, fld2, fld3 ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], ObserverEvent::Insert, 'test'), $sqlStatement ); $sqlStatement = $this->object->build(new SqliteDialect()); $this->assertEquals( - new SqlStatement('INSERT INTO `test`( `fld1`, `fld2`, `fld3` ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C']), + new OrmSqlStatement('INSERT INTO `test`( `fld1`, `fld2`, `fld3` ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], ObserverEvent::Insert, 'test'), $sqlStatement ); } @@ -52,7 +53,7 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -61,7 +62,7 @@ public function testQueryUpdatable() $this->object->set('fld3', 'C'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->convert()->build() ); @@ -69,7 +70,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -77,7 +78,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -85,7 +86,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } diff --git a/tests/ObserverBridgeTest.php b/tests/ObserverBridgeTest.php new file mode 100644 index 0000000..4dda2e8 --- /dev/null +++ b/tests/ObserverBridgeTest.php @@ -0,0 +1,370 @@ +received[] = $observerData; + } + + #[Override] + public function onError(Throwable $exception, ObserverData $observerData): void + { + $this->errors[] = $exception; + } + + #[Override] + public function getObservedTable(): string + { + return $this->table; + } +} + +/** + * A processor that also hooks BEFORE_EXECUTE and can veto the write. + */ +class VetoObserver extends RecordingObserver implements StatementHookInterface +{ + /** @var OrmSqlStatement[] */ + public array $beforeStatements = []; + + public bool $veto = false; + + #[Override] + public function beforeStatement(OrmSqlStatement $statement, Repository $repository): void + { + $this->beforeStatements[] = $statement; + if ($this->veto) { + throw new Exception("Vetoed by observer"); + } + } +} + +/** + * Raw anydataset-db observer, attached through Repository::addObserver(). + */ +class RawRecordingObserver implements DatabaseEventObserverInterface +{ + /** @var DatabaseEvent[] */ + public array $events = []; + + #[Override] + public function subscribedEvents(): array + { + return [DatabaseEventTypeEnum::BEFORE_EXECUTE, DatabaseEventTypeEnum::AFTER_EXECUTE]; + } + + #[Override] + public function handleEvent(DatabaseEvent $event): void + { + $this->events[] = $event; + } +} + +class ObserverBridgeTest extends TestCase +{ + protected Repository $userRepository; + protected Mapper $userMapper; + protected DatabaseExecutor $executor; + + #[Override] + public function setUp(): void + { + $dbDriver = ConnectionUtil::getConnection("testmicroorm_observer"); + $this->executor = DatabaseExecutor::using($dbDriver); + $this->userMapper = new Mapper(Users::class, 'users', 'Id'); + $this->userRepository = new Repository($this->executor, $this->userMapper); + + $this->executor->execute('create table users ( + id integer primary key auto_increment, + name varchar(45), + createdate datetime);' + ); + $this->executor->execute("insert into users (name, createdate) values ('John Doe', '2015-05-02')"); + $this->executor->execute("insert into users (name, createdate) values ('Jane Doe', '2017-01-04')"); + + $this->executor->execute('create table info ( + id integer primary key auto_increment, + iduser INTEGER, + property decimal(10, 2), + registration_id VARCHAR(255) not null, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at datetime);' + ); + $this->executor->execute("insert into info (iduser, property, registration_id) values (1, 30.4, 'REG001')"); + } + + #[Override] + public function tearDown(): void + { + $this->executor->execute('drop table if exists users;'); + $this->executor->execute('drop table if exists info;'); + ORM::resetMemory(); + } + + public function testSoftDeleteFiresSoftDeleteEvent() + { + $infoRepository = new Repository($this->executor, ModelWithAttributes::class); + + $observer = new RecordingObserver('info'); + $infoRepository->addObserver($observer); + + $infoRepository->delete(1); + + $this->assertCount(1, $observer->received); + $event = $observer->received[0]; + $this->assertEquals(ObserverEvent::SoftDelete, $event->getEvent()); + $this->assertEquals('info', $event->getTable()); + $this->assertNull($event->getData()); + $this->assertEquals(['pkid' => 1], $event->getOldData()); + + // soft delete: the row still exists, with deleted_at set + $deletedAt = $this->executor->getScalar('select deleted_at from info where id = 1'); + $this->assertNotNull($deletedAt); + } + + public function testDirectExecutorWriteFiresObserver() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + // a built statement executed directly on the executor, bypassing the repository + $insert = InsertQuery::getInstance('users', ['name' => 'Direct', 'createdate' => '2024-01-01'])->build(); + $this->executor->execute($insert); + + $update = UpdateQuery::getInstance() + ->table('users') + ->set('name', 'DirectChanged') + ->where('name = :name', ['name' => 'Direct']) + ->build(); + $this->executor->execute($update); + + $delete = DeleteQuery::getInstance() + ->table('users') + ->where('name = :name', ['name' => 'DirectChanged']) + ->build(); + $this->executor->execute($delete); + + $this->assertCount(3, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + // no entity is available outside save(): the params are provided instead + $this->assertEquals(['name' => 'Direct', 'createdate' => '2024-01-01'], $observer->received[0]->getData()); + $this->assertEquals(ObserverEvent::Update, $observer->received[1]->getEvent()); + $this->assertEquals(ObserverEvent::Delete, $observer->received[2]->getEvent()); + $this->assertNull($observer->received[2]->getData()); + $this->assertEquals(['name' => 'DirectChanged'], $observer->received[2]->getOldData()); + } + + public function testBulkExecuteFiresObserversAfterCommit() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + $this->userRepository->bulkExecute([ + InsertQuery::getInstance('users', ['name' => 'Bulk1', 'createdate' => '2024-01-01']), + UpdateQuery::getInstance() + ->table('users') + ->set('name', 'Bulk2') + ->where('name = :name', ['name' => 'Bulk1']), + ]); + + $this->assertCount(2, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + $this->assertEquals(ObserverEvent::Update, $observer->received[1]->getEvent()); + } + + public function testBulkExecuteRollbackFiresNothing() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + $exceptionThrown = false; + try { + $this->userRepository->bulkExecute([ + InsertQuery::getInstance('users', ['name' => 'BulkFail', 'createdate' => '2024-01-01']), + InsertQuery::getInstance('users', ['nonexistent_column' => 'x']), + ]); + } catch (Exception $ex) { + $exceptionThrown = true; + } + + $this->assertTrue($exceptionThrown); + $this->assertCount(0, $observer->received); + $this->assertEquals(0, $this->executor->getScalar("select count(*) from users where name = 'BulkFail'")); + } + + public function testObserverScopingIsPerExecutor() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + // another executor over the same database: the observer must NOT fire + $otherExecutor = DatabaseExecutor::using(ConnectionUtil::getConnection("testmicroorm_observer")); + $otherRepository = new Repository($otherExecutor, $this->userMapper); + $users = new Users(); + $users->setName('OtherConnection'); + $users->setCreatedate('2024-01-01'); + $otherRepository->save($users); + + $this->assertCount(0, $observer->received); + + // a repository sharing the same executor: the observer fires + $sharedRepository = new Repository($this->executor, $this->userMapper); + $users = new Users(); + $users->setName('SharedConnection'); + $users->setCreatedate('2024-01-01'); + $sharedRepository->save($users); + + $this->assertCount(1, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + } + + public function testStatementHookReceivesSqlAndCanVeto() + { + $observer = new VetoObserver('users'); + $this->userRepository->addObserver($observer); + + // hook receives the statement before it executes + $users = new Users(); + $users->setName('HookTest'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertCount(1, $observer->beforeStatements); + $this->assertStringContainsString('INSERT INTO `users`', $observer->beforeStatements[0]->getSql()); + $this->assertCount(1, $observer->received); + + // a throwing hook vetoes the write: the row must not be persisted and process() must not fire + $observer->veto = true; + $users = new Users(); + $users->setName('Vetoed'); + $users->setCreatedate('2024-01-01'); + + $exceptionThrown = false; + try { + $this->userRepository->save($users); + } catch (Exception $ex) { + $exceptionThrown = true; + $this->assertEquals("Vetoed by observer", $ex->getMessage()); + } + + $this->assertTrue($exceptionThrown); + $this->assertEquals(0, $this->executor->getScalar("select count(*) from users where name = 'Vetoed'")); + $this->assertCount(1, $observer->received); + } + + public function testObserverDataCarriesStatement() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + $users = new Users(); + $users->setName('WithStatement'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertCount(1, $observer->received); + $statement = $observer->received[0]->getStatement(); + $this->assertInstanceOf(OrmSqlStatement::class, $statement); + $this->assertStringContainsString('INSERT INTO `users`', $statement->getSql()); + $this->assertEquals('WithStatement', $statement->getParams()['name']); + // deferred dispatch: the entity is rehydrated before the observer runs + $this->assertNotEmpty($observer->received[0]->getData()->getId()); + } + + public function testRawObserverPassthrough() + { + $rawObserver = new RawRecordingObserver(); + $this->userRepository->addObserver($rawObserver); + + $users = new Users(); + $users->setName('RawObserved'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertNotEmpty($rawObserver->events); + $types = array_map(fn($event) => $event->getType(), $rawObserver->events); + $this->assertContains(DatabaseEventTypeEnum::BEFORE_EXECUTE, $types); + $this->assertContains(DatabaseEventTypeEnum::AFTER_EXECUTE, $types); + } + + public function testAddDbDriverForWriteAttachesObservers() + { + $observer = new RecordingObserver('users'); + $this->userRepository->addObserver($observer); + + // writes switch to a new executor after the observer was registered + $writeExecutor = DatabaseExecutor::using(ConnectionUtil::getConnection("testmicroorm_observer")); + $this->userRepository->addDbDriverForWrite($writeExecutor); + + $users = new Users(); + $users->setName('WriteExecutor'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + $this->assertCount(1, $observer->received); + $this->assertEquals(ObserverEvent::Insert, $observer->received[0]->getEvent()); + } + + public function testOnErrorStillSwallowsProcessExceptions() + { + $observer = new class('users') extends RecordingObserver { + #[Override] + public function process(ObserverData $observerData): void + { + parent::process($observerData); + throw new Exception("Observer failure"); + } + }; + $this->userRepository->addObserver($observer); + + $users = new Users(); + $users->setName('ErrorSwallowed'); + $users->setCreatedate('2024-01-01'); + $this->userRepository->save($users); + + // the write succeeded despite the observer exception, routed to onError() + $this->assertEquals(1, $this->executor->getScalar("select count(*) from users where name = 'ErrorSwallowed'")); + $this->assertCount(1, $observer->errors); + $this->assertEquals("Observer failure", $observer->errors[0]->getMessage()); + } +} diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 74757d2..f52ee7f 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -4,7 +4,8 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Literal\Literal; @@ -42,7 +43,7 @@ public function testQueryBasic() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->build() ); @@ -52,7 +53,7 @@ public function testQueryBasic() ->fields(['fld2', 'fld3']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->build() ); @@ -60,7 +61,7 @@ public function testQueryBasic() ->orderBy(['fld1']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test ORDER BY fld1'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test ORDER BY fld1'), $this->object->build() ); @@ -68,7 +69,7 @@ public function testQueryBasic() ->groupBy(['fld1', 'fld2', 'fld3']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test GROUP BY fld1, fld2, fld3 ORDER BY fld1'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test GROUP BY fld1, fld2, fld3 ORDER BY fld1'), $this->object->build() ); @@ -76,7 +77,7 @@ public function testQueryBasic() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), $this->object->build() ); @@ -84,7 +85,7 @@ public function testQueryBasic() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), $this->object->build() ); @@ -92,7 +93,7 @@ public function testQueryBasic() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), $this->object->build() ); @@ -100,7 +101,7 @@ public function testQueryBasic() ->having('count(fld1) > 1'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40]), $this->object->build() ); @@ -108,7 +109,7 @@ public function testQueryBasic() $iteratorFilter->and('fld4', Relation::EQUAL, 40); $this->object->where($iteratorFilter); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 AND fld4 = :fld4 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40, 'fld4' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 AND fld4 = :fld4 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1', ['teste' => 10, 'teste2' => 40, 'fld4' => 40]), $this->object->build() ); } @@ -126,7 +127,7 @@ public function testQueryWhereIteratorFilter() $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE fld1 = :fld1 and fld2 = :fld2 or fld1 = :fld10 ', ['fld1' => 10, 'fld2' => '20', 'fld10' => '30']), + new OrmSqlStatement('SELECT * FROM test WHERE fld1 = :fld1 and fld2 = :fld2 or fld1 = :fld10 ', ['fld1' => 10, 'fld2' => '20', 'fld10' => '30']), $this->object->build() ); } @@ -156,14 +157,14 @@ public function testConvertQueryToQueryBasic() $expectedSql = 'WITH RECURSIVE table6() AS (SELECT UNION ALL SELECT FROM table6 WHERE ) SELECT fld1, fld2, fld3 FROM test INNER JOIN table2 ON table2.id = test.id LEFT JOIN table3 ON table3.id = test.id RIGHT JOIN table4 ON table4.id = test.id CROSS JOIN table5 as table5.id = test.id WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1 ORDER BY fld1'; $this->assertEquals( - new SqlStatement($expectedSql, ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement($expectedSql, ['teste' => 10, 'teste2' => 40]), $query->build() ); $queryBasic = $query->getQueryBasic(); $expectedSql2 = 'WITH RECURSIVE table6() AS (SELECT UNION ALL SELECT FROM table6 WHERE ) SELECT fld1, fld2, fld3 FROM test INNER JOIN table2 ON table2.id = test.id LEFT JOIN table3 ON table3.id = test.id RIGHT JOIN table4 ON table4.id = test.id CROSS JOIN table5 as table5.id = test.id WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2 GROUP BY fld1, fld2, fld3 HAVING count(fld1) > 1'; $this->assertEquals( - new SqlStatement($expectedSql2, ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement($expectedSql2, ['teste' => 10, 'teste2' => 40]), $queryBasic->build() ); } @@ -177,7 +178,7 @@ public function testLiteral() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE field = ABC', []), + new OrmSqlStatement('SELECT * FROM test WHERE field = ABC', []), $result ); } @@ -192,7 +193,7 @@ public function testLiteral2() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE field = ABC AND other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM test WHERE field = ABC AND other = :other', ['other' => 'test']), $result ); } @@ -202,7 +203,7 @@ public function testTableAlias() $this->object->table('test', "t"); $this->object->where("1 = 1"); $this->assertEquals( - new SqlStatement('SELECT * FROM test as t WHERE 1 = 1', []), + new OrmSqlStatement('SELECT * FROM test as t WHERE 1 = 1', []), $this->object->build() ); } @@ -218,7 +219,7 @@ public function testJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo INNER JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo INNER JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -234,7 +235,7 @@ public function testJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo INNER JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo INNER JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -250,7 +251,7 @@ public function testLeftJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo LEFT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo LEFT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -266,7 +267,7 @@ public function testLeftJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo LEFT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo LEFT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -282,7 +283,7 @@ public function testRightJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo RIGHT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo RIGHT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -298,7 +299,7 @@ public function testRightJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo RIGHT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo RIGHT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -314,7 +315,7 @@ public function testCrossJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo CROSS JOIN bar WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo CROSS JOIN bar WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -330,7 +331,7 @@ public function testCrossJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM foo CROSS JOIN bar as b WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new OrmSqlStatement('SELECT * FROM foo CROSS JOIN bar as b WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -355,7 +356,7 @@ public function testSubQueryTable() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq WHERE sq.date < :date', ['date' => '2020-06-01']), + new OrmSqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq WHERE sq.date < :date', ['date' => '2020-06-01']), $result ); @@ -413,7 +414,7 @@ public function testSubQueryTableWithFilter() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest WHERE date > :test GROUP BY id) as sq WHERE sq.date < :date', [ + new OrmSqlStatement('SELECT * FROM (SELECT id, max(date) as date FROM subtest WHERE date > :test GROUP BY id) as sq WHERE sq.date < :date', [ 'test' => '2020-06-01', 'date' => '2020-06-28', ]), @@ -442,7 +443,7 @@ public function testSubQueryJoin() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT * FROM test INNER JOIN (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq ON test.id = sq.id WHERE test.date < :date', ['date' => '2020-06-28']), + new OrmSqlStatement('SELECT * FROM test INNER JOIN (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq ON test.id = sq.id WHERE test.date < :date', ['date' => '2020-06-28']), $result ); @@ -531,7 +532,7 @@ public function testSubQueryField() $result = $query->build(); $this->assertEquals( - new SqlStatement('SELECT test.id, test.name, test.date, (SELECT max(date) as date FROM subtest) as subdate FROM test WHERE test.date < :date', ['date' => '2020-06-28']), + new OrmSqlStatement('SELECT test.id, test.name, test.date, (SELECT max(date) as date FROM subtest) as subdate FROM test WHERE test.date < :date', ['date' => '2020-06-28']), $result ); @@ -544,7 +545,7 @@ public function testSoftDeleteQuery() ->table('info') ->where('iduser = :id', ['id' => 3]) ->orderBy(['property']); - $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); @@ -552,11 +553,11 @@ public function testSoftDeleteQuery() ->table('info') ->where('iduser = :id', ['id' => 3]) ->orderBy(['property']); - $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id AND info.deleted_at is null ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("SELECT * FROM info WHERE iduser = :id AND info.deleted_at is null ORDER BY property", ["id" => 3]), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); } public function testSoftDeleteUpdate() @@ -566,7 +567,7 @@ public function testSoftDeleteUpdate() ->table('info') ->set('property', 'value') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3], ObserverEvent::Update, 'info'), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); @@ -574,11 +575,11 @@ public function testSoftDeleteUpdate() ->table('info') ->set('property', 'value') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id AND info.deleted_at is null", ["property" => "value", "id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("UPDATE info SET property = :property WHERE iduser = :id AND info.deleted_at is null", ["property" => "value", "id" => 3], ObserverEvent::Update, 'info'), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3], ObserverEvent::Update, 'info'), $query->build()); } public function testSoftDeleteDelete() @@ -587,18 +588,18 @@ public function testSoftDeleteDelete() $query = DeleteQuery::getInstance() ->table('info') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3], ObserverEvent::Delete, 'info'), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); $query = DeleteQuery::getInstance() ->table('info') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id AND info.deleted_at is null", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("DELETE FROM info WHERE iduser = :id AND info.deleted_at is null", ["id" => 3], ObserverEvent::Delete, 'info'), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3]), $query->build()); + $this->assertEquals(new OrmSqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3], ObserverEvent::Delete, 'info'), $query->build()); } public function testQueryBasicDistinct() @@ -609,7 +610,7 @@ public function testQueryBasicDistinct() ->fields(['fld1', 'fld2']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2 FROM test'), $queryBasic->build() ); @@ -620,7 +621,7 @@ public function testQueryBasicDistinct() ->distinct(); $this->assertEquals( - new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $queryBasic->build() ); } @@ -633,7 +634,7 @@ public function testQueryDistinct() ->fields(['fld1', 'fld2']); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2 FROM test'), $query->build() ); @@ -644,14 +645,14 @@ public function testQueryDistinct() ->distinct(); $this->assertEquals( - new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $query->build() ); // Test getQueryBasic preserves distinct $queryBasic = $query->getQueryBasic(); $this->assertEquals( - new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), + new OrmSqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $queryBasic->build() ); } diff --git a/tests/RecursiveTest.php b/tests/RecursiveTest.php index daee710..6d69305 100644 --- a/tests/RecursiveTest.php +++ b/tests/RecursiveTest.php @@ -2,7 +2,7 @@ namespace Tests; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Recursive; use PHPUnit\Framework\TestCase; @@ -26,7 +26,7 @@ public function testRecursive() $expected = $expected . "SELECT start, end FROM test"; $this->assertEquals( - new SqlStatement($expected), + new OrmSqlStatement($expected), $query->build() ); diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 0932e5b..cd21933 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -5,7 +5,7 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Db\DatabaseExecutor; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\Cache\Psr16\ArrayCacheEngine; use ByJG\MicroOrm\CacheQueryResult; use ByJG\MicroOrm\Constraint\CustomConstraint; @@ -28,7 +28,6 @@ use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\ObserverData; use ByJG\MicroOrm\ORM; -use ByJG\MicroOrm\ORMSubject; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\Union; @@ -80,7 +79,6 @@ public function setUp(): void $this->infoMapper->addFieldMapping(FieldMapping::create('registrationId')->withFieldName('registration_id')); $this->repository = new Repository($executor, $this->userMapper); - ORMSubject::getInstance()->clearObservers(); $executor->execute('create table users ( id integer primary key auto_increment, @@ -374,7 +372,7 @@ public function testInsertFromObject() $sqlStatement = $insertQuery->build(); $this->assertEquals( - new SqlStatement("INSERT INTO users( name, createdate ) values ( X'6565', :createdate ) ", ["createdate" => "2015-08-09"]), + new OrmSqlStatement("INSERT INTO users( name, createdate ) values ( X'6565', :createdate ) ", ["createdate" => "2015-08-09"], ObserverEvent::Insert, 'users'), $sqlStatement ); @@ -614,7 +612,7 @@ public function testUpdateObject() $sqlStatement = $updateQuery->build(); $this->assertEquals( - new SqlStatement("UPDATE users SET name = X'6565' , createdate = :createdate WHERE id = :pkid", ["createdate" => "2020-01-02", "pkid" => 1]), + new OrmSqlStatement("UPDATE users SET name = X'6565' , createdate = :createdate WHERE id = :pkid", ["createdate" => "2020-01-02", "pkid" => 1], ObserverEvent::Update, 'users'), $sqlStatement ); diff --git a/tests/TransactionManagerTest.php b/tests/TransactionManagerTest.php index 06beb1c..ead73bb 100644 --- a/tests/TransactionManagerTest.php +++ b/tests/TransactionManagerTest.php @@ -37,12 +37,12 @@ public function tearDown(): void $this->object->destroy(); $this->object = null; - $dbDriver = ConnectionUtil::getConnection("a"); - $dbDriver->execute('drop table if exists users;'); - $dbDriver->execute('drop table if exists users1;'); + $executor = DatabaseExecutor::using(ConnectionUtil::getConnection("a")); + $executor->execute('drop table if exists users;'); + $executor->execute('drop table if exists users1;'); - $dbDriver = ConnectionUtil::getConnection("b"); - $dbDriver->execute('drop table if exists users2;'); + $executor = DatabaseExecutor::using(ConnectionUtil::getConnection("b")); + $executor->execute('drop table if exists users2;'); } public function testAddConnectionError() @@ -171,18 +171,20 @@ public function testTransaction() { $dbDrive1 = ConnectionUtil::getConnection("a"); $dbDrive2 = ConnectionUtil::getConnection("b"); + $executor1 = DatabaseExecutor::using($dbDrive1); + $executor2 = DatabaseExecutor::using($dbDrive2); - $dbDrive1->execute('create table users1 ( + $executor1->execute('create table users1 ( id integer primary key auto_increment, name varchar(45));' ); - $dbDrive2->execute('create table users2 ( + $executor2->execute('create table users2 ( id integer primary key auto_increment, name varchar(45));' ); - $this->assertEquals(0, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(0, $dbDrive2->getScalar("select count(*) from users2")); + $this->assertEquals(0, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(0, $executor2->getScalar("select count(*) from users2")); // Create Transaction Manager @@ -191,27 +193,27 @@ public function testTransaction() // Initialize Transaction $this->object->beginTransaction(); - $dbDrive1->execute("insert into users1 (name) values ('John1')"); - $dbDrive2->execute("insert into users2 (name) values ('John2')"); - $this->assertEquals(1, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(1, $dbDrive2->getScalar("select count(*) from users2")); + $executor1->execute("insert into users1 (name) values ('John1')"); + $executor2->execute("insert into users2 (name) values ('John2')"); + $this->assertEquals(1, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(1, $executor2->getScalar("select count(*) from users2")); $this->object->rollbackTransaction(); // After rollback, no records should be added. - $this->assertEquals(0, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(0, $dbDrive2->getScalar("select count(*) from users2")); + $this->assertEquals(0, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(0, $executor2->getScalar("select count(*) from users2")); // Initialize a new Transaction $this->object->beginTransaction(); - $dbDrive1->execute("insert into users1 (name) values ('John1')"); - $dbDrive2->execute("insert into users2 (name) values ('John2')"); - $this->assertEquals(1, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(1, $dbDrive2->getScalar("select count(*) from users2")); + $executor1->execute("insert into users1 (name) values ('John1')"); + $executor2->execute("insert into users2 (name) values ('John2')"); + $this->assertEquals(1, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(1, $executor2->getScalar("select count(*) from users2")); $this->object->commitTransaction(); // After commit, records should be added. - $this->assertEquals(1, $dbDrive1->getScalar("select count(*) from users1")); - $this->assertEquals(1, $dbDrive2->getScalar("select count(*) from users2")); + $this->assertEquals(1, $executor1->getScalar("select count(*) from users1")); + $this->assertEquals(1, $executor2->getScalar("select count(*) from users2")); } } diff --git a/tests/UpdateQueryTest.php b/tests/UpdateQueryTest.php index b9e2d80..c1d4639 100644 --- a/tests/UpdateQueryTest.php +++ b/tests/UpdateQueryTest.php @@ -7,7 +7,8 @@ use ByJG\AnyDataset\Db\SqlDialect\SqliteDialect; use ByJG\AnyDataset\Db\PdoMysql; use ByJG\AnyDataset\Db\PdoObj; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Enum\ObserverEvent; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\UpdateQuery; @@ -46,18 +47,20 @@ public function testUpdate() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE test SET fld1 = :fld1 , fld2 = :fld2 , fld3 = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); $sqlStatement = $this->object->build(new SqliteDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -80,7 +83,7 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlStatement('SELECT * FROM test'), + new OrmSqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -91,7 +94,7 @@ public function testQueryUpdatable() ->set('fld3', 'C'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->convert()->build() ); @@ -99,7 +102,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -107,7 +110,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -115,7 +118,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), + new OrmSqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } @@ -139,9 +142,10 @@ public function testUpdateJoinMySQl() $sqlStatement = $this->object->build(new MysqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` INNER JOIN `table2` ON table2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -158,9 +162,10 @@ public function testUpdateJoinMySQlAlias() $sqlObject = $this->object->build(new MysqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` INNER JOIN `table2` AS t2 ON t2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlObject ); @@ -189,9 +194,10 @@ public function __construct() $sqlObject = $this->object->build($dbDriver); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` INNER JOIN (SELECT * FROM table2 WHERE id = 10) AS t2 ON t2.id = test.id SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlObject ); @@ -208,9 +214,10 @@ public function testUpdateJoinPostgres() $sqlStatement = $this->object->build(new PgsqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE "test" SET "fld1" = :fld1 , "fld2" = :fld2 , "fld3" = :fld3 FROM "table2" ON table2.id = test.id WHERE fld1 = :id', - ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -224,9 +231,10 @@ public function testSetLiteral() $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE test SET counter = counter + 1 WHERE id = :id', - ['id' => 10] + ['id' => 10], + ObserverEvent::Update, 'test' ), $sqlStatement ); @@ -234,9 +242,10 @@ public function testSetLiteral() // Test with database helper $sqlStatement = $this->object->build(new MysqlDialect()); $this->assertEquals( - new SqlStatement( + new OrmSqlStatement( 'UPDATE `test` SET `counter` = counter + 1 WHERE id = :id', - ['id' => 10] + ['id' => 10], + ObserverEvent::Update, 'test' ), $sqlStatement ); diff --git a/tests/WhereTraitTest.php b/tests/WhereTraitTest.php index 2409c87..36f9cc5 100644 --- a/tests/WhereTraitTest.php +++ b/tests/WhereTraitTest.php @@ -2,7 +2,7 @@ namespace Tests; -use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\OrmSqlStatement; use ByJG\MicroOrm\QueryBasic; use PHPUnit\Framework\TestCase; @@ -15,7 +15,7 @@ public function testWhereIsNull() ->whereIsNull('test.field'); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE test.field IS NULL', []), + new OrmSqlStatement('SELECT * FROM test WHERE test.field IS NULL', []), $query->build() ); } @@ -27,7 +27,7 @@ public function testWhereIsNotNull() ->whereIsNotNull('test.field'); $this->assertEquals( - new SqlStatement('SELECT * FROM test WHERE test.field IS NOT NULL', []), + new OrmSqlStatement('SELECT * FROM test WHERE test.field IS NOT NULL', []), $query->build() ); } @@ -62,7 +62,7 @@ public function testWhereInEmpty() ->whereIn('test.field', []); $this->assertEquals( - new SqlStatement('SELECT * FROM test', []), + new OrmSqlStatement('SELECT * FROM test', []), $query->build() ); } From ec6680d3009ec8265a23bb79fe0ecda4e3136d6d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 18 Jul 2026 18:38:21 -0400 Subject: [PATCH 02/18] FieldUuidAttribute: accept parentTable to declare FK relationships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldUuidAttribute exposed only primaryKey/fieldName/fieldAlias/syncWithDb and hard-coded the UUID select/update mappers, so a binary UUID foreign key could not register a relationship via ORM::addRelationship — unlike a plain FieldAttribute. Add the parentTable pass-through (updateFunction/selectFunction stay fixed to the UUID mappers). The join SQL built from the relationship is dialect-agnostic (QueryBasic::getJoin concatenates a standard INNER JOIN ... ON), so this works across MySQL, Postgres, Sqlite and SqlServer. insertFunction is intentionally not exposed (the UUID conversion for writes is handled by the update function on the value-prepare path). --- src/Attributes/FieldUuidAttribute.php | 13 +++++++++++-- tests/Model/Class5.php | 16 ++++++++++++++++ tests/ORMTest.php | 22 ++++++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 tests/Model/Class5.php diff --git a/src/Attributes/FieldUuidAttribute.php b/src/Attributes/FieldUuidAttribute.php index f4d1c1b..91f6b16 100644 --- a/src/Attributes/FieldUuidAttribute.php +++ b/src/Attributes/FieldUuidAttribute.php @@ -13,9 +13,18 @@ public function __construct( ?bool $primaryKey = null, ?string $fieldName = null, ?string $fieldAlias = null, - ?bool $syncWithDb = null + ?bool $syncWithDb = null, + ?string $parentTable = null ) { - parent::__construct($primaryKey, $fieldName, $fieldAlias, $syncWithDb, FormatUpdateUuidMapper::class, FormatSelectUuidMapper::class); + parent::__construct( + primaryKey: $primaryKey, + fieldName: $fieldName, + fieldAlias: $fieldAlias, + syncWithDb: $syncWithDb, + updateFunction: FormatUpdateUuidMapper::class, + selectFunction: FormatSelectUuidMapper::class, + parentTable: $parentTable + ); } } diff --git a/tests/Model/Class5.php b/tests/Model/Class5.php new file mode 100644 index 0000000..777a4a4 --- /dev/null +++ b/tests/Model/Class5.php @@ -0,0 +1,16 @@ +assertEquals("SELECT * FROM table1 WHERE field1 = 'testando' AND field2 = 'Joana D''Arc'", $sql); $this->assertEquals([], $params); } + + public function testFieldUuidAttributeRegistersParentTableRelationship() + { + // table1 (mapper1) is already registered in setUp. Building Class5's mapper + // must register the FK relationship declared via FieldUuidAttribute(parentTable:). + new Mapper(Class5::class); + + $this->assertEquals(['table1,table5'], ORM::getRelationship('table1', 'table5')); + + $data = ORM::getRelationshipData('table1', 'table5'); + $this->assertCount(1, $data); + $this->assertEquals('table1', $data[0]['parent']); + $this->assertEquals('table5', $data[0]['child']); + $this->assertEquals('id', $data[0]['pk']); + $this->assertEquals('id_table1', $data[0]['fk']); + + // And the dynamic query builder joins them without hand-written SQL. + $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $this->assertStringContainsString('INNER JOIN table5', $sql); + $this->assertStringContainsString('table1.id = table5.id_table1', $sql); + } } From db747aa3ca03092d59b71c94a62722758aca512f Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 18 Jul 2026 19:00:41 -0400 Subject: [PATCH 03/18] ORM: resolve incomplete relationships when the parent registers later getRelationship() retried incomplete relationships (parent primary key still '?') with an inverted condition: it skipped exactly the case where the parent mapper had since been registered, so a child mapper created before its parent kept a '?' primary key and produced a broken join (parent.? = child.fk). Flip the guard so the retry runs once the parent is available and the real primary key is filled in. This matters when relationships are declared via FieldAttribute(parentTable:) and mappers register in dependency-injection order rather than parent-first. --- src/ORM.php | 5 +++-- tests/ORMTest.php | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/ORM.php b/src/ORM.php index 2973e55..520b7af 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -59,9 +59,10 @@ public static function addRelationship(string|Mapper $parent, string|Mapper $chi public static function getRelationship(string ...$tables): array { - // First time we try to fix the incomplete relationships + // Retry incomplete relationships whose parent mapper is now registered, + // so the parent primary key ('?') can finally be resolved. foreach (static::$incompleteRelationships as $relationship) { - if (isset(static::$mapper[$relationship["parent"]])) { + if (!isset(static::$mapper[$relationship["parent"]])) { continue; } static::addRelationship($relationship["parent"], $relationship["child"], $relationship["fk"]); diff --git a/tests/ORMTest.php b/tests/ORMTest.php index 624e903..dcdb1f5 100644 --- a/tests/ORMTest.php +++ b/tests/ORMTest.php @@ -172,6 +172,21 @@ public function testProcessLiteralString() $this->assertEquals([], $params); } + public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() + { + // Register the CHILD (table5, FK id_table1 -> table1) BEFORE its parent. + // At this point table1's primary key is unknown, so the relationship is + // stored as incomplete ('?'). Registering the parent afterwards must let + // getRelationship back-fill the real primary key. + ORM::resetMemory(); + new Mapper(Class5::class); + new Mapper(Class1::class, 'table1', 'id'); + + $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $this->assertStringContainsString('table1.id = table5.id_table1', $sql); + $this->assertStringNotContainsString('table1.? =', $sql); + } + public function testFieldUuidAttributeRegistersParentTableRelationship() { // table1 (mapper1) is already registered in setUp. Building Class5's mapper From 8da3229082a3ac26914082fde3670caf617c12bb Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 19 Jul 2026 11:29:09 -0300 Subject: [PATCH 04/18] QueryBasic: add auto-discovering joinRelated; joinWith on it; remove ORM::getQueryInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit joinRelated/leftJoinRelated/rightJoinRelated derive a JOIN's ON condition from a registered parentTable relationship and connect a table to one already in the query. If they are not directly related, the intermediate tables on the shortest relationship path are joined automatically (tables already in the query are skipped); the query keeps its own base table, so it composes with a repository query. A shared addRelatedJoin() helper walks the path and delegates to join()/leftJoin()/rightJoin(). ActiveRecord::joinWith() now builds on joinRelated() (starting from the model's own table) instead of ORM::getQueryInstance. ORM::getQueryInstance() is removed — joinRelated/joinWith replace it, keeping the base table, supporting INNER/LEFT/RIGHT, and auto-joining intermediates without the malformed double-join it produced. The relationship registry (addRelationship/getRelationship/ getRelationshipData) is unchanged. Docs (auto-discovering-relationship, comparison-with-other-orms) and CHANGELOG-7.0 updated. --- CHANGELOG-7.0.md | 15 ++++ docs/auto-discovering-relationship.md | 47 ++++++++---- docs/comparison-with-other-orms.md | 6 +- src/ORM.php | 35 --------- src/QueryBasic.php | 100 ++++++++++++++++++++++++++ src/Trait/ActiveRecord.php | 13 +++- tests/ORMTest.php | 98 +++++++++++++++++-------- 7 files changed, 230 insertions(+), 84 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index f387d9a..491f437 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -43,6 +43,15 @@ no observers are registered), and multi-connection safe. statement, including reads and raw SQL) - New `Repository::removeObserver()` for both observer kinds +### Relationship joins on the query builder +- New `QueryBasic::joinRelated()`, `leftJoinRelated()` and `rightJoinRelated()`: add a JOIN whose + `ON` condition is derived from a registered `parentTable` relationship, connecting the table to one + already in the query. If they are not directly related, the intermediate tables on the shortest + relationship path are joined automatically; tables already in the query are skipped. The query + keeps its own base table, so this composes with a repository query +- `ActiveRecord::joinWith()` now builds on `joinRelated()` (starting from the model's own table) +- See: `docs/auto-discovering-relationship.md` + ## Breaking Changes ### ORMSubject removed @@ -74,6 +83,12 @@ no observers are registered), and multi-connection safe. - New `SoftDelete` case: exhaustive `match` expressions over `ObserverEvent` in userland need a new arm +### ORM::getQueryInstance() removed +- `ORM::getQueryInstance(...$tables)` is removed. Build relationship joins with + `Query::getInstance()->table($base)->joinRelated($related)` (or `Model::joinWith(...)` for Active + Record) — it keeps your base table, supports INNER/LEFT/RIGHT, and auto-joins intermediates. The + relationship registry (`addRelationship`/`getRelationship`/`getRelationshipData`) is unchanged + ## Migration from 6.x | 6.x | 7.0 | diff --git a/docs/auto-discovering-relationship.md b/docs/auto-discovering-relationship.md index 3100f35..7fad453 100644 --- a/docs/auto-discovering-relationship.md +++ b/docs/auto-discovering-relationship.md @@ -42,32 +42,51 @@ relationship registry. ## Generating Queries with Relationships -To generate the SQL query with the relationship, you can use the `ORM` static class: - -```php -table('table1') - ->join('table2', 'table2.id_table1 = table1.id'); + ->table('table2') + ->joinRelated('table1'); +// SELECT * FROM table2 INNER JOIN table1 ON table1.id = table2.id_table1 ``` -## Queries with Multiple Tables +`joinRelated()` appends the join to whichever table is already in the query (the base +table or a previously joined one), so it keeps your own base table. Because of that it +composes on top of an existing query — including one a `Repository` already scoped to +its table, where `$repository->getByQuery($query)` still returns that repository's +entity. + +There are `joinRelated()`, `leftJoinRelated()` and `rightJoinRelated()`, mirroring +`join()`/`leftJoin()`/`rightJoin()`, so you can pick the join type. -You can also create queries with multiple joined tables by passing more table names: +If the target table is **not directly related** to a table already in the query, the +intermediate tables on the shortest relationship path are joined automatically — you +don't have to remember them (though keep in mind the extra joins have a cost). Tables +already in the query are skipped, so chaining stays consistent: ```php table2 -> table4: table2 is joined for you. +$query = Query::getInstance()->table('table1')->joinRelated('table4'); +// SELECT * FROM table1 +// INNER JOIN table2 ON table1.id = table2.id_table1 +// INNER JOIN table4 ON table2.id = table4.id_table2 + +// Naming the intermediate explicitly gives the same result (table2 is not joined twice): +$query = Query::getInstance()->table('table1')->joinRelated('table2')->joinRelated('table4'); ``` -The ORM will automatically discover the path to connect these tables if relationships have been defined. +For a `left`/`rightJoinRelated()` that spans intermediates, every hop it adds uses that +join type. `joinRelated()` throws an `InvalidArgumentException` only when no relationship +path connects the tables. For aliased joins, use `join()`/`leftJoin()`/`rightJoin()` with +an explicit `ON`. + +Active Record models expose the same through `Model::joinWith(...$tables)`, which starts +from the model's own table and joins the related ones. ## Manual Relationship Definition diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md index 62a69a5..7cb25fb 100644 --- a/docs/comparison-with-other-orms.md +++ b/docs/comparison-with-other-orms.md @@ -127,9 +127,9 @@ class Post { public ?string $title; } -// 2. Auto-generate JOIN query from relationship -$query = ORM::getQueryInstance("users", "posts"); -// Automatically generates: JOIN posts ON posts.user_id = users.id +// 2. Build a JOIN query from the relationship +$query = Query::getInstance()->table("users")->joinRelated("posts"); +// Automatically generates: FROM users INNER JOIN posts ON users.id = posts.user_id $results = $userRepo->getByQuery($query, [$postMapper]); foreach ($results as [$user, $post]) { diff --git a/src/ORM.php b/src/ORM.php index 520b7af..2230194 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -126,41 +126,6 @@ public static function getMapper(string $tableName): ?Mapper return static::$mapper[$tableName] ?? null; } - public static function getQueryInstance(string ...$tables): Query - { - $query = new Query(); - - $relationships = static::getRelationshipData(...$tables); - - if (empty($relationships)) { - if (count($tables) === 1) { - $query->table($tables[0]); - return $query; - } else { - throw new InvalidArgumentException("No relationship found between the tables"); - } - } - - $first = true; - foreach ($relationships as $relationship) { - $parent = $relationship["parent"]; - $child = $relationship["child"]; - $foreignKey = $relationship["fk"]; - $primaryKey = $relationship["pk"]; - - $parentAlis = static::$mapper[$parent]->getTableAlias(); - $childAlias = static::$mapper[$child]->getTableAlias(); - - if ($first) { - $query->table($parent, $parentAlis); - $first = false; - } - $query->join($child, "{$parentAlis}.{$primaryKey} = {$childAlias}.{$foreignKey}", $childAlias); - } - - return $query; - } - private static function getNormalizedKey(string $table1, string $table2): string { return strcmp($table1, $table2) < 0 ? "$table1,$table2" : "$table2,$table1"; diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 56da25b..2b4b33e 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -167,6 +167,106 @@ public function crossJoin(QueryBasic|string $table, ?string $alias = null): stat return $this; } + /** + * Add an INNER JOIN to $table using the registered parentTable relationships, + * deriving the ON condition instead of writing it by hand. $table is connected to + * a table already in the query (base or a previous join); if they are not directly + * related, the intermediate tables on the shortest relationship path are joined too + * — so you don't have to remember them — while tables already in the query are + * skipped. The query keeps its own base table, so this composes on top of an + * existing query, including one a repository has scoped to its table. + * + * Example: + * $query->table('task')->joinRelated('project'); + * // INNER JOIN project ON project.id = task.project_id + * + * $query->table('project')->joinRelated('note'); + * // auto-joins task in between: INNER JOIN task ON … INNER JOIN note ON … + * + * For aliased joins, use join()/leftJoin()/rightJoin() with an explicit ON. + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + public function joinRelated(string $table): static + { + return $this->addRelatedJoin($table, 'INNER'); + } + + /** + * Like joinRelated(), but every hop it adds is a LEFT JOIN. + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + public function leftJoinRelated(string $table): static + { + return $this->addRelatedJoin($table, 'LEFT'); + } + + /** + * Like joinRelated(), but every hop it adds is a RIGHT JOIN. + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + public function rightJoinRelated(string $table): static + { + return $this->addRelatedJoin($table, 'RIGHT'); + } + + /** + * Walk the relationship path from a table already in the query to $table and join + * each hop's not-yet-present table, delegating to join()/leftJoin()/rightJoin(). + * + * @throws InvalidArgumentException When no relationship path connects $table to the query. + */ + private function addRelatedJoin(string $table, string $type): static + { + foreach ($this->relatedTables() as $present) { + $path = ORM::getRelationshipData($present, $table); + if (empty($path)) { + continue; + } + + foreach ($path as $rel) { + $current = $this->relatedTables(); + $parentPresent = in_array($rel['parent'], $current, true); + if ($parentPresent && in_array($rel['child'], $current, true)) { + continue; // both tables already in the query + } + $newTable = $parentPresent ? $rel['child'] : $rel['parent']; + $on = "{$rel['parent']}.{$rel['pk']} = {$rel['child']}.{$rel['fk']}"; + match ($type) { + 'LEFT' => $this->leftJoin($newTable, $on), + 'RIGHT' => $this->rightJoin($newTable, $on), + default => $this->join($newTable, $on), + }; + } + + return $this; + } + + throw new InvalidArgumentException("No relationship registered between '$table' and the query tables."); + } + + /** + * Table names already present in the query (base + joined), used to resolve a + * relationship for joinRelated(). + * + * @return string[] + */ + private function relatedTables(): array + { + $tables = []; + if (is_string($this->table) && $this->table !== '') { + $tables[] = $this->table; + } + foreach ($this->join as $item) { + if (is_string($item['table'])) { + $tables[] = $item['table']; + } + } + return $tables; + } + public function withRecursive(Recursive $recursive): static { $this->recursive = $recursive; diff --git a/src/Trait/ActiveRecord.php b/src/Trait/ActiveRecord.php index 0a3d411..a25c150 100644 --- a/src/Trait/ActiveRecord.php +++ b/src/Trait/ActiveRecord.php @@ -120,11 +120,20 @@ public static function all(int $page = 0, int $limit = 50): array return self::$repository->getByFilter(page: $page, limit: $limit); } + /** + * Start a query on this model's table, optionally joining related tables via + * their registered parentTable relationships. Built on Query::joinRelated(), so + * the model's own table stays the base and each joined table must be directly + * related to a table already in the query (chain for multi-hop). + */ public static function joinWith(string ...$tables): Query { self::initialize(); - $tables[] = self::$repository->getMapper()->getTable(); - return ORM::getQueryInstance(...$tables); + $query = Query::getInstance()->table(self::$repository->getMapper()->getTable()); + foreach ($tables as $table) { + $query->joinRelated($table); + } + return $query; } public function toArray(bool $includeNullValue = false): array diff --git a/tests/ORMTest.php b/tests/ORMTest.php index dcdb1f5..6a537e6 100644 --- a/tests/ORMTest.php +++ b/tests/ORMTest.php @@ -2,12 +2,14 @@ namespace Tests; +use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\FieldMapping; use ByJG\MicroOrm\Literal\HexUuidLiteral; use ByJG\MicroOrm\Literal\Literal; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\ORM; use ByJG\MicroOrm\ORMHelper; +use ByJG\MicroOrm\Query; use Override; use PHPUnit\Framework\TestCase; use Tests\Model\Class1; @@ -105,33 +107,9 @@ public function testSanityCheckData() $this->assertEquals([$table1Table3, $table1Table2], ORM::getRelationshipData('table3', 'table2')); } - public function testGetQuery() - { - $query = ORM::getQueryInstance('table1'); - $this->assertEquals("SELECT * FROM table1", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table2', 'table1'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table4', 'table2', 'table1'); - $this->assertEquals("SELECT * FROM table2 INNER JOIN table4 ON table2.id = table4.id_table2 INNER JOIN table2 ON table1.id = table2.id_table1 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table4', 'table1', 'table2'); - $this->assertEquals("SELECT * FROM table2 INNER JOIN table4 ON table2.id = table4.id_table2 INNER JOIN table2 ON table1.id = table2.id_table1 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table2', 'table1', 'table4'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table1', 'table4'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", $query->build()->getSql()); - - $query = ORM::getQueryInstance('table2', 'table3'); - $this->assertEquals("SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 INNER JOIN table3 ON table1.id = table3.id_table1", $query->build()->getSql()); - } - public function testProcessLiteral() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new Literal('upper(field1)')]); $this->assertEquals("SELECT * FROM table1 WHERE field1 = upper(field1)", $query->build()->getSql()); @@ -139,14 +117,14 @@ public function testProcessLiteral() public function testProcessHexUuidLiteral() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new HexUuidLiteral(hex2bin('01010101010101010101010101010101'))]); $this->assertEquals("SELECT * FROM table1 WHERE field1 = X'01010101010101010101010101010101'", $query->build()->getSql()); } public function testProcessLiteralUnsafe() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new Literal(10)]); $sqlStatement = $query->build(); @@ -159,7 +137,7 @@ public function testProcessLiteralUnsafe() public function testProcessLiteralString() { - $query = ORM::getQueryInstance('table1'); + $query = Query::getInstance()->table('table1'); $query->where('field1 = :value', ['value' => new Literal("'testando'")]); $query->where('field2 = :value2', ['value2' => new Literal("'Joana D''Arc'")]); @@ -172,6 +150,66 @@ public function testProcessLiteralString() $this->assertEquals([], $params); } + public function testJoinRelatedDerivesOnFromRelationshipKeepingBaseTable() + { + // table3 has a FK id_table1 -> table1. Base stays table3; project is added. + $sql = Query::getInstance()->table('table3')->joinRelated('table1')->build()->getSql(); + $this->assertStringContainsString('FROM table3 INNER JOIN table1 ON table1.id = table3.id_table1', $sql); + } + + public function testLeftJoinRelated() + { + $sql = Query::getInstance()->table('table3')->leftJoinRelated('table1')->build()->getSql(); + $this->assertStringContainsString('LEFT JOIN table1 ON table1.id = table3.id_table1', $sql); + } + + public function testRightJoinRelated() + { + $sql = Query::getInstance()->table('table3')->rightJoinRelated('table1')->build()->getSql(); + $this->assertStringContainsString('RIGHT JOIN table1 ON table1.id = table3.id_table1', $sql); + } + + public function testJoinRelatedAutoDiscoversIntermediateTables() + { + // table1 is not directly related to table4 (table1 -> table2 -> table4). The + // intermediate table2 is joined automatically, and table4's soft-delete filter + // is kept. The base table (table1) is preserved. + $sql = Query::getInstance()->table('table1')->joinRelated('table4')->build()->getSql(); + $this->assertEquals( + "SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 " + . "INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", + $sql + ); + + // table2 -> table3 auto-discovers table1 sitting between them. + $sql = Query::getInstance()->table('table2')->joinRelated('table3')->build()->getSql(); + $this->assertEquals( + "SELECT * FROM table2 INNER JOIN table1 ON table1.id = table2.id_table1 " + . "INNER JOIN table3 ON table1.id = table3.id_table1", + $sql + ); + } + + public function testJoinRelatedSkipsTablesAlreadyInTheQuery() + { + // Naming the intermediate explicitly must not join table2 twice. + $sql = Query::getInstance()->table('table1') + ->joinRelated('table2') + ->joinRelated('table4') + ->build()->getSql(); + $this->assertEquals( + "SELECT * FROM table1 INNER JOIN table2 ON table1.id = table2.id_table1 " + . "INNER JOIN table4 ON table2.id = table4.id_table2 WHERE table4.deleted_at is null", + $sql + ); + } + + public function testJoinRelatedThrowsWhenNoRelationshipPathExists() + { + $this->expectException(InvalidArgumentException::class); + Query::getInstance()->table('table1')->joinRelated('unrelated_table'); + } + public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() { // Register the CHILD (table5, FK id_table1 -> table1) BEFORE its parent. @@ -182,7 +220,7 @@ public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() new Mapper(Class5::class); new Mapper(Class1::class, 'table1', 'id'); - $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $sql = Query::getInstance()->table('table1')->joinRelated('table5')->build()->getSql(); $this->assertStringContainsString('table1.id = table5.id_table1', $sql); $this->assertStringNotContainsString('table1.? =', $sql); } @@ -203,7 +241,7 @@ public function testFieldUuidAttributeRegistersParentTableRelationship() $this->assertEquals('id_table1', $data[0]['fk']); // And the dynamic query builder joins them without hand-written SQL. - $sql = ORM::getQueryInstance('table1', 'table5')->build()->getSql(); + $sql = Query::getInstance()->table('table1')->joinRelated('table5')->build()->getSql(); $this->assertStringContainsString('INNER JOIN table5', $sql); $this->assertStringContainsString('table1.id = table5.id_table1', $sql); } From b5b96cbb9f62c38aaf0f4062fe0f691497744efa Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Tue, 21 Jul 2026 23:06:38 -0300 Subject: [PATCH 05/18] joinRelated/joinWith accept a model class (on-demand mapper registration) Passing a class-string to joinRelated/leftJoinRelated/rightJoinRelated/joinWith now resolves it to its table via new ORM::getTableFromClass(), registering the entity's mapper on demand. Registration is reflection-only (no DB connection), so relationship auto-discovery works on a request that never touched that entity's repository. Name each entity in a multi-hop path (like hasManyThrough's through-model) to make the intermediate mapper known. Backward compatible: a table-name string does not class_exists, so it is used verbatim. --- CHANGELOG-7.0.md | 7 ++++++ docs/auto-discovering-relationship.md | 34 +++++++++++++++++++++++++++ src/ORM.php | 22 +++++++++++++++++ src/QueryBasic.php | 16 +++++++++++++ src/Trait/ActiveRecord.php | 13 ++++++---- tests/ORMTest.php | 29 +++++++++++++++++++++++ 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 491f437..691c5c7 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -50,6 +50,13 @@ no observers are registered), and multi-connection safe. relationship path are joined automatically; tables already in the query are skipped. The query keeps its own base table, so this composes with a repository query - `ActiveRecord::joinWith()` now builds on `joinRelated()` (starting from the model's own table) +- `joinRelated()`/`leftJoinRelated()`/`rightJoinRelated()`/`joinWith()` accept a **model class** + as well as a table name. Passing a class (e.g. `Project::class`) registers that entity's mapper + on demand — reflection only, no DB connection — so its table and `parentTable` relationships + become known even on a request that never touched its repository. To span a hidden intermediate, + name each entity in the path, like Eloquent's `hasManyThrough` through-model: + `Note::joinWith(Task::class, Project::class)`. New helper `ORM::getTableFromClass()` performs the + resolution - See: `docs/auto-discovering-relationship.md` ## Breaking Changes diff --git a/docs/auto-discovering-relationship.md b/docs/auto-discovering-relationship.md index 7fad453..c0a99f0 100644 --- a/docs/auto-discovering-relationship.md +++ b/docs/auto-discovering-relationship.md @@ -88,6 +88,40 @@ an explicit `ON`. Active Record models expose the same through `Model::joinWith(...$tables)`, which starts from the model's own table and joins the related ones. +### Passing a model class (on-demand mapper registration) + +`joinRelated()` (and `joinWith()`) accept a **model class** as well as a table name. +Relationship discovery walks the ORM's registry of mappers, and a mapper is only known +after it has been built. On a request that only touched one entity, the other entities' +mappers are not registered yet, so their relationships are invisible. Passing a class +fixes that: it registers that entity's mapper on demand — this reads the class attributes +(reflection) only and does **not** open a database connection — before resolving the join. + +```php +table('task')->joinRelated(Project::class); +// Project's mapper is registered on demand, then: +// SELECT * FROM task INNER JOIN project ON project.id = task.project_id +``` + +To span a **hidden intermediate**, name each entity in the path — the same way Eloquent's +`hasManyThrough` names its through-model. Auto-discovery can only join an intermediate +whose mapper is registered, so naming it is what makes it known: + +```php + task -> project. A note has no project_id, so 'task' is the through-entity. +$notes = Note::joinWith(Task::class, Project::class) + ->field('note.*') + ->where('project.id = :id', ['id' => $projectId]); +// FROM note INNER JOIN task ON task.id = note.task_id +// INNER JOIN project ON project.id = task.project_id +``` + +`ORM::getTableFromClass(Model::class)` exposes the same resolution directly, returning the +table name and registering the mapper if needed. + ## Manual Relationship Definition If you need to define relationships manually (without using attributes), you can use the `addRelationship` method: diff --git a/src/ORM.php b/src/ORM.php index 2230194..7968e14 100644 --- a/src/ORM.php +++ b/src/ORM.php @@ -126,6 +126,28 @@ public static function getMapper(string $tableName): ?Mapper return static::$mapper[$tableName] ?? null; } + /** + * Resolve a model class to its table name, registering its mapper on demand. + * + * Building the Mapper only reads the class attributes (reflection) — it does not + * open a database connection — so calling this at any point simply makes the + * entity's table and its parentTable relationships known to the ORM. This is what + * lets joinRelated()/joinWith() take a model class and discover the relationship + * graph on the current request, without every mapper being pre-registered. + * + * @param class-string $class + */ + public static function getTableFromClass(string $class): string + { + foreach (static::$mapper as $mapper) { + if ($mapper->getEntity() === $class) { + return $mapper->getTable(); + } + } + + return (new Mapper($class))->getTable(); + } + private static function getNormalizedKey(string $table1, string $table2): string { return strcmp($table1, $table2) < 0 ? "$table1,$table2" : "$table2,$table1"; diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 2b4b33e..03d0dc1 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -176,6 +176,13 @@ public function crossJoin(QueryBasic|string $table, ?string $alias = null): stat * skipped. The query keeps its own base table, so this composes on top of an * existing query, including one a repository has scoped to its table. * + * $table may be a table name or a model class. Passing a class (e.g. + * Project::class) registers that entity's mapper on demand — reflection only, no DB + * connection — so its table and parentTable relationships are known even on a + * request that never touched its repository. To join across a hidden intermediate, + * name each entity in the path (like Eloquent's hasManyThrough through-model): + * joinRelated(Task::class)->joinRelated(Project::class). + * * Example: * $query->table('task')->joinRelated('project'); * // INNER JOIN project ON project.id = task.project_id @@ -185,6 +192,7 @@ public function crossJoin(QueryBasic|string $table, ?string $alias = null): stat * * For aliased joins, use join()/leftJoin()/rightJoin() with an explicit ON. * + * @param string $table Table name or model class-string. * @throws InvalidArgumentException When no relationship path connects $table to the query. */ public function joinRelated(string $table): static @@ -220,6 +228,14 @@ public function rightJoinRelated(string $table): static */ private function addRelatedJoin(string $table, string $type): static { + // Accept either a table name or a model class. A class is resolved to its + // table and registered on demand (reflection only, no DB connection), so the + // intermediate/target entities of the join become known even on a request that + // never touched their repositories. + if (class_exists($table)) { + $table = ORM::getTableFromClass($table); + } + foreach ($this->relatedTables() as $present) { $path = ORM::getRelationshipData($present, $table); if (empty($path)) { diff --git a/src/Trait/ActiveRecord.php b/src/Trait/ActiveRecord.php index a25c150..e5f1728 100644 --- a/src/Trait/ActiveRecord.php +++ b/src/Trait/ActiveRecord.php @@ -121,10 +121,15 @@ public static function all(int $page = 0, int $limit = 50): array } /** - * Start a query on this model's table, optionally joining related tables via - * their registered parentTable relationships. Built on Query::joinRelated(), so - * the model's own table stays the base and each joined table must be directly - * related to a table already in the query (chain for multi-hop). + * Start a query on this model's table, optionally joining related tables via their + * registered parentTable relationships. Built on Query::joinRelated(), so the + * model's own table stays the base. Each argument may be a table name or a model + * class; passing a class registers that entity's mapper on demand (reflection only, + * no DB connection). Name each entity in a multi-hop path — including the + * intermediate — so its mapper is known, e.g.: + * + * Note::joinWith(Task::class, Project::class) + * // FROM note JOIN task ON … JOIN project ON … (note has no project_id) */ public static function joinWith(string ...$tables): Query { diff --git a/tests/ORMTest.php b/tests/ORMTest.php index 6a537e6..ddcd086 100644 --- a/tests/ORMTest.php +++ b/tests/ORMTest.php @@ -225,6 +225,35 @@ public function testIncompleteRelationshipResolvesWhenParentRegisteredLater() $this->assertStringNotContainsString('table1.? =', $sql); } + public function testGetTableFromClassRegistersMapperOnDemand() + { + // Only table1 is known; Class5 (table5) has never been registered. + ORM::resetMemory(); + new Mapper(Class1::class, 'table1', 'id'); + $this->assertNull(ORM::getMapper('table5')); + + // Resolving the class registers its mapper (reflection only) and returns its table. + $this->assertEquals('table5', ORM::getTableFromClass(Class5::class)); + $this->assertNotNull(ORM::getMapper('table5')); + + // Idempotent: a second call reuses the already-registered mapper. + $this->assertSame(ORM::getMapper('table5'), ORM::getMapper('table5')); + $this->assertEquals('table5', ORM::getTableFromClass(Class5::class)); + } + + public function testJoinRelatedAcceptsModelClassRegisteringMapperOnDemand() + { + // table5's mapper is not pre-registered; passing the class must register it on + // demand and join it, just like passing the 'table5' string would after setup. + ORM::resetMemory(); + new Mapper(Class1::class, 'table1', 'id'); + $this->assertNull(ORM::getMapper('table5')); + + $sql = Query::getInstance()->table('table1')->joinRelated(Class5::class)->build()->getSql(); + $this->assertStringContainsString('INNER JOIN table5 ON table1.id = table5.id_table1', $sql); + $this->assertNotNull(ORM::getMapper('table5')); + } + public function testFieldUuidAttributeRegistersParentTableRelationship() { // table1 (mapper1) is already registered in setUp. Building Class5's mapper From f67f80e7e186a11a4fc7cbd2d35cddf73e6ee84d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 23 Aug 2026 23:17:44 -0400 Subject: [PATCH 06/18] Prepare 7.0: PHP 8.3-8.6, PHPUnit 12.5, Psalm as a tool Widen the PHP constraint to ">=8.3 <8.7"; the previous "<8.6" is exclusive and excluded PHP 8.6. Bump byjg/* dependencies to ^7.0. While 7.0 is unreleased these resolve to 7.0.x-dev from each component's 7.0 branch, via minimum-stability dev with prefer-stable. Pin PHPUnit to ^12.5 and move Psalm to tools/psalm/composer.json. Psalm enumerates supported PHP versions and no release lists 8.6, so as a require-dev it made "composer install" fail on the 8.6 build before any test ran. "composer psalm" bootstraps the tool and runs it. CI: add PHP 8.6 to the matrix; the Psalm job runs on 8.5. Housekeeping: rename phpunit.xml.dist to phpunit.xml, carry the 6.0 changelog onto this branch, and update CHANGELOG-7.0.md. --- .github/workflows/phpunit.yml | 10 +++++++-- CHANGELOG-7.0.md | 37 +++++++++++++++++++++++++++++++++ composer.json | 12 ++++++----- phpunit.xml.dist => phpunit.xml | 0 psalm.xml | 2 +- tools/psalm/composer.json | 5 +++++ 6 files changed, 58 insertions(+), 8 deletions(-) rename phpunit.xml.dist => phpunit.xml (100%) create mode 100644 tools/psalm/composer.json diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 0784ef2..4e4ff62 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -18,6 +18,7 @@ jobs: strategy: matrix: php-version: + - "8.6" - "8.5" - "8.4" - "8.3" @@ -51,7 +52,7 @@ jobs: # for github/codeql-action/upload-sarif to upload SARIF results security-events: write container: - image: byjg/php:8.4-cli + image: byjg/php:8.5-cli options: --user root --privileged steps: @@ -61,10 +62,15 @@ jobs: - name: Composer run: composer install + - name: Composer (psalm) + + run: composer --working-dir=tools/psalm update --no-interaction + + - name: Psalm # Note: Ignoring error code 2, which just signals that some # flaws were found, not that Psalm itself failed to run. - run: ./vendor/bin/psalm + run: ./tools/psalm/vendor/bin/psalm --show-info=true --report=psalm-results.sarif || [ $? = 2 ] diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 691c5c7..4a15bdf 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -106,3 +106,40 @@ no observers are registered), and multi-connection safe. | 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 | + +## Requirements + +- PHP 8.3, 8.4, 8.5 and 8.6 are now supported: `"php": ">=8.3 <8.7"`. + The previous `<8.6` upper bound excluded PHP 8.6, since `<8.6` is exclusive. + +### ByJG dependencies + +- `byjg/anydataset-db` is now `^7.0`. +- `byjg/cache-engine` is now `^7.0`. + +While 7.0 is unreleased these resolve to `7.0.x-dev` from each component's +`7.0` branch, via `minimum-stability: dev` with `prefer-stable: true`. + +## Toolchain + +- PHPUnit updated to `^12.5`. +- Psalm moved out of `require-dev` into its own manifest, `tools/psalm/composer.json`. + + Psalm enumerates the PHP versions it supports and no published release lists + 8.6. As a dev dependency it made `composer install` fail on the 8.6 build job + before any test ran. It now installs separately, only for the Psalm job. + + `composer psalm` still works — it bootstraps the tool and runs it. + +- PHPUnit 13 is deliberately **not** used. It requires PHP `>=8.4.1`, breaking the + 8.3 floor, and needs `sebastian/diff ^9.0`, which stable Psalm 6.16.1 rejects — + a combination that silently resolves Psalm to an unreleased `6.x-dev` branch. + +## Continuous Integration + +- The build matrix now includes PHP 8.6. +- The Psalm job runs on PHP 8.5 and installs Psalm from `tools/psalm`. + +## Housekeeping + +- `phpunit.xml.dist` renamed to `phpunit.xml`. diff --git a/composer.json b/composer.json index 311b063..9be1d6c 100644 --- a/composer.json +++ b/composer.json @@ -14,15 +14,14 @@ "prefer-stable": true, "minimum-stability": "dev", "require": { - "php": ">=8.3 <8.6", + "php": ">=8.3 <8.7", "ext-json": "*", "ext-pdo": "*", "byjg/anydataset-db": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^10.5|^11.5", - "byjg/cache-engine": "^6.0", - "vimeo/psalm": "^5.9|^6.13" + "phpunit/phpunit": "^12.5", + "byjg/cache-engine": "^7.0" }, "suggest": { "ext-curl": "*", @@ -30,7 +29,10 @@ }, "scripts": { "test": "vendor/bin/phpunit", - "psalm": "vendor/bin/psalm --threads=1" + "psalm": [ + "@composer --working-dir=tools/psalm update --no-interaction", + "tools/psalm/vendor/bin/psalm --threads=1" + ] }, "license": "MIT" } diff --git a/phpunit.xml.dist b/phpunit.xml similarity index 100% rename from phpunit.xml.dist rename to phpunit.xml diff --git a/psalm.xml b/psalm.xml index ca6a5ef..5da448b 100644 --- a/psalm.xml +++ b/psalm.xml @@ -7,7 +7,7 @@ cacheDirectory="/tmp/psalm" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" - xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" + xsi:schemaLocation="https://getpsalm.org/schema/config tools/psalm/vendor/vimeo/psalm/config.xsd" > diff --git a/tools/psalm/composer.json b/tools/psalm/composer.json new file mode 100644 index 0000000..197dd5f --- /dev/null +++ b/tools/psalm/composer.json @@ -0,0 +1,5 @@ +{ + "require": { + "vimeo/psalm": "^6.16" + } +} From 69b5e597ba7bd0fd506f64f2358cecbc25035522 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 14 Sep 2026 12:08:26 -0400 Subject: [PATCH 07/18] Fix the license badge link and drop the hand-written dependency graph licensing.html returns 404; the page is /license/. The Dependencies mermaid drifted from composer.json, which is now what the site's graph is built from (collect-deps.py in add-doc). --- README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/README.md b/README.md index a1fdf9c..09d5d2a 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This library intended to be very small and very simple to use; [![Build Status](https://github.com/byjg/php-micro-orm/actions/workflows/phpunit.yml/badge.svg?branch=master)](https://github.com/byjg/php-micro-orm/actions/workflows/phpunit.yml) [![Opensource ByJG](https://img.shields.io/badge/opensource-byjg-success.svg)](http://opensource.byjg.com) [![GitHub source](https://img.shields.io/badge/Github-source-informational?logo=github)](https://github.com/byjg/php-micro-orm/) -[![GitHub license](https://img.shields.io/github/license/byjg/php-micro-orm.svg)](https://opensource.byjg.com/opensource/licensing.html) +[![GitHub license](https://img.shields.io/github/license/byjg/php-micro-orm.svg)](https://opensource.byjg.com/license/) [![GitHub release](https://img.shields.io/github/release/byjg/php-micro-orm.svg)](https://github.com/byjg/php-micro-orm/releases/) Key Features: @@ -225,13 +225,5 @@ composer require "byjg/micro-orm" * [USDocker](https://github.com/usdocker/usdocker) * [Serializer](https://github.com/byjg/serializer) -## Dependencies - -```mermaid -flowchart TD - byjg/micro-orm --> byjg/anydataset-db - byjg/micro-orm --> ext-json -``` - ---- [Open source ByJG](http://opensource.byjg.com) \ No newline at end of file From 1673052f5f85a23abe038feb433260a7197d18e6 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 17 Sep 2026 09:36:38 -0400 Subject: [PATCH 08/18] Remove CONTRIBUTING.md in favour of the byjg/.github default This copy came from a template shared by eleven repositories: its Issues link was the placeholder github.com/your-repo/issues, and nothing in it was specific to this repository. GitHub now serves the account-wide CONTRIBUTING.md from byjg/.github, which links the guide on opensource.byjg.com. --- CONTRIBUTING.md | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index be68259..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,37 +0,0 @@ -# Contributing to byjg/php-micro-orm - -First of all, thank you for taking the time to contribute! - -## How to Contribute - -### Issues - -If you encounter any issues, have questions, or need clarification, please open an issue on our [Issues page](https://github.com/your-repo/issues). This helps us track and prioritize bug fixes and enhancements. - -### Branches - -We have three main branches in this project: - -- **master**: Contains the latest code. It is generally stable, but we recommend using it with caution. -- **a.b**: Use this branch for creating PRs. The naming convention follows `a.b`, where `a` is the major release and `b` is the minor release of the current version. For example, if the current release is 4.9.2, use the branch `4.9` for your PR. You can also use `4.9.x-dev` in your composer for development purposes. -- **future release**: This branch is typically `(a+1).0`. For instance, if the current release is 4.9.2, the future release branch will be `5.0`. - - -### Code Style and Guidelines - -- **Follow PSR Standards**: We follow [PSR-1](https://www.php-fig.org/psr/psr-1/), [PSR-2](https://www.php-fig.org/psr/psr-2/), and [PSR-12](https://www.php-fig.org/psr/psr-12/). -- **Write Clear Commit Messages**: Use the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. -- **Documentation**: Update the documentation for any new features or changes. - -### Common Practices - -- **Keep Pull Requests Small**: Smaller PRs are easier to review and merge. Focus on one feature or fix per PR. -- **Write Tests**: Ensure your changes are covered by tests. We aim for a high level of test coverage. -- **Respect Reviewers' Time**: Be responsive to feedback and willing to make necessary changes. - -### Community - -- **Be Respectful**. -- **Collaborate**: We encourage collaboration and open discussion. Don’t hesitate to ask for help or provide feedback. - -Thank you for contributing to byjg/php-micro-orm! Your help is appreciated and makes a big difference. From f38bf046d54e7e3e7b47ab1794f202fdd2ac5875 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 9 Oct 2026 15:55:23 -0400 Subject: [PATCH 09/18] Align with the 7.0 standard - Install Psalm as psalm/phar instead of the tools/psalm project - MapperFunctionsTest stubs DatabaseExecutor instead of mocking it without expectations (two PHPUnit notices) - phpunit.xml: PHPUnit 12.5 schema and failOnPhpunitDeprecation - Workflow: actions/checkout@v7 - README and docs: Installation and Running Tests sections, sidebar positions, https links - Remove FUNDING.yml (comes from byjg/.github), IDE files and test.php - CHANGELOG-7.0.md: in-development banner --- .github/FUNDING.yml | 3 --- .github/workflows/phpunit.yml | 11 +++------- .gitignore | 1 + .gitpod.yml | 28 ------------------------ .vscode/launch.json | 35 ------------------------------ CHANGELOG-7.0.md | 21 ++++++++++-------- README.md | 7 +++--- composer.json | 8 +++---- docs/architecture-layers.md | 4 ++++ docs/comparison-with-other-orms.md | 4 ++++ docs/query-raw.md | 4 ++++ phpunit.xml | 3 ++- psalm.xml | 2 +- test.php | 16 -------------- tests/MapperFunctionsTest.php | 4 ++-- tools/psalm/composer.json | 5 ----- 16 files changed, 40 insertions(+), 116 deletions(-) delete mode 100644 .github/FUNDING.yml delete mode 100644 .gitpod.yml delete mode 100644 .vscode/launch.json delete mode 100644 test.php delete mode 100644 tools/psalm/composer.json diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 3a9251f..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,3 +0,0 @@ -# These are supported funding model platforms - -github: byjg diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 4e4ff62..50c1bac 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -41,7 +41,7 @@ jobs: MYSQL_TEST_HOST: mysql steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - run: composer install - run: composer test @@ -57,20 +57,15 @@ jobs: steps: - name: Git checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Composer run: composer install - - name: Composer (psalm) - - run: composer --working-dir=tools/psalm update --no-interaction - - - name: Psalm # Note: Ignoring error code 2, which just signals that some # flaws were found, not that Psalm itself failed to run. - run: ./tools/psalm/vendor/bin/psalm + run: ./vendor/bin/psalm.phar --show-info=true --report=psalm-results.sarif || [ $? = 2 ] diff --git a/.gitignore b/.gitignore index 0786d04..03e0209 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ vendor !.idea/runConfigurations .phpunit.result.cache phpunit.xml.dist.bak +.vscode diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index 91893fd..0000000 --- a/.gitpod.yml +++ /dev/null @@ -1,28 +0,0 @@ -tasks: - - name: Run Composer - command: | - composer install - -image: byjg/gitpod-image:latest - -jetbrains: - phpstorm: - vmoptions: '-Xmx4g' - plugins: - - com.github.copilot - - com.intellij.kubernetes - - com.intellij.mermaid - - ru.adelf.idea.dotenv - - org.toml.lang - -vscode: - extensions: - - ikappas.composer - - hbenl.test-adapter-converter - - hbenl.vscode-test-explorer - - felixfbecker.php-debug - - neilbrayfield.php-docblocker - - bmewburn.vscode-intelephense-client - - getpsalm.psalm-vscode-plugin - - SonarSource.sonarlint-vscode - - recca0120.vscode-phpunit \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index ade1bf7..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Debug current Script in Console", - "type": "php", - "request": "launch", - "program": "${file}", - "cwd": "${fileDirname}", - "port": 9003, - "runtimeArgs": [ - "-dxdebug.start_with_request=yes" - ], - "env": { - "XDEBUG_MODE": "debug,develop", - "XDEBUG_CONFIG": "client_port=${port}" - } - }, - { - "name": "PHPUnit Debug", - "type": "php", - "request": "launch", - "program": "${workspaceFolder}/vendor/bin/phpunit", - "cwd": "${workspaceFolder}", - "port": 9003, - "runtimeArgs": [ - "-dxdebug.start_with_request=yes" - ], - "env": { - "XDEBUG_MODE": "debug,develop", - "XDEBUG_CONFIG": "client_port=${port}" - } - } - ] -} \ No newline at end of file diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 4a15bdf..737a68a 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -1,5 +1,8 @@ # Changelog - Version 7.0 +> **Status: in development.** This document tracks changes landing on the `7.0` branch. +> Nothing here is released yet, and the contents may still change. + ## Overview Version 7.0 rewires the entire observer system on top of the low-level `DatabaseExecutor` observer @@ -123,22 +126,22 @@ While 7.0 is unreleased these resolve to `7.0.x-dev` from each component's ## Toolchain - PHPUnit updated to `^12.5`. -- Psalm moved out of `require-dev` into its own manifest, `tools/psalm/composer.json`. - - Psalm enumerates the PHP versions it supports and no published release lists - 8.6. As a dev dependency it made `composer install` fail on the 8.6 build job - before any test ran. It now installs separately, only for the Psalm job. +- Psalm is installed as `psalm/phar` instead of `vimeo/psalm`. - `composer psalm` still works — it bootstraps the tool and runs it. + `vimeo/psalm` lists the PHP versions it supports and no published release includes + 8.6, so as a dev dependency it made `composer install` fail on the 8.6 build job + before any test ran. `psalm/phar` requires only `php ^8.2` and bundles its own + dependencies, so it installs on every PHP version in the matrix and cannot conflict + with the project's. Psalm itself still refuses to *run* on 8.6, which is why the + Psalm job uses 8.5. `composer psalm` runs it. - PHPUnit 13 is deliberately **not** used. It requires PHP `>=8.4.1`, breaking the - 8.3 floor, and needs `sebastian/diff ^9.0`, which stable Psalm 6.16.1 rejects — - a combination that silently resolves Psalm to an unreleased `6.x-dev` branch. + 8.3 floor. ## Continuous Integration - The build matrix now includes PHP 8.6. -- The Psalm job runs on PHP 8.5 and installs Psalm from `tools/psalm`. +- The Psalm job runs on PHP 8.5. ## Housekeeping diff --git a/README.md b/README.md index 09d5d2a..3c1be3d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ This library intended to be very small and very simple to use; [![Sponsor](https://img.shields.io/badge/Sponsor-%23ea4aaa?logo=githubsponsors&logoColor=white&labelColor=0d1117)](https://github.com/sponsors/byjg) [![Build Status](https://github.com/byjg/php-micro-orm/actions/workflows/phpunit.yml/badge.svg?branch=master)](https://github.com/byjg/php-micro-orm/actions/workflows/phpunit.yml) -[![Opensource ByJG](https://img.shields.io/badge/opensource-byjg-success.svg)](http://opensource.byjg.com) +[![Opensource ByJG](https://img.shields.io/badge/opensource-byjg-success.svg)](https://opensource.byjg.com) +[![Install MCP Server](https://img.shields.io/badge/Install-MCP_Server-8A2BE2?logo=modelcontextprotocol&logoColor=white)](https://opensource.byjg.com/docs/ai/mcpserver-byjg-docs/) [![GitHub source](https://img.shields.io/badge/Github-source-informational?logo=github)](https://github.com/byjg/php-micro-orm/) [![GitHub license](https://img.shields.io/github/license/byjg/php-micro-orm.svg)](https://opensource.byjg.com/license/) [![GitHub release](https://img.shields.io/github/release/byjg/php-micro-orm.svg)](https://github.com/byjg/php-micro-orm/releases/) @@ -203,7 +204,7 @@ $result = $repository->getByQuery($query); * [Building SQL Queries](docs/query-build.md) * [UUID Support](docs/uuid-support.md) -## Install +## Installation Just type: @@ -226,4 +227,4 @@ composer require "byjg/micro-orm" * [Serializer](https://github.com/byjg/serializer) ---- -[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file +[Open source ByJG](https://opensource.byjg.com) \ No newline at end of file diff --git a/composer.json b/composer.json index 9be1d6c..31688ff 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ }, "require-dev": { "phpunit/phpunit": "^12.5", - "byjg/cache-engine": "^7.0" + "byjg/cache-engine": "^7.0", + "psalm/phar": "^6.16" }, "suggest": { "ext-curl": "*", @@ -29,10 +30,7 @@ }, "scripts": { "test": "vendor/bin/phpunit", - "psalm": [ - "@composer --working-dir=tools/psalm update --no-interaction", - "tools/psalm/vendor/bin/psalm --threads=1" - ] + "psalm": "vendor/bin/psalm.phar" }, "license": "MIT" } diff --git a/docs/architecture-layers.md b/docs/architecture-layers.md index e7ec012..6e7da72 100644 --- a/docs/architecture-layers.md +++ b/docs/architecture-layers.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 20 +--- + # Architecture Layers: Infrastructure vs Domain ## Overview diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md index 7cb25fb..bb64a61 100644 --- a/docs/comparison-with-other-orms.md +++ b/docs/comparison-with-other-orms.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 21 +--- + # MicroORM vs Other ORMs ## Overview diff --git a/docs/query-raw.md b/docs/query-raw.md index d9d86cb..db663a0 100644 --- a/docs/query-raw.md +++ b/docs/query-raw.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 19 +--- + # QueryRaw QueryRaw lets you execute a raw SQL statement while still benefiting from the repository pipeline (parameter binding, diff --git a/phpunit.xml b/phpunit.xml index a25ebca..d605383 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,9 +17,10 @@ and open the template in the editor. failOnDeprecation="true" failOnNotice="true" failOnWarning="true" + failOnPhpunitDeprecation="true" stopOnFailure="false" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"> + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"> diff --git a/psalm.xml b/psalm.xml index 5da448b..5d2dfdf 100644 --- a/psalm.xml +++ b/psalm.xml @@ -7,7 +7,7 @@ cacheDirectory="/tmp/psalm" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" - xsi:schemaLocation="https://getpsalm.org/schema/config tools/psalm/vendor/vimeo/psalm/config.xsd" + xsi:schemaLocation="https://getpsalm.org/schema/config https://raw.githubusercontent.com/vimeo/psalm/6.x/config.xsd" > diff --git a/test.php b/test.php deleted file mode 100644 index fd91504..0000000 --- a/test.php +++ /dev/null @@ -1,16 +0,0 @@ -with('Y-m-d H:i:s') ->willReturn('2024-01-15 12:30:45'); - $executorMock = $this->createMock(DatabaseExecutor::class); + $executorMock = $this->createStub(DatabaseExecutor::class); $executorMock->method('getHelper') ->willReturn($dbHelperMock); @@ -120,7 +120,7 @@ public function testNowUtcMapperIgnoresInputValue() ->with('Y-m-d H:i:s') ->willReturn('2024-01-15 12:30:45'); - $executorMock = $this->createMock(DatabaseExecutor::class); + $executorMock = $this->createStub(DatabaseExecutor::class); $executorMock->method('getHelper') ->willReturn($dbHelperMock); diff --git a/tools/psalm/composer.json b/tools/psalm/composer.json deleted file mode 100644 index 197dd5f..0000000 --- a/tools/psalm/composer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "require": { - "vimeo/psalm": "^6.16" - } -} From ceb1c2e2f5468c8e9051f8be3a40b9e205843787 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 12 Oct 2026 22:21:13 -0400 Subject: [PATCH 10/18] Rename Enums to use PascalCase as standard --- CHANGELOG-7.0.md | 2 +- docs/active-record.md | 2 +- docs/comparison-with-other-orms.md | 2 +- docs/observers.md | 6 +++--- docs/repository.md | 2 +- docs/updating-the-database.md | 2 +- src/ObserverBridge.php | 6 +++--- src/OrmEventContext.php | 2 +- src/Repository.php | 6 +++--- tests/ObserverBridgeTest.php | 8 ++++---- tests/QueryTest.php | 8 ++++---- tests/RepositoryTest.php | 6 +++--- 12 files changed, 26 insertions(+), 26 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 737a68a..5e6295d 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -42,7 +42,7 @@ no observers are registered), and multi-connection safe. ### 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 + repository executors (receives `BeforeQuery`/`AfterQuery` and `BeforeExecute`/`AfterExecute` for every statement, including reads and raw SQL) - New `Repository::removeObserver()` for both observer kinds diff --git a/docs/active-record.md b/docs/active-record.md index 624ae8d..bfc40ca 100644 --- a/docs/active-record.md +++ b/docs/active-record.md @@ -142,7 +142,7 @@ $results = $query->toArray(); ### Complex Filtering (Legacy) ```php -$myClassList = MyClass::filter((new IteratorFilter())->and('someProperty', Relation::EQUAL, 123)); +$myClassList = MyClass::filter((new IteratorFilter())->and('someProperty', Relation::Equal, 123)); foreach ($myClassList as $myClass) { echo $myClass->someProperty; } diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md index bb64a61..d8c76b3 100644 --- a/docs/comparison-with-other-orms.md +++ b/docs/comparison-with-other-orms.md @@ -819,7 +819,7 @@ $user->name = 'New Name'; $user->save(); $activeUsers = User::filter( - (new IteratorFilter())->and('status', Relation::EQUAL, 'active') + (new IteratorFilter())->and('status', Relation::Equal, 'active') ); ``` diff --git a/docs/observers.md b/docs/observers.md index bd8518b..d4fd2e4 100644 --- a/docs/observers.md +++ b/docs/observers.md @@ -16,7 +16,7 @@ executed directly on the executor. ```mermaid flowchart TD A[MyRepository] --> |1. addObserver| B[ObserverBridge attached to the DatabaseExecutor] - C[Any write through the same executor] --> |2. BEFORE/AFTER_EXECUTE| B + C[Any write through the same executor] --> |2. BeforeExecute / AfterExecute| B B --> |3. Execute Callback| A ``` @@ -106,5 +106,5 @@ class MyObserver implements ObserverProcessorInterface, StatementHookInterface `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. +repository executors and receives the anydataset-db events (`BeforeQuery`, `AfterQuery`, +`BeforeExecute`, `AfterExecute`) for every statement, including reads and raw SQL. diff --git a/docs/repository.md b/docs/repository.md index 4e001fc..80175cb 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -128,7 +128,7 @@ use ByJG\AnyDataset\Core\IteratorFilter; use ByJG\AnyDataset\Core\Enum\Relation; $filter = new IteratorFilter(); -$filter->addRelation('name', Relation::EQUAL, 'John'); +$filter->addRelation('name', Relation::Equal, 'John'); $result = $repository->getByFilter($filter); ``` diff --git a/docs/updating-the-database.md b/docs/updating-the-database.md index ea3bbda..a4b1bf2 100644 --- a/docs/updating-the-database.md +++ b/docs/updating-the-database.md @@ -230,4 +230,4 @@ Notes: non-SELECT statements and safely renames parameters to avoid collisions; the trailing SELECT (if present) is executed separately. - If you need a specific transaction isolation level, pass it as the second argument, e.g., - `IsolationLevelEnum::SERIALIZABLE`. \ No newline at end of file + `IsolationLevelEnum::Serializable`. \ No newline at end of file diff --git a/src/ObserverBridge.php b/src/ObserverBridge.php index ea50be8..e032c06 100644 --- a/src/ObserverBridge.php +++ b/src/ObserverBridge.php @@ -111,7 +111,7 @@ public function removeProcessor(ObserverProcessorInterface $processor): void #[Override] public function subscribedEvents(): array { - return [DatabaseEventTypeEnum::BEFORE_EXECUTE, DatabaseEventTypeEnum::AFTER_EXECUTE]; + return [DatabaseEventTypeEnum::BeforeExecute, DatabaseEventTypeEnum::AfterExecute]; } #[Override] @@ -126,7 +126,7 @@ public function handleEvent(DatabaseEvent $event): void return; } - if ($event->getType() === DatabaseEventTypeEnum::BEFORE_EXECUTE) { + if ($event->getType() === DatabaseEventTypeEnum::BeforeExecute) { foreach ($this->processors[$table] as $observer) { $processor = $observer->getObservedProcessor(); if ($processor instanceof StatementHookInterface) { @@ -137,7 +137,7 @@ public function handleEvent(DatabaseEvent $event): void return; } - // AFTER_EXECUTE + // AfterExecute $context = $statement->getOrmContext(); $context->markExecuted(); if ($context->isDeferred()) { diff --git a/src/OrmEventContext.php b/src/OrmEventContext.php index 424ff02..314ed94 100644 --- a/src/OrmEventContext.php +++ b/src/OrmEventContext.php @@ -41,7 +41,7 @@ public function getOldEntity(): mixed } /** - * Postpone observer notification: at AFTER_EXECUTE the bridge parks itself + * Postpone observer notification: at AfterExecute 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). */ diff --git a/src/Repository.php b/src/Repository.php index af9a62d..24c8c68 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -460,7 +460,7 @@ public function filterIn(array|string|int|LiteralInterface $arrValues, string $f } $iteratorFilter = new IteratorFilter(); - $iteratorFilter->and($field, Relation::IN, $arrValues); + $iteratorFilter->and($field, Relation::In, $arrValues); return $this->getByFilter($iteratorFilter); } @@ -669,7 +669,7 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda /** * Build the statement and mark it for deferred observer notification: - * the bridges seeing AFTER_EXECUTE park themselves in the statement context + * the bridges seeing AfterExecute 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 @@ -789,7 +789,7 @@ protected function saveUpdatableInternal(mixed $instance): array * 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). + * and receives the low-level anydataset-db events (BeforeQuery, AfterQuery, BeforeExecute, AfterExecute). * * @throws InvalidArgumentException */ diff --git a/tests/ObserverBridgeTest.php b/tests/ObserverBridgeTest.php index 4dda2e8..2cf57e6 100644 --- a/tests/ObserverBridgeTest.php +++ b/tests/ObserverBridgeTest.php @@ -59,7 +59,7 @@ public function getObservedTable(): string } /** - * A processor that also hooks BEFORE_EXECUTE and can veto the write. + * A processor that also hooks BeforeExecute and can veto the write. */ class VetoObserver extends RecordingObserver implements StatementHookInterface { @@ -89,7 +89,7 @@ class RawRecordingObserver implements DatabaseEventObserverInterface #[Override] public function subscribedEvents(): array { - return [DatabaseEventTypeEnum::BEFORE_EXECUTE, DatabaseEventTypeEnum::AFTER_EXECUTE]; + return [DatabaseEventTypeEnum::BeforeExecute, DatabaseEventTypeEnum::AfterExecute]; } #[Override] @@ -323,8 +323,8 @@ public function testRawObserverPassthrough() $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); + $this->assertContains(DatabaseEventTypeEnum::BeforeExecute, $types); + $this->assertContains(DatabaseEventTypeEnum::AfterExecute, $types); } public function testAddDbDriverForWriteAttachesObservers() diff --git a/tests/QueryTest.php b/tests/QueryTest.php index f52ee7f..4359f90 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -106,7 +106,7 @@ public function testQueryBasic() ); $iteratorFilter = new IteratorFilter(); - $iteratorFilter->and('fld4', Relation::EQUAL, 40); + $iteratorFilter->and('fld4', Relation::Equal, 40); $this->object->where($iteratorFilter); $this->assertEquals( 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]), @@ -119,9 +119,9 @@ public function testQueryWhereIteratorFilter() $this->object->table('test'); $filter = IteratorFilter::getInstance() - ->and('fld1', Relation::EQUAL, 10) - ->and('fld2', Relation::EQUAL, 20) - ->or('fld1', Relation::EQUAL, 30); + ->and('fld1', Relation::Equal, 10) + ->and('fld2', Relation::Equal, 20) + ->or('fld1', Relation::Equal, 30); $this->object->where($filter); diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index cd21933..7d02f20 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -184,7 +184,7 @@ public function testGetByFilter() $this->assertEquals('2015-05-02 00:00:00', $users[0]->getCreatedate()); $filter = new IteratorFilter(); - $filter->and('id', Relation::EQUAL, 2); + $filter->and('id', Relation::Equal, 2); $users = $this->repository->getByFilter($filter); $this->assertCount(1, $users); $this->assertEquals(2, $users[0]->getId()); @@ -1470,7 +1470,7 @@ public function testMappingAttributeSoftDeleteAndGetByFilter() $infoRepository = new Repository($this->repository->getExecutor(), ModelWithAttributes::class); $iteratorFilter = new IteratorFilter(); - $iteratorFilter->and('iduser', Relation::EQUAL, 3); + $iteratorFilter->and('iduser', Relation::Equal, 3); $result = $infoRepository->getByFilter($iteratorFilter); $this->assertEquals(3, $result[0]->getPk()); @@ -1700,7 +1700,7 @@ public function testActiveRecordFilter() { ActiveRecordModel::initialize($this->repository->getExecutor()); - $model = ActiveRecordModel::filter((new IteratorFilter())->and('iduser', Relation::EQUAL, 1)); + $model = ActiveRecordModel::filter((new IteratorFilter())->and('iduser', Relation::Equal, 1)); $this->assertCount(2, $model); $this->assertEquals(1, $model[0]->getPk()); From cf997e3d21b4734ac9f2d9cb6f62d942f8dd5760 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 15 Oct 2026 18:45:48 -0400 Subject: [PATCH 11/18] Keep a primary key set on the instance when inserting Repository::save() replaced the primary key of a new instance: the table's seed function generated a new one, and without a seed function the last insert id read back after the insert took its place, so an id of 100 came back as 1. The given key is now inserted as is; a key is only generated or read back when it is empty. --- CHANGELOG-7.0.md | 6 ++++++ src/Repository.php | 23 +++++++++++++++-------- tests/RepositoryTest.php | 20 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 5e6295d..4d26f45 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -64,6 +64,12 @@ no observers are registered), and multi-connection safe. ## Breaking Changes +### Fixed: a primary key set on the instance is kept on insert +- `Repository::save()` replaced a primary key set on a new instance: the seed function of the table + attribute (e.g. `TableUuidPKAttribute`) generated a new one, and without a seed function the value + read back after the insert (the last insert id) took its place, so an `id` of 100 came back as 1. + The given key is now inserted as is; a key is only generated or read back when it is empty + ### ORMSubject removed - `ORMSubject` (global singleton) no longer exists. `Repository::addObserver()` keeps working with the same signature for `ObserverProcessorInterface` diff --git a/src/Repository.php b/src/Repository.php index 24c8c68..ddcc9ec 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -620,14 +620,21 @@ public function save(mixed $instance, UpdateConstraintInterface|array|null $upda // Execute the Insert or Update if ($isInsert) { - $keyGen = $this->getMapper()->generateKey($this->getExecutorWrite(), $instance) ?? []; - if (!empty($keyGen) && !is_array($keyGen)) { - $keyGen = [$keyGen]; - } - $position = 0; - foreach ($keyGen as $value) { - $array[$pkList[$position]] = $value; - $updatable->set($this->mapper->getPrimaryKey()[$position++], $value); + // A primary key set on the instance is inserted as given. Only an empty one is + // generated (seed function) or read back from the database (auto increment). + $givenKey = array_filter(array_map(fn ($pk) => $array[$pk] ?? null, $pkList), fn ($value) => !empty($value)); + if (!empty($givenKey)) { + $keyGen = $givenKey; + } else { + $keyGen = $this->getMapper()->generateKey($this->getExecutorWrite(), $instance) ?? []; + if (!empty($keyGen) && !is_array($keyGen)) { + $keyGen = [$keyGen]; + } + $position = 0; + foreach ($keyGen as $value) { + $array[$pkList[$position]] = $value; + $updatable->set($this->mapper->getPrimaryKey()[$position++], $value); + } } $sqlStatement = $this->prepareForDeferredNotify($updatable, $instance, $oldInstance); $keyReturned = $this->insert($sqlStatement, $keyGen); diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 7d02f20..a31ffe8 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -299,6 +299,26 @@ public function testInsert() $this->assertEquals('2015-08-09 00:00:00', $users2->getCreatedate()); } + public function testInsertWithAGivenKeyKeepsIt() + { + $users = new Users(); + $users->setId(100); + $users->setName('Given'); + $users->setCreatedate('2015-08-09'); + + $this->repository->save($users); + $this->assertEquals(100, $users->getId()); + + $this->assertEquals('Given', $this->repository->get(100)->getName()); + + // The auto increment still fills in the next one + $next = new Users(); + $next->setName('Next'); + $next->setCreatedate('2015-08-09'); + $this->repository->save($next); + $this->assertEquals(101, $next->getId()); + } + public function testInsertReadOnly() { $this->expectException(RepositoryReadOnlyException::class); From a1a09c5000d97d10c72b6c0c00a4ec3931790d9d Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 15 Oct 2026 18:45:48 -0400 Subject: [PATCH 12/18] Generate UUID keys in PHP and write them in each database's syntax TableUuidPKAttribute generates a version 7 UUID (time-ordered) in PHP and FieldUuidAttribute writes UUIDs in the syntax of the repository's database: X'...' for BINARY(16) on MySQL and SQLite, 0x... for BINARY(16) on SQL Server, and the quoted text for a native uuid on PostgreSQL. It used to write X'...' everywhere, which PostgreSQL and SQL Server do not accept. New HexUuidLiteral::v7() and HexUuidLiteral::forDatabase(). The MySQL, SQLite and PostgreSQL table attributes are deprecated aliases of TableUuidPKAttribute; the MySQL one called uuid_to_bin(uuid()), which failed on any other database. PostgresUuidLiteral now writes the quoted text form instead of an unquoted \x.... The UUID repository tests run on MySQL, SQLite, PostgreSQL and SQL Server (pdo_dblib and pdo_sqlsrv), which are added to docker-compose and CI. --- .github/workflows/phpunit.yml | 28 +++ CHANGELOG-7.0.md | 24 +++ docker-compose.yml | 28 +++ docs/model-attribute.md | 42 ++--- docs/uuid-support.md | 151 +++++++--------- src/Attributes/TableMySqlUuidPKAttribute.php | 24 +-- src/Attributes/TablePgsqlUuidPKAttribute.php | 20 +-- src/Attributes/TableSqliteUuidPKAttribute.php | 24 +-- src/Attributes/TableUuidPKAttribute.php | 8 +- src/Literal/HexUuidLiteral.php | 41 +++++ src/Literal/PostgresUuidLiteral.php | 22 ++- .../FormatUpdateUuidMapper.php | 6 +- tests/Model/UuidKey.php | 21 +++ tests/Model/UuidKeyWithMySqlAttribute.php | 21 +++ tests/MysqlRepositoryUuidKeyTest.php | 33 ++++ tests/PostgresRepositoryUuidKeyTest.php | 34 ++++ tests/PostgresUuidLiteralTest.php | 4 +- tests/RepositoryUuidKeyTestCase.php | 166 ++++++++++++++++++ tests/SqlServerDblibRepositoryUuidKeyTest.php | 37 ++++ .../SqlServerSqlsrvRepositoryUuidKeyTest.php | 37 ++++ tests/SqliteRepositoryUuidKeyTest.php | 34 ++++ 21 files changed, 630 insertions(+), 175 deletions(-) create mode 100644 tests/Model/UuidKey.php create mode 100644 tests/Model/UuidKeyWithMySqlAttribute.php create mode 100644 tests/MysqlRepositoryUuidKeyTest.php create mode 100644 tests/PostgresRepositoryUuidKeyTest.php create mode 100644 tests/RepositoryUuidKeyTestCase.php create mode 100644 tests/SqlServerDblibRepositoryUuidKeyTest.php create mode 100644 tests/SqlServerSqlsrvRepositoryUuidKeyTest.php create mode 100644 tests/SqliteRepositoryUuidKeyTest.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 50c1bac..28e142d 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -37,11 +37,39 @@ jobs: --health-timeout=20s --health-retries=10 + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + sqlserver: + # Pinned to 2022: the 2025 image ("latest") crashes on startup on hybrid + # CPUs with a topology assert (see docker-compose.yml) + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: Pa55word + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -U sa -P Pa55word -Q 'SELECT 1'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: MYSQL_TEST_HOST: mysql + PSQL_TEST_HOST: postgres + MSSQL_TEST_HOST: sqlserver steps: - uses: actions/checkout@v7 + - name: Install pdo_dblib for the SQL Server tests + run: apk add --no-cache ${PHP_VARIANT}-pdo_dblib - run: composer install - run: composer test diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 4d26f45..c0b1c1a 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -62,6 +62,21 @@ no observers are registered), and multi-connection safe. resolution - See: `docs/auto-discovering-relationship.md` +### UUID primary keys on every database +- `TableUuidPKAttribute` generates a version 7 UUID (time-ordered, RFC 9562) in PHP and writes it + in the syntax of the repository's database: `X'...'` for a `BINARY(16)` on MySQL and SQLite, + `0x...` for a `BINARY(16)` on SQL Server, and the quoted text for a native `uuid` on PostgreSQL +- `FieldUuidAttribute` writes the same per-database syntax, also for a `HexUuidLiteral` created + without knowing the database (e.g. in a setter). It used to write `X'...'` everywhere, which + PostgreSQL reads as a bit string and SQL Server rejects +- New `HexUuidLiteral::v7()` returns a new version 7 UUID, and `HexUuidLiteral::forDatabase($executor, $uuid)` + returns the literal for the executor's database, for manual `where()` clauses +- `TableMySqlUuidPKAttribute`, `TableSqliteUuidPKAttribute` and `TablePgsqlUuidPKAttribute` are + deprecated aliases of `TableUuidPKAttribute`. The MySQL one asked MySQL for + `uuid_to_bin(uuid())`, so it failed on any other database; the SQLite one asked SQLite for + `randomblob(16)`, a round trip for what PHP generates itself +- See: `docs/uuid-support.md` + ## Breaking Changes ### Fixed: a primary key set on the instance is kept on insert @@ -70,6 +85,12 @@ no observers are registered), and multi-connection safe. read back after the insert (the last insert id) took its place, so an `id` of 100 came back as 1. The given key is now inserted as is; a key is only generated or read back when it is empty +### UUID literals +- `PostgresUuidLiteral` writes `'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'`, the text form a `uuid` + column takes. It wrote `\x...` without quotes, which is not valid SQL +- The keys generated by the UUID table attributes are version 7 UUIDs. `TableMySqlUuidPKAttribute` + generated version 1 (MySQL's `uuid()`), and the others 128 random bits with no version + ### ORMSubject removed - `ORMSubject` (global singleton) no longer exists. `Repository::addObserver()` keeps working with the same signature for `ObserverProcessorInterface` @@ -146,6 +167,9 @@ While 7.0 is unreleased these resolve to `7.0.x-dev` from each component's ## Continuous Integration +- The tests also run on PostgreSQL and SQL Server (through `pdo_dblib`), which are added to + `docker-compose.yml` and to the workflow. The `pdo_sqlsrv` test runs when the extension is + installed. - The build matrix now includes PHP 8.6. - The Psalm job runs on PHP 8.5. diff --git a/docker-compose.yml b/docker-compose.yml index 072f693..146a387 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,3 +11,31 @@ services: timeout: 20s interval: 10s retries: 10 + + postgres: + image: postgres + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=password + ports: + - "5432:5432" + healthcheck: + test: [ "CMD", "pg_isready", "-q", "-h", "localhost", "-U", "postgres" ] + timeout: 20s + interval: 10s + retries: 10 + + sqlserver: + # Pinned to 2022: the SQL Server 2025 image (current "latest") crashes on startup on + # hybrid CPUs (e.g. Intel P/E-cores) with a topology assert. 2019/2022 are not affected. + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + - ACCEPT_EULA=Y + - MSSQL_SA_PASSWORD=Pa55word + ports: + - "1433:1433" + healthcheck: + test: ["CMD", "/opt/mssql-tools18/bin/sqlcmd", "-C", "-S", "localhost,1433", "-U", "sa", "-P", "Pa55word", "-Q", "SELECT 1"] + timeout: 20s + interval: 10s + retries: 10 diff --git a/docs/model-attribute.md b/docs/model-attribute.md index dd861db..03a93c7 100644 --- a/docs/model-attribute.md +++ b/docs/model-attribute.md @@ -104,21 +104,19 @@ See also [Controlling the Data](controlling-the-data.md) for more information ab ## Special Table Attributes for UUID primary keys -MicroOrm provides specialized table attributes for tables with UUID primary keys: +`TableUuidPKAttribute` is the table attribute for a table whose primary key is a UUID. On insert it generates a +version 7 UUID (time-ordered) in PHP and writes it in the syntax of the repository's database. It works on MySQL, +SQLite, PostgreSQL and SQL Server. -* `TableUuidPKAttribute` - Base class for UUID primary key tables (generic implementation) -* `TableMySqlUuidPKAttribute` - Specific implementation for MySQL databases with UUID primary keys -* `TableSqliteUuidPKAttribute` - Specific implementation for SQLite databases with UUID primary keys - -These attributes automatically configure the primary key generation for UUID fields. They handle the creation of binary -UUID values in a format appropriate for the specific database engine. +`TableMySqlUuidPKAttribute`, `TableSqliteUuidPKAttribute` and `TablePgsqlUuidPKAttribute` are deprecated aliases of +`TableUuidPKAttribute`, kept so existing models keep working. ### Using UUID attributes Here's an example of how to use the UUID attributes: ```php -#[TableSqliteUuidPKAttribute(tableName: 'my_table')] +#[TableUuidPKAttribute(tableName: 'my_table')] class MyModelWithUuid { #[FieldUuidAttribute(primaryKey: true)] @@ -131,47 +129,41 @@ class MyModelWithUuid In this example: -- `TableSqliteUuidPKAttribute` sets up the model to work with SQLite's UUID handling +- `TableUuidPKAttribute` generates the UUID for new records - `FieldUuidAttribute` marks the `$id` field as a UUID primary key -- The library will automatically generate UUID values for new records ### Field UUID Attribute The `FieldUuidAttribute` is a specialized field attribute designed for UUID fields. It internally configures the -appropriate mapper functions for handling binary UUID values: +appropriate mapper functions for handling UUID values: -- For reading from the database: Uses `FormatSelectUuidMapper` to convert binary UUID to string format -- For writing to the database: Uses `FormatUpdateUuidMapper` to convert string UUID to binary format +- For reading from the database: Uses `FormatSelectUuidMapper` to convert the stored UUID to string format +- For writing to the database: Uses `FormatUpdateUuidMapper` to convert the UUID to the literal of the database ### Database Schema Requirements -To use UUID primary keys, you need to define your table with a binary field for the UUID. For example: +Define the UUID column as `BINARY(16)`, or as `uuid` on PostgreSQL: ```sql --- SQLite example +-- MySQL, SQLite and SQL Server CREATE TABLE my_table ( id BINARY(16) PRIMARY KEY, name VARCHAR(45) ); --- MySQL example +-- PostgreSQL CREATE TABLE my_table ( - id BINARY(16) PRIMARY KEY, + id UUID PRIMARY KEY, name VARCHAR(45) ); ``` ### UUID Literals -The library provides specialized literal classes for handling UUID values in different database engines: - -- `HexUuidLiteral` - Base class for UUID literals -- `MySqlUuidLiteral` - MySQL-specific UUID literal -- `SqliteUuidLiteral` - SQLite-specific UUID literal -- `PostgresUuidLiteral` - PostgreSQL-specific UUID literal - -These literals help ensure the UUID values are properly formatted for each database engine. +Each database writes a UUID its own way: `HexUuidLiteral` (`X'...'`, MySQL and SQLite), `SqlServerUuidLiteral` +(`0x...`) and `PostgresUuidLiteral` (`'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'`). `HexUuidLiteral::forDatabase()` +returns the right one for an executor. See [UUID Support](uuid-support.md). diff --git a/docs/uuid-support.md b/docs/uuid-support.md index ebee9ef..a5b3605 100644 --- a/docs/uuid-support.md +++ b/docs/uuid-support.md @@ -21,7 +21,7 @@ MicroOrm handles the complexities of working with UUIDs, including: - Converting between string UUID representations and binary database storage - Generating new UUIDs for primary keys automatically -- Database-specific UUID handling (MySQL, PostgreSQL, SQLite) +- Database-specific UUID syntax (MySQL, SQLite, PostgreSQL, SQL Server) - Automatic formatting when reading from and writing to the database - Support for UUID fields beyond just primary keys @@ -29,51 +29,45 @@ MicroOrm handles the complexities of working with UUIDs, including: ### 1. Database Schema -First, set up your database table with a binary field to store the UUID: +Store the UUID as 16 bytes, or in PostgreSQL's native `uuid` type: ```sql --- MySQL example +-- MySQL and SQLite CREATE TABLE my_table ( id BINARY(16) PRIMARY KEY, -- Binary format is more efficient than VARCHAR - name VARCHAR(100), - -- other fields + name VARCHAR(100) ); --- SQLite example +-- SQL Server CREATE TABLE my_table ( id BINARY(16) PRIMARY KEY, - name VARCHAR(100), - -- other fields + name VARCHAR(100) ); --- PostgreSQL example (can use native UUID type) +-- PostgreSQL CREATE TABLE my_table ( id UUID PRIMARY KEY, - name VARCHAR(100), - -- other fields + name VARCHAR(100) ); ``` +On SQL Server use `BINARY(16)` rather than `UNIQUEIDENTIFIER`: SQL Server reorders the first 8 bytes of a +`UNIQUEIDENTIFIER`, so the value read back would not be the one written. + ### 2. Model Definition -Then define your model using the appropriate UUID attributes: +Then define your model with `TableUuidPKAttribute` and `FieldUuidAttribute`. The same model works on every database: ```php getExecutor(); -// ✓ CORRECT - Wrap in HexUuidLiteral for WHERE clauses +// ✓ CORRECT - The literal for the repository's database $query = $repository->getMapper()->getQuery(); -$query->where('id = :id', ['id' => new HexUuidLiteral($uuid)]); +$query->where('id = :id', ['id' => HexUuidLiteral::forDatabase($executor, $uuid)]); $results = $repository->getByQuery($query); // ✓ Also correct for non-primary key UUID fields -$query->where('user_id = :user_id', ['user_id' => new HexUuidLiteral($userId)]); +$query->where('user_id = :user_id', ['user_id' => HexUuidLiteral::forDatabase($executor, $userId)]); // ✗ INCORRECT - This won't work correctly $query->where('id = :id', ['id' => $uuid]); // String won't match binary in DB @@ -228,49 +242,21 @@ $query->where('id = :id', ['id' => $uuid]); // String won't match binary in DB - **`where()` clauses**: These are generic and don't know which fields are UUIDs, so you must explicitly wrap UUID values in the appropriate literal class -### Database-Specific Literals - -You can use database-specific literal classes if needed: - -```php -use ByJG\MicroOrm\Literal\MySqlUuidLiteral; -use ByJG\MicroOrm\Literal\SqliteUuidLiteral; -use ByJG\MicroOrm\Literal\PostgresUuidLiteral; - -// MySQL - uses UUID_TO_BIN() -$query->where('id = :id', ['id' => new MySqlUuidLiteral($uuid)]); - -// PostgreSQL - uses ::uuid cast -$query->where('id = :id', ['id' => new PostgresUuidLiteral($uuid)]); - -// SQLite/Generic - uses hex format -$query->where('id = :id', ['id' => new HexUuidLiteral($uuid)]); -``` - ## Working with UUID Literals -UUID Literal classes help you work with UUIDs in raw SQL queries. Each database has its own literal format: +A UUID literal is written into the SQL as it is. Each database has its own syntax: -- `HexUuidLiteral` - Generic hexadecimal format: `X'550E8400E29B41D4A716446655440000'` -- `MySqlUuidLiteral` - MySQL-specific: `UUID_TO_BIN('550e8400-e29b-41d4-a716-446655440000')` -- `PostgresUuidLiteral` - PostgreSQL: `'550e8400-e29b-41d4-a716-446655440000'::uuid` -- `SqliteUuidLiteral` - SQLite hexadecimal format +| Class | Written as | Database / column | +|------------------------|--------------------------------------------|-------------------------------| +| `HexUuidLiteral` | `X'550E8400E29B41D4A716446655440000'` | MySQL, SQLite: `BINARY(16)` | +| `SqlServerUuidLiteral` | `0x550E8400E29B41D4A716446655440000` | SQL Server: `BINARY(16)` | +| `PostgresUuidLiteral` | `'550E8400-E29B-41D4-A716-446655440000'` | PostgreSQL: `uuid` | -### Example Usage +`MySqlUuidLiteral` and `SqliteUuidLiteral` are the same as `HexUuidLiteral`. -```php -use ByJG\MicroOrm\Literal\HexUuidLiteral; - -// Create a literal from a UUID string -$uuid = '550e8400-e29b-41d4-a716-446655440000'; -$literal = new HexUuidLiteral($uuid); - -// Use in queries -$query->where('id = :id', ['id' => $literal]); - -// The literal will be converted to the appropriate format for your database -// e.g., X'550E8400E29B41D4A716446655440000' for direct SQL -``` +`HexUuidLiteral::forDatabase($executor, $uuid)` returns the right one for the executor's database (and +`HexUuidLiteral` when the executor is `null`). Every class accepts a UUID in any of the formats listed under +[Supported Input Formats](#supported-input-formats), or another UUID literal. ## Format Helpers @@ -323,19 +309,18 @@ All formats are automatically detected and converted to the standard formatted s ## Database-Specific Notes -### MySQL +### MySQL and SQLite -MySQL doesn't have a native UUID type, so UUIDs are stored as BINARY(16). The `TableMySqlUuidPKAttribute` uses MySQL's -`UUID_TO_BIN()` and `BIN_TO_UUID()` functions. +Neither has a native UUID type, so UUIDs are stored as `BINARY(16)` and written as `X'...'`. -### SQLite +### PostgreSQL -SQLite also doesn't have a native UUID type. The `TableSqliteUuidPKAttribute` uses SQLite's `randomblob()` function to -generate UUIDs. +PostgreSQL has a native `uuid` type. The UUID is written as its quoted text form, which PostgreSQL converts to `uuid`, +and read back as text. -### PostgreSQL +### SQL Server -PostgreSQL has a native UUID type. The library provides support through the `PostgresUuidLiteral` class. +UUIDs are stored as `BINARY(16)` and written as `0x...`. Both `pdo_dblib` and `pdo_sqlsrv` are supported. ## Performance Considerations @@ -447,7 +432,7 @@ $order->setStatus('pending'); $repository->save($order); // ID is automatically generated -echo $order->getId(); // e.g., '3B4E18A0-C719-4ED0-C9B2-026A91889DC0' +echo $order->getId(); // e.g., '01A141AF-F7F3-7498-B30C-E7C6C3D82E68' // Query by UUID $found = $repository->get($order->getId()); diff --git a/src/Attributes/TableMySqlUuidPKAttribute.php b/src/Attributes/TableMySqlUuidPKAttribute.php index 1ddb4eb..b1fa4ea 100644 --- a/src/Attributes/TableMySqlUuidPKAttribute.php +++ b/src/Attributes/TableMySqlUuidPKAttribute.php @@ -3,26 +3,12 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\AnyDataset\Db\DatabaseExecutor; -use ByJG\MicroOrm\Interface\MapperFunctionInterface; -use ByJG\MicroOrm\Literal\Literal; -use InvalidArgumentException; -use Override; +/** + * @deprecated Use TableUuidPKAttribute, which generates the UUID in PHP and works on every + * database. This class only remains so existing models keep working. + */ #[Attribute(Attribute::TARGET_CLASS)] -class TableMySqlUuidPKAttribute extends TableAttribute implements MapperFunctionInterface +class TableMySqlUuidPKAttribute extends TableUuidPKAttribute { - public function __construct(string $tableName) - { - parent::__construct($tableName, primaryKeySeedFunction: $this); - } - - #[Override] - public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed - { - if ($executor === null) { - throw new InvalidArgumentException('DatabaseExecutor is required for TableMySqlUuidPKAttribute'); - } - return new Literal("X'" . $executor->getScalar("SELECT hex(uuid_to_bin(uuid()))") . "'"); - } } diff --git a/src/Attributes/TablePgsqlUuidPKAttribute.php b/src/Attributes/TablePgsqlUuidPKAttribute.php index 7caf84b..53cff08 100644 --- a/src/Attributes/TablePgsqlUuidPKAttribute.php +++ b/src/Attributes/TablePgsqlUuidPKAttribute.php @@ -3,22 +3,12 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\AnyDataset\Db\DatabaseExecutor; -use ByJG\MicroOrm\Interface\MapperFunctionInterface; -use Override; +/** + * @deprecated Use TableUuidPKAttribute, which generates the UUID in PHP and works on every + * database. This class only remains so existing models keep working. + */ #[Attribute(Attribute::TARGET_CLASS)] -class TablePgsqlUuidPKAttribute extends TableAttribute implements MapperFunctionInterface +class TablePgsqlUuidPKAttribute extends TableUuidPKAttribute { - public function __construct(string $tableName) - { - parent::__construct($tableName, primaryKeySeedFunction: $this); - } - - #[Override] - public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed - { - $bytes = random_bytes(16); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); - } } diff --git a/src/Attributes/TableSqliteUuidPKAttribute.php b/src/Attributes/TableSqliteUuidPKAttribute.php index fc4512b..f1b6ff1 100644 --- a/src/Attributes/TableSqliteUuidPKAttribute.php +++ b/src/Attributes/TableSqliteUuidPKAttribute.php @@ -3,26 +3,12 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\AnyDataset\Db\DatabaseExecutor; -use ByJG\MicroOrm\Interface\MapperFunctionInterface; -use ByJG\MicroOrm\Literal\Literal; -use InvalidArgumentException; -use Override; +/** + * @deprecated Use TableUuidPKAttribute, which generates the UUID in PHP and works on every + * database. This class only remains so existing models keep working. + */ #[Attribute(Attribute::TARGET_CLASS)] -class TableSqliteUuidPKAttribute extends TableAttribute implements MapperFunctionInterface +class TableSqliteUuidPKAttribute extends TableUuidPKAttribute { - public function __construct(string $tableName) - { - parent::__construct($tableName, primaryKeySeedFunction: $this); - } - - #[Override] - public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed - { - if ($executor === null) { - throw new InvalidArgumentException('DatabaseExecutor is required for TableSqliteUuidPKAttribute'); - } - return new Literal("X'" . $executor->getScalar("SELECT hex(randomblob(16))") . "'"); - } } diff --git a/src/Attributes/TableUuidPKAttribute.php b/src/Attributes/TableUuidPKAttribute.php index 67fba67..8806000 100644 --- a/src/Attributes/TableUuidPKAttribute.php +++ b/src/Attributes/TableUuidPKAttribute.php @@ -5,9 +5,13 @@ use Attribute; use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Interface\MapperFunctionInterface; -use ByJG\MicroOrm\Literal\Literal; +use ByJG\MicroOrm\Literal\HexUuidLiteral; use Override; +/** + * A table whose primary key is a UUID, generated in PHP on insert: a version 7 UUID + * (time-ordered), written in the syntax of the database (see HexUuidLiteral::forDatabase()). + */ #[Attribute(Attribute::TARGET_CLASS)] class TableUuidPKAttribute extends TableAttribute implements MapperFunctionInterface { @@ -19,6 +23,6 @@ public function __construct(string $tableName) #[Override] public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { - return new Literal("X'" . bin2hex(random_bytes(16)) . "'"); + return HexUuidLiteral::forDatabase($executor, HexUuidLiteral::v7()); } } diff --git a/src/Literal/HexUuidLiteral.php b/src/Literal/HexUuidLiteral.php index 6c2047a..edfaeee 100644 --- a/src/Literal/HexUuidLiteral.php +++ b/src/Literal/HexUuidLiteral.php @@ -2,6 +2,9 @@ namespace ByJG\MicroOrm\Literal; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\SqlDialect\DblibDialect; +use ByJG\AnyDataset\Db\SqlDialect\PgsqlDialect; use ByJG\MicroOrm\Exception\InvalidArgumentException; class HexUuidLiteral extends Literal @@ -16,6 +19,44 @@ public function __construct(LiteralInterface|string $value) parent::__construct($this->binaryString($value)); } + /** + * A new version 7 UUID (RFC 9562), formatted as XXXXXXXX-XXXX-7XXX-YXXX-XXXXXXXXXXXX. + * + * The first 48 bits are the Unix time in milliseconds and the rest is random, so a key + * created later sorts after the earlier ones and a new row goes to the end of the index. + * It tells when it was created: for a secret token use a random (version 4) UUID instead. + */ + public static function v7(): string + { + $bytes = substr(pack('J', (int)floor(microtime(true) * 1000.0)), 2) . random_bytes(10); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x70); // version 7 + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); // RFC 9562 variant + + return (string)self::getFormattedUuid($bytes); + } + + /** + * The UUID literal in the syntax of the executor's database: a native uuid on PostgreSQL, + * 0x... for a binary(16) on SQL Server, and X'...' for a binary(16) on MySQL and SQLite + * (also the default when there is no executor). + * + * @throws InvalidArgumentException + */ + public static function forDatabase(?DatabaseExecutor $executor, LiteralInterface|string $value): HexUuidLiteral + { + $dialect = $executor?->getHelper(); + $class = match (true) { + $dialect instanceof PgsqlDialect => PostgresUuidLiteral::class, + $dialect instanceof DblibDialect => SqlServerUuidLiteral::class, + default => HexUuidLiteral::class, + }; + + if ($value instanceof HexUuidLiteral && get_class($value) === $class) { + return $value; + } + return new $class($value); + } + public static function create(mixed $value): mixed { if ($value instanceof HexUuidLiteral || is_null($value)) { diff --git a/src/Literal/PostgresUuidLiteral.php b/src/Literal/PostgresUuidLiteral.php index 2ec3b94..a402759 100644 --- a/src/Literal/PostgresUuidLiteral.php +++ b/src/Literal/PostgresUuidLiteral.php @@ -2,16 +2,22 @@ namespace ByJG\MicroOrm\Literal; +use ByJG\MicroOrm\Exception\InvalidArgumentException; +use Override; + +/** + * A UUID for a native PostgreSQL uuid column, written as the quoted text form + * ('XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'), which PostgreSQL converts to uuid. + */ class PostgresUuidLiteral extends HexUuidLiteral { - public function __construct(LiteralInterface|string $value) + /** + * @throws InvalidArgumentException + */ + #[Override] + public function binaryString(LiteralInterface|string $value): string { - if ($value instanceof HexUuidLiteral) { - $value = $value->formattedUuid; - } - - $this->prefix = '\\x'; - $this->suffix = ''; - parent::__construct($value); + parent::binaryString($value); + return "'" . $this->formattedUuid . "'"; } } diff --git a/src/MapperFunctions/FormatUpdateUuidMapper.php b/src/MapperFunctions/FormatUpdateUuidMapper.php index 4b2ae33..7bdfeac 100644 --- a/src/MapperFunctions/FormatUpdateUuidMapper.php +++ b/src/MapperFunctions/FormatUpdateUuidMapper.php @@ -16,8 +16,10 @@ public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor if (empty($value)) { return null; } - if (!($value instanceof LiteralInterface)) { - $value = new HexUuidLiteral($value); + // A UUID literal made without knowing the database (e.g. new HexUuidLiteral() in a + // setter) is written in the syntax of the database it goes to. Other literals pass as is. + if ($value instanceof HexUuidLiteral || !($value instanceof LiteralInterface)) { + return HexUuidLiteral::forDatabase($executor, $value); } return $value; } diff --git a/tests/Model/UuidKey.php b/tests/Model/UuidKey.php new file mode 100644 index 0000000..ba88183 --- /dev/null +++ b/tests/Model/UuidKey.php @@ -0,0 +1,21 @@ +instance($value); @@ -28,7 +28,7 @@ public function testBinaryString() #[Override] public function testFormatUuidFromBinRepresent() { - $value = '\xF47AC10B58CC4372A5670E02B2C3D479'; + $value = "'f47ac10b-58cc-4372-a567-0e02b2c3d479'"; $expectedFormattedUuid = 'F47AC10B-58CC-4372-A567-0E02B2C3D479'; $hexUuidLiteral = $this->instance($value); diff --git a/tests/RepositoryUuidKeyTestCase.php b/tests/RepositoryUuidKeyTestCase.php new file mode 100644 index 0000000..a3f5ba7 --- /dev/null +++ b/tests/RepositoryUuidKeyTestCase.php @@ -0,0 +1,166 @@ +executor = DatabaseExecutor::using($this->createDriver()); + $this->executor->execute('DROP TABLE IF EXISTS uuidkey'); + $this->executor->execute('CREATE TABLE uuidkey (id ' . $this->uuidColumnType() . ' NOT NULL PRIMARY KEY, name VARCHAR(45), ref ' . $this->uuidColumnType() . ' NULL)'); + $this->repository = new Repository($this->executor, UuidKey::class); + } + + public function tearDown(): void + { + $this->executor->execute('DROP TABLE IF EXISTS uuidkey'); + } + + protected function insert(string $name, string $class = UuidKey::class): void + { + $repository = new Repository($this->executor, $class); + $model = new $class(); + $model->name = $name; + $repository->save($model); + } + + /** + * @return array name => id, as read back from the database + */ + protected function idsByName(): array + { + $ids = []; + foreach ($this->repository->getByQuery($this->repository->getMapper()->getQuery()) as $row) { + $ids[$row->name] = $row->id; + } + return $ids; + } + + /** + * What the database stores in $column for the row $name, read without the ORM. + */ + protected function stored(string $column, string $name): string + { + return (string)$this->executor->getScalar( + 'SELECT ' . $this->storedValueSql($column) . ' FROM uuidkey WHERE name = :name', + ['name' => $name] + ); + } + + public function testInsertGeneratesAVersion7Key(): void + { + $this->insert('first'); + $id = $this->idsByName()['first']; + + $this->assertMatchesRegularExpression( + '/^[0-9A-F]{8}-[0-9A-F]{4}-7[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/', + $id + ); + $this->assertSame($this->expectedStoredValue($id), $this->stored('id', 'first')); + } + + public function testAGivenUuidIsStoredInTheColumnTypeAndReadBackAsAString(): void + { + $model = new UuidKey(); + $model->id = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + $model->name = 'given'; + $model->ref = '550e8400-e29b-41d4-a716-446655440000'; + $this->repository->save($model); + + // The given key is kept, not replaced by a generated one + $this->assertSame('F47AC10B-58CC-4372-A567-0E02B2C3D479', $model->id); + $this->assertSame('550E8400-E29B-41D4-A716-446655440000', $model->ref); + + $this->assertSame($this->expectedStoredValue('F47AC10B-58CC-4372-A567-0E02B2C3D479'), $this->stored('id', 'given')); + $this->assertSame($this->expectedStoredValue('550E8400-E29B-41D4-A716-446655440000'), $this->stored('ref', 'given')); + + $found = $this->repository->get('F47AC10B-58CC-4372-A567-0E02B2C3D479'); + $this->assertSame('F47AC10B-58CC-4372-A567-0E02B2C3D479', $found->id); + $this->assertSame('550E8400-E29B-41D4-A716-446655440000', $found->ref); + } + + public function testAnEmptyUuidFieldStaysNull(): void + { + $this->insert('no-ref'); + + $this->assertNull($this->executor->getScalar("SELECT ref FROM uuidkey WHERE name = 'no-ref'")); + $this->assertNull($this->repository->get($this->idsByName()['no-ref'])->ref); + } + + public function testGetUpdateAndDeleteByTheFormattedId(): void + { + $this->insert('first'); + $this->insert('second'); + $id = $this->idsByName()['first']; + + $found = $this->repository->get($id); + $this->assertSame('first', $found->name); + $this->assertSame($id, $found->id); + + $found->name = 'renamed'; + $this->repository->save($found); + // The update changed the row in place; it did not insert a new one + $this->assertEqualsCanonicalizing(['renamed', 'second'], array_keys($this->idsByName())); + $this->assertSame('renamed', $this->repository->get($id)->name); + + $this->assertTrue($this->repository->delete($id)); + $this->assertEmpty($this->repository->get($id)); + $this->assertSame(['second'], array_keys($this->idsByName())); + } + + public function testKeysSortInCreationOrder(): void + { + foreach (['a', 'b', 'c'] as $name) { + $this->insert($name); + usleep(2000); // v7 orders by millisecond; within one millisecond the order is random + } + + $query = $this->repository->getMapper()->getQuery()->orderBy(['id']); + $names = array_map(fn ($row) => $row->name, $this->repository->getByQuery($query)); + + $this->assertSame(['a', 'b', 'c'], $names); + } + + public function testTheDeprecatedMySqlAttributeStillWorks(): void + { + $this->insert('legacy', UuidKeyWithMySqlAttribute::class); + + $id = $this->idsByName()['legacy']; + $this->assertSame('legacy', $this->repository->get($id)->name); + $this->assertSame($id, HexUuidLiteral::getFormattedUuid($id)); + } +} diff --git a/tests/SqlServerDblibRepositoryUuidKeyTest.php b/tests/SqlServerDblibRepositoryUuidKeyTest.php new file mode 100644 index 0000000..a9b27cf --- /dev/null +++ b/tests/SqlServerDblibRepositoryUuidKeyTest.php @@ -0,0 +1,37 @@ +markTestSkipped('The pdo_dblib extension is not loaded'); + } + return Factory::getDbInstance('dblib://sa:Pa55word@' . (getenv('MSSQL_TEST_HOST') ?: '127.0.0.1') . '/tempdb'); + } + + #[Override] + protected function uuidColumnType(): string + { + return 'binary(16)'; + } + + #[Override] + protected function storedValueSql(string $column): string + { + return 'concat(datalength(' . $column . '), \':\', convert(varchar(32), ' . $column . ', 2))'; + } + + #[Override] + protected function expectedStoredValue(string $uuid): string + { + return '16:' . str_replace('-', '', $uuid); + } +} diff --git a/tests/SqlServerSqlsrvRepositoryUuidKeyTest.php b/tests/SqlServerSqlsrvRepositoryUuidKeyTest.php new file mode 100644 index 0000000..489567b --- /dev/null +++ b/tests/SqlServerSqlsrvRepositoryUuidKeyTest.php @@ -0,0 +1,37 @@ +markTestSkipped('The pdo_sqlsrv extension is not loaded'); + } + return Factory::getDbInstance('sqlsrv://sa:Pa55word@' . (getenv('MSSQL_TEST_HOST') ?: '127.0.0.1') . '/tempdb'); + } + + #[Override] + protected function uuidColumnType(): string + { + return 'binary(16)'; + } + + #[Override] + protected function storedValueSql(string $column): string + { + return 'concat(datalength(' . $column . '), \':\', convert(varchar(32), ' . $column . ', 2))'; + } + + #[Override] + protected function expectedStoredValue(string $uuid): string + { + return '16:' . str_replace('-', '', $uuid); + } +} diff --git a/tests/SqliteRepositoryUuidKeyTest.php b/tests/SqliteRepositoryUuidKeyTest.php new file mode 100644 index 0000000..e04a990 --- /dev/null +++ b/tests/SqliteRepositoryUuidKeyTest.php @@ -0,0 +1,34 @@ + Date: Thu, 15 Oct 2026 22:06:31 -0400 Subject: [PATCH 13/18] Skip the UUID database tests before setUp() when the driver is missing The SQL Server tests called markTestSkipped() inside setUp(), so on a machine without the extension (CI has no pdo_sqlsrv) tearDown() still ran and used the executor setUp() never set: 6 errors. Each per-database test now declares #[RequiresPhpExtension], which PHPUnit checks before setUp(). --- tests/MysqlRepositoryUuidKeyTest.php | 2 ++ tests/PostgresRepositoryUuidKeyTest.php | 2 ++ tests/SqlServerDblibRepositoryUuidKeyTest.php | 5 ++--- tests/SqlServerSqlsrvRepositoryUuidKeyTest.php | 5 ++--- tests/SqliteRepositoryUuidKeyTest.php | 2 ++ 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/MysqlRepositoryUuidKeyTest.php b/tests/MysqlRepositoryUuidKeyTest.php index 27017e1..52baced 100644 --- a/tests/MysqlRepositoryUuidKeyTest.php +++ b/tests/MysqlRepositoryUuidKeyTest.php @@ -4,7 +4,9 @@ use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use Override; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +#[RequiresPhpExtension('pdo_mysql')] class MysqlRepositoryUuidKeyTest extends RepositoryUuidKeyTestCase { #[Override] diff --git a/tests/PostgresRepositoryUuidKeyTest.php b/tests/PostgresRepositoryUuidKeyTest.php index 4fbbf39..2e509d5 100644 --- a/tests/PostgresRepositoryUuidKeyTest.php +++ b/tests/PostgresRepositoryUuidKeyTest.php @@ -5,7 +5,9 @@ use ByJG\AnyDataset\Db\Factory; use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use Override; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +#[RequiresPhpExtension('pdo_pgsql')] class PostgresRepositoryUuidKeyTest extends RepositoryUuidKeyTestCase { #[Override] diff --git a/tests/SqlServerDblibRepositoryUuidKeyTest.php b/tests/SqlServerDblibRepositoryUuidKeyTest.php index a9b27cf..d1e03bd 100644 --- a/tests/SqlServerDblibRepositoryUuidKeyTest.php +++ b/tests/SqlServerDblibRepositoryUuidKeyTest.php @@ -5,15 +5,14 @@ use ByJG\AnyDataset\Db\Factory; use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use Override; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +#[RequiresPhpExtension('pdo_dblib')] class SqlServerDblibRepositoryUuidKeyTest extends RepositoryUuidKeyTestCase { #[Override] protected function createDriver(): DbDriverInterface { - if (!extension_loaded('pdo_dblib')) { - $this->markTestSkipped('The pdo_dblib extension is not loaded'); - } return Factory::getDbInstance('dblib://sa:Pa55word@' . (getenv('MSSQL_TEST_HOST') ?: '127.0.0.1') . '/tempdb'); } diff --git a/tests/SqlServerSqlsrvRepositoryUuidKeyTest.php b/tests/SqlServerSqlsrvRepositoryUuidKeyTest.php index 489567b..fca443b 100644 --- a/tests/SqlServerSqlsrvRepositoryUuidKeyTest.php +++ b/tests/SqlServerSqlsrvRepositoryUuidKeyTest.php @@ -5,15 +5,14 @@ use ByJG\AnyDataset\Db\Factory; use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use Override; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +#[RequiresPhpExtension('pdo_sqlsrv')] class SqlServerSqlsrvRepositoryUuidKeyTest extends RepositoryUuidKeyTestCase { #[Override] protected function createDriver(): DbDriverInterface { - if (!extension_loaded('pdo_sqlsrv')) { - $this->markTestSkipped('The pdo_sqlsrv extension is not loaded'); - } return Factory::getDbInstance('sqlsrv://sa:Pa55word@' . (getenv('MSSQL_TEST_HOST') ?: '127.0.0.1') . '/tempdb'); } diff --git a/tests/SqliteRepositoryUuidKeyTest.php b/tests/SqliteRepositoryUuidKeyTest.php index e04a990..778ff3e 100644 --- a/tests/SqliteRepositoryUuidKeyTest.php +++ b/tests/SqliteRepositoryUuidKeyTest.php @@ -5,7 +5,9 @@ use ByJG\AnyDataset\Db\Factory; use ByJG\AnyDataset\Db\Interfaces\DbDriverInterface; use Override; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; +#[RequiresPhpExtension('pdo_sqlite')] class SqliteRepositoryUuidKeyTest extends RepositoryUuidKeyTestCase { #[Override] From a562ca32348a3c591287230bac7deb9e22b8ca5e Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 16 Oct 2026 00:09:43 -0400 Subject: [PATCH 14/18] Write PostgreSQL and SQL Server compatible inserts and timestamps - An empty primary key is left out of the INSERT instead of written as NULL: MySQL and SQLite generate the key anyway, but PostgreSQL (serial) and SQL Server (identity) reject an explicit NULL. - created_at and updated_at (NowUtcMapper) and a soft delete (deleted_at) write CURRENT_TIMESTAMP. They wrote the dialect's sqlDate(), a text made to display a date, which PostgreSQL rejects for a timestamp column and SQL Server could not convert. Tested on MySQL, SQLite, PostgreSQL and SQL Server (dblib and sqlsrv); CURRENT_TIMESTAMP was also checked on Oracle. --- CHANGELOG-7.0.md | 16 +++ src/MapperFunctions/NowUtcMapper.php | 15 ++- src/Repository.php | 11 +- tests/MapperFunctionsTest.php | 29 +---- tests/Model/AutoIncrementKey.php | 16 +++ tests/Model/Timestamps.php | 23 ++++ tests/MysqlRepositoryTimestampsTest.php | 29 +++++ tests/PostgresRepositoryAutoIncrementTest.php | 49 +++++++++ tests/PostgresRepositoryTimestampsTest.php | 30 ++++++ tests/RepositoryTimestampsTestCase.php | 100 ++++++++++++++++++ ...SqlServerDblibRepositoryTimestampsTest.php | 30 ++++++ ...qlServerSqlsrvRepositoryTimestampsTest.php | 30 ++++++ tests/SqliteRepositoryTimestampsTest.php | 30 ++++++ 13 files changed, 378 insertions(+), 30 deletions(-) create mode 100644 tests/Model/AutoIncrementKey.php create mode 100644 tests/Model/Timestamps.php create mode 100644 tests/MysqlRepositoryTimestampsTest.php create mode 100644 tests/PostgresRepositoryAutoIncrementTest.php create mode 100644 tests/PostgresRepositoryTimestampsTest.php create mode 100644 tests/RepositoryTimestampsTestCase.php create mode 100644 tests/SqlServerDblibRepositoryTimestampsTest.php create mode 100644 tests/SqlServerSqlsrvRepositoryTimestampsTest.php create mode 100644 tests/SqliteRepositoryTimestampsTest.php diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index c0b1c1a..d70120b 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -85,6 +85,22 @@ no observers are registered), and multi-connection safe. read back after the insert (the last insert id) took its place, so an `id` of 100 came back as 1. The given key is now inserted as is; a key is only generated or read back when it is empty +### Fixed: an empty primary key is left out of the INSERT +- A new instance without a key was inserted with an explicit `NULL` for it. MySQL and SQLite + generate the key anyway, but PostgreSQL rejects it for a `serial` column + (`null value in column "id" … violates not-null constraint`). An empty primary key is now left + out of the INSERT, so the database fills it in, or the seed function's key is used + +### Fixed: created_at, updated_at and a soft delete write a real date +- `NowUtcMapper` (the `CreatedAt` and `UpdatedAt` traits) and a soft delete (`deleted_at`) wrote + "now" as the dialect's `sqlDate('Y-m-d H:i:s')`, an expression that formats a date as text. On + PostgreSQL the text is rejected for a `timestamp` column (`expression is of type text`), and on + SQL Server the `FORMAT()` pattern produced `YYYY-10-16 …`, which no date column accepts. Both now + write `CURRENT_TIMESTAMP`, standard SQL that MySQL, PostgreSQL, SQLite, SQL Server and Oracle all + store as a date. As before, it is the database's current time (its session time zone; UTC on SQLite) +- `NowUtcMapper` no longer needs a `DatabaseExecutor`; a test that mocked `sqlDate()` for it no + longer applies + ### UUID literals - `PostgresUuidLiteral` writes `'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'`, the text form a `uuid` column takes. It wrote `\x...` without quotes, which is not valid SQL diff --git a/src/MapperFunctions/NowUtcMapper.php b/src/MapperFunctions/NowUtcMapper.php index 819466d..ac46910 100644 --- a/src/MapperFunctions/NowUtcMapper.php +++ b/src/MapperFunctions/NowUtcMapper.php @@ -5,17 +5,22 @@ use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\MicroOrm\Literal\Literal; -use InvalidArgumentException; use Override; +/** + * The current date and time, for a date column (created_at, updated_at): CURRENT_TIMESTAMP, which + * MySQL, PostgreSQL, SQLite, SQL Server and Oracle all accept for a date or timestamp column. + * + * It used to be the dialect's sqlDate('Y-m-d H:i:s'), a text made to display a date: PostgreSQL + * rejects text for a timestamp column, and SQL Server's FORMAT() pattern was not a valid one. + */ class NowUtcMapper implements MapperFunctionInterface { + public const NOW = 'CURRENT_TIMESTAMP'; + #[Override] public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { - if ($executor === null) { - throw new InvalidArgumentException('DatabaseExecutor is required for NowUtcMapper'); - } - return new Literal($executor->getHelper()->sqlDate('Y-m-d H:i:s')); + return new Literal(self::NOW); } } \ No newline at end of file diff --git a/src/Repository.php b/src/Repository.php index ddcc9ec..5df009f 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -27,6 +27,7 @@ use ByJG\MicroOrm\Interface\UpdateConstraintInterface; use ByJG\MicroOrm\Literal\Literal; use ByJG\MicroOrm\Literal\LiteralInterface; +use ByJG\MicroOrm\MapperFunctions\NowUtcMapper; use ByJG\MicroOrm\PropertyHandler\MapFromDbToInstanceHandler; use ByJG\MicroOrm\PropertyHandler\PrepareToUpdateHandler; use ByJG\Serializer\ObjectCopy; @@ -271,7 +272,7 @@ public function delete(array|string|int|LiteralInterface $pkId): bool if ($this->mapper->isSoftDeleteEnabled()) { $updatable = UpdateQuery::getInstance() ->table($this->mapper->getTable()) - ->set('deleted_at', new Literal($this->getExecutorWrite()->getHelper()->sqlDate('Y-m-d H:i:s'))) + ->set('deleted_at', new Literal(NowUtcMapper::NOW)) ->where($filterList, $filterKeys); // the SQL is an UPDATE, but the caller intent is a delete $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()) @@ -771,6 +772,14 @@ protected function saveUpdatableInternal(mixed $instance): array $array[$fieldMap->getFieldName()] = $fieldValue; } } + // An empty primary key is not inserted: the database fills it in (auto increment, + // default) or save() sets the one of the seed function. An explicit NULL is accepted + // by MySQL and SQLite but rejected by PostgreSQL for a serial column. + foreach ($pkList as $pk) { + if (array_key_exists($pk, $array) && $array[$pk] === null) { + unset($array[$pk]); + } + } $updatable = InsertQuery::getInstance($this->mapper->getTable(), $array); } else { if (!empty($this->beforeUpdate)) { diff --git a/tests/MapperFunctionsTest.php b/tests/MapperFunctionsTest.php index 674e21b..58fd29a 100644 --- a/tests/MapperFunctionsTest.php +++ b/tests/MapperFunctionsTest.php @@ -95,40 +95,21 @@ public function testReadOnlyMapperWithNumericValue() // NowUtcMapper Tests - public function testNowUtcMapperReturnsLiteral() + public function testNowUtcMapperReturnsCurrentTimestamp() { - $dbHelperMock = $this->createMock(SqlDialectInterface::class); - $dbHelperMock->method('sqlDate') - ->with('Y-m-d H:i:s') - ->willReturn('2024-01-15 12:30:45'); - - $executorMock = $this->createStub(DatabaseExecutor::class); - $executorMock->method('getHelper') - ->willReturn($dbHelperMock); - $mapper = new NowUtcMapper(); - $result = $mapper->processedValue(null, new stdClass(), $executorMock); + $result = $mapper->processedValue(null, new stdClass()); $this->assertInstanceOf(LiteralInterface::class, $result); - $this->assertEquals('2024-01-15 12:30:45', $result->getLiteralValue()); + $this->assertEquals('CURRENT_TIMESTAMP', $result->getLiteralValue()); } public function testNowUtcMapperIgnoresInputValue() { - $dbHelperMock = $this->createMock(SqlDialectInterface::class); - $dbHelperMock->method('sqlDate') - ->with('Y-m-d H:i:s') - ->willReturn('2024-01-15 12:30:45'); - - $executorMock = $this->createStub(DatabaseExecutor::class); - $executorMock->method('getHelper') - ->willReturn($dbHelperMock); - $mapper = new NowUtcMapper(); - $result = $mapper->processedValue('ignored value', new stdClass(), $executorMock); + $result = $mapper->processedValue('ignored value', new stdClass()); - $this->assertInstanceOf(LiteralInterface::class, $result); - $this->assertEquals('2024-01-15 12:30:45', $result->getLiteralValue()); + $this->assertEquals('CURRENT_TIMESTAMP', $result->getLiteralValue()); } // FormatUpdateUuidMapper Tests diff --git a/tests/Model/AutoIncrementKey.php b/tests/Model/AutoIncrementKey.php new file mode 100644 index 0000000..fd31024 --- /dev/null +++ b/tests/Model/AutoIncrementKey.php @@ -0,0 +1,16 @@ +executor = DatabaseExecutor::using( + Factory::getDbInstance('pgsql://postgres:password@' . (getenv('PSQL_TEST_HOST') ?: '127.0.0.1')) + ); + $this->executor->execute('DROP TABLE IF EXISTS autoincrementkey'); + $this->executor->execute('CREATE TABLE autoincrementkey (id serial PRIMARY KEY, name VARCHAR(45))'); + $this->repository = new Repository($this->executor, AutoIncrementKey::class); + } + + public function tearDown(): void + { + $this->executor->execute('DROP TABLE IF EXISTS autoincrementkey'); + } + + public function testANewInstanceGetsTheKeyTheDatabaseGenerates(): void + { + foreach (['first', 'second'] as $name) { + $model = new AutoIncrementKey(); + $model->name = $name; + $this->repository->save($model); + } + + $this->assertSame(2, $model->id); + $this->assertSame('first', $this->repository->get(1)->name); + $this->assertSame('second', $this->repository->get(2)->name); + } +} diff --git a/tests/PostgresRepositoryTimestampsTest.php b/tests/PostgresRepositoryTimestampsTest.php new file mode 100644 index 0000000..04e6f08 --- /dev/null +++ b/tests/PostgresRepositoryTimestampsTest.php @@ -0,0 +1,30 @@ +executor = DatabaseExecutor::using($this->createDriver()); + $this->executor->execute('DROP TABLE IF EXISTS timestamps'); + $date = $this->dateTimeType(); + $this->executor->execute( + 'CREATE TABLE timestamps (' . $this->idColumn() . ', name VARCHAR(45), ' + . "created_at $date NULL, updated_at $date NULL, deleted_at $date NULL)" + ); + $this->repository = new Repository($this->executor, Timestamps::class); + } + + public function tearDown(): void + { + $this->executor->execute('DROP TABLE IF EXISTS timestamps'); + } + + /** + * A date the database stored, which must be about now: the session time zone of the + * database may differ from PHP's, so a day either way is accepted. + */ + protected function assertIsAboutNow(mixed $value, string $column): void + { + $this->assertNotEmpty($value, "$column was not written"); + $time = strtotime((string)$value); + $this->assertNotFalse($time, "$column is not a date: $value"); + $this->assertLessThan(86400 + 60, abs($time - time()), "$column is not about now: $value"); + } + + protected function stored(string $column): mixed + { + return $this->executor->getScalar("SELECT $column FROM timestamps WHERE name = 'row'"); + } + + public function testCreatedAtIsWrittenOnInsert(): void + { + $model = new Timestamps(); + $model->name = 'row'; + $this->repository->save($model); + + $this->assertIsAboutNow($this->stored('created_at'), 'created_at'); + $this->assertNull($this->stored('deleted_at')); + } + + public function testUpdatedAtIsWrittenOnUpdate(): void + { + $model = new Timestamps(); + $model->name = 'row'; + $this->repository->save($model); + + $model = $this->repository->get($model->id); + $this->repository->save($model); + + $this->assertIsAboutNow($this->stored('updated_at'), 'updated_at'); + } + + public function testASoftDeleteWritesDeletedAt(): void + { + $model = new Timestamps(); + $model->name = 'row'; + $this->repository->save($model); + + $this->assertTrue($this->repository->delete($model->id)); + + $this->assertIsAboutNow($this->stored('deleted_at'), 'deleted_at'); + $this->assertEmpty($this->repository->get($model->id)); + } +} diff --git a/tests/SqlServerDblibRepositoryTimestampsTest.php b/tests/SqlServerDblibRepositoryTimestampsTest.php new file mode 100644 index 0000000..0ae9ffe --- /dev/null +++ b/tests/SqlServerDblibRepositoryTimestampsTest.php @@ -0,0 +1,30 @@ + Date: Fri, 16 Oct 2026 00:28:50 -0400 Subject: [PATCH 15/18] Test and document backed enum properties A property typed with a backed enum (public ?Status $status) is saved as the enum's value, loaded back as the enum, and filters by its value in queryInstance($model). byjg/serializer 7.0 does the conversion (a1ca2ea); RepositoryBackedEnumTest runs the three paths on SQLite, and docs/getting-started-model.md shows the model. --- CHANGELOG-7.0.md | 7 +++ docs/getting-started-model.md | 26 +++++++++++ tests/Model/Order.php | 16 +++++++ tests/Model/OrderStatus.php | 9 ++++ tests/RepositoryBackedEnumTest.php | 69 ++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 tests/Model/Order.php create mode 100644 tests/Model/OrderStatus.php create mode 100644 tests/RepositoryBackedEnumTest.php diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index d70120b..e7638cc 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -77,6 +77,13 @@ no observers are registered), and multi-connection safe. `randomblob(16)`, a round trip for what PHP generates itself - See: `docs/uuid-support.md` +### Backed enum properties +- A property typed with a backed enum (`public ?Status $status`) is saved as the enum's value, + loaded back as the enum, and filters by its value in `queryInstance($model)`. It used to fail + on save (the enum object reached the query), on load (`TypeError` assigning `'PAID'`) and in + the filter. Done by `byjg/serializer` 7.0; tested in `RepositoryBackedEnumTest` +- See: `docs/getting-started-model.md` + ## Breaking Changes ### Fixed: a primary key set on the instance is kept on insert diff --git a/docs/getting-started-model.md b/docs/getting-started-model.md index d273415..b80cb42 100644 --- a/docs/getting-started-model.md +++ b/docs/getting-started-model.md @@ -50,6 +50,32 @@ public function setId(?int $id): void | Property without type | No need to set default value | `public $id;` | | Protected/Private property | Must have getter and setter public methods | `protected ?int $id = null;`
`public function getId(): ?int {...};`
`public function setId(?int $id): void {...};` | +### Backed enum properties + +A property (or a setter parameter) typed with a backed enum is stored as the enum's value and +loaded back as the enum, so the column holds `'PAID'` and the model holds `Status::Paid`: + +```php +enum Status: string +{ + case Pending = 'PENDING'; + case Paid = 'PAID'; +} + +#[TableAttribute(tableName: 'orders')] +class Order +{ + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute] + public ?Status $status = null; +} +``` + +A value in the database that the enum does not have throws a `ValueError` when the row is loaded. +The conversion is done by `byjg/serializer`; a pure (non-backed) enum is not supported. + ## Property Mapping Strategy with the Database | Strategy | Description | Example | diff --git a/tests/Model/Order.php b/tests/Model/Order.php new file mode 100644 index 0000000..6fd87b7 --- /dev/null +++ b/tests/Model/Order.php @@ -0,0 +1,16 @@ +executor = DatabaseExecutor::using(Factory::getDbInstance('sqlite://' . sys_get_temp_dir() . '/micro-orm-enum.db')); + $this->executor->execute('DROP TABLE IF EXISTS orders'); + $this->executor->execute('CREATE TABLE orders (id INTEGER PRIMARY KEY AUTOINCREMENT, status VARCHAR(10))'); + $this->repository = new Repository($this->executor, Order::class); + } + + public function tearDown(): void + { + $this->executor->execute('DROP TABLE IF EXISTS orders'); + } + + public function testTheEnumIsStoredAsItsValue(): void + { + $order = new Order(); + $order->status = OrderStatus::Pending; + $this->repository->save($order); + + $this->assertSame('PENDING', $this->executor->getScalar('SELECT status FROM orders WHERE id = :id', ['id' => $order->id])); + + $order->status = OrderStatus::Paid; + $this->repository->save($order); + + $this->assertSame('PAID', $this->executor->getScalar('SELECT status FROM orders WHERE id = :id', ['id' => $order->id])); + } + + public function testTheValueIsLoadedAsTheEnum(): void + { + $this->executor->execute("INSERT INTO orders (status) VALUES ('PAID')"); + + $order = $this->repository->get(1); + + $this->assertSame(OrderStatus::Paid, $order->status); + } + + public function testAModelWithAnEnumFiltersByItsValue(): void + { + $this->executor->execute("INSERT INTO orders (status) VALUES ('PENDING'), ('PAID'), ('PAID')"); + + $filter = new Order(); + $filter->status = OrderStatus::Paid; + $orders = $this->repository->getByQuery($this->repository->queryInstance($filter)); + + $this->assertCount(2, $orders); + $this->assertSame(OrderStatus::Paid, $orders[0]->status); + } +} From c17e825c1eaa8f43e11697870e573237e7c6835b Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sat, 17 Oct 2026 08:05:58 -0400 Subject: [PATCH 16/18] Align documentation and code. --- CHANGELOG-7.0.md | 10 ++- README.md | 21 ++--- docs/active-record.md | 14 +++- docs/architecture-layers.md | 3 +- docs/auto-discovering-relationship.md | 4 +- docs/common-traits.md | 10 +-- docs/comparison-with-other-orms.md | 19 ++--- docs/controlling-the-data.md | 37 ++------- docs/getting-started-model.md | 2 +- docs/mapper-functions.md | 6 +- docs/observers.md | 3 + docs/query-build.md | 6 +- docs/query-raw.md | 4 +- docs/querying-the-database.md | 20 ++++- docs/repository.md | 80 ++++++++++---------- docs/softdelete.md | 15 ++-- docs/tables-without-auto-increment-fields.md | 20 ++--- docs/the-literal-object.md | 6 +- docs/updating-the-database.md | 3 +- docs/using-fieldalias.md | 4 +- docs/using-mapper-object.md | 9 ++- docs/uuid-support.md | 12 ++- 22 files changed, 164 insertions(+), 144 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index e7638cc..94d7173 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -6,7 +6,7 @@ ## 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 +introduced by `byjg/anydataset-db` 7.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. @@ -30,6 +30,8 @@ no observers are registered), and multi-connection safe. `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) +- `Updatable::build()` is declared to return `OrmSqlStatement`, so a custom `Updatable` subclass must return one. + New `Updatable::getTable()` ### SoftDelete observer event - New `ObserverEvent::SoftDelete` case: `delete()` on a mapper with soft delete enabled now fires @@ -75,6 +77,7 @@ no observers are registered), and multi-connection safe. deprecated aliases of `TableUuidPKAttribute`. The MySQL one asked MySQL for `uuid_to_bin(uuid())`, so it failed on any other database; the SQLite one asked SQLite for `randomblob(16)`, a round trip for what PHP generates itself +- `FieldUuidAttribute` accepts `parentTable`, like `FieldAttribute`, to declare a foreign key relationship - See: `docs/uuid-support.md` ### Backed enum properties @@ -149,6 +152,11 @@ no observers are registered), and multi-connection safe. Record) — it keeps your base table, supports INNER/LEFT/RIGHT, and auto-joins intermediates. The relationship registry (`addRelationship`/`getRelationship`/`getRelationshipData`) is unchanged +## Bug Fixes + +- Relationships declared before the parent mapper was registered (`parentTable` pointing to a table not yet known) + were never completed. `ORM::getRelationship()` now resolves them once the parent mapper is registered + ## Migration from 6.x | 6.x | 7.0 | diff --git a/README.md b/README.md index 3c1be3d..01f3d54 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ class MyModel #[FieldAttribute()] public ?string $name = null; - #[FieldAttribute(fieldName: 'company_id') + #[FieldAttribute(fieldName: 'company_id')] public ?int $companyId = null; } ``` @@ -140,7 +140,8 @@ After defining the Model, you can connect the Model with the repository. ```php $dbDriver = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@server/schema'); -$repository = new \ByJG\MicroOrm\Repository($dbDriver, MyModel::class); +$executor = \ByJG\AnyDataset\Db\DatabaseExecutor::using($dbDriver); +$repository = new \ByJG\MicroOrm\Repository($executor, MyModel::class); ``` ### Querying the database @@ -204,6 +205,14 @@ $result = $repository->getByQuery($query); * [Building SQL Queries](docs/query-build.md) * [UUID Support](docs/uuid-support.md) +## Related Projects + +* [Database Migration](https://github.com/byjg/migration) +* [Anydataset](https://github.com/byjg/anydataset) +* [PHP Rest Template](https://github.com/byjg/php-rest-template) +* [USDocker](https://github.com/usdocker/usdocker) +* [Serializer](https://github.com/byjg/serializer) + ## Installation Just type: @@ -218,13 +227,5 @@ composer require "byjg/micro-orm" ./vendor/bin/phpunit ``` -## Related Projects - -* [Database Migration](https://github.com/byjg/migration) -* [Anydataset](https://github.com/byjg/anydataset) -* [PHP Rest Template](https://github.com/byjg/php-rest-template) -* [USDocker](https://github.com/usdocker/usdocker) -* [Serializer](https://github.com/byjg/serializer) - ---- [Open source ByJG](https://opensource.byjg.com) \ No newline at end of file diff --git a/docs/active-record.md b/docs/active-record.md index bfc40ca..454e9da 100644 --- a/docs/active-record.md +++ b/docs/active-record.md @@ -65,17 +65,20 @@ class MyClass If you have more than one database connection, you can define a default database connection: ```php -// Set a default DBDriver -ORM::defaultDriver($dbDriver); +// Set a default DatabaseExecutor +ORM::defaultDbDriver(DatabaseExecutor::using($dbDriver)); ``` or call the `initialize` method with the database connection: ```php -// Initialize the Active Record with a specific DBDriver -MyClass::initialize($dbDriver); +// Initialize the Active Record with a specific DatabaseExecutor +MyClass::initialize(DatabaseExecutor::using($dbDriver)); ``` +`MyClass::reset($executor)` discards the current connection and, when an executor is given, initializes the model +with it. + ## Using the Active Record Once properly configured, you can use the Active Record pattern for database operations: @@ -119,6 +122,9 @@ if (MyClass::where('someProperty > :min', ['min' => 100])->exists()) { echo "Records found!"; } +// Or throw an exception if none is found +MyClass::where('someProperty > :min', ['min' => 100])->existsOrFail(); + // Get all matching records as array $myClassList = MyClass::where('someProperty = :value', ['value' => 123]) ->orderBy(['id DESC']) diff --git a/docs/architecture-layers.md b/docs/architecture-layers.md index 6e7da72..8421bdb 100644 --- a/docs/architecture-layers.md +++ b/docs/architecture-layers.md @@ -168,7 +168,8 @@ $users = $userRepo->getIterator($query)->toEntities(); // Returns User[] objects ```php // Business logic: Find user by email -$user = $repository->getByFilter(['email' => 'user@example.com']); +$filter = (new IteratorFilter())->and('email', Relation::Equal, 'user@example.com'); +$user = $repository->getByFilter($filter)[0]; // Update user $user->setName('New Name'); diff --git a/docs/auto-discovering-relationship.md b/docs/auto-discovering-relationship.md index c0a99f0..b9e5261 100644 --- a/docs/auto-discovering-relationship.md +++ b/docs/auto-discovering-relationship.md @@ -33,8 +33,8 @@ class Class2 public ?int $idTable1; } -$repository1 = new Repository($dbDriver, Class1::class); -$repository2 = new Repository($dbDriver, Class2::class); +$repository1 = new Repository($executor, Class1::class); +$repository2 = new Repository($executor, Class2::class); ``` This will automatically create the relationship between `table1` and `table2` through the ORM system's internal diff --git a/docs/common-traits.md b/docs/common-traits.md index 6c40f2b..bcd709f 100644 --- a/docs/common-traits.md +++ b/docs/common-traits.md @@ -11,8 +11,8 @@ These traits make it easy to implement standard fields like `created_at`, `updat ### CreatedAt Trait -The `CreatedAt` trait automatically handles the `created_at` timestamp field, setting it to the current UTC time when a -record is created, +The `CreatedAt` trait automatically handles the `created_at` timestamp field, setting it to the database's current time +(`CURRENT_TIMESTAMP`) when a record is created, and preventing it from being updated on subsequent saves. ```php @@ -34,8 +34,8 @@ When you use this trait, it adds a `created_at` field to your model with the fol ### UpdatedAt Trait -The `UpdatedAt` trait automatically handles the `updated_at` timestamp field, setting it to the current UTC time -whenever a record is updated. +The `UpdatedAt` trait automatically handles the `updated_at` timestamp field, setting it to the database's current +time (`CURRENT_TIMESTAMP`) whenever a record is updated. ```php use ByJG\MicroOrm\Trait\UpdatedAt; @@ -71,7 +71,7 @@ class MyModel When you use this trait, it adds a `deleted_at` field to your model with the following features: -- Field is marked with `syncWithDb: false` which ensures it is included in database operations +- Field is marked with `syncWithDb: false`, so `save()` does not update it (the soft delete sets it) - Enables soft delete functionality in the Repository class - Provides getter and setter methods for the field diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md index d8c76b3..f9fea7d 100644 --- a/docs/comparison-with-other-orms.md +++ b/docs/comparison-with-other-orms.md @@ -47,7 +47,7 @@ these differences will help you choose the right tool for your project. // 1. Repository + Data Mapper (recommended for complex apps) $repository = new Repository($executor, User::class); -$users = $repository->getByFilter(['status' => 'active']); +$users = $repository->getByFilter((new IteratorFilter())->and('status', Relation::Equal, 'active')); // 2. Active Record (simple apps) class User { @@ -429,10 +429,7 @@ $update = UpdateQuery::getInstance() $update->buildAndExecute(DatabaseExecutor::using($driver)); // 4. InsertQuery - INSERT statements -$insert = InsertQuery::getInstance() - ->table('users') - ->fields(['name', 'email']) - ->values(['name' => 'John', 'email' => 'john@example.com']); +$insert = InsertQuery::getInstance('users', ['name' => 'John', 'email' => 'john@example.com']); $insert->buildAndExecute(DatabaseExecutor::using($driver)); // 5. QueryRaw - Raw SQL with parameter binding @@ -916,9 +913,9 @@ class UserStatusChangedObserver implements ObserverProcessorInterface public function process(ObserverData $observerData): void { - if ($observerData->getEvent() === ObserverEvent::UPDATE) { - $oldInstance = $observerData->getOldInstance(); - $newInstance = $observerData->getNewInstance(); + if ($observerData->getEvent() === ObserverEvent::Update) { + $oldInstance = $observerData->getOldData(); + $newInstance = $observerData->getData(); // Detect domain event: status changed if ($oldInstance->getStatus() !== $newInstance->getStatus()) { @@ -1018,7 +1015,7 @@ class EventStoreObserver implements ObserverProcessorInterface { // Store event in event store $event = new DomainEvent( - aggregateId: $observerData->getNewInstance()->getId(), + aggregateId: $observerData->getData()->getId(), eventType: $observerData->getEvent()->value, eventData: $this->serializeChanges($observerData), timestamp: new DateTime() @@ -1090,8 +1087,8 @@ $repository->addObserver(new class implements ObserverProcessorInterface { public function process(ObserverData $data): void { - if ($data->getEvent() === ObserverEvent::UPDATE) { - $newOrder = $data->getNewInstance(); + if ($data->getEvent() === ObserverEvent::Update) { + $newOrder = $data->getData(); // Domain Event: Order Completed if ($newOrder->status === 'completed') { diff --git a/docs/controlling-the-data.md b/docs/controlling-the-data.md index a869440..8a72133 100644 --- a/docs/controlling-the-data.md +++ b/docs/controlling-the-data.md @@ -81,7 +81,7 @@ The `processedValue` method in your custom mapper class will receive three argum |--------------------------|------------------------------------------------------------------------------------------------------------| | `StandardMapper` | Defines the basic behavior for select and update fields; You don't need to set it. Just know it exists. | | `ReadOnlyMapper` | Defines a read-only field. It can be retrieved from the database but will not be updated. | -| `NowUtcMapper` | Returns the current date/time in UTC. It is used to set the current date/time in the database. | +| `NowUtcMapper` | Writes `CURRENT_TIMESTAMP`, the database's current date/time. It is used to set the current date/time. | | `FormatUpdateUuidMapper` | Converts a UUID string to a binary representation. It is used to store UUID in a binary field. | | `FormatSelectUuidMapper` | Converts a binary representation of a UUID to a string. It is used to retrieve a UUID from a binary field. | @@ -125,36 +125,13 @@ $fieldMap = \ByJG\MicroOrm\FieldMapping::create('name') ## Generic function to be processed before any insert or update -You can process entities before insert or update operations at the record level using two different approaches: +You can process entities before insert or update operations at the record level. `setBeforeInsert()` and +`setBeforeUpdate()` accept an `EntityProcessorInterface` instance or the name of a class implementing it; closures are +not accepted. -### 1. Using Closures (Legacy Approach) +### 1. Using EntityProcessorInterface -```php -setBeforeInsert(function ($instance) { - // Manipulate the instance before insert - return $instance; -}); - -$repository->setBeforeUpdate(function ($instance) { - // Manipulate the instance before update - return $instance; -}); - -// Or apply globally to all repository instances -\ByJG\MicroOrm\Repository::setBeforeInsert(function ($instance) { - return $instance; -}); - -\ByJG\MicroOrm\Repository::setBeforeUpdate(function ($instance) { - return $instance; -}); -``` - -### 2. Using EntityProcessorInterface (Recommended Approach) - -A more structured approach is to implement the `EntityProcessorInterface`: +Implement the `EntityProcessorInterface`: ```php setBeforeUpdate(new BeforeUpdateProcessor()); The return of the process method will be the instance that will be used to insert or update the record. -### 3. Using TableAttribute (Class-Level Approach) +### 2. Using TableAttribute (Class-Level Approach) You can also define processors at the class level using the `TableAttribute`: diff --git a/docs/getting-started-model.md b/docs/getting-started-model.md index b80cb42..3878e66 100644 --- a/docs/getting-started-model.md +++ b/docs/getting-started-model.md @@ -158,7 +158,7 @@ After defining the Model, you can connect the Model with the repository. ```php $dbDriver = Factory::getDbInstance('mysql://user:password@server/schema'); -$repository = new Repository($dbDriver, MyModel::class); +$repository = new Repository(DatabaseExecutor::using($dbDriver), MyModel::class); ``` ### Querying the database diff --git a/docs/mapper-functions.md b/docs/mapper-functions.md index ead7f42..e90c4bf 100644 --- a/docs/mapper-functions.md +++ b/docs/mapper-functions.md @@ -63,15 +63,15 @@ included in the update statement. ### NowUtcMapper -Automatically sets the field value to the current UTC date and time. This is particularly useful for timestamp fields +Automatically sets the field value to the current date and time. This is particularly useful for timestamp fields like `created_at` and `updated_at`. ```php use ByJG\MicroOrm\MapperFunctions\NowUtcMapper; ``` -When applied, this function will generate a SQL statement that sets the field to the current timestamp using the -database server's date/time functions. +When applied, this function writes `CURRENT_TIMESTAMP` (standard SQL, accepted by MySQL, PostgreSQL, SQLite, +SQL Server and Oracle), so the value is the database's current time in its session time zone (UTC on SQLite). ### FormatUpdateUuidMapper and FormatSelectUuidMapper diff --git a/docs/observers.md b/docs/observers.md index d4fd2e4..2307ab1 100644 --- a/docs/observers.md +++ b/docs/observers.md @@ -76,6 +76,9 @@ The `ObserverData` class contains the following properties: *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. +`Repository::removeObserver()` detaches an observer added with `addObserver()`. Adding the same observer class twice +for the same table and repository class throws `InvalidArgumentException` ("Observer already exists"). + ## Observer scope Observers are scoped to the `DatabaseExecutor` instance(s) of the repository where diff --git a/docs/query-build.md b/docs/query-build.md index a10f308..aad61b3 100644 --- a/docs/query-build.md +++ b/docs/query-build.md @@ -26,7 +26,7 @@ $sqlStatement = $query->build($dbDriver); ## Return Value -Returns a `SqlStatement` object containing: +Returns a `SqlStatement` object (an `OrmSqlStatement`, which extends it) containing: - The generated SQL query string - The parameters to be used in the prepared statement @@ -129,7 +129,7 @@ $query = Query::getInstance() ->table('users') ->fields(['id', 'name', 'email']); -$iterator = $query->buildAndGetIterator($dbDriver); +$iterator = $query->buildAndGetIterator(DatabaseExecutor::using($dbDriver)); foreach ($iterator as $row) { // Process each row @@ -143,7 +143,7 @@ You can also use iterators with caching to improve performance for frequently ac ```php $cache = new CacheQueryResult($cacheDriver, 'users_list', 3600); // Cache for 1 hour -$iterator = $query->buildAndGetIterator($dbDriver, $cache); +$iterator = $query->buildAndGetIterator(DatabaseExecutor::using($dbDriver), $cache); foreach ($iterator as $row) { // Process cached results diff --git a/docs/query-raw.md b/docs/query-raw.md index db663a0..7f0cfb5 100644 --- a/docs/query-raw.md +++ b/docs/query-raw.md @@ -26,9 +26,9 @@ When NOT to use QueryRaw Behavior -- build(): returns a SqlObject with your SQL and parameters unchanged. The optional DbDriver parameter is ignored for +- build(): returns a SqlStatement with your SQL and parameters unchanged. The optional DbDriver parameter is ignored for SQL generation. -- buildAndGetIterator($dbDriver): executes your SQL against the provided driver and returns a GenericIterator. +- buildAndGetIterator($executor): executes your SQL against the provided DatabaseExecutor and returns a GenericIterator. - Parameter binding: pass parameters as an array (named or positional) and the underlying driver will bind them. Examples diff --git a/docs/querying-the-database.md b/docs/querying-the-database.md index 6aefc83..44357c5 100644 --- a/docs/querying-the-database.md +++ b/docs/querying-the-database.md @@ -48,8 +48,7 @@ $collection = $orderRepository->getByQuery( $itemRepository->getMapper() ] ); - - +``` ## QueryBasic Object @@ -149,6 +148,19 @@ Left Join another table. Right Join another table. +#### crossJoin(string $table, string $alias = null) + +Cross Join another table. + +#### joinRelated(string $table), leftJoinRelated(string $table), rightJoinRelated(string $table) + +Join a table (or a model class) deriving the `ON` condition from the registered `parentTable` relationships. +See [Auto Discovering Relationship](auto-discovering-relationship.md). + +#### distinct() + +Add `DISTINCT` to the select. + #### groupBy(array $field) Group by a field or an array of fields. @@ -205,6 +217,10 @@ e.g.: ->top(10) ``` +#### forUpdate() + +Add `FOR UPDATE` to the select, locking the rows read. + ## Union Object The Union object is used to combine two queries. Since the Union operation is a set operation, diff --git a/docs/repository.md b/docs/repository.md index 80175cb..3e33f74 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -10,8 +10,9 @@ To achieve this, you need to create an instance of the Repository class and pass ```php $dbDriver = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@server/schema'); +$executor = \ByJG\AnyDataset\Db\DatabaseExecutor::using($dbDriver); -$repository = new \ByJG\MicroOrm\Repository($dbDriver, MyModel::class); +$repository = new \ByJG\MicroOrm\Repository($executor, MyModel::class); ``` ## Read and Write Separation @@ -22,17 +23,21 @@ However, in some cases, you may want to use separate database connections for re for database replication scenarios where you have a master database for writing and one or more read-only replicas for reading. -To configure separate database connections, you can use the `addDbDriverForWrite` method: +To configure separate database connections, you can use the `addDbDriverForWrite` method, which receives a +`DatabaseExecutor`: ```php +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\Factory; + // Main connection used for reading -$dbDriverRead = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@readserver/schema'); +$executorRead = DatabaseExecutor::using(Factory::getDbInstance('mysql://user:password@readserver/schema')); // Separate connection for write operations -$dbDriverWrite = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@writeserver/schema'); +$executorWrite = DatabaseExecutor::using(Factory::getDbInstance('mysql://user:password@writeserver/schema')); -$repository = new \ByJG\MicroOrm\Repository($dbDriverRead, MyModel::class); -$repository->addDbDriverForWrite($dbDriverWrite); +$repository = new \ByJG\MicroOrm\Repository($executorRead, MyModel::class); +$repository->addDbDriverForWrite($executorWrite); ``` Alternatively, you can use the `setRepositoryReadOnly` method to make the repository read-only: @@ -66,10 +71,11 @@ $model->setCompanyId(1); $query = $repository->queryInstance($model); ``` -You can also add additional tables for joins: +To join related tables, chain `joinRelated()` on the query +(see [Auto Discovering Relationship](auto-discovering-relationship.md)): ```php -$query = $repository->queryInstance($model, 'other_table', 'third_table'); +$query = $repository->queryInstance($model)->joinRelated('other_table'); ``` ### getMapper @@ -227,21 +233,17 @@ $myModel->setName('New Name'); $repository->save($myModel); ``` -You can also use update constraints: +You can also pass one or more update constraints, checked before an update +(see [Update Constraints](update-constraints.md)): ```php -use ByJG\MicroOrm\UpdateConstraint; - -$updateConstraint = new UpdateConstraint(); -$updateConstraint->addField('name'); -$updateConstraint->addField('email'); +use ByJG\MicroOrm\Constraint\RequireChangedValuesConstraint; $myModel = $repository->get(1); $myModel->setName('New Name'); -$myModel->setEmail('newemail@example.com'); -$myModel->setAge(30); // This won't be updated due to constraint -$repository->save($myModel, $updateConstraint); +// Throws RequireChangedValuesConstraintException if 'name' did not change +$repository->save($myModel, new RequireChangedValuesConstraint('name')); ``` ### delete @@ -274,18 +276,10 @@ $repository->deleteByQuery($deleteQuery); ### setBeforeInsert -Set a processor to be called before inserting a record. You can use either a closure or an implementation of -`EntityProcessorInterface`. +Set a processor to be called before inserting a record. It accepts an implementation of `EntityProcessorInterface` +or the name of a class implementing it. ```php -// Using a closure -$repository->setBeforeInsert(function($instance) { - // Modify the instance before inserting - $instance->createdAt = date('Y-m-d H:i:s'); - return $instance; -}); - -// Using EntityProcessorInterface (recommended) use ByJG\MicroOrm\Interface\EntityProcessorInterface; use Override; @@ -307,18 +301,10 @@ $repository->setBeforeInsert(new BeforeInsertProcessor()); ### setBeforeUpdate -Set a processor to be called before updating a record. You can use either a closure or an implementation of -`EntityProcessorInterface`. +Set a processor to be called before updating a record. It accepts an implementation of `EntityProcessorInterface` +or the name of a class implementing it. ```php -// Using a closure -$repository->setBeforeUpdate(function($instance) { - // Modify the instance before updating - $instance->updatedAt = date('Y-m-d H:i:s'); - return $instance; -}); - -// Using EntityProcessorInterface (recommended) use ByJG\MicroOrm\Interface\EntityProcessorInterface; use Override; @@ -347,16 +333,26 @@ Add an observer to the repository to monitor database operations. ```php class MyObserver implements ObserverProcessorInterface { - public function update(ObserverData $observerData): void + public function process(ObserverData $observerData): void + { + $event = $observerData->getEvent(); // ObserverEvent::Insert, Update, Delete or SoftDelete + $data = $observerData->getData(); + $oldData = $observerData->getOldData(); + } + + public function onError(Throwable $exception, ObserverData $observerData): void { - // Process the update event - $action = $observerData->getAction(); // INSERT, UPDATE, DELETE - $model = $observerData->getModel(); - $oldModel = $observerData->getOldModel(); + } + + public function getObservedTable(): string + { + return 'mytable'; } } $repository->addObserver(new MyObserver()); ``` +`removeObserver()` detaches it again. + See [Observers](observers.md) for more details. \ No newline at end of file diff --git a/docs/softdelete.md b/docs/softdelete.md index 4392799..59c64b9 100644 --- a/docs/softdelete.md +++ b/docs/softdelete.md @@ -64,9 +64,9 @@ class MyModel ```php addFieldMapping( - FieldMap::create('deletedAt') + FieldMapping::create('deletedAt') ->withFieldName('deleted_at') - ->withUpdateFunction(MapperFunctions::READ_ONLY)); + ->withUpdateFunction(ReadOnlyMapper::class)); ``` +## Deleting a record + +With soft delete enabled, `$repository->delete($id)` does not remove the row: it sets `deleted_at` to +`CURRENT_TIMESTAMP` and fires the `ObserverEvent::SoftDelete` observer event (see [Observers](observers.md)). + ## Disabling Soft Delete Temporarily Once one of the methods above is used, the soft delete is enabled by default. If you want to disable it temporarily diff --git a/docs/tables-without-auto-increment-fields.md b/docs/tables-without-auto-increment-fields.md index 7a589cd..e23856f 100644 --- a/docs/tables-without-auto-increment-fields.md +++ b/docs/tables-without-auto-increment-fields.md @@ -6,7 +6,7 @@ sidebar_position: 15 Some tables don't have auto-increment fields, for example, when you have a table with UUID binary or varchar primary keys. -In this case, you need to provide a function to calculate the unique ID. +In this case, you can provide a seed function (a `MapperFunctionInterface`) to calculate the unique ID. ```php withPrimaryKeySeedFunction(MyIdSeedFunction::class); // calculates and returns the unique ID ``` +A primary key set on the instance before the first `save()` is inserted as given; the seed function is only called +when the key is empty. + ## Varchar Primary Keys For tables with varchar primary keys where you want to provide the value yourself (e.g., SKU codes, custom IDs), -you need to create a seed function that returns the value from the instance. This prevents the ORM from trying -to use auto-increment behavior. +setting the key on the instance is enough: it is inserted as given. A seed function that returns the value from the +instance, as below, is optional. ### Using Repository Pattern @@ -120,7 +121,6 @@ $product->save(); $retrieved = Product::get('PROD-001'); ``` -**Important Note:** When using varchar primary keys, the seed function is required to prevent the ORM from -attempting to retrieve an auto-increment ID from the database. Without this function, the ORM will call -`executeAndGetInsertedId()` which may return unexpected values for non-integer primary keys. +**Important Note:** Only when the primary key is empty and there is no seed function does the ORM read the +auto-increment ID back from the database (`executeAndGetInsertedId()`). diff --git a/docs/the-literal-object.md b/docs/the-literal-object.md index 6fefad2..50a0846 100644 --- a/docs/the-literal-object.md +++ b/docs/the-literal-object.md @@ -14,7 +14,7 @@ To pass a literal value you need to use the `Literal` object. ```php name = "John"; @@ -31,9 +31,9 @@ The same applies when you are doing a query: ```php field('name') ->where('createdate < :date', ['date' => new Literal('NOW()')]); ``` diff --git a/docs/updating-the-database.md b/docs/updating-the-database.md index a4b1bf2..e4159da 100644 --- a/docs/updating-the-database.md +++ b/docs/updating-the-database.md @@ -181,10 +181,11 @@ use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\InsertQuery; use ByJG\MicroOrm\UpdateQuery; use ByJG\MicroOrm\DeleteQuery; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Factory; $db = Factory::getDbInstance('sqlite:///tmp/example.db'); -$repository = new Repository($db, MyModel::class); +$repository = new Repository(DatabaseExecutor::using($db), MyModel::class); $insert = InsertQuery::getInstance('users', [ 'name' => 'Alice', diff --git a/docs/using-fieldalias.md b/docs/using-fieldalias.md index 8c6de7c..4665e09 100644 --- a/docs/using-fieldalias.md +++ b/docs/using-fieldalias.md @@ -15,7 +15,7 @@ In that scenario, the value of ID will be overridden. The solution is use the Fi $orderMapper = new \ByJG\MicroOrm\Mapper(...); $orderMapper->addFieldMapping(FieldMapping::create('id')->withFieldAlias('orderid')); $itemMapper = new \ByJG\MicroOrm\Mapper(...); -$itemMapper->addFieldMappping(FieldMapping::create('id')->withFieldAlias('itemid')); +$itemMapper->addFieldMapping(FieldMapping::create('id')->withFieldAlias('itemid')); $query = \ByJG\MicroOrm\Query::getInstance() ->field('order.id', 'orderid') @@ -46,6 +46,6 @@ You can also add a MAPPER as a Field. In that case the MAPPER will create the fi $query = \ByJG\MicroOrm\Query::getInstance() ->fields([ $orderRepository->getMapper(), - $itemRepository->getMapper, + $itemRepository->getMapper(), ]); ``` diff --git a/docs/using-mapper-object.md b/docs/using-mapper-object.md index d403496..6ab0b13 100644 --- a/docs/using-mapper-object.md +++ b/docs/using-mapper-object.md @@ -100,8 +100,8 @@ echo $user->name; // ← Comes from 'full_name' column $user->name = "New name"; $repository->save($user); // ← Updates 'full_name' column -// Delete -$repository->delete($user); +// Delete by primary key +$repository->delete(10); ``` **Key Point**: The `Users` class remains completely untouched - all mapping happens externally! @@ -177,10 +177,11 @@ class UserMapper $mapper->addFieldMapping( FieldMapping::create('createdAt') ->withFieldName('created_at') + // Classes implementing MapperFunctionInterface (see Controlling the data) // Transform DateTime to string for DB - ->withUpdateFunction(fn($value) => $value->format('Y-m-d H:i:s')) + ->withUpdateFunction(DateTimeToStringMapper::class) // Transform string from DB to DateTime - ->withSelectFunction(fn($value) => new \DateTimeImmutable($value)) + ->withSelectFunction(StringToDateTimeMapper::class) ); return $mapper; diff --git a/docs/uuid-support.md b/docs/uuid-support.md index a5b3605..7a3a81a 100644 --- a/docs/uuid-support.md +++ b/docs/uuid-support.md @@ -124,6 +124,14 @@ The `FieldUuidAttribute` is used to mark UUID fields (both primary keys and regu `HexUuidLiteral` made without knowing the database, e.g. in a setter) - `FormatSelectUuidMapper` - Converts the value read from the database to a formatted string (with dashes) +Like `FieldAttribute`, it accepts `parentTable` to declare a foreign key to another table +(see [Auto Discovering Relationship](auto-discovering-relationship.md)): + +```php +#[FieldUuidAttribute(fieldName: 'user_id', parentTable: 'users')] +protected ?string $userId = null; +``` + These mapper functions ensure that: - Your application code works with human-readable UUID strings (e.g., `'550e8400-e29b-41d4-a716-446655440000'`) @@ -199,7 +207,7 @@ When querying by UUID primary keys using `get()` or `delete()`, the conversion i ```php use ByJG\MicroOrm\Repository; -$repository = new Repository($dbDriver, MyModel::class); +$repository = new Repository($executor, MyModel::class); $uuid = '550e8400-e29b-41d4-a716-446655440000'; // For UUID primary keys, just pass the string directly - NO need to wrap in HexUuidLiteral @@ -422,7 +430,7 @@ CREATE TABLE orders ( */ // Usage -$repository = new Repository($dbDriver, Order::class); +$repository = new Repository($executor, Order::class); // Create new order $order = new Order(); From ed38aa0446cce6f3de3be11e125b42f474e82c52 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 19 Oct 2026 10:20:50 -0400 Subject: [PATCH 17/18] Fix getByFilter() paging and ActiveRecord::where() type - Paging returned the wrong rows past the first page. - where() accepted an array that always failed with a TypeError. --- CHANGELOG-7.0.md | 6 ++++++ docs/active-record.md | 6 ++++-- docs/repository.md | 2 +- src/Repository.php | 3 ++- src/Trait/ActiveRecord.php | 4 ++-- tests/ActiveRecordQueryTest.php | 12 ++++++++++++ tests/RepositoryTest.php | 10 ++++++++++ 7 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index 94d7173..f259923 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -154,6 +154,12 @@ no observers are registered), and multi-connection safe. ## Bug Fixes +- `Repository::getByFilter()` paging (and `ActiveRecord::all()`, which uses it) returned the wrong rows past the + first page: it called `limit($page, ($page + 1) * $limit)`, which reads as start and quantity, so page 2 with + limit 10 returned 30 rows from the 3rd. It now returns `$limit` rows starting at `$page * $limit`. +- `ActiveRecord::where()` declared `array|string $filter`, but an array was forwarded to + `ActiveRecordQuery::createWhere(string $filter)` and always threw a `TypeError`. The parameter is now `string`, + matching what it actually supports - Relationships declared before the parent mapper was registered (`parentTable` pointing to a table not yet known) were never completed. `ORM::getRelationship()` now resolves them once the parent mapper is registered diff --git a/docs/active-record.md b/docs/active-record.md index 454e9da..bdc5b73 100644 --- a/docs/active-record.md +++ b/docs/active-record.md @@ -106,7 +106,9 @@ $myClass->save(); ### Query with Fluent API -The new fluent query API provides a more intuitive way to build and execute queries: +The new fluent query API provides a more intuitive way to build and execute queries. +`MyClass::where()` takes a SQL condition string and its parameters; to filter with an `IteratorFilter`, +use `MyClass::filter()` or `MyClass::newQuery()->where($iteratorFilter)`. ```php // Get first record matching criteria @@ -160,7 +162,7 @@ foreach ($myClassList as $myClass) { // Get all records (paginated, default is page 0, limit 50) $myClassList = MyClass::all(); -// Get page 2 with 20 records per page +// Pages start at 0: page 2 with 20 records per page returns records 41 to 60 $myClassList = MyClass::all(2, 20); ``` diff --git a/docs/repository.md b/docs/repository.md index 3e33f74..126e2ab 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -142,7 +142,7 @@ $result = $repository->getByFilter($filter); You can also add pagination: ```php -// Get page 2 with 10 items per page +// Pages start at 0: page 2 with 10 items per page returns items 21 to 30 $result = $repository->getByFilter($filter, page: 2, limit: 10); ``` diff --git a/src/Repository.php b/src/Repository.php index 5df009f..f733684 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -440,7 +440,8 @@ public function getByFilter(string|IteratorFilter $filter = "", array $params = } if (!is_null($limit)) { - $query->limit($page, ($page + 1) * $limit); + // limit(start, quantity): page is zero-based + $query->limit($page * $limit, $limit); } return $this->getByQuery($query); diff --git a/src/Trait/ActiveRecord.php b/src/Trait/ActiveRecord.php index e5f1728..2485c2f 100644 --- a/src/Trait/ActiveRecord.php +++ b/src/Trait/ActiveRecord.php @@ -177,11 +177,11 @@ public static function newQuery(): ActiveRecordQuery * * Example: User::where('email = :email', ['email' => 'test@example.com'])->first() * - * @param array|string $filter + * @param string $filter * @param array $params * @return ActiveRecordQuery */ - public static function where(array|string $filter, array $params = []): ActiveRecordQuery + public static function where(string $filter, array $params = []): ActiveRecordQuery { self::initialize(); return ActiveRecordQuery::createWhere(self::$repository, $filter, $params); diff --git a/tests/ActiveRecordQueryTest.php b/tests/ActiveRecordQueryTest.php index 6c66721..fdb70ac 100644 --- a/tests/ActiveRecordQueryTest.php +++ b/tests/ActiveRecordQueryTest.php @@ -252,6 +252,18 @@ public function testQueryWithNoWhereClause() $this->assertCount(4, $results); } + public function testWhereFilterTypeMatchesCreateWhere() + { + // ActiveRecord::where() forwards $filter to ActiveRecordQuery::createWhere(), so it must not accept more + $where = new \ReflectionMethod(ActiveRecordModel::class, 'where'); + $createWhere = new \ReflectionMethod(ActiveRecordQuery::class, 'createWhere'); + + $this->assertSame( + (string)$createWhere->getParameters()[1]->getType(), + (string)$where->getParameters()[0]->getType() + ); + } + public function testWhereWithNoResults() { $result = ActiveRecordModel::where('1 = 0')->first(); diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index a31ffe8..b9b4655 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -192,6 +192,16 @@ public function testGetByFilter() $this->assertEquals('2017-01-04 00:00:00', $users[0]->getCreatedate()); } + public function testGetByFilterPaging() + { + $ids = fn(array $users) => array_map(fn($user) => $user->getId(), $users); + + $this->assertEquals([1, 2], $ids($this->repository->getByFilter(page: 0, limit: 2))); + $this->assertEquals([3], $ids($this->repository->getByFilter(page: 1, limit: 2))); + $this->assertEquals([2], $ids($this->repository->getByFilter(page: 1, limit: 1))); + $this->assertEquals([], $this->repository->getByFilter(page: 3, limit: 1)); + } + public function testGetSelectFunction() { $this->userMapper = new Mapper(UsersMap::class, 'users', 'id'); From 1356d3fef6128356bebe2c5bf0dfe29ec1b99800 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Mon, 19 Oct 2026 17:22:46 -0400 Subject: [PATCH 18/18] Rename the Query::limit() quantity parameter and property --- CHANGELOG-7.0.md | 9 +++++++++ docs/querying-the-database.md | 8 +++++--- src/Query.php | 16 ++++++++-------- src/Union.php | 8 ++++---- tests/RepositoryTest.php | 14 ++++++++++++++ tests/UnionTest.php | 12 ++++++++++++ 6 files changed, 52 insertions(+), 15 deletions(-) diff --git a/CHANGELOG-7.0.md b/CHANGELOG-7.0.md index f259923..273bc4b 100644 --- a/CHANGELOG-7.0.md +++ b/CHANGELOG-7.0.md @@ -152,6 +152,14 @@ no observers are registered), and multi-connection safe. Record) — it keeps your base table, supports INNER/LEFT/RIGHT, and auto-joins intermediates. The relationship registry (`addRelationship`/`getRelationship`/`getRelationshipData`) is unchanged +### Query::limit() second parameter renamed to $limit +- `Query::limit()` and `Union::limit()` named their second parameter `$end`, but it has always been the number + of rows to return (the dialects build `LIMIT start, quantity`), not an end position. It is now `$limit`: + `limit(int $start, int $limit)`. Positional calls are unaffected; calls using the named argument + `limit(end: ...)` must change to `limit(limit: ...)` +- The protected property `Query::$limitEnd` that stores it is renamed to `Query::$limitQuantity`. Subclasses of + `Query` reading or writing `$this->limitEnd` must use `$this->limitQuantity` + ## Bug Fixes - `Repository::getByFilter()` paging (and `ActiveRecord::all()`, which uses it) returned the wrong rows past the @@ -173,6 +181,7 @@ no observers are registered), and multi-connection safe. | 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 | +| `->limit(start: $s, end: $n)` (named argument) | `->limit(start: $s, limit: $n)` | ## Requirements diff --git a/docs/querying-the-database.md b/docs/querying-the-database.md index 44357c5..1e090e5 100644 --- a/docs/querying-the-database.md +++ b/docs/querying-the-database.md @@ -197,14 +197,16 @@ e.g.: ->orderBy(['field1', 'field2']) ``` -#### limit(int $start, int $pageSize) +#### limit(int $start, int $limit) -Limit the number of records to retrieve. +Return `$limit` records starting at the zero-based offset `$start`. The second argument is the number of +records, not an end position. e.g.: ```php -->limit(10, 20) +->limit(10, 20) // 20 records, starting at the 11th +->limit(start: 10, limit: 20) // the same, with named arguments ``` #### top(int $top) diff --git a/src/Query.php b/src/Query.php index 6b718cf..30257d2 100644 --- a/src/Query.php +++ b/src/Query.php @@ -11,7 +11,7 @@ class Query extends QueryBasic { protected array $orderBy = []; protected ?int $limitStart = null; - protected ?int $limitEnd = null; + protected ?int $limitQuantity = null; protected ?int $top = null; protected bool $forUpdate = false; @@ -44,18 +44,18 @@ public function forUpdate(): static } /** - * @param int $start - * @param int $end + * @param int $start Zero-based offset of the first row + * @param int $limit Number of rows to return (not an end position) * @return $this * @throws InvalidArgumentException */ - public function limit(int $start, int $end): static + public function limit(int $start, int $limit): static { if (!is_null($this->top)) { throw new InvalidArgumentException('You cannot mix TOP and LIMIT'); } $this->limitStart = $start; - $this->limitEnd = $end; + $this->limitQuantity = $limit; return $this; } @@ -162,11 +162,11 @@ protected function addLimit(?DbDriverInterface $dbDriver, string $sql): string throw new InvalidArgumentException('To get Limit and Top working you have to pass the DbDriver'); } - if ($this->limitEnd === null) { - throw new InvalidArgumentException('Limit end cannot be null'); + if ($this->limitQuantity === null) { + throw new InvalidArgumentException('Limit quantity cannot be null'); } - return $dbDriver->getSqlDialect()->limit($sql, $this->limitStart, $this->limitEnd); + return $dbDriver->getSqlDialect()->limit($sql, $this->limitStart, $this->limitQuantity); } /** diff --git a/src/Union.php b/src/Union.php index 9315b1f..f8424a4 100644 --- a/src/Union.php +++ b/src/Union.php @@ -64,14 +64,14 @@ public function groupBy(array $fields): Union } /** - * @param int $start - * @param int $end + * @param int $start Zero-based offset of the first row + * @param int $limit Number of rows to return (not an end position) * @return $this * @throws InvalidArgumentException */ - public function limit(int $start, int $end): Union + public function limit(int $start, int $limit): Union { - $this->queryAggregation->limit($start, $end); + $this->queryAggregation->limit($start, $limit); return $this; } diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index b9b4655..76f31a6 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -952,6 +952,20 @@ public function testLimit() $this->assertEquals(1, count($result)); } + public function testLimitNamedArguments() + { + // The second argument is the number of rows, not an end position + $query = $this->repository->queryInstance() + ->orderBy(['id']) + ->limit(start: 1, limit: 2); + + $result = $this->repository->getByQuery($query); + + $this->assertCount(2, $result); + $this->assertEquals(2, $result[0]->getId()); + $this->assertEquals(3, $result[1]->getId()); + } + public function testQueryRaw() { $query = $this->repository->queryInstance() diff --git a/tests/UnionTest.php b/tests/UnionTest.php index bbe2eb6..5895724 100644 --- a/tests/UnionTest.php +++ b/tests/UnionTest.php @@ -35,6 +35,18 @@ public function testAddQueryWithTop() $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParams()); } + public function testAddQueryWithLimitNamedArguments() + { + $union = new Union(); + $union->addQuery(QueryBasic::getInstance()->table("table1")->fields(['name', 'price'])->where('name like :name', ['name' => 'a%'])); + $union->addQuery(QueryBasic::getInstance()->table("table2")->fields(['name', 'price'])->where('price > :price', ['price' => 10])); + $union->limit(start: 20, limit: 10); + + $build = $union->build(Factory::getDbInstance(new Uri('sqlite:///tmp/teste.db'))); + + $this->assertEquals("SELECT name, price FROM table1 WHERE name like :name UNION SELECT name, price FROM table2 WHERE price > :price LIMIT 20, 10", $build->getSql()); + } + public function testAddQueryWithOrderBy() { $union = new Union();