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 0784ef2..28e142d 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" @@ -36,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@v5 + - 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 @@ -51,12 +80,12 @@ 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: - name: Git checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Composer run: composer install @@ -64,7 +93,7 @@ jobs: - 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: ./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-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..273bc4b --- /dev/null +++ b/CHANGELOG-7.0.md @@ -0,0 +1,224 @@ +# 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 +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. + +`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) +- `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 + 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 `BeforeQuery`/`AfterQuery` and `BeforeExecute`/`AfterExecute` for every + statement, including reads and raw SQL) +- New `Repository::removeObserver()` for both observer kinds + +### Relationship joins on the query builder +- New `QueryBasic::joinRelated()`, `leftJoinRelated()` and `rightJoinRelated()`: add a JOIN whose + `ON` condition is derived from a registered `parentTable` relationship, connecting the table to one + already in the query. If they are not directly related, the intermediate tables on the shortest + relationship path are joined automatically; tables already in the query are skipped. The query + keeps its own base table, so this composes with a repository query +- `ActiveRecord::joinWith()` now builds on `joinRelated()` (starting from the model's own table) +- `joinRelated()`/`leftJoinRelated()`/`rightJoinRelated()`/`joinWith()` accept a **model class** + as well as a table name. Passing a class (e.g. `Project::class`) registers that entity's mapper + on demand — reflection only, no DB connection — so its table and `parentTable` relationships + become known even on a request that never touched its repository. To span a hidden intermediate, + name each entity in the path, like Eloquent's `hasManyThrough` through-model: + `Note::joinWith(Task::class, Project::class)`. New helper `ORM::getTableFromClass()` performs the + resolution +- See: `docs/auto-discovering-relationship.md` + +### 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 +- `FieldUuidAttribute` accepts `parentTable`, like `FieldAttribute`, to declare a foreign key relationship +- 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 +- `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 + +### 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 +- 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` +- Remove any `ORMSubject::getInstance()->clearObservers()` calls (typically in test suites) — + bridges die with their executors, there is no global state to clear + +### Observer scope: global → per executor +- Observers now fire for writes flowing through the `DatabaseExecutor` instance(s) of the + repository where they were registered. Two repositories over different executors no longer + cross-notify, even on the same database. Register the observer on each connection if needed +- `addDbDriverForWrite()` re-attaches the repository observers to the new write executor + +### More events than before +- Soft deletes (`SoftDelete`), `bulkExecute()` writes, `buildAndExecute()` and direct executions of + built statements now notify. Review observers that assumed only `save()`/`deleteByQuery()` fired + +### Event payload for non-save() writes +- Events from writes without an entity in scope (bulk, direct executions) carry the SQL parameters + array in `getData()`/`getOldData()` instead of entity instances. Use `getStatement()` for the SQL + +### Repository protected helpers +- `insert()`, `insertWithAutoInc()`, `insertWithKeyGen()` and `update()` now receive the pre-built + `OrmSqlStatement` instead of the query builder. Subclasses overriding them must adopt the new + signatures + +### ObserverEvent enum +- New `SoftDelete` case: exhaustive `match` expressions over `ObserverEvent` in userland need a new + arm + +### ORM::getQueryInstance() removed +- `ORM::getQueryInstance(...$tables)` is removed. Build relationship joins with + `Query::getInstance()->table($base)->joinRelated($related)` (or `Model::joinWith(...)` for Active + Record) — it keeps your base table, supports INNER/LEFT/RIGHT, and auto-joins intermediates. The + relationship registry (`addRelationship`/`getRelationship`/`getRelationshipData`) is unchanged + +### 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 + 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 + +## 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 | +| `->limit(start: $s, end: $n)` (named argument) | `->limit(start: $s, limit: $n)` | + +## 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 is installed as `psalm/phar` instead of `vimeo/psalm`. + + `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. + +## 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. + +## Housekeeping + +- `phpunit.xml.dist` renamed to `phpunit.xml`. diff --git a/README.md b/README.md index a1fdf9c..01f3d54 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ 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/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: @@ -120,7 +121,7 @@ class MyModel #[FieldAttribute()] public ?string $name = null; - #[FieldAttribute(fieldName: 'company_id') + #[FieldAttribute(fieldName: 'company_id')] public ?int $companyId = null; } ``` @@ -139,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 @@ -203,7 +205,15 @@ $result = $repository->getByQuery($query); * [Building SQL Queries](docs/query-build.md) * [UUID Support](docs/uuid-support.md) -## Install +## 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: @@ -217,21 +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) - -## 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 +[Open source ByJG](https://opensource.byjg.com) \ No newline at end of file diff --git a/composer.json b/composer.json index 9bfd58a..31688ff 100644 --- a/composer.json +++ b/composer.json @@ -14,15 +14,15 @@ "prefer-stable": true, "minimum-stability": "dev", "require": { - "php": ">=8.3 <8.6", + "php": ">=8.3 <8.7", "ext-json": "*", "ext-pdo": "*", - "byjg/anydataset-db": "^6.0" + "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", + "psalm/phar": "^6.16" }, "suggest": { "ext-curl": "*", @@ -30,7 +30,7 @@ }, "scripts": { "test": "vendor/bin/phpunit", - "psalm": "vendor/bin/psalm --threads=1" + "psalm": "vendor/bin/psalm.phar" }, "license": "MIT" } 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/active-record.md b/docs/active-record.md index 624ae8d..bdc5b73 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: @@ -103,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 @@ -119,6 +124,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']) @@ -142,7 +150,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; } @@ -154,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/architecture-layers.md b/docs/architecture-layers.md index e7ec012..8421bdb 100644 --- a/docs/architecture-layers.md +++ b/docs/architecture-layers.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 20 +--- + # Architecture Layers: Infrastructure vs Domain ## Overview @@ -164,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 3100f35..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 @@ -42,32 +42,85 @@ relationship registry. ## Generating Queries with Relationships -To generate the SQL query with the relationship, you can use the `ORM` static class: +Once the relationships are registered, use `Query::joinRelated()` to add a +relationship-derived join to a query. It derives the `ON` condition from the +registered `parentTable` relationship instead of writing it by hand: ```php table('table2') + ->joinRelated('table1'); +// SELECT * FROM table2 INNER JOIN table1 ON table1.id = table2.id_table1 ``` -The command above will return a query object with the appropriate join, equivalent to: +`joinRelated()` appends the join to whichever table is already in the query (the base +table or a previously joined one), so it keeps your own base table. Because of that it +composes on top of an existing query — including one a `Repository` already scoped to +its table, where `$repository->getByQuery($query)` still returns that repository's +entity. + +There are `joinRelated()`, `leftJoinRelated()` and `rightJoinRelated()`, mirroring +`join()`/`leftJoin()`/`rightJoin()`, so you can pick the join type. + +If the target table is **not directly related** to a table already in the query, the +intermediate tables on the shortest relationship path are joined automatically — you +don't have to remember them (though keep in mind the extra joins have a cost). Tables +already in the query are skipped, so chaining stays consistent: ```php table('table1') - ->join('table2', 'table2.id_table1 = table1.id'); +// table1 -> table2 -> table4: table2 is joined for you. +$query = Query::getInstance()->table('table1')->joinRelated('table4'); +// SELECT * FROM table1 +// INNER JOIN table2 ON table1.id = table2.id_table1 +// INNER JOIN table4 ON table2.id = table4.id_table2 + +// Naming the intermediate explicitly gives the same result (table2 is not joined twice): +$query = Query::getInstance()->table('table1')->joinRelated('table2')->joinRelated('table4'); ``` -## Queries with Multiple Tables +For a `left`/`rightJoinRelated()` that spans intermediates, every hop it adds uses that +join type. `joinRelated()` throws an `InvalidArgumentException` only when no relationship +path connects the tables. For aliased joins, use `join()`/`leftJoin()`/`rightJoin()` with +an explicit `ON`. + +Active Record models expose the same through `Model::joinWith(...$tables)`, which starts +from the model's own table and joins the related ones. + +### Passing a model class (on-demand mapper registration) + +`joinRelated()` (and `joinWith()`) accept a **model class** as well as a table name. +Relationship discovery walks the ORM's registry of mappers, and a mapper is only known +after it has been built. On a request that only touched one entity, the other entities' +mappers are not registered yet, so their relationships are invisible. Passing a class +fixes that: it registers that entity's mapper on demand — this reads the class attributes +(reflection) only and does **not** open a database connection — before resolving the join. + +```php +table('task')->joinRelated(Project::class); +// Project's mapper is registered on demand, then: +// SELECT * FROM task INNER JOIN project ON project.id = task.project_id +``` -You can also create queries with multiple joined tables by passing more table names: +To span a **hidden intermediate**, name each entity in the path — the same way Eloquent's +`hasManyThrough` names its through-model. Auto-discovery can only join an intermediate +whose mapper is registered, so naming it is what makes it known: ```php task -> project. A note has no project_id, so 'task' is the through-entity. +$notes = Note::joinWith(Task::class, Project::class) + ->field('note.*') + ->where('project.id = :id', ['id' => $projectId]); +// FROM note INNER JOIN task ON task.id = note.task_id +// INNER JOIN project ON project.id = task.project_id ``` -The ORM will automatically discover the path to connect these tables if relationships have been defined. +`ORM::getTableFromClass(Model::class)` exposes the same resolution directly, returning the +table name and registering the mapper if needed. ## Manual Relationship Definition 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 aa52c74..f9fea7d 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 @@ -43,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 { @@ -127,9 +131,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]) { @@ -425,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 @@ -815,7 +816,7 @@ $user->name = 'New Name'; $user->save(); $activeUsers = User::filter( - (new IteratorFilter())->and('status', Relation::EQUAL, 'active') + (new IteratorFilter())->and('status', Relation::Equal, 'active') ); ``` @@ -912,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()) { @@ -928,7 +929,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); @@ -1014,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() @@ -1081,13 +1082,13 @@ 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 { - 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 d273415..3878e66 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 | @@ -132,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/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/observers.md b/docs/observers.md index a923b89..2307ab1 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. BeforeExecute / AfterExecute| 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,63 @@ $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. + +`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 +`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 (`BeforeQuery`, `AfterQuery`, +`BeforeExecute`, `AfterExecute`) for every statement, including reads and raw SQL. diff --git a/docs/query-build.md b/docs/query-build.md index 7179e62..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 @@ -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/query-raw.md b/docs/query-raw.md index d9d86cb..7f0cfb5 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, @@ -22,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..1e090e5 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. @@ -185,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) @@ -205,6 +219,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 f05e764..126e2ab 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 @@ -80,25 +86,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 @@ -128,7 +134,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); ``` @@ -136,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); ``` @@ -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 ea3bbda..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', @@ -230,4 +231,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/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 ebee9ef..7a3a81a 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 +250,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 +317,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 @@ -437,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(); @@ -447,7 +440,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/phpunit.xml.dist b/phpunit.xml similarity index 95% rename from phpunit.xml.dist rename to phpunit.xml index a25ebca..d605383 100644 --- a/phpunit.xml.dist +++ 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 ca6a5ef..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 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/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/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/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 @@ +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/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/ORM.php b/src/ORM.php index 2973e55..7968e14 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"]); @@ -125,39 +126,26 @@ public static function getMapper(string $tableName): ?Mapper return static::$mapper[$tableName] ?? null; } - public static function getQueryInstance(string ...$tables): Query + /** + * 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 { - $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; + foreach (static::$mapper as $mapper) { + if ($mapper->getEntity() === $class) { + return $mapper->getTable(); } - $query->join($child, "{$parentAlis}.{$primaryKey} = {$childAlias}.{$foreignKey}", $childAlias); } - return $query; + return (new Mapper($class))->getTable(); } private static function getNormalizedKey(string $table1, string $table2): string diff --git a/src/ORMSubject.php b/src/ORMSubject.php deleted file mode 100644 index 72d4de4..0000000 --- a/src/ORMSubject.php +++ /dev/null @@ -1,70 +0,0 @@ - - */ - 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..e032c06 --- /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::BeforeExecute, DatabaseEventTypeEnum::AfterExecute]; + } + + #[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::BeforeExecute) { + 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; + } + + // AfterExecute + $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..314ed94 --- /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 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). + */ + 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..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; } @@ -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 @@ -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/QueryBasic.php b/src/QueryBasic.php index 7a6265d..03d0dc1 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -167,6 +167,122 @@ 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. + * + * $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 + * + * $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. + * + * @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 + { + 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 + { + // 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)) { + 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; @@ -335,7 +451,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..f733684 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; @@ -26,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; @@ -64,6 +66,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 +105,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 @@ -246,9 +272,12 @@ 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); - $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 +314,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 +334,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 +378,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 +406,6 @@ public function deleteByQuery(DeleteQuery $updatable): bool $this->getExecutorWrite()->execute($sqlStatement); - ORMSubject::getInstance()->notify($this->mapper->getTable(), ObserverEvent::Delete, null, $sqlStatement->getParams()); - return true; } @@ -399,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); @@ -420,7 +462,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); } @@ -576,18 +618,28 @@ 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) ?? []; - 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); + } } - $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 +661,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 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 + { + $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 @@ -705,6 +773,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)) { @@ -722,69 +798,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 (BeforeQuery, AfterQuery, BeforeExecute, AfterExecute). + * * @throws InvalidArgumentException */ - public function addObserver(ObserverProcessorInterface $observerProcessor): void + public function addObserver(ObserverProcessorInterface|DatabaseEventObserverInterface $observer): void { - ORMSubject::getInstance()->addObserver($observerProcessor, $this); + 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 + { + 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/Trait/ActiveRecord.php b/src/Trait/ActiveRecord.php index 0a3d411..2485c2f 100644 --- a/src/Trait/ActiveRecord.php +++ b/src/Trait/ActiveRecord.php @@ -120,11 +120,25 @@ 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. 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 { 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 @@ -163,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/src/Union.php b/src/Union.php index 369f397..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; } @@ -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/test.php b/test.php deleted file mode 100644 index fd91504..0000000 --- a/test.php +++ /dev/null @@ -1,16 +0,0 @@ -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/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/MapperFunctionsTest.php b/tests/MapperFunctionsTest.php index ca876a1..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->createMock(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->createMock(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 @@ +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()); @@ -138,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(); @@ -158,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'")]); @@ -170,4 +149,129 @@ public function testProcessLiteralString() $this->assertEquals("SELECT * FROM table1 WHERE field1 = 'testando' AND field2 = 'Joana D''Arc'", $sql); $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. + // 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 = Query::getInstance()->table('table1')->joinRelated('table5')->build()->getSql(); + $this->assertStringContainsString('table1.id = table5.id_table1', $sql); + $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 + // 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 = Query::getInstance()->table('table1')->joinRelated('table5')->build()->getSql(); + $this->assertStringContainsString('INNER JOIN table5', $sql); + $this->assertStringContainsString('table1.id = table5.id_table1', $sql); + } } diff --git a/tests/ObserverBridgeTest.php b/tests/ObserverBridgeTest.php new file mode 100644 index 0000000..2cf57e6 --- /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 BeforeExecute 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::BeforeExecute, DatabaseEventTypeEnum::AfterExecute]; + } + + #[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::BeforeExecute, $types); + $this->assertContains(DatabaseEventTypeEnum::AfterExecute, $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/PostgresRepositoryAutoIncrementTest.php b/tests/PostgresRepositoryAutoIncrementTest.php new file mode 100644 index 0000000..22bd558 --- /dev/null +++ b/tests/PostgresRepositoryAutoIncrementTest.php @@ -0,0 +1,49 @@ +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 @@ +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/QueryTest.php b/tests/QueryTest.php index 74757d2..4359f90 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,15 +101,15 @@ 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() ); $iteratorFilter = new IteratorFilter(); - $iteratorFilter->and('fld4', Relation::EQUAL, 40); + $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() ); } @@ -118,15 +119,15 @@ 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); $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/RepositoryBackedEnumTest.php b/tests/RepositoryBackedEnumTest.php new file mode 100644 index 0000000..125e37c --- /dev/null +++ b/tests/RepositoryBackedEnumTest.php @@ -0,0 +1,69 @@ +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); + } +} diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 0932e5b..76f31a6 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, @@ -186,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()); @@ -194,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'); @@ -301,6 +309,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); @@ -374,7 +402,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 +642,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 ); @@ -924,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() @@ -1472,7 +1514,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()); @@ -1702,7 +1744,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()); diff --git a/tests/RepositoryTimestampsTestCase.php b/tests/RepositoryTimestampsTestCase.php new file mode 100644 index 0000000..64e2b39 --- /dev/null +++ b/tests/RepositoryTimestampsTestCase.php @@ -0,0 +1,100 @@ +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/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/SqlServerDblibRepositoryTimestampsTest.php b/tests/SqlServerDblibRepositoryTimestampsTest.php new file mode 100644 index 0000000..0ae9ffe --- /dev/null +++ b/tests/SqlServerDblibRepositoryTimestampsTest.php @@ -0,0 +1,30 @@ +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/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(); 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() ); }