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;
[](https://github.com/sponsors/byjg)
[](https://github.com/byjg/php-micro-orm/actions/workflows/phpunit.yml)
-[](http://opensource.byjg.com)
+[](https://opensource.byjg.com)
+[](https://opensource.byjg.com/docs/ai/mcpserver-byjg-docs/)
[](https://github.com/byjg/php-micro-orm/)
-[](https://opensource.byjg.com/opensource/licensing.html)
+[](https://opensource.byjg.com/license/)
[](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()
);
}