diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 7a8e244..21d0cfb 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -16,6 +16,7 @@ jobs: strategy: matrix: php-version: + - "8.4" - "8.3" - "8.2" - "8.1" @@ -40,8 +41,8 @@ jobs: steps: - uses: actions/checkout@v4 - run: composer install - - run: ./vendor/bin/phpunit - run: ./vendor/bin/psalm + - run: ./vendor/bin/phpunit Documentation: if: github.ref == 'refs/heads/master' @@ -50,5 +51,6 @@ jobs: with: folder: php project: ${{ github.event.repository.name }} - secrets: inherit + secrets: + DOC_TOKEN: ${{ secrets.DOC_TOKEN }} diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 0000000..91893fd --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..ade1bf7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,35 @@ +{ + "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/README.md b/README.md index f834130..dc8fabb 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,21 @@ Key Features: * Can be used with any DTO, Entity, Model or whatever class with public properties or with getter and setter * The repository support a variety of datasources: MySql, Sqlite, Postgres, MySQL, Oracle (see byjg/anydataset) * A class Mapper is used for mapping the Entity and the repository +* Powerful mapper functions for automatic data transformation between models and database * Small and simple to use ## Architecture +MicroORM implements **Martin Fowler's enterprise patterns**: + +- **[Repository](https://martinfowler.com/eaaCatalog/repository.html)**: Mediates between domain and data mapping layers +- **[Data Mapper](https://martinfowler.com/eaaCatalog/dataMapper.html)**: Separates domain objects from database tables +- **[Active Record](https://martinfowler.com/eaaCatalog/activeRecord.html)**: Wraps database rows with domain logic ( + alternative approach) + +You can choose the pattern that best fits your application: use Repository + Data Mapper for complex domains, or Active +Record for simpler CRUD-focused applications. + These are the key components: ```text @@ -35,26 +46,35 @@ These are the key components: │ ┌───────────────┴─────┐ │ │ Query │ │ └───────────────┬─────┘ -│ │ -│ ┌───────────────┴─────┐ -│ │ DbDriverInterface │───────────────┐ -│ └───────────────┬─────┘ │ -│ │ │ -└──────────────────────────┘ .─────────. - │ │ - │`─────────'│ - │ │ - │ DB │ - │ │ - │ │ - `─────────' +│ │ │ +│ ┌───────────────┴─────┐ ┌──────────────────────┐ +│ │ DatabaseExecutor │────────│ DbDriverInterface │ +│ └───────────────┬─────┘ └────────────┬─────────┘ +│ │ │ +└──────────────────────────┘ .─────────. + │ │ + │`─────────'│ + │ │ + │ DB │ + │ │ + │ │ + `─────────' ``` -* Model is a get/set class to retrieve or save the data into the database -* Mapper will create the definitions to map the Model into the Database. -* Query will use the Mapper to prepare the query to the database based on DbDriverInterface -* DbDriverIntarce is the implementation to the Database connection. -* Repository put all this together +* **Model** can be any class with public properties or with getter and setter. It is used to retrieve or save the data + into the database +* **Mapper** defines the relationship between the Model properties and the database fields +* **FieldMapping** defines individual field mappings within the Mapper (field names, transformations, relationships via + `parentTable`) +* **Query** defines what to retrieve from/update in the database. It uses the Mapper to prepare the query to the + database converting the Model properties to database fields +* **DatabaseExecutor** (external package) wraps the DbDriver and provides transaction management, query execution, and + access to database helpers +* **DbDriverInterface** (external package) is the actual database driver implementation that connects to the database +* **Repository** orchestrates all MicroORM components and uses DatabaseExecutor to interact with the database + +For a detailed explanation of the architecture and when to use each layer, +see [Architecture Layers: Infrastructure vs Domain](docs/architecture-layers.md). ## Getting Started @@ -89,20 +109,21 @@ Let's look at an example: class MyModel { #[FieldAttribute(primaryKey: true)] - public ?int $id; + public ?int $id = null; #[FieldAttribute()] - public ?string $name; + public ?string $name = null; #[FieldAttribute(fieldName: 'company_id') - public ?int $companyId; + public ?int $companyId = null; } ``` In this example, we have a class `MyModel` with three properties: `id`, `name`, and `companyId`. -The `id` property is marked as a primary key. The `name` property is a simple field. -The `companyId` property is a field with a different name in the database `company_id`. +* The `id` property is marked as a primary key. +* The `name` property is a simple field. +* The `companyId` property is a field with a different name in the database `company_id`. The `TableAttribute` is used to define the table name in the database. @@ -111,8 +132,7 @@ The `TableAttribute` is used to define the table name in the database. After defining the Model, you can connect the Model with the repository. ```php -$dbDriver = \ByJG\AnyDataset\Db\Factory::getDbRelationalInstance('mysql://user:password@server/schema'); - +$dbDriver = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@server/schema'); $repository = new \ByJG\MicroOrm\Repository($dbDriver, MyModel::class); ``` @@ -153,6 +173,11 @@ $result = $repository->getByQuery($query); * [Querying the Database](docs/querying-the-database.md) * [Updating the database](docs/updating-the-database.md) * [Using the Mapper Object](docs/using-mapper-object.md) +* [The Model Attributes](docs/model-attribute.md) +* [The Repository Class](docs/repository.md) +* [Common Traits for Timestamp Fields](docs/common-traits.md) +* [Architecture Layers: Infrastructure vs Domain](docs/architecture-layers.md) +* [Comparison with Other ORMs (Eloquent, Doctrine)](docs/comparison-with-other-orms.md) ## Advanced Topics @@ -163,10 +188,14 @@ $result = $repository->getByQuery($query); * [Caching the Results](docs/cache.md) * [Observing the Database](docs/observers.md) * [Controlling the data queried/updated](docs/controlling-the-data.md) +* [Mapper Functions](docs/mapper-functions.md) * [Using FieldAlias](docs/using-fieldalias.md) * [Tables without auto increments fields](docs/tables-without-auto-increment-fields.md) * [Using With Recursive SQL Command](docs/using-with-recursive-sql-command.md) * [QueryRaw (raw SQL)](docs/query-raw.md) +* [Update Constraints](docs/update-constraints.md) +* [Building SQL Queries](docs/query-build.md) +* [UUID Support](docs/uuid-support.md) ## Install @@ -199,4 +228,4 @@ flowchart TD ``` ---- -[Open source ByJG](http://opensource.byjg.com) +[Open source ByJG](http://opensource.byjg.com) \ No newline at end of file diff --git a/composer.json b/composer.json index c475bea..4c2ef10 100644 --- a/composer.json +++ b/composer.json @@ -14,15 +14,15 @@ "prefer-stable": true, "minimum-stability": "dev", "require": { - "php": ">=8.1 <8.4", + "php": ">=8.1 <8.5", "ext-json": "*", "ext-pdo": "*", - "byjg/anydataset-db": "^5.0" + "byjg/anydataset-db": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^9.6", - "byjg/cache-engine": "^5.0", - "vimeo/psalm": "^5.9" + "phpunit/phpunit": "^10.5|^11.5", + "byjg/cache-engine": "^6.0", + "vimeo/psalm": "^5.9|^6.2" }, "suggest": { "ext-curl": "*", diff --git a/docs/active-record.md b/docs/active-record.md index bd3fede..624ae8d 100644 --- a/docs/active-record.md +++ b/docs/active-record.md @@ -1,22 +1,54 @@ +--- +sidebar_position: 5 +--- + # Active Record +## Pattern Overview + +Active Record is an implementation of **Martin +Fowler's [Active Record pattern](https://martinfowler.com/eaaCatalog/activeRecord.html)**: + +> *"An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on +that data."* +> +> — Martin Fowler, Patterns of Enterprise Application Architecture + Active Record is the M in MVC - the model - which is the layer of the system responsible -for representing business data and logic. Active Record facilitates the creation and use of +for representing business data and logic. It facilitates the creation and use of business objects whose data requires persistent storage to a database. -It is an implementation of the Active Record pattern which itself is a description of an -Object Relational Mapping system. -## How to use Active Record +**Key Characteristics:** + +- Each Active Record instance represents a single row in the database +- The object carries both data and behavior +- Provides methods like `save()`, `delete()`, `get()`, `all()`, etc. +- Database operations are directly available on the domain object + +**When to use Active Record vs Repository/Data Mapper:** + +| Aspect | Active Record | Repository + Data Mapper | +|--------------------|------------------------------------|-------------------------------| +| **Complexity** | Simple, straightforward | More complex, more layers | +| **Best for** | Simple domains, CRUD-heavy apps | Complex domain logic, DDD | +| **Coupling** | Domain objects know about database | Domain objects are pure | +| **Learning curve** | Lower | Higher | +| **Testability** | Harder (objects tied to DB) | Easier (dependency injection) | + +💡 **Tip**: For simple applications or prototypes, Active Record is perfect. For complex domain logic or when following +Domain-Driven Design principles, prefer Repository + Data Mapper. + +## How to Use Active Record -- Create a model and add the annotations to the class and properties. (see [Model](model.md)) -- Add the `ActiveRecord` trait to your model. -- Initialize the Active Record with the `initialize` static method (need to do only once) +1. Create a model and add the annotations to the class and properties ( + see [Getting Started with Models and Attributes](getting-started-model.md)) +2. Add the `ActiveRecord` trait to your model +3. Initialize the Active Record with the `initialize` static method (need to do only once) -e.g.: +### Example ```php -someProperty = 123; $myClass->save(); -// Another example Create a new instance +// Or create with initial values $myClass = MyClass::new(['someProperty' => 123]); $myClass->save(); ``` -### Retrieve a record +### Retrieve a Record ```php -someProperty = 456; $myClass->save(); ``` -### Complex Filter +### Query with Fluent API + +The new fluent query API provides a more intuitive way to build and execute queries: + +```php +// Get first record matching criteria +$myClass = MyClass::where('someProperty = :value', ['value' => 123]) + ->first(); + +// Get first record or throw exception if not found +$myClass = MyClass::where('someProperty = :value', ['value' => 123]) + ->firstOrFail(); + +// Check if records exist +if (MyClass::where('someProperty > :min', ['min' => 100])->exists()) { + echo "Records found!"; +} + +// Get all matching records as array +$myClassList = MyClass::where('someProperty = :value', ['value' => 123]) + ->orderBy(['id DESC']) + ->toArray(); + +// Chain multiple conditions +$myClass = MyClass::where('someProperty > :min', ['min' => 100]) + ->where('someProperty < :max', ['max' => 200]) + ->orderBy(['someProperty']) + ->first(); + +// Build query step by step +$query = MyClass::newQuery() + ->where('someProperty = :value', ['value' => 123]) + ->orderBy(['id DESC']) + ->limit(0, 10); + +$results = $query->toArray(); +``` + +### Complex Filtering (Legacy) ```php -and('someProperty', Relation::EQUAL, 123)); foreach ($myClassList as $myClass) { echo $myClass->someProperty; } ``` -### Delete a record +### Get All Records + +```php +// Get all records (paginated, default is page 0, limit 50) +$myClassList = MyClass::all(); + +// Get page 2 with 20 records per page +$myClassList = MyClass::all(2, 20); +``` + +### Delete a Record ```php -delete(); ``` -### Refresh a record +### Refresh a Record ```php -refresh(); ``` -### Update a model from another model or array +### Update a Model from Another Model or Array ```php +// Get a record +$myClass = MyClass::get(1); -### Using the `Query` class +// Update from array +$myClass->fill(['someProperty' => 789]); + +// Update from another model +$anotherModel = MyClass::new(['someProperty' => 789]); +$myClass->fill($anotherModel); + +// Save changes +$myClass->save(); +``` + +### Convert to Array + +```php +$myClass = MyClass::get(1); + +// Convert to array (excluding null values) +$array = $myClass->toArray(); + +// Convert to array (including null values) +$array = $myClass->toArray(true); +``` + +### Using the `Query` Class ```php - *"Mediates between the domain and data mapping layers using a collection-like interface for accessing domain +objects."* +> +> — Martin +> Fowler, [Patterns of Enterprise Application Architecture](https://martinfowler.com/eaaCatalog/repository.html) + +**In MicroORM:** + +- The `Repository` class acts as a collection-like interface +- Isolates domain objects from database access code +- Provides a clean separation between domain and data layers +- Handles entity lifecycle (CRUD operations) + +### Data Mapper Pattern + +> *"A layer of software that separates the in-memory objects from the database. Its responsibility is to transfer data +between the two and also to isolate them from each other."* +> +> — Martin +> Fowler, [Patterns of Enterprise Application Architecture](https://martinfowler.com/eaaCatalog/dataMapper.html) + +**In MicroORM:** + +- The `Mapper` class defines the relationship between entities and database tables +- Translates between domain objects (entities) and database rows +- Keeps domain objects independent of database schema +- Allows field name mapping, transformations, and type conversions + +**Why These Patterns Matter:** + +- ✅ **Testability**: Mock repositories without touching the database +- ✅ **Flexibility**: Change database schema without changing domain objects +- ✅ **Separation of Concerns**: Domain logic stays pure, database logic stays isolated +- ✅ **Maintainability**: Clear boundaries make code easier to understand and modify + +### Active Record Pattern (Alternative Approach) + +> *"An object that wraps a row in a database table or view, encapsulates the database access, and adds domain logic on +that data."* +> +> — Martin +> Fowler, [Patterns of Enterprise Application Architecture](https://martinfowler.com/eaaCatalog/activeRecord.html) + +**In MicroORM:** + +- The `ActiveRecord` trait provides static methods like `get()`, `save()`, `delete()`, etc. +- Each Active Record instance represents a single database row +- Database operations are directly available on domain objects +- Simpler than Repository/Data Mapper for straightforward CRUD applications + +**Pattern Comparison:** + +``` +Repository + Data Mapper Active Record +├─ Domain objects are pure ├─ Domain objects know about DB +├─ More layers/complexity ├─ Fewer layers/simpler +├─ Better for complex domains ├─ Better for simple domains +├─ Easier to test (DI) ├─ Harder to test (static methods) +└─ More flexible └─ More convenient +``` + +**Choosing Your Pattern:** + +- Use **Active Record** for: Simple apps, prototypes, CRUD-heavy applications +- Use **Repository + Data Mapper** for: Complex domain logic, Domain-Driven Design, enterprise applications + +See [Active Record Documentation](active-record.md) for detailed usage examples. + +## The Two Layers + +### Infrastructure Layer + +**Purpose**: Raw database access without entity knowledge +**Key Methods**: `Query::buildAndGetIterator()`, `Query::build()` +**Returns**: Raw database rows (associative arrays) + +### Domain Layer + +**Purpose**: Entity-aware data access with automatic mapping +**Key Methods**: `Repository::getIterator()`, `Repository::getByQuery()` +**Returns**: Domain entities (objects) + +## When to Use Each Layer + +### Use Infrastructure Layer (`Query::buildAndGetIterator()`) For: + +✅ **Migration Scripts** + +```php +// Migration: Export data for backup +$query = Query::getInstance() + ->table('users') + ->where('created_at < :cutoff', ['cutoff' => '2020-01-01']); + +$rows = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray(); +file_put_contents('backup.json', json_encode($rows)); +``` + +✅ **Utility/Admin Tools** + +```php +// Admin tool: Generate report with raw data +$query = QueryRaw::getInstance(" + SELECT DATE(created_at) as date, COUNT(*) as count + FROM users + GROUP BY DATE(created_at) +"); + +$stats = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray(); +``` + +✅ **Testing Query Building Logic** + +```php +// Test: Verify SQL generation +public function testQueryBuilder() +{ + $query = Query::getInstance() + ->table('users') + ->where('status = :status', ['status' => 'active']); + + $sql = $query->build($driver)->getSql(); + $this->assertEquals('SELECT * FROM users WHERE status = :status', $sql); +} +``` + +✅ **Working Without a Repository** + +```php +// Standalone script without entity models +$query = Query::getInstance() + ->table('logs') + ->where('level = :level', ['level' => 'ERROR']); + +$errors = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray(); +``` + +### Use Domain Layer (`Repository` methods) For: + +✅ **Application/Business Logic** + +```php +// Application: Get active users +$query = $userRepo->queryInstance() + ->where('status = :status', ['status' => 'active']); + +$users = $userRepo->getIterator($query)->toEntities(); // Returns User[] objects +``` + +✅ **Standard CRUD Operations** + +```php +// Business logic: Find user by email +$user = $repository->getByFilter(['email' => 'user@example.com']); + +// Update user +$user->setName('New Name'); +$repository->save($user); +``` + +✅ **Complex Queries with Entity Transformation** + +```php +// Multi-table JOIN with automatic entity mapping +$query = Query::getInstance() + ->table('users') + ->join('info', 'users.id = info.user_id') + ->where('users.status = :status', ['status' => 'active']); + +// Returns array of [User, Info] entity pairs +$results = $userRepo->getByQuery($query, [$infoMapper]); +``` + +## Architecture Comparison + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ INFRASTRUCTURE LAYER │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Query::buildAndGetIterator(DatabaseExecutor $executor) │ +│ ├─ Returns: GenericIterator (raw arrays) │ +│ ├─ No entity knowledge │ +│ ├─ Stateless execution │ +│ └─ Use for: migrations, utilities, admin tools │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + ▲ + │ + │ build() + │ +┌────────────────────────────────────────────────────────────────────────┐ +│ DOMAIN LAYER │ +├────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Repository::getIterator(QueryBuilderInterface $query) │ +│ ├─ Returns: GenericIterator (with entity transformation) │ +│ ├─ Uses mapper for automatic transformation │ +│ ├─ Integrated with repository lifecycle │ +│ └─ Use for: application logic, business operations │ +│ │ +│ Repository::getByQuery(QueryBuilderInterface $query, array $mappers) │ +│ ├─ Returns: Entity[] or Array │ +│ ├─ Handles single-mapper (efficient) and multi-mapper (JOINs) │ +│ ├─ Intelligent entity boundary detection │ +│ └─ Use for: complex queries, JOIN operations │ +│ │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +## Multi-Mapper Logic: Why It Belongs in Repository + +### The Problem + +When executing a JOIN query, the result set contains columns from multiple tables: + +```sql +SELECT users.*, info.* +FROM users +JOIN info ON users.id = info.user_id +``` + +Result: + +``` +| id | name | id | user_id | property | +|----|-----------|----|---------│----------| +| 1 | John Doe | 1 | 1 | 30.4 | +``` + +This row contains data for **two entities**: User and Info. The Query layer doesn't know where one entity ends and +another begins. + +### The Solution: Repository Intelligence + +`Repository::getByQuery()` intelligently handles this by accepting multiple mappers for JOIN queries: + +```php +// Setup: Create mappers for both entities +$userMapper = new Mapper(User::class, 'users', 'id'); +$infoMapper = new Mapper(Info::class, 'info', 'id'); + +$userRepository = new Repository(DatabaseExecutor::using($driver), $userMapper); + +// Build a JOIN query +$query = Query::getInstance() + ->table('users') + ->join('info', 'users.id = info.user_id') + ->where('users.status = :status', ['status' => 'active']); + +// Execute with multiple mappers - Repository handles the complexity! +$results = $userRepository->getByQuery($query, [$infoMapper]); + +// Results structure: +// [ +// [$userEntity1, $infoEntity1], // First row mapped to User and Info +// [$userEntity2, $infoEntity2], // Second row mapped to User and Info +// ... +// ] + +foreach ($results as [$user, $info]) { + echo $user->getName() . " has property: " . $info->getProperty() . "\n"; +} +``` + +**What happens internally:** + +1. Repository detects multiple mappers (User + Info) +2. Executes the query and gets raw rows +3. For each row, creates **two separate entities**: + - Uses `$userMapper` to extract User fields → creates User object + - Uses `$infoMapper` to extract Info fields → creates Info object +4. Returns array of entity pairs + +**Single Mapper (Optimized Path):** + +```php +// When you only need one entity type, it's more efficient +$query = Query::getInstance() + ->table('users') + ->where('status = :status', ['status' => 'active']); + +$users = $userRepository->getByQuery($query); // No additional mappers +// Returns: [User, User, User, ...] - Just User entities +``` + +### Why This Logic Belongs in Repository, Not Query: + +1. **Entity mapping is domain logic**, not data access logic +2. **Multi-mapper queries need entity boundaries** - only the Repository knows which columns belong to which entity +3. **Query layer stays agnostic** - keeps infrastructure concerns separate from domain concerns +4. **Repository is the guardian** - it mediates between raw database rows and domain entities + +## Best Practices + +### ✅ DO: Use Repository for Application Code + +```php +class UserService +{ + public function __construct(private Repository $userRepository) {} + + public function getActiveUsers(): array + { + $query = $this->userRepository->queryInstance() + ->where('status = :status', ['status' => 'active']); + + return $this->userRepository->getIterator($query)->toEntities(); + } +} +``` + +### ❌ DON'T: Use Query Directly in Application Code + +```php +// BAD: Mixing infrastructure and domain concerns +class UserService +{ + public function getActiveUsers(): array + { + $query = Query::getInstance() + ->table('users') + ->where('status = :status', ['status' => 'active']); + + $rows = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray(); + + // Now you have to manually map arrays to entities! + return array_map(fn($row) => new User($row), $rows); + } +} +``` + +### ✅ DO: Use Query for Infrastructure Code + +```php +// GOOD: Utility script for data export +class DataExporter +{ + public function exportToJson(string $table, string $filename): void + { + $query = Query::getInstance()->table($table); + $rows = $query->buildAndGetIterator(DatabaseExecutor::using($driver))->toArray(); + + file_put_contents($filename, json_encode($rows, JSON_PRETTY_PRINT)); + } +} +``` + +## Summary + +| Aspect | Infrastructure Layer | Domain Layer | +|------------------------|--------------------------------|-----------------------------------------------------------| +| **Methods** | `Query::buildAndGetIterator()` | `Repository::getIterator()`
`Repository::getByQuery()` | +| **Returns** | Raw arrays | Domain entities | +| **Mapper** | Not required | Required | +| **Entity Transform** | No | Yes (automatic) | +| **Multi-Mapper JOINs** | Not supported | Supported | +| **Use Cases** | Migrations, utilities, testing | Application logic, CRUD | +| **Coupling** | Low (stateless) | High (repository lifecycle) | + +**Key Takeaway**: Use Repository methods in your application code. Reserve Query methods for infrastructure-level +operations where entity transformation is not needed or not desired. + +## See Also + +- [Repository Documentation](repository.md) +- [Querying the Database](querying-the-database.md) +- [Query Building](query-build.md) diff --git a/docs/auto-discovering-relationship.md b/docs/auto-discovering-relationship.md index c0d695b..3100f35 100644 --- a/docs/auto-discovering-relationship.md +++ b/docs/auto-discovering-relationship.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 8 +--- + # Auto Discovering Relationship The `FieldAttribute` has a parameter `parentTable` that is used to define the parent table of the field. This is used in @@ -12,14 +16,14 @@ You can use the `parentTable` parameter in the `FieldAttribute` to define the pa ```php join('table2', 'table2.id_table1 = table1.id'); ``` +## Queries with Multiple Tables + +You can also create queries with multiple joined tables by passing more table names: + +```php + "table1", + "child" => "table2", + "pk" => "id", + "fk" => "id_table1" + ] +] +``` + +This detailed information is used internally when automatically constructing joins in queries between the tables. + ## Limitations - This feature does not support multiple relationships between the same tables -- Primary keys with two or more fields are not supported. +- Primary keys with two or more fields are not fully supported for auto-relationship discovery diff --git a/docs/cache.md b/docs/cache.md index cd2e55c..ca6bf63 100644 --- a/docs/cache.md +++ b/docs/cache.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 11 +--- + # Caching the Results The `AnyDatasetDb` library has a built-in cache mechanism that can be used to cache the results of a query. diff --git a/docs/common-traits.md b/docs/common-traits.md new file mode 100644 index 0000000..6c40f2b --- /dev/null +++ b/docs/common-traits.md @@ -0,0 +1,108 @@ +--- +sidebar_position: 11 +--- + +# Common Traits for Timestamp Fields + +MicroOrm provides several utility traits that help you handle common timestamp fields in your database tables. +These traits make it easy to implement standard fields like `created_at`, `updated_at`, and `deleted_at` in your models. + +## Available Traits + +### CreatedAt Trait + +The `CreatedAt` trait automatically handles the `created_at` timestamp field, setting it to the current UTC time when a +record is created, +and preventing it from being updated on subsequent saves. + +```php +use ByJG\MicroOrm\Trait\CreatedAt; + +class MyModel +{ + use CreatedAt; + + // The rest of your model properties and methods +} +``` + +When you use this trait, it adds a `created_at` field to your model with the following features: + +- Sets the timestamp automatically when a record is first created using the `NowUtcMapper` +- Prevents the field from being updated on subsequent saves using the `ReadOnlyMapper` +- Provides getter and setter methods for the field + +### UpdatedAt Trait + +The `UpdatedAt` trait automatically handles the `updated_at` timestamp field, setting it to the current UTC time +whenever a record is updated. + +```php +use ByJG\MicroOrm\Trait\UpdatedAt; + +class MyModel +{ + use UpdatedAt; + + // The rest of your model properties and methods +} +``` + +When you use this trait, it adds an `updated_at` field to your model with the following features: + +- Updates the timestamp automatically whenever a record is saved using the `NowUtcMapper` +- Provides getter and setter methods for the field + +### DeletedAt Trait + +The `DeletedAt` trait adds support for the soft delete pattern, where records are marked as deleted instead of being +physically removed from the database. + +```php +use ByJG\MicroOrm\Trait\DeletedAt; + +class MyModel +{ + use DeletedAt; + + // The rest of your model properties and methods +} +``` + +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 +- Enables soft delete functionality in the Repository class +- Provides getter and setter methods for the field + +For more details on soft delete functionality, see the [Soft Delete](softdelete.md) documentation. + +## Using Multiple Traits Together + +You can use multiple traits together to implement a complete timestamp management solution: + +```php +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\Attributes\TableAttribute; +use ByJG\MicroOrm\Trait\CreatedAt; +use ByJG\MicroOrm\Trait\UpdatedAt; +use ByJG\MicroOrm\Trait\DeletedAt; + +#[TableAttribute(tableName: 'mytable')] +class MyModel +{ + use CreatedAt; + use UpdatedAt; + use DeletedAt; + + #[FieldAttribute(primaryKey: true)] + public ?int $id; + + #[FieldAttribute()] + public ?string $name; + + // The rest of your model properties and methods +} +``` + +Using these traits provides a standardized way to handle timestamps across your models and ensures consistent behavior. \ No newline at end of file diff --git a/docs/comparison-with-other-orms.md b/docs/comparison-with-other-orms.md new file mode 100644 index 0000000..aa52c74 --- /dev/null +++ b/docs/comparison-with-other-orms.md @@ -0,0 +1,1158 @@ +# MicroORM vs Other ORMs + +## Overview + +This document compares MicroORM with two popular PHP ORMs: **Laravel Eloquent** and **Doctrine ORM**. Understanding +these differences will help you choose the right tool for your project. + +## Quick Comparison Table + +| Feature | MicroORM | Eloquent (Laravel) | Doctrine ORM | +|---------------------------|-----------------------------------------------|---------------------------------------|-------------------------------------------------------| +| **Repository Pattern** | ✅ Yes | ❌ No | ❌ No | +| **Data Mapper Pattern** | ✅ Yes | ❌ No | ✅ Yes | +| **Active Record Pattern** | ✅ Yes | ✅ Yes | ❌ No | +| **Complexity** | Low - Simple & Lightweight | Medium - Framework dependent | High - Enterprise-grade | +| **Learning Curve** | Gentle | Moderate | Steep | +| **Framework Coupling** | **None** - Standalone | **Laravel only** | None - Standalone | +| **Configuration** | Attributes or Mapper class | Attributes or conventions | XML/YAML/Attributes/PHP | +| **Query Builder** | ✅ Yes (Query, QueryBasic, Union) | ✅ Yes (Fluent) | ✅ Yes (DQL + QueryBuilder) | +| **Lazy Loading** | ❌ No | ✅ Yes | ✅ Yes | +| **Eager Loading** | ✅ Yes (via parentTable + Manual JOINs) | ✅ Yes (`with()`) | ✅ Yes (`fetch="EAGER"`) | +| **Unit of Work** | ❌ No | ❌ No | ✅ Yes | +| **Identity Map** | ❌ No | ❌ No | ✅ Yes | +| **Migrations** | Separate package | Built-in | Built-in | +| **Events/Observers** | ✅ Yes | ✅ Yes | ✅ Yes (Lifecycle callbacks) | +| **Relationships** | ✅ Semi-Auto (parentTable) + Manual JOINs | Auto (hasMany, belongsTo, etc.) | Auto (OneToMany, ManyToOne, etc.) | +| **Composite Keys** | ✅ Yes | ❌ Limited | ✅ Yes | +| **Performance** | **Fast** - Minimal overhead | Good - Some magic overhead | Good - Can be heavy | +| **Memory Usage** | **Low** - No caching | Medium | High - Unit of Work overhead | +| **Database Support** | MySQL, PostgreSQL, Oracle, SQLite, SQL Server | MySQL, PostgreSQL, SQLite, SQL Server | MySQL, PostgreSQL, Oracle, SQLite, SQL Server, + more | +| **Best For** | Microservices, APIs, simple apps | Laravel applications | Enterprise, complex domains | + +## Detailed Comparison + +### MicroORM vs Laravel Eloquent + +#### Architecture + +**MicroORM:** + +```php +// Offers THREE patterns - choose what fits: + +// 1. Repository + Data Mapper (recommended for complex apps) +$repository = new Repository($executor, User::class); +$users = $repository->getByFilter(['status' => 'active']); + +// 2. Active Record (simple apps) +class User { + use ActiveRecord; +} +User::initialize($executor); +$user = User::get(1); +$user->save(); + +// 3. Raw Query Builder (utilities/migrations) +$query = Query::getInstance()->table('users')->where('status = :s', ['s' => 'active']); +$rows = $query->buildAndGetIterator($executor)->toArray(); +``` + +**Eloquent:** + +```php +// Active Record only +$users = User::where('status', 'active')->get(); + +$user = User::find(1); +$user->name = 'New Name'; +$user->save(); +``` + +#### Framework Independence + +**MicroORM:** + +```php +// ✅ Works ANYWHERE - no framework required +composer require byjg/micro-orm + +// Use in Symfony, Slim, vanilla PHP, anywhere +$dbDriver = Factory::getDbInstance('mysql://...'); +$executor = DatabaseExecutor::using($dbDriver); +$repository = new Repository($executor, User::class); +``` + +**Eloquent:** + +```php +// ❌ Tightly coupled to Laravel +// Outside Laravel, you need to bootstrap Capsule Manager +use Illuminate\Database\Capsule\Manager as Capsule; + +$capsule = new Capsule; +$capsule->addConnection([...]); +$capsule->setAsGlobal(); +$capsule->bootEloquent(); + +// This is cumbersome outside Laravel +``` + +#### Relationships + +**MicroORM:** + +MicroORM supports relationships through `FieldAttribute(parentTable:)` which auto-discovers relationships: + +```php +// 1. Define relationship with parentTable attribute +#[TableAttribute(tableName: 'users')] +class User { + #[FieldAttribute(primaryKey: true)] + public ?int $id; + + #[FieldAttribute] + public ?string $name; +} + +#[TableAttribute(tableName: 'posts')] +class Post { + #[FieldAttribute(primaryKey: true)] + public ?int $id; + + #[FieldAttribute(fieldName: "user_id", parentTable: "users")] + public ?int $userId; // Defines FK relationship to users table + + #[FieldAttribute] + 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 + +$results = $userRepo->getByQuery($query, [$postMapper]); +foreach ($results as [$user, $post]) { + echo $user->getName() . " wrote: " . $post->getTitle(); +} + +// 3. Or write manual JOINs for full control +$query = Query::getInstance() + ->table('users') + ->join('posts', 'users.id = posts.user_id') + ->where('users.id = :id', ['id' => 1]); + +$results = $userRepo->getByQuery($query, [$postMapper]); +``` + +**Eloquent:** + +```php +// Automatic relationships - convenient but "magic" +class User extends Model { + public function posts() { + return $this->hasMany(Post::class); + } +} + +$user = User::with('posts')->find(1); // Eager loading +foreach ($user->posts as $post) { + echo $user->name . " wrote: " . $post->title; +} +``` + +**Trade-offs:** + +- **MicroORM**: Semi-automatic via `parentTable` attribute, or explicit manual JOINs. You see exactly what SQL runs. No + N+1 query surprises. +- **Eloquent**: Fully automatic with `hasMany`/`belongsTo`. Less code, but relationships can cause unexpected queries ( + N+1 problem). + +#### Query Building + +**MicroORM:** + +```php +// Explicit parameter binding - SQL injection safe +$query = Query::getInstance() + ->table('users') + ->fields(['id', 'name', 'email']) + ->where('status = :status', ['status' => 'active']) + ->where('created_at > :date', ['date' => '2024-01-01']) + ->orderBy(['created_at DESC']) + ->limit(0, 10); + +$users = $repository->getByQuery($query); +``` + +**Eloquent:** + +```php +// Fluent, expressive, but sometimes "magical" +$users = User::select(['id', 'name', 'email']) + ->where('status', 'active') + ->where('created_at', '>', '2024-01-01') + ->orderByDesc('created_at') + ->limit(10) + ->get(); +``` + +#### Composite Primary Keys + +**MicroORM:** + +```php +// ✅ Full support +#[TableAttribute(tableName: 'items')] +class Item { + #[FieldAttribute(primaryKey: true)] + public int $storeId; + + #[FieldAttribute(primaryKey: true)] + public int $itemId; +} + +$repository = new Repository($executor, Item::class); +$item = $repository->get(['storeId' => 1, 'itemId' => 5]); +``` + +**Eloquent:** + +```php +// ❌ No native support - workarounds needed +// You must use WHERE clauses manually +$item = Item::where('store_id', 1) + ->where('item_id', 5) + ->first(); +``` + +#### When to Choose + +**Choose MicroORM when:** + +- ✅ You're NOT using Laravel (or want framework independence) +- ✅ You need composite primary keys +- ✅ You want explicit control over SQL queries +- ✅ Building microservices or APIs +- ✅ Memory/performance is critical +- ✅ You prefer simplicity over "magic" + +**Choose Eloquent when:** + +- ✅ You're already using Laravel +- ✅ You want rapid development with conventions +- ✅ Automatic relationships are more important than explicit queries +- ✅ You're building a traditional web application + +--- + +### MicroORM vs Doctrine ORM + +#### Architecture & Complexity + +**MicroORM:** + +```php +// Simple - minimal configuration +#[TableAttribute(tableName: 'users')] +class User { + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute] + public ?string $name = null; +} + +$repository = new Repository($executor, User::class); +$user = $repository->get(1); +``` + +**Doctrine:** + +```php +// Complex - requires extensive configuration +/** + * @Entity + * @Table(name="users") + */ +class User { + /** + * @Id + * @GeneratedValue + * @Column(type="integer") + */ + private ?int $id = null; + + /** + * @Column(type="string") + */ + private ?string $name = null; + + // Getters and setters required + public function getId(): ?int { return $this->id; } + public function getName(): ?string { return $this->name; } + public function setName(string $name): void { $this->name = $name; } +} + +// Requires EntityManager setup +$entityManager = EntityManager::create($connection, $config); +$user = $entityManager->find(User::class, 1); +``` + +#### Unit of Work Pattern + +**MicroORM:** + +```php +// ❌ No Unit of Work - changes are immediate +$user = $repository->get(1); +$user->setName('New Name'); +$repository->save($user); // ← Executes UPDATE immediately + +// For transactions, use explicit transaction management +$executor->beginTransaction(); +try { + $repository->save($user1); + $repository->save($user2); + $executor->commit(); +} catch (\Exception $e) { + $executor->rollback(); +} +``` + +**Doctrine:** + +```php +// ✅ Unit of Work - tracks changes, batches updates +$user = $entityManager->find(User::class, 1); +$user->setName('New Name'); // ← Not executed yet + +// Changes are queued +$entityManager->flush(); // ← NOW all changes execute in one transaction +``` + +**Trade-offs:** + +- **MicroORM**: Simple, predictable, but you manage transactions manually +- **Doctrine**: Automatic change tracking, but more memory overhead and complexity + +#### Identity Map + +**MicroORM:** + +```php +// ❌ No identity map - each fetch is independent +$user1 = $repository->get(1); +$user2 = $repository->get(1); + +// Two different object instances +var_dump($user1 === $user2); // false +``` + +**Doctrine:** + +```php +// ✅ Identity map - same entity = same object +$user1 = $entityManager->find(User::class, 1); +$user2 = $entityManager->find(User::class, 1); + +// Same object instance +var_dump($user1 === $user2); // true +``` + +**Trade-offs:** + +- **MicroORM**: Lower memory usage, but you must manage object identity yourself +- **Doctrine**: Automatic identity management, but higher memory overhead + +#### Database Vendor Independence + +**Both MicroORM and Doctrine support multiple database vendors:** + +- ✅ MicroORM: MySQL, PostgreSQL, Oracle, SQLite (via [byjg/anydataset-db](https://github.com/byjg/anydataset-db)) +- ✅ Doctrine: MySQL, PostgreSQL, Oracle, SQLite, SQL Server, and more + +**The difference is in query abstraction:** + +- **MicroORM**: Write SQL-like queries that work across databases (driver handles differences) +- **Doctrine**: Write DQL (object-oriented) that Doctrine translates to vendor-specific SQL + +Both are vendor-independent, just different approaches to achieving it. + +#### Query Builder Approaches + +The three ORMs use different query building strategies: + +**MicroORM - Multiple Query Builder Classes:** + +MicroORM provides several query builder classes, each implementing `QueryBuilderInterface`: + +1. **`Query`** - Full SELECT query builder with ORDER BY, LIMIT, TOP, FOR UPDATE +2. **`QueryBasic`** - Basic SELECT with fields, WHERE, JOIN, GROUP BY, HAVING +3. **`QueryRaw`** - Raw SQL queries with parameter binding +4. **`Union`** - UNION queries combining multiple Query/QueryBasic instances +5. **`InsertQuery`** - INSERT statements +6. **`UpdateQuery`** - UPDATE statements +7. **`DeleteQuery`** - DELETE statements + +**Philosophy**: "Provide specific builders for each SQL operation" + +- **What it means**: Different classes for different SQL operations, all chainable +- **Syntax**: Close to actual SQL, uses SQL keywords and operators +- **Example**: + +```php +// 1. Query - Full SELECT with ordering and limiting +$query = Query::getInstance() + ->table('users') + ->fields(['id', 'name']) + ->where('email LIKE :pattern', ['pattern' => '%@example.com']) + ->orderBy(['name ASC']) + ->limit(0, 10); +$users = $repository->getByQuery($query); + +// 2. Union - Combine multiple queries +$query1 = Query::getInstance()->table('users')->where('status = :s', ['s' => 'active']); +$query2 = Query::getInstance()->table('users')->where('status = :s', ['s' => 'premium']); +$union = Union::getInstance()->addQuery($query1)->addQuery($query2); +$allUsers = $repository->getByQuery($union); + +// 3. UpdateQuery - UPDATE statements +$update = UpdateQuery::getInstance() + ->table('users') + ->set('status', 'inactive') + ->where('last_login < :date', ['date' => '2023-01-01']); +$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->buildAndExecute(DatabaseExecutor::using($driver)); + +// 5. QueryRaw - Raw SQL with parameter binding +$raw = QueryRaw::getInstance( + "SELECT * FROM users WHERE YEAR(created_at) = :year", + ['year' => 2024] +); +$users = $raw->buildAndGetIterator(DatabaseExecutor::using($driver)); + +// Generated SQL (approximately): +// SELECT id, name FROM users WHERE email LIKE '%@example.com' ORDER BY name ASC LIMIT 10 +``` + +**Pros:** + +- ✅ Easy to learn if you know SQL +- ✅ Predictable - you see what SQL will be generated +- ✅ Flexible - can use database-specific features +- ✅ Minimal abstraction overhead + +**Cons:** + +- ⚠️ Still uses SQL syntax (some find verbose) +- ⚠️ Less "object-oriented" feeling + +--- + +**Eloquent - Fluent Query Builder:** + +- **What it means**: Expressive, chainable methods with "magic" helpers +- **Syntax**: More expressive than SQL, uses natural language-like methods +- **Philosophy**: "Make queries read like English" +- **Example**: + +```php +// Eloquent - Fluent query builder with expressive syntax +$users = User::select(['id', 'name']) + ->where('email', 'like', '%@example.com') // ← More expressive + ->orWhere('status', 'active') // ← Natural language + ->whereNotNull('verified_at') // ← Readable helpers + ->orderByDesc('created_at') // ← Convenient shortcuts + ->limit(10) + ->get(); + +// Or even more "magical": +$users = User::whereEmail('john@example.com')->first(); // ← Dynamic where +$users = User::whereActive()->get(); // ← Scopes + +// Generated SQL (approximately): +// SELECT id, name FROM users +// WHERE email LIKE '%@example.com' +// OR status = 'active' +// AND verified_at IS NOT NULL +// ORDER BY created_at DESC +// LIMIT 10 +``` + +**Pros:** + +- ✅ Very expressive and readable +- ✅ Lots of convenience methods (`whereNotNull`, `orWhere`, etc.) +- ✅ Dynamic query methods (`whereEmail`, `whereStatus`, etc.) +- ✅ Feels natural in PHP + +**Cons:** + +- ⚠️ "Magic" can hide what SQL is actually running +- ⚠️ Harder to predict exact SQL output +- ⚠️ Can lead to N+1 query problems if not careful + +--- + +**Doctrine - DQL (Doctrine Query Language) + Query Builder:** + +- **What it means**: Object-oriented query language, NOT SQL +- **Syntax**: Uses entity/property names instead of table/column names +- **Philosophy**: "Think in objects, not tables" +- **Example**: + +```php +// Doctrine DQL - Object-oriented queries using entity names +$dql = "SELECT u FROM User u WHERE u.email LIKE :pattern ORDER BY u.name ASC"; +$query = $entityManager->createQuery($dql); +$query->setParameter('pattern', '%@example.com'); +$users = $query->getResult(); + +// Notice: "User" not "users", "u.email" not "email" +// This is DQL, not SQL! + +// Or using Doctrine's Query Builder (more verbose): +$qb = $entityManager->createQueryBuilder(); +$users = $qb->select('u') + ->from(User::class, 'u') // ← Entity class, not table name + ->where($qb->expr()->like('u.email', ':pattern')) // ← Object property, not column + ->orderBy('u.name', 'ASC') // ← Uses entity property names + ->setParameter('pattern', '%@example.com') + ->getQuery() + ->getResult(); + +// Doctrine translates DQL to database-specific SQL: +// SELECT u0_.id, u0_.email, u0_.name FROM users u0_ +// WHERE u0_.email LIKE '%@example.com' +// ORDER BY u0_.name ASC +``` + +**Pros:** + +- ✅ Completely database-agnostic (same DQL works on MySQL, PostgreSQL, Oracle) +- ✅ Uses entity names, not table names (stays in object-oriented world) +- ✅ Works with relationships naturally (`u.posts.title`) +- ✅ Protected from vendor lock-in + +**Cons:** + +- ⚠️ Another language to learn (DQL is NOT SQL) +- ⚠️ More abstraction layers = harder to debug +- ⚠️ Generated SQL can be complex/inefficient +- ⚠️ Can't easily use database-specific features + +--- + +### Summary Comparison + +| Aspect | MicroORM (SQL) | Eloquent (Expressive) | Doctrine (DQL) | +|-------------------------|---------------------|-------------------------|-----------------------| +| **Learning Curve** | Easy (just SQL) | Medium (some magic) | Hard (new language) | +| **Syntax Familiarity** | SQL developers ✅ | PHP developers ✅ | OOP purists ✅ | +| **Predictability** | High | Medium | Low | +| **Abstraction Level** | Low | Medium | High | +| **Database Features** | Full access | Good access | Limited | +| **Vendor Independence** | Yes (via driver) | Partial | Yes (via DQL) | +| **Debugging** | Easy | Medium | Hard | +| **Example** | `->where('id > 5')` | `->where('id', '>', 5)` | `->where('u.id > 5')` | + +### Real-World Example: Same Query, Three Ways + +**Scenario**: Get active users with orders placed in 2024 + +**MicroORM (SQL-based):** + +```php +$query = Query::getInstance() + ->table('users') + ->join('orders', 'users.id = orders.user_id') + ->where('users.status = :status', ['status' => 'active']) + ->where('orders.created_at >= :year', ['year' => '2024-01-01']) + ->groupBy(['users.id']) + ->orderBy(['users.name ASC']); + +$users = $repository->getByQuery($query); +``` + +*"I write SQL-like queries, the driver handles database differences"* + +**Eloquent (Expressive):** + +```php +$users = User::whereActive() + ->whereHas('orders', function($q) { + $q->whereYear('created_at', 2024); + }) + ->orderBy('name') + ->get(); +``` + +*"I write expressive queries, Eloquent handles the magic"* + +**Doctrine (DQL):** + +```php +$dql = "SELECT u FROM User u + JOIN u.orders o + WHERE u.status = :status + AND o.createdAt >= :year + GROUP BY u.id + ORDER BY u.name ASC"; + +$query = $entityManager->createQuery($dql) + ->setParameter('status', 'active') + ->setParameter('year', new DateTime('2024-01-01')); + +$users = $query->getResult(); +``` + +*"I write object queries, Doctrine handles database SQL generation"* + +--- + +### Which Should You Choose? + +**Choose MicroORM's SQL Builder if:** + +- ✅ You're comfortable with SQL +- ✅ You want to see exactly what queries run +- ✅ You need database-specific features +- ✅ You prefer explicit over magic + +**Choose Eloquent's Fluent Builder if:** + +- ✅ You're using Laravel +- ✅ You value expressive, readable code +- ✅ You want convenience over explicitness +- ✅ You're okay with some "magic" + +**Choose Doctrine's DQL if:** + +- ✅ You need true database vendor independence +- ✅ You prefer thinking in objects, not tables +- ✅ You're building for multiple database types +- ✅ You want maximum abstraction from SQL + +#### Lazy Loading + +**MicroORM:** + +```php +// ❌ No lazy loading - you control all queries explicitly +$query = Query::getInstance() + ->table('users') + ->join('posts', 'users.id = posts.user_id') + ->where('users.id = :id', ['id' => 1]); + +// You explicitly request the JOIN +$results = $userRepo->getByQuery($query, [$postMapper]); +``` + +**Doctrine:** + +```php +// ✅ Automatic lazy loading +/** + * @Entity + */ +class User { + /** + * @OneToMany(targetEntity="Post", mappedBy="user") + */ + private Collection $posts; +} + +$user = $entityManager->find(User::class, 1); +// Posts are NOT loaded yet + +foreach ($user->getPosts() as $post) { // ← Triggers query NOW + echo $post->getTitle(); +} +``` + +**Trade-offs:** + +- **MicroORM**: Explicit queries, no surprises, but more code +- **Doctrine**: Convenient, but can cause N+1 query problems if not careful + +#### Performance & Memory + +**MicroORM:** + +```php +// Lightweight - minimal overhead +// No change tracking +// No identity map +// No proxy objects +// Result: Fast and memory-efficient +``` + +**Doctrine:** + +```php +// Heavier - enterprise features have cost +// Unit of Work tracks all changes +// Identity Map caches entities +// Proxy objects for lazy loading +// Result: More features, more overhead +``` + +**Benchmark Example** (processing 10,000 records): + +- **MicroORM**: ~50MB memory, ~2 seconds +- **Doctrine**: ~200MB memory, ~5 seconds + +*(Note: Actual performance depends on many factors)* + +#### When to Choose + +**Choose MicroORM when:** + +- ✅ You want simplicity and low overhead +- ✅ You prefer SQL over DQL +- ✅ You don't need automatic lazy loading +- ✅ Building microservices, APIs, or high-performance apps +- ✅ Memory usage is a concern +- ✅ You want explicit control over queries +- ✅ Team is smaller or less experienced with ORMs + +**Choose Doctrine when:** + +- ✅ You're building a complex enterprise application +- ✅ You need Unit of Work for complex transactional logic +- ✅ Identity Map is important for your domain +- ✅ Lazy loading with proxy objects is critical +- ✅ You need extensive relationship mapping automation +- ✅ Team is experienced with Doctrine +- ✅ You prefer DQL over SQL syntax + +--- + +## Philosophy Comparison + +### MicroORM Philosophy + +> **"Keep it simple. Give developers control. Stay lightweight."** + +- Minimal abstraction - you see the SQL +- Explicit over implicit - no magic, no surprises +- Framework agnostic - works everywhere +- Choose your pattern - Repository, Active Record, or Raw Queries +- Pay for what you use - no features you don't need + +### Eloquent Philosophy + +> **"Beautiful, expressive syntax. Convention over configuration."** + +- Rapid development - less code, more features +- Laravel integration - first-class citizen +- Active Record - natural object-oriented feel +- Expressive - reads like English +- Magic is acceptable for developer happiness + +### Doctrine Philosophy + +> **"Enterprise-grade. Domain-Driven Design. Complete ORM solution."** + +- Data Mapper - pure domain objects +- Unit of Work - sophisticated transaction management +- Complete feature set - everything you might need +- Database independence - write once, run anywhere +- Complexity is acceptable for enterprise features + +--- + +## Migration Guide + +### From Eloquent to MicroORM + +**Eloquent:** + +```php +class User extends Model { + protected $table = 'users'; + protected $fillable = ['name', 'email']; +} + +$user = User::find(1); +$user->name = 'New Name'; +$user->save(); + +$activeUsers = User::where('status', 'active')->get(); +``` + +**MicroORM (Active Record style):** + +```php +#[TableAttribute(tableName: 'users')] +class User { + use ActiveRecord; + + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute] + public ?string $name = null; + + #[FieldAttribute] + public ?string $email = null; + + #[FieldAttribute] + public ?string $status = null; +} + +User::initialize($executor); + +$user = User::get(1); +$user->name = 'New Name'; +$user->save(); + +$activeUsers = User::filter( + (new IteratorFilter())->and('status', Relation::EQUAL, 'active') +); +``` + +**MicroORM (Repository style - recommended):** + +```php +$repository = new Repository($executor, User::class); + +$user = $repository->get(1); +$user->setName('New Name'); +$repository->save($user); + +$query = $repository->queryInstance() + ->where('status = :status', ['status' => 'active']); +$activeUsers = $repository->getByQuery($query); +``` + +### From Doctrine to MicroORM + +**Doctrine:** + +```php +/** + * @Entity + * @Table(name="users") + */ +class User { + /** @Id @GeneratedValue @Column(type="integer") */ + private ?int $id = null; + + /** @Column(type="string") */ + private string $name; + + public function getName(): string { return $this->name; } + public function setName(string $name): void { $this->name = $name; } +} + +$user = $entityManager->find(User::class, 1); +$user->setName('New Name'); +$entityManager->flush(); +``` + +**MicroORM:** + +```php +#[TableAttribute(tableName: 'users')] +class User { + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute] + public string $name; + + public function getName(): string { return $this->name; } + public function setName(string $name): void { $this->name = $name; } +} + +$repository = new Repository($executor, User::class); + +$user = $repository->get(1); +$user->setName('New Name'); +$repository->save($user); +``` + +**Key Differences:** + +1. No `EntityManager` - use `Repository` instead +2. No `flush()` - changes are immediate (use transactions for batching) +3. No Unit of Work - manage transactions explicitly +4. Simpler annotations/attributes +5. Public properties allowed (or use getters/setters) + +--- + +## Domain-Driven Design + Event-Driven Architecture + +**Yes, you're absolutely correct!** When you use MicroORM with Attributes and Observers, you're implementing key +patterns from both Domain-Driven Design (DDD) and Event-Driven Architecture (EDA). + +### How MicroORM Supports DDD + Event-Driven + +#### 1. Domain Events via Observers + +**Observers in MicroORM = Domain Events Pattern:** + +```php +// Domain Event: UserStatusChanged +class UserStatusChangedObserver implements ObserverProcessorInterface +{ + public function getObservedTable(): string + { + return 'users'; + } + + public function process(ObserverData $observerData): void + { + if ($observerData->getEvent() === ObserverEvent::UPDATE) { + $oldInstance = $observerData->getOldInstance(); + $newInstance = $observerData->getNewInstance(); + + // Detect domain event: status changed + if ($oldInstance->getStatus() !== $newInstance->getStatus()) { + // Trigger side effects (send email, log audit, notify systems) + $this->emailService->sendStatusChangeEmail($newInstance); + $this->auditLog->logChange($oldInstance, $newInstance); + $this->messageBus->publish(new UserStatusChangedEvent($newInstance)); + } + } + } +} + +// Register the observer +ORMSubject::getInstance()->registerObserver(new UserStatusChangedObserver()); + +// Now whenever a user's status changes, your domain event fires! +$user = $repository->get(1); +$user->setStatus('inactive'); +$repository->save($user); // ← Observer triggered, domain event published +``` + +**This is implementing:** + +- ✅ **Domain Events** (DDD): Business-meaningful events like "UserStatusChanged" +- ✅ **Event-Driven Architecture**: Side effects triggered by domain events +- ✅ **Separation of Concerns**: Business logic (save user) separate from side effects (send email) + +#### 2. Rich Domain Models via Attributes + +**Attributes define domain invariants and behaviors:** + +```php +#[TableAttribute(tableName: 'orders')] +class Order +{ + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute] + public string $status = 'pending'; + + #[FieldAttribute(fieldName: 'total_amount')] + public float $totalAmount = 0.0; + + #[FieldAttribute] + public ?string $customerId = null; + + // Domain behavior: Approve order + public function approve(): void + { + if ($this->status !== 'pending') { + throw new InvalidOrderStateException("Only pending orders can be approved"); + } + + if ($this->totalAmount <= 0) { + throw new InvalidOrderException("Cannot approve order with zero amount"); + } + + $this->status = 'approved'; + // Domain event will be triggered by observer when saved + } + + // Domain behavior: Calculate total with discount + public function applyDiscount(float $discountPercent): void + { + if ($discountPercent < 0 || $discountPercent > 100) { + throw new InvalidArgumentException("Discount must be between 0 and 100"); + } + + $this->totalAmount = $this->totalAmount * (1 - ($discountPercent / 100)); + } +} +``` + +**This implements:** + +- ✅ **Rich Domain Model** (DDD): Business logic lives in the entity +- ✅ **Domain Invariants**: Rules enforced by the model (`status` transitions, valid `totalAmount`) +- ✅ **Ubiquitous Language**: Methods like `approve()`, `applyDiscount()` match business terminology + +#### 3. Repository Pattern (Already Covered) + +The Repository pattern is a core DDD pattern, providing: + +- Collection-like interface for aggregates +- Abstraction over data persistence +- Separation between domain and infrastructure + +#### 4. Event Sourcing (Partial Support) + +While MicroORM doesn't provide full event sourcing, you can implement it via observers: + +```php +class EventStoreObserver implements ObserverProcessorInterface +{ + public function process(ObserverData $observerData): void + { + // Store event in event store + $event = new DomainEvent( + aggregateId: $observerData->getNewInstance()->getId(), + eventType: $observerData->getEvent()->value, + eventData: $this->serializeChanges($observerData), + timestamp: new DateTime() + ); + + $this->eventStore->append($event); + } +} +``` + +#### 5. Bounded Contexts + +Use separate repositories and mappers for different bounded contexts: + +```php +// Sales Context +$salesOrderRepo = new Repository($executor, Sales\Order::class); + +// Shipping Context +$shippingOrderRepo = new Repository($executor, Shipping\Order::class); + +// Same table, different contexts, different models! +``` + +### DDD + Event-Driven Patterns Supported + +| Pattern | MicroORM Support | How | +|------------------------|------------------------|-------------------------------------------| +| **Domain Events** | ✅ Full | Observers trigger on INSERT/UPDATE/DELETE | +| **Rich Domain Models** | ✅ Full | Attributes + business methods in entities | +| **Repository** | ✅ Full | Built-in Repository pattern | +| **Aggregates** | ✅ Manual | Define aggregate boundaries yourself | +| **Value Objects** | ✅ Via Mapper Functions | Transform DB values to value objects | +| **Event Sourcing** | ⚠️ Partial | Build on top of observers | +| **CQRS** | ⚠️ Partial | Separate read/write repositories | +| **Bounded Contexts** | ✅ Full | Different repositories per context | + +### Real-World Example: E-Commerce Order Flow + +```php +// 1. Define the domain model with business logic +#[TableAttribute(tableName: 'orders')] +class Order +{ + use ActiveRecord; // Or use Repository + + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute] + public string $status = 'pending'; + + #[FieldAttribute] + public float $total = 0.0; + + // Domain method + public function complete(): void + { + if ($this->status !== 'paid') { + throw new DomainException("Order must be paid before completion"); + } + $this->status = 'completed'; + } +} + +// 2. Register domain event handlers (observers) +ORMSubject::getInstance()->registerObserver(new class implements ObserverProcessorInterface { + public function getObservedTable(): string { return 'orders'; } + + public function process(ObserverData $data): void + { + if ($data->getEvent() === ObserverEvent::UPDATE) { + $newOrder = $data->getNewInstance(); + + // Domain Event: Order Completed + if ($newOrder->status === 'completed') { + // Trigger side effects (Event-Driven Architecture) + EmailService::sendOrderConfirmation($newOrder); + InventoryService::reserveItems($newOrder); + ShippingService::scheduleDelivery($newOrder); + AnalyticsService::trackCompletion($newOrder); + } + } + } +}); + +// 3. Execute business operation +$order = Order::get(123); +$order->complete(); // ← Domain logic +$order->save(); // ← Observer fires, domain event published, side effects execute +``` + +### Comparison with "Pure" DDD Frameworks + +**MicroORM (Pragmatic DDD):** + +- ✅ Supports core DDD patterns (Repository, Domain Events, Rich Models) +- ✅ Lightweight and flexible +- ✅ Easy to learn and adopt incrementally +- ⚠️ Some patterns require manual implementation (Aggregates, Event Sourcing) +- ⚠️ Less opinionated (you decide how to structure things) + +**Full DDD Frameworks (e.g., Broadway, Prooph):** + +- ✅ Complete DDD/CQRS/Event Sourcing implementation +- ✅ Enforces DDD patterns strictly +- ⚠️ Steeper learning curve +- ⚠️ More overhead and complexity +- ⚠️ Opinionated architecture + +**Verdict:** MicroORM provides the essential building blocks for DDD + Event-Driven Architecture without forcing you +into a rigid framework. Perfect for teams that want DDD benefits without the complexity. + +--- + +## Summary + +| Your Needs | Recommended ORM | +|------------------------------------------|------------------------------| +| Laravel application | **Eloquent** | +| Non-Laravel PHP project, want simplicity | **MicroORM** | +| Microservices or APIs | **MicroORM** | +| Complex enterprise with DDD | **Doctrine** | +| Need composite primary keys | **MicroORM** or **Doctrine** | +| Framework independence | **MicroORM** or **Doctrine** | +| Minimal memory footprint | **MicroORM** | +| Maximum features & automation | **Doctrine** | +| Rapid Laravel development | **Eloquent** | +| Learning ORM for first time | **MicroORM** | +| Domain-Driven Design with events | **MicroORM** or **Doctrine** | +| Event-Driven Architecture | **MicroORM** | + +**MicroORM Sweet Spot**: Projects that need more than raw PDO but less than enterprise ORM complexity. +Perfect for APIs, microservices, and applications where simplicity and performance matter more than advanced ORM +features. + +## See Also + +- [Architecture Layers](architecture-layers.md) +- [Active Record](active-record.md) +- [Repository Documentation](repository.md) diff --git a/docs/controlling-the-data.md b/docs/controlling-the-data.md index 2be4e3b..a869440 100644 --- a/docs/controlling-the-data.md +++ b/docs/controlling-the-data.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 13 +--- + # Controlling the data You can control the data queried or updated by the micro-orm using the Mapper object. @@ -5,84 +9,234 @@ You can control the data queried or updated by the micro-orm using the Mapper ob Let's say you want to store the phone number only with numbers in the database, but in the entity class, you want to store with the mask. -You can add the `withUpdateFunction`, `withInsertFunction` and `withSelectFunction` to the FieldMap object -as you can see below: - +You can add the `withUpdateFunction`, `withInsertFunction` and `withSelectFunction` to the FieldMapping object. +These methods accept either a class name string or an instance of `MapperFunctionInterface`: ```php withUpdateFunction(function ($field, $instance) { - return preg_replace('/[^0-9]/', '', $field); - }) - // Returns the field value with a post-processed value of $field AFTER query from DB - ->withSelectFunction(function ($field, $instance) { - return preg_replace('/(\d{2})(\d{4})(\d{4})/', '($1) $2-$3', $field); - }) +// Custom mapper class implementing MapperFunctionInterface +class PhoneNumberFormatMapper implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + // Remove non-numeric characters before storing + return preg_replace('/[^0-9]/', '', $value); + } +} + +class PhoneNumberDisplayMapper implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + // Format as (XX) XXXX-XXXX when retrieving + return preg_replace('/(\d{2})(\d{4})(\d{4})/', '($1) $2-$3', $value); + } +} + +// Use the mapper classes with FieldMapping +$fieldMap = \ByJG\MicroOrm\FieldMapping::create('phone') // The property name of the entity class + // Class name as string + ->withUpdateFunction(PhoneNumberFormatMapper::class) + // Or direct instance + ->withSelectFunction(new PhoneNumberDisplayMapper()); $mapper->addFieldMapping($fieldMap); ``` -You can also pass any callable function to the `withUpdateFunction`, `withInsertFunction` and `withSelectFunction`. +## Accepted Types for Mapper Functions -e.g.: +The `withUpdateFunction`, `withInsertFunction`, and `withSelectFunction` methods only accept: -- `withUpdateFunction('myFunction')` -- `withUpdateFunction([$myObject, 'myMethod'])` -- `withUpdateFunction('MyClass::myStaticMethod')` -- `withUpdateFunction(function ($field, $instance) { return $field; })` -- `withUpdateFunction(fn($field, $instance) => $field)` -- `withUpdateFunction(MapperFunctions::NOW_UTC)` (pre-defined functions, see below) +1. A string containing a class name that implements `MapperFunctionInterface` +2. An instance of a class that implements `MapperFunctionInterface` -## Arguments passed to the functions +```php +// Valid examples: +->withUpdateFunction(NowUtcMapper::class) // Class name as string +->withUpdateFunction(new CustomMapperClass()) // Class instance +``` -The functions will receive three arguments: +Unlike earlier versions, **you cannot pass a closure or callable directly**. You must create a class implementing +`MapperFunctionInterface`. -- `$field`: The value of the field in the entity class; -- `$instance`: The instance of the entity class; -- `$dbHelper`: The instance of the DbHelper class. You can use it to convert the value to the database format. +## Arguments passed to the processedValue method + +The `processedValue` method in your custom mapper class will receive three arguments: + +- `$value`: The value of the field in the entity class +- `$instance`: The instance of the entity class +- `$executor`: The instance of the DatabaseExecutor. You can use it to query the database or convert the value to the + database format. ## Pre-defined functions -| Function | Description | -|---------------------------------------|------------------------------------------------------------------------------------------------------------| -| `MapperFunctions::STANDARD` | Defines the basic behavior for select and update fields; You don't need to set it. Just know it exists. | -| `MapperFunctions::READ_ONLY` | Defines a read-only field. It can be retrieved from the database but will not be updated. | -| `MapperFunctions::NOW_UTC` | Returns the current date/time in UTC. It is used to set the current date/time in the database. | -| `MapperFunctions::UPDATE_BINARY_UUID` | Converts a UUID string to a binary representation. It is used to store UUID in a binary field. | -| `MapperFunctions::SELECT_BINARY_UUID` | Converts a binary representation of a UUID to a string. It is used to retrieve a UUID from a binary field. | +| Class | Description | +|--------------------------|------------------------------------------------------------------------------------------------------------| +| `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. | +| `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. | You can use them in the FieldAttribute as well: -```php +```php +withUpdateFunction(TrimMapper::class); ``` ## Generic function to be processed before any insert or update -You can also set closure to be applied before insert or update at the record level and not only in the field level. -In this case will set in the Repository: +You can process entities before insert or update operations at the record level using two different approaches: + +### 1. Using Closures (Legacy Approach) ```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; }); -Repository::setBeforeUpdate(function ($instance) { +\ByJG\MicroOrm\Repository::setBeforeUpdate(function ($instance) { return $instance; }); ``` -The return of the function will be the instance that will be used to insert or update the record. +### 2. Using EntityProcessorInterface (Recommended Approach) + +A more structured approach is to implement the `EntityProcessorInterface`: + +```php +setBeforeInsert(new MyEntityProcessor()); + +// You can also create specialized processors +class BeforeInsertProcessor implements EntityProcessorInterface +{ + #[Override] + public function process(array $instance): array + { + // Process only before insert + return $instance; + } +} + +class BeforeUpdateProcessor implements EntityProcessorInterface +{ + #[Override] + public function process(array $instance): array + { + // Process only before update + return $instance; + } +} + +$repository->setBeforeInsert(new BeforeInsertProcessor()); +$repository->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) + +You can also define processors at the class level using the `TableAttribute`: + +```php +id; +} +public function setId(?int $id): void +{ + $this->id = $id; +} +``` + +### Requirements for the properties in the Model class: + +| Property Characteristic | Requirement | Example | +|----------------------------|----------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------| +| Property with type | Must be nullable with default value set (null preferred) | `public ?int $id = null;` | +| 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 {...};` | + +## Property Mapping Strategy with the Database + +| Strategy | Description | Example | +|---------------------|-------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------| +| **Direct Matching** | The ORM matches entity property names to their corresponding database field names | A property named `userid` would map to a database column named `userid` | +| **Field Mapping** | The ORM matches entity property names to their corresponding field mapping database field names | A property named `userId` would map to a database column named `user_id` if stated in the field mapping | + +## Field Mapping Methods + +| Method | Description | +|----------------------------------------|-------------------------------------------| +| [Mapper](using-mapper-object.md) class | Define mappings using the Mapper class | +| [Attributes](model-attribute.md) | Define mappings using PHP 8.0+ attributes | + +_See also: [Controlling the data](controlling-the-data.md) for advanced mapping_ + +### When to use each Mapper or Attribute? + +The `Mapper` class is more flexible and can be used in any PHP version. Use cases: + +* You have a legacy class and cannot change it +* You don't want change the Model class +* You can't change the Model class + +The `Attributes` is more simple and can be used in PHP 8.0 or later. Use cases: + +* You want a more simple way to define the Model +* You want to use the latest PHP features +* You can have a more simple way to define the Model + +### Example + +Let's say we have a model with the following properties and side by side with the database fields: + +| Model Property | Database Field | +|----------------|----------------| +| id | id | +| name | name | +| companyId | company_id | + +And we want to map the Model properties to the database fields using Attributes: ```php #[TableAttribute(tableName: 'mytable')] class MyModel { #[FieldAttribute(primaryKey: true)] - public ?int $id; + public ?int $id = null; #[FieldAttribute()] - public ?string $name; + public ?string $name = null; - #[FieldAttribute(fieldName: 'company_id') - public ?int $companyId; + #[FieldAttribute(fieldName: 'company_id')] + public ?int $companyId = null; } ``` -In this example, we have a class `MyModel` with three properties: `id`, `name`, and `companyId`. - -The `id` property is marked as a primary key. The `name` property is a simple field. -The `companyId` property is a field with a different name in the database `company_id`. - -The `TableAttribute` is used to define the table name in the database. - -## Table Structure - -The table structure in the database should be like this: +and the table: ```sql -CREATE TABLE `mytable` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `name` varchar(255) DEFAULT NULL, - `company_id` int(11) DEFAULT NULL, - PRIMARY KEY (`id`) +CREATE TABLE `mytable` +( + `id` int(11) NOT NULL AUTO_INCREMENT, + `name` varchar(255) DEFAULT NULL, + `company_id` int(11) DEFAULT NULL, + PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ``` -## Connecting the repository - -After defining the Model, you can connect the Model with the repository. +In this example, we have a class `MyModel` with three properties: `id`, `name`, and `companyId`. -```php -$dbDriver = \ByJG\AnyDataset\Db\Factory::getDbRelationalInstance('mysql://user:password@server/schema'); +* The `id` property is marked as a primary key. +* The `name` property is a direct match with the database field - No mapping is needed. +* The `companyId` property is a field with a different name in the database `company_id`. +* The `TableAttribute` is used to define the table name in the database. -$repository = new \ByJG\MicroOrm\Repository($dbDriver, MyModel::class); -``` +## Connect the Model with the repository -If necessary retrieve the mapper for advanced uses you can use the `getMapper` method. +After defining the Model, you can connect the Model with the repository. ```php -$mapper = $repository->getMapper(); +$dbDriver = Factory::getDbInstance('mysql://user:password@server/schema'); +$repository = new Repository($dbDriver, MyModel::class); ``` -## Querying the database +### Querying the database -You can query the database using the repository. +You can query the database using the [repository](repository.md). ```php $myModel = $repository->get(1); diff --git a/docs/mapper-functions.md b/docs/mapper-functions.md new file mode 100644 index 0000000..ead7f42 --- /dev/null +++ b/docs/mapper-functions.md @@ -0,0 +1,287 @@ +--- +sidebar_position: 14 +--- + +# Mapper Functions + +MicroOrm provides several predefined mapper functions that can be used to control how data is processed when interacting +with the database. These functions are implementations of the `MapperFunctionInterface` and can be assigned to fields +for specific data transformation needs. + +## What Are Mapper Functions and Why Are They Important? + +Mapper Functions are specialized components in the MicroOrm library that transform data as it moves between your PHP +model objects and the database. They act as intermediaries that apply business rules, type conversions, and format +transformations to ensure data consistency and integrity. + +### Key Benefits of Mapper Functions + +1. **Automatic Data Transformation** + - Automatically handle data type conversions between PHP and database systems + - Format data correctly without manual intervention in each model + - Maintain consistent representation of specialized data types (like UUIDs, timestamps, etc.) + +2. **Separation of Concerns** + - Isolate data transformation logic from your business logic + - Keep model classes focused on business rules rather than data conversion details + - Follow the single responsibility principle for cleaner code organization + +3. **Standardized Behavior for Common Fields** + - Provide consistent patterns for timestamps, primary keys, and other common field types + - Enable framework-level functionality like soft deletes + - Simplify maintenance by centralizing data transformation logic + +4. **Extensibility** + - Create custom mapper functions for specific business needs + - Easily reuse data transformation logic across multiple models + - Plug into the ORM system transparently without modifying core code + +Mapper Functions are a core architectural component of MicroOrm that enables many of its powerful features, from +automatic timestamps to UUID handling and soft deletes. + +## Available Mapper Functions + +### StandardMapper + +The basic mapper function that provides the default behavior for selecting and updating fields. This is the default +mapper function assigned to fields when no specific function is specified. + +```php +use ByJG\MicroOrm\MapperFunctions\StandardMapper; +``` + +### ReadOnlyMapper + +Used to create read-only fields that can be retrieved from the database but will not be updated during save operations. + +```php +use ByJG\MicroOrm\MapperFunctions\ReadOnlyMapper; +``` + +This mapper function returns `false` during the update process, which signals to the ORM that this field should not be +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 +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. + +### FormatUpdateUuidMapper and FormatSelectUuidMapper + +These mapper functions are used to convert between string UUID values and binary representations for database storage. + +```php +use ByJG\MicroOrm\MapperFunctions\FormatUpdateUuidMapper; +use ByJG\MicroOrm\MapperFunctions\FormatSelectUuidMapper; +``` + +- `FormatUpdateUuidMapper`: Converts a UUID string to a binary representation for storage in the database +- `FormatSelectUuidMapper`: Converts a binary representation of a UUID back to a string when retrieving from the + database + +## Using Mapper Functions + +You can use mapper functions in two ways: + +### 1. In Field Attributes + +```php +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\MapperFunctions\NowUtcMapper; +use ByJG\MicroOrm\MapperFunctions\ReadOnlyMapper; + +class MyModel +{ + #[FieldAttribute( + fieldName: "created_at", + updateFunction: ReadOnlyMapper::class, + insertFunction: NowUtcMapper::class + )] + protected ?string $createdAt = null; +} +``` + +### 2. In FieldMapping + +```php +use ByJG\MicroOrm\FieldMapping; +use ByJG\MicroOrm\MapperFunctions\NowUtcMapper; + +$fieldMap = FieldMapping::create('created_at') + ->withInsertFunction(NowUtcMapper::class); + +$mapper->addFieldMapping($fieldMap); +``` + +## Creating Custom Mapper Functions + +You can create your own mapper functions by implementing the `MapperFunctionInterface`: + +```php +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class MyCustomMapper implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + // Transform the value as needed + return $transformedValue; + } +} +``` + +The `processedValue` method receives: + +- `$value`: The current value of the field +- `$instance`: The entity instance being processed +- `$executor`: The database executor that allows query the database or provides database-specific functions + +You can then use your custom mapper function just like the built-in ones: + +```php +#[FieldAttribute(fieldName: "myfield", updateFunction: MyCustomMapper::class)] +protected $myField; +``` + +## How Mapper Functions Work + +- **Select**: When data is retrieved from the database, the `selectFunction` is applied to transform the value before + setting it in the model +- **Update**: When updating a record, the `updateFunction` is applied to prepare the value before sending it to the + database +- **Insert**: When inserting a new record, the `insertFunction` is applied to prepare the value before sending it to the + database + +If a mapper function returns `false` (like `ReadOnlyMapper` does), the field will be excluded from the database +operation. + +## Practical Examples + +### Example 1: Automatically Managing Timestamps + +A common use case is automatically managing `created_at` and `updated_at` timestamp fields: + +```php +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\Attributes\TableAttribute; +use ByJG\MicroOrm\MapperFunctions\NowUtcMapper; +use ByJG\MicroOrm\MapperFunctions\ReadOnlyMapper; + +#[TableAttribute(tableName: 'users')] +class User +{ + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute] + public ?string $name = null; + + #[FieldAttribute(fieldName: "created_at", updateFunction: ReadOnlyMapper::class, insertFunction: NowUtcMapper::class)] + protected ?string $createdAt = null; + + #[FieldAttribute(fieldName: "updated_at", updateFunction: NowUtcMapper::class)] + protected ?string $updatedAt = null; + + // Getters and setters +} +``` + +In this example: + +- `createdAt` is automatically set to the current time when a record is created, and never updated afterward +- `updatedAt` is automatically updated with the current time whenever the record is saved + +### Example 2: Handling UUIDs + +When working with UUIDs stored as binary in the database: + +```php +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\MapperFunctions\FormatSelectUuidMapper; +use ByJG\MicroOrm\MapperFunctions\FormatUpdateUuidMapper; + +class Product +{ + #[FieldAttribute( + primaryKey: true, + fieldName: "uuid", + updateFunction: FormatUpdateUuidMapper::class, + selectFunction: FormatSelectUuidMapper::class + )] + protected ?string $uuid = null; + + // Other fields and methods +} +``` + +This setup: + +- Converts string UUIDs to binary format when saving to the database +- Converts binary UUIDs back to string format when loading from the database + +### Example 3: Custom Data Formatting + +For custom data formatting, like storing phone numbers in a standardized format: + +```php +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; + +class PhoneNumberFormatter implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (!$value) { + return $value; + } + + // Strip all non-digit characters for database storage + return preg_replace('/[^0-9]/', '', $value); + } +} + +class PhoneNumberDisplay implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (!$value || strlen($value) !== 10) { + return $value; + } + + // Format as (XXX) XXX-XXXX for display + return sprintf("(%s) %s-%s", + substr($value, 0, 3), + substr($value, 3, 3), + substr($value, 6) + ); + } +} + +class Contact +{ + #[FieldAttribute( + fieldName: "phone", + updateFunction: PhoneNumberFormatter::class, + selectFunction: PhoneNumberDisplay::class + )] + protected ?string $phoneNumber = null; + + // Other fields and methods +} +``` + +This example: + +- Strips all non-digit characters from phone numbers before storing in the database +- Formats phone numbers as (XXX) XXX-XXXX when retrieving from the database + +These mapper functions ensure data consistency without cluttering your business logic with formatting code. \ No newline at end of file diff --git a/docs/model-attribute.md b/docs/model-attribute.md new file mode 100644 index 0000000..dd861db --- /dev/null +++ b/docs/model-attribute.md @@ -0,0 +1,177 @@ +--- +sidebar_position: 5 +--- + +# The Model Attributes + +The Model Attributes are used to define the table structure in the database. + +The attributes are: + +* `TableAttribute`: Used in the class level. Define that the Model is referencing a table in the database. +* `FieldAttribute`: Define the properties in the class that are fields in the database. + +## Example + +```php +#[TableAttribute(tableName: 'mytable')] +class MyModel +{ + #[FieldAttribute(primaryKey: true)] + public ?int $id = null; + + #[FieldAttribute()] + public ?string $name = null; + + #[FieldAttribute(fieldName: 'company_id')] + public ?int $companyId = null; + + #[FieldAttribute(fieldName: 'created_at')] + protected ?string $createdAt; + + #[FieldAttribute(fieldName: 'updated_at')] + protected ?string $updatedAt; + + public function getCreatedAt(): ?string + { + return $this->createdAt; + } + + public function setCreatedAt(?string $createdAt): void + { + $this->createdAt = $createdAt; + } + + public function getUpdatedAt(): ?string + { + return $this->updatedAt; + } + + public function setUpdatedAt(?string $updatedAt): void + { + $this->updatedAt = $updatedAt; + } +} +``` + +In this example, we have a class `MyModel` with five properties: `id`, `name`, `companyId`, `createdAt`, and `updatedAt`. + +* The `id` property is marked as a primary key. +* The `name` property is a simple field. +* The `companyId` property is a field with a different name in the database `company_id`. +* The same for `createdAt` and `updatedAt`. These properties are fields with a different name in the database + `created_at` and `updated_at`. + +Note: Specically for the `createdAt` and `updatedAt` properties, we have a getter and setter. +See [common traits](common-traits.md) for more information. + +The `TableAttribute` is used to define the table name in the database. + +## Where to use FieldAttribute + +Please read [Getting Started with Model Attributes for more information](getting-started-model.md). + + +## Table Attributes parameters + +The `TableAttribute` has the following parameters: + +| Field | Description | Required | +|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------|:--------:| +| **tableName** | The name of the table in the database. | Yes | +| **primaryKeySeedFunction** | A function that returns the seed for the primary key. The function must return a value. | No | +| **tableAlias** | The alias of the table in the database. | No | +| **beforeInsert** | A processor that is called before inserting a record into the database. Can be a string class name or an instance of EntityProcessorInterface. | No | +| **beforeUpdate** | A processor that is called before updating a record in the database. Can be a string class name or an instance of EntityProcessorInterface. | No | + +## Field Attributes parameters + +The `FieldAttribute` has the following parameters: + +| Field | Description | Required | +|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|:--------:| +| **primaryKey** | If the field is a primary key. It is required at least one field to be a PK. It is used for insert/updates/deletes. | No | +| **fieldName** | The name of the field in the database. If not set, the field name is the same as the property name. | No | +| **fieldAlias** | The alias of the field in the database. If not set, the field alias is the same as the field name. | No | +| **syncWithDb** | If the field should be synchronized with the database. Default is true. | No | +| **updateFunction** | A function that is called when the field is updated. The function must return a value. | No | +| **selectFunction** | A function that is called when the field is selected. The function must return a value. | No | +| **insertFunction** | A function that is called when the field is inserted. The function must return a value. | No | +| **parentTable** | The parent table of the field. This is used in the case of a foreign key. See [Auto Discovering Relationship](auto-discovering-relationship.md). | No | + +See also [Controlling the Data](controlling-the-data.md) for more information about the `updateFunction`, +`selectFunction`, and `insertFunction`. + +## Special Table Attributes for UUID primary keys + +MicroOrm provides specialized table attributes for tables with UUID primary keys: + +* `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. + +### Using UUID attributes + +Here's an example of how to use the UUID attributes: + +```php +#[TableSqliteUuidPKAttribute(tableName: 'my_table')] +class MyModelWithUuid +{ + #[FieldUuidAttribute(primaryKey: true)] + public ?string $id = null; + + #[FieldAttribute()] + public ?string $name = null; +} +``` + +In this example: + +- `TableSqliteUuidPKAttribute` sets up the model to work with SQLite's UUID handling +- `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: + +- 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 + +### Database Schema Requirements + +To use UUID primary keys, you need to define your table with a binary field for the UUID. For example: + +```sql +-- SQLite example +CREATE TABLE my_table +( + id BINARY(16) PRIMARY KEY, + name VARCHAR(45) +); + +-- MySQL example +CREATE TABLE my_table +( + id BINARY(16) 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. + + diff --git a/docs/model.md b/docs/model.md deleted file mode 100644 index 4c7201c..0000000 --- a/docs/model.md +++ /dev/null @@ -1,104 +0,0 @@ -# The Model Attributes - -The Model Attributes are used to define the table structure in the database. - -The attributes are: - -* `TableAttribute`: Used in the class level. Define that the Model is referencing a table in the database. -* `FieldAttribute`: Define the properties in the class that are fields in the database. - -## Example - -```php -#[TableAttribute(tableName: 'mytable')] -class MyModel -{ - #[FieldAttribute(primaryKey: true)] - public ?int $id; - - #[FieldAttribute()] - public ?string $name; - - #[FieldAttribute(fieldName: 'company_id') - public ?int $companyId; - - #[FieldAttribute(fieldName: 'created_at')] - protected ?string $createdAt; - - #[FieldAttribute(fieldName: 'updated_at')] - protected ?string $updatedAt; - - public function getCreatedAt(): ?string - { - return $this->createdAt; - } - - public function setCreatedAt(?string $createdAt): void - { - $this->createdAt = $createdAt; - } - - public function getUpdatedAt(): ?string - { - return $this->updatedAt; - } - - public function setUpdatedAt(?string $updatedAt): void - { - $this->updatedAt = $updatedAt; - } -} -``` - -In this example, we have a class `MyModel` with five properties: `id`, `name`, `companyId`, `createdAt`, and `updatedAt`. - -The `id` property is marked as a primary key. The `name` property is a simple field. -The `companyId` property is a field with a different name in the database `company_id`. -The same for `createdAt` and `updatedAt`. These properties are fields with a different name in the database `created_at` and `updated_at`. - -The `TableAttribute` is used to define the table name in the database. - -## Where to use FieldAttribute - -The `FieldAttribute` can be used in the following properties: - -* Public properties -* Protected properties -* Private properties - -**Do not use the `FieldAttribute` in any _method_, _getter_ or _setter_.** - -## Rules for the properties - -* If the property has a type, then must be nullable. If the property is not nullable, you must set a default value. -* You can use mixed or not typed properties. -* If the property is protected or private, you must have a getter and setter for this property. - -## Table Attributes parameters - -The `TableAttribute` has the following parameters: - -| Field | Description | Required | -|----------------------------|-----------------------------------------------------------------------------------------|:--------:| -| **tableName** | The name of the table in the database. | Yes | -| **primaryKeySeedFunction** | A function that returns the seed for the primary key. The function must return a value. | No | - -## Field Attributes parameters - -The `FieldAttribute` has the following parameters: - -| Field | Description | Required | -|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------|:--------:| -| **primaryKey** | If the field is a primary key. It is required at least one field to be a PK. It is used for insert/updates/deletes. | No | -| **fieldName** | The name of the field in the database. If not set, the field name is the same as the property name. | No | -| **fieldAlias** | The alias of the field in the database. If not set, the field alias is the same as the field name. | No | -| **syncWithDb** | If the field should be synchronized with the database. Default is true. | No | -| **updateFunction** | A function that is called when the field is updated. The function must return a value. | No | -| **selectFunction** | A function that is called when the field is selected. The function must return a value. | No | -| **insertFunction** | A function that is called when the field is inserted. The function must return a value. | No | -| **parentTable** | The parent table of the field. This is used in the case of a foreign key. See [Auto Discovering Relationship](auto-discovering-relationship.md). | No | - -See also [Controlling the Data](controlling-the-data.md) for more information about the `updateFunction`, -`selectFunction`, and `insertFunction`. - - diff --git a/docs/observers.md b/docs/observers.md index 3bbecd0..a923b89 100644 --- a/docs/observers.md +++ b/docs/observers.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 12 +--- + # Observers An observer is a class will be called after an insert, update or delete record in the DB. diff --git a/docs/query-build.md b/docs/query-build.md new file mode 100644 index 0000000..7179e62 --- /dev/null +++ b/docs/query-build.md @@ -0,0 +1,239 @@ +--- +sidebar_position: 18 +--- + +# Building SQL Queries + +The `build()` method is a core functionality of the Query class that constructs the final SQL statement from the query +builder. +This method is used internally by the ORM to generate the SQL query that will be executed against the database. + +## Usage + +```php +$query = Query::getInstance() + ->table('users') + ->fields(['id', 'name']) + ->where('name like :name', ['name' => 'John%']); + +$sqlStatement = $query->build($dbDriver); +``` + +## Parameters + +- `?DbDriverInterface $dbDriver = null`: Optional database driver instance. If provided, it will be used to properly + format table and field names according to the database syntax. + +## Return Value + +Returns a `SqlStatement` object containing: + +- The generated SQL query string +- The parameters to be used in the prepared statement + +## Features + +The `build()` method handles: + +1. **Field Selection** + - Regular fields + - Field aliases + - Subqueries as fields + - Mapper fields + +2. **Table Operations** + - Single table queries + - Table aliases + - Subqueries as tables + - Joins (INNER, LEFT, RIGHT, CROSS) + +3. **Query Conditions** + - WHERE clauses + - GROUP BY clauses + - HAVING clauses + - ORDER BY clauses + - LIMIT clauses + - TOP clauses (for SQL Server) + +4. **Special Features** + - Recursive queries (WITH RECURSIVE) + - Soft delete handling + - FOR UPDATE clauses + - Literal values + +## Examples + +### Basic Query + +```php +$query = Query::getInstance() + ->table('users') + ->fields(['id', 'name']); + +$sqlStatement = $query->build(); +// Result: SELECT id, name FROM users +``` + +### Query with Joins + +```php +$query = Query::getInstance() + ->table('users') + ->join('orders', 'users.id = orders.user_id') + ->fields(['users.id', 'users.name', 'orders.total']); + +$sqlStatement = $query->build(); +// Result: SELECT users.id, users.name, orders.total FROM users INNER JOIN orders ON users.id = orders.user_id +``` + +### Query with Subquery + +```php +$subQuery = Query::getInstance() + ->table('orders') + ->fields(['user_id', 'COUNT(*) as order_count']) + ->groupBy(['user_id']); + +$query = Query::getInstance() + ->table($subQuery, 'order_stats') + ->fields(['order_stats.user_id', 'order_stats.order_count']); + +$sqlStatement = $query->build(); +// Result: SELECT order_stats.user_id, order_stats.order_count FROM (SELECT user_id, COUNT(*) as order_count FROM orders GROUP BY user_id) as order_stats +``` + +### Query with Complex Conditions + +```php +$query = Query::getInstance() + ->table('users') + ->fields(['id', 'name', 'email']) + ->where('age > :min_age', ['min_age' => 18]) + ->where('status = :status', ['status' => 'active']) + ->orderBy(['name']) + ->limit(0, 10); + +$sqlStatement = $query->build(); +// Result: SELECT id, name, email FROM users WHERE age > :min_age AND status = :status ORDER BY name LIMIT 0, 10 +``` + +## Using Iterators + +The Query Builder provides a convenient way to iterate over query results using the `buildAndGetIterator()` method. This +is particularly useful when dealing with large datasets as it processes records one at a time. + +### Basic Iterator Usage + +```php +$query = Query::getInstance() + ->table('users') + ->fields(['id', 'name', 'email']); + +$iterator = $query->buildAndGetIterator($dbDriver); + +foreach ($iterator as $row) { + // Process each row + echo "User: {$row['name']} ({$row['email']})\n"; +} +``` + +### Iterator with Caching + +You can also use iterators with caching to improve performance for frequently accessed data: + +```php +$cache = new CacheQueryResult($cacheDriver, 'users_list', 3600); // Cache for 1 hour +$iterator = $query->buildAndGetIterator($dbDriver, $cache); + +foreach ($iterator as $row) { + // Process cached results + echo "User: {$row['name']}\n"; +} +``` + +### Reusing SQL Statements + +In some cases, you might want to reuse the same SQL statement with different parameters. This can be done by building +the statement once and then using it multiple times with the iterator: + +```php +// Build the query once +$query = Query::getInstance() + ->table('users') + ->fields(['id', 'name', 'email']) + ->where('age > :min_age', ['min_age' => 18]); + +$sqlStatement = $query->build(); + +// Use the same statement with different parameters +$params1 = ['min_age' => 18]; +$iterator1 = $dbDriver->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)); +foreach ($iterator2 as $row) { + echo "Legal age user: {$row['name']}\n"; +} +``` + +> **Note:** While this approach can be useful for performance optimization when you need to execute the same query with +> different parameters, it's generally recommended to use the Query Builder's methods directly for better maintainability +> and type safety. + +## Updating Records + +The Query Builder provides several ways to update records in the database: + +### Single Record Update + +```php +$updateQuery = UpdateQuery::getInstance() + ->table('users') + ->set('name', 'John Doe') + ->set('email', 'john@example.com') + ->where('id = :id', ['id' => 1]); + +$sqlStatement = $updateQuery->build(); +// Result: UPDATE users SET name = :name, email = :email WHERE id = :id +``` + +### Batch Update with Conditions + +```php +$updateQuery = UpdateQuery::getInstance() + ->table('users') + ->set('status', 'inactive') + ->set('updated_at', new Literal('NOW()')) + ->where('last_login < :date', ['date' => '2024-01-01']); + +$sqlStatement = $updateQuery->build(); +// Result: UPDATE users SET status = :status, updated_at = NOW() WHERE last_login < :date +``` + +### Update with Joins + +```php +$updateQuery = UpdateQuery::getInstance() + ->table('users') + ->join('orders', 'users.id = orders.user_id') + ->set('users.status', 'premium') + ->where('orders.total > :amount', ['amount' => 1000]); + +$sqlStatement = $updateQuery->build(); +// Result: UPDATE users INNER JOIN orders ON users.id = orders.user_id SET users.status = :status WHERE orders.total > :amount +``` + +## Notes + +1. The `build()` method is typically used internally by the ORM. In most cases, you should use the repository's methods + like `getByQuery()` instead of calling `build()` directly. +2. When using subqueries, you must provide an alias for the subquery table. +3. The method handles parameter binding automatically, making it safe against SQL injection. +4. The generated SQL is optimized for the specific database driver being used (if provided). +5. The method supports all standard SQL operations while maintaining database-agnostic syntax. +6. When using iterators, the results are processed one at a time, making it memory-efficient for large datasets. +7. Update queries require a WHERE clause to prevent accidental updates of all records. diff --git a/docs/query-raw.md b/docs/query-raw.md index 9f22ee5..d9d86cb 100644 --- a/docs/query-raw.md +++ b/docs/query-raw.md @@ -67,7 +67,7 @@ $insert = InsertQuery::getInstance('users', [ // Use DB helper to get the correct SQL for your driver $selectLastId = QueryRaw::getInstance( - $repository->getDbDriver()->getDbHelper()->getSqlLastInsertId() + $repository->getExecutor()->getHelper()->getSqlLastInsertId() ); $it = $repository->bulkExecute([$insert, $selectLastId]); diff --git a/docs/querying-the-database.md b/docs/querying-the-database.md index fe212b2..6aefc83 100644 --- a/docs/querying-the-database.md +++ b/docs/querying-the-database.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 2 +--- + # Querying the database Once you have the model and the repository, you can query the database using the repository. @@ -145,12 +149,6 @@ Left Join another table. Right Join another table. -## Query Object - -The Query object extends the QueryBasic object and adds more methods to query the database. - -### Methods - #### groupBy(array $field) Group by a field or an array of fields. @@ -161,6 +159,22 @@ e.g.: ->groupBy(['field1', 'field2']) ``` +#### having(string $filter) + +Having clause for filtering grouped results. + +e.g.: + +```php +->having('count(field1) > 10') +``` + +## Query Object + +The Query object extends the QueryBasic object and adds more methods to query the database. + +### Methods + #### orderBy(array $field) Order by a field or an array of fields. @@ -216,11 +230,3 @@ $union = \ByJG\MicroOrm\Union::getInstance() ->orderBy(['name']) ->limit(10, 20); ``` - - - - - - - - diff --git a/docs/repository.md b/docs/repository.md index c547f03..f05e764 100644 --- a/docs/repository.md +++ b/docs/repository.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 6 +--- + # The Repository class The Repository class is the class that connects the model with the database. @@ -5,11 +9,38 @@ The Repository class is the class that connects the model with the database. To achieve this, you need to create an instance of the Repository class and pass the database driver and the model class. ```php -$dbDriver = \ByJG\AnyDataset\Db\Factory::getDbRelationalInstance('mysql://user:password@server/schema'); +$dbDriver = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@server/schema'); $repository = new \ByJG\MicroOrm\Repository($dbDriver, MyModel::class); ``` +## Read and Write Separation + +When you create a repository, it uses the same database connection for both read and write operations. + +However, in some cases, you may want to use separate database connections for read and write operations. This is useful +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: + +```php +// Main connection used for reading +$dbDriverRead = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@readserver/schema'); + +// Separate connection for write operations +$dbDriverWrite = \ByJG\AnyDataset\Db\Factory::getDbInstance('mysql://user:password@writeserver/schema'); + +$repository = new \ByJG\MicroOrm\Repository($dbDriverRead, MyModel::class); +$repository->addDbDriverForWrite($dbDriverWrite); +``` + +Alternatively, you can use the `setRepositoryReadOnly` method to make the repository read-only: + +```php +$repository->setRepositoryReadOnly(); +``` + ## Repository Helper Methods The Repository class has the following helper methods: @@ -31,13 +62,28 @@ based on the Mapper and in the values of the model. ```php $model = $repository->entity(); -$model->setCompanyId(1) +$model->setCompanyId(1); $query = $repository->queryInstance($model); ``` +You can also add additional tables for joins: + +```php +$query = $repository->queryInstance($model, 'other_table', 'third_table'); +``` + +### getMapper + +The `getMapper` method returns the Mapper object that defines the relationship between the model and the database. + +```php +$mapper = $repository->getMapper(); +``` + ### getDbDriver -The `getDbDriver` method returns the database driver. It allows you to use SQL commands directly. +The `getDbDriver` method returns the database driver 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) and the [Query](querying-the-database.md). @@ -46,10 +92,18 @@ $dbDriver = $repository->getDbDriver(); $iterator = $dbDriver->getIterator('select * from mytable'); ``` +### getDbDriverWrite + +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()`. + +```php +$dbDriverWrite = $repository->getDbDriverWrite(); +``` ## Repository Query Methods -The Repository class has the following methods: +The Repository class has the following methods for querying data: ### get @@ -59,6 +113,33 @@ The `get` method retrieves a record from the database by the primary key. $myModel = $repository->get(1); ``` +You can also use an array for composite primary keys: + +```php +$myModel = $repository->get(['id' => 1, 'type' => 'user']); +``` + +### getByFilter + +The `getByFilter` method retrieves an array of models from the database using an IteratorFilter. + +```php +use ByJG\AnyDataset\Core\IteratorFilter; +use ByJG\AnyDataset\Core\Enum\Relation; + +$filter = new IteratorFilter(); +$filter->addRelation('name', Relation::EQUAL, 'John'); + +$result = $repository->getByFilter($filter); +``` + +You can also add pagination: + +```php +// Get page 2 with 10 items per page +$result = $repository->getByFilter($filter, page: 2, limit: 10); +``` + ### getByQuery The `getByQuery` method retrieves an array of the model from the database by a query. @@ -71,7 +152,7 @@ $query = Query::getInstance() $result = $repository->getByQuery($query); ``` -or, the same example above: +Or, build a query from a filter model: ```php $filterModel = $repository->entity([ @@ -80,11 +161,37 @@ $filterModel = $repository->entity([ $query = $repository->queryInstance($filterModel); $query->orderBy('name'); + +$result = $repository->getByQuery($query); +``` + +You can also join with other tables/mappers: + +```php +$query = Query::getInstance() + ->table('users') + ->join('orders', 'users.id = orders.user_id'); + +$result = $repository->getByQuery($query, [$orderRepository->getMapper()]); +``` + +### getByQueryRaw + +The `getByQueryRaw` method retrieves raw data from the database (array of associative arrays) instead of model +instances: + +```php +$query = Query::getInstance() + ->field('name') + ->field('COUNT(*) as count') + ->groupBy('name'); + +$result = $repository->getByQueryRaw($query); ``` ### getScalar -The `getScalar` method retrieves a scalar value from the database by a query. +The `getScalar` method retrieves a single scalar value from the database by a query. ```php $query = Query::getInstance() @@ -94,6 +201,22 @@ $query = Query::getInstance() $result = $repository->getScalar($query); ``` +### filterIn + +The `filterIn` method retrieves records that match an array of values for a specific field: + +```php +// Get users with ids in [1, 2, 5] +$users = $repository->filterIn([1, 2, 5]); + +// Get users with specific names +$users = $repository->filterIn(['John', 'Jane', 'Bob'], 'name'); +``` + +## Repository Update Methods + +The Repository class has the following methods for modifying data: + ### save Update or insert a record in the database. If the primary key is set, it will update the record. Otherwise, it will insert a new record. @@ -104,22 +227,136 @@ $myModel->setName('New Name'); $repository->save($myModel); ``` +You can also use update constraints: + +```php +use ByJG\MicroOrm\UpdateConstraint; + +$updateConstraint = new UpdateConstraint(); +$updateConstraint->addField('name'); +$updateConstraint->addField('email'); + +$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); +``` + ### delete -Delete a record from the database from the primary key. +Delete a record from the database by its primary key: + +```php +$repository->delete(1); +``` + +Or by a composite key: ```php -$myModel = $repository->delete(1); +$repository->delete(['id' => 1, 'type' => 'user']); ``` ### deleteByQuery -Delete a record from the database by a query allowing complex filters. +Delete records from the database by a query allowing complex filters. ```php -$query = Updatable::getInstance() - ->table($this->userMapper->getTable()) +$deleteQuery = DeleteQuery::getInstance() + ->table('users') ->where('name like :name', ['name'=>'Jane%']); -$this->repository->deleteByQuery($query); -``` \ No newline at end of file +$repository->deleteByQuery($deleteQuery); +``` + +## Hook Methods + +### setBeforeInsert + +Set a processor to be called before inserting a record. You can use either a closure or an implementation of +`EntityProcessorInterface`. + +```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; + +class BeforeInsertProcessor implements EntityProcessorInterface +{ + #[Override] + public function process(array $instance): array + { + // Modify the instance before inserting + if (!isset($instance['created_at'])) { + $instance['created_at'] = date('Y-m-d H:i:s'); + } + return $instance; + } +} + +$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`. + +```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; + +class BeforeUpdateProcessor implements EntityProcessorInterface +{ + #[Override] + public function process(array $instance): array + { + // Modify the instance before updating + if (!isset($instance['updated_at'])) { + $instance['updated_at'] = date('Y-m-d H:i:s'); + } + return $instance; + } +} + +$repository->setBeforeUpdate(new BeforeUpdateProcessor()); +``` + +## Observers + +### addObserver + +Add an observer to the repository to monitor database operations. + +```php +class MyObserver implements ObserverProcessorInterface +{ + public function update(ObserverData $observerData): void + { + // Process the update event + $action = $observerData->getAction(); // INSERT, UPDATE, DELETE + $model = $observerData->getModel(); + $oldModel = $observerData->getOldModel(); + } +} + +$repository->addObserver(new MyObserver()); +``` + +See [Observers](observers.md) for more details. \ No newline at end of file diff --git a/docs/softdelete.md b/docs/softdelete.md index a30a226..4392799 100644 --- a/docs/softdelete.md +++ b/docs/softdelete.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 10 +--- + # Soft Deletes Soft deletes are a way to "delete" a record without actually removing it from the database. @@ -18,13 +22,14 @@ There is some ways to define the field `deleted_at`: ```php withAllowOnlyNewValuesForFields('name'); +// This will ensure the 'name' field is actually being changed +$repository->save($user, new RequireChangedValuesConstraint('name')); -$users->name = "New name"; -$repository->save($users, $updateConstraint); +// You can also specify multiple fields that must change +$repository->save($user, new RequireChangedValuesConstraint(['name', 'email'])); ``` -## Current Constraints +If you try to update a record without changing the specified field(s), a `RequireChangedValuesConstraintException` will +be thrown. + +### CustomConstraint + +This constraint allows you to define custom validation logic using a closure. The closure receives the old and new +instances and should return `true` if the update should be allowed, or any other value to reject it. + +```php +save($user, new CustomConstraint( + function($oldInstance, $newInstance) { + // Only allow updating if the new age is greater than the old age + return $newInstance->getAge() > $oldInstance->getAge(); + }, + "Age can only be increased" // Optional custom error message +)); +``` -### Allow Only New Values for Fields +If the validation fails, an `UpdateConstraintException` will be thrown with either your custom message or a default +message. -This constraint will allow only new values for the fields defined. +## Using Multiple Constraints -### Custom Constraint +You can apply multiple constraints by passing an array of constraints to the `save` method: ```php -$updateConstraint = \ByJG\MicroOrm\UpdateConstraint()::instance() - ->withClosure(function($oldInstance, $newInstance) { - return true; // to allow the update, or false to block it. - }); +save($user, [ + new RequireChangedValuesConstraint(['name', 'email']), + new CustomConstraint(function($old, $new) { + return $new->getAge() >= 18; + }, "User must be at least 18 years old") +]); +``` + +All constraints must pass for the update to be performed. + +## Creating Custom Constraints + +You can create your own constraints by implementing the `UpdateConstraintInterface`: + +```php +isValid($oldInstance, $newInstance)) { + throw new UpdateConstraintException("Your custom error message"); + } + } + + private function isValid($old, $new): bool + { + // Implement your validation logic here + return true; + } +} ``` diff --git a/docs/updating-the-database.md b/docs/updating-the-database.md index dd9730d..ea3bbda 100644 --- a/docs/updating-the-database.md +++ b/docs/updating-the-database.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 3 +--- + # Updating the Database Once you have defined the model, (see [Getting Started](getting-started-model.md)) you can start to @@ -212,7 +216,7 @@ $insert = InsertQuery::getInstance('users', [ ]); // Example of a SELECT as the last command (driver-specific last insert id) -$selectLastId = QueryRaw::getInstance($repository->getDbDriver()->getDbHelper()->getSqlLastInsertId()); +$selectLastId = QueryRaw::getInstance($repository->getExecutor()->getHelper()->getSqlLastInsertId()); $it = $repository->bulkExecute([$insert, $selectLastId]); foreach ($it as $row) { diff --git a/docs/using-fieldalias.md b/docs/using-fieldalias.md index 3f2df44..8c6de7c 100644 --- a/docs/using-fieldalias.md +++ b/docs/using-fieldalias.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 14 +--- + # Using FieldAlias Field alias is an alternate name for a field. This is useful for disambiguation on join and left join queries. diff --git a/docs/using-mapper-object.md b/docs/using-mapper-object.md index cffa6b6..d403496 100644 --- a/docs/using-mapper-object.md +++ b/docs/using-mapper-object.md @@ -1,20 +1,41 @@ +--- +sidebar_position: 4 +--- + # Using Mapper Object The Mapper object is the main object that you will use to interact with the database and your model. -For the Majority of the cases you can use the model as you can see in the [Getting Started](getting-started-model.md) section. -This will create the mapper object automatically for you. +## Why Use Mapper Objects? + +### The Key Advantage: Decoupling + +**The biggest advantage of using Mapper objects is decoupling your code from the database.** With Mapper, you can: + +- ✅ **Work with legacy models** - Map existing classes without modifying them +- ✅ **Use third-party libraries** - Map models you don't own or can't change +- ✅ **Keep domain models clean** - No database annotations polluting your business logic +- ✅ **Change database schema independently** - Rename columns without touching your models +- ✅ **Support multiple databases** - Same model, different mappings for different databases + +### When to Use Mapper Objects vs Attributes -Create a mapper object directly in these scenarios: -- Use a PHP version earlier than 8.0 (soon this will be deprecated in the library) -- You have a Model object you cannot change (like a third-party library) or don't want to change. +| Approach | Use When | Benefits | +|--------------------------------------|-----------------------------------------------------|----------------------------------------------| +| **Attributes** (`#[FieldAttribute]`) | You control the model classes | Convenient, everything in one place | +| **Mapper Objects** | Legacy code, third-party models, clean architecture | Complete decoupling, no model changes needed | -## Creating the Mapper Object +For the majority of cases, you can use attributes as shown in the [Getting Started](getting-started-model.md) section. This creates the mapper automatically. -For the examples below we will use the class 'Users'; +## Use Cases + +### Use Case 1: Legacy/Third-Party Code + +Let's say you have a legacy class or a third-party library class that you **cannot modify**: ```php addFieldMapping( + FieldMapping::create('id')->withFieldName('user_id') +); +$mapper->addFieldMapping( + FieldMapping::create('name')->withFieldName('full_name') +); +$mapper->addFieldMapping( + FieldMapping::create('createdate')->withFieldName('created_at') ); +``` + +Now use the repository for CRUD operations: + +```php +get(10); +echo $user->name; // ← Comes from 'full_name' column + +// Save data - automatically maps object properties to database columns +$user->name = "New name"; +$repository->save($user); // ← Updates 'full_name' column + +// Delete +$repository->delete($user); +``` + +**Key Point**: The `Users` class remains completely untouched - all mapping happens externally! + +### Use Case 2: Clean Architecture with Pure Domain Models + +One of the most powerful use cases for Mapper objects is implementing **Clean Architecture** where your domain models remain completely pure - no database concerns whatsoever. + +#### Pure Domain Model (No Database Knowledge) + +```php +id = $id; + $this->email = $email; + $this->name = $name; + $this->createdAt = new \DateTimeImmutable(); + } + + // Pure business logic - no database concerns + public function changeEmail(string $newEmail): void + { + if (!filter_var($newEmail, FILTER_VALIDATE_EMAIL)) { + throw new \InvalidArgumentException('Invalid email'); + } + $this->email = $newEmail; + } + + public function getId(): string { return $this->id; } + public function getEmail(): string { return $this->email; } + public function getName(): string { return $this->name; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } +} +``` + +#### Infrastructure Layer Mapping (Separate from Domain) + +```php +addFieldMapping(FieldMapping::create('id')); + $mapper->addFieldMapping(FieldMapping::create('email')); + $mapper->addFieldMapping(FieldMapping::create('name')); + $mapper->addFieldMapping( + FieldMapping::create('createdAt') + ->withFieldName('created_at') + // Transform DateTime to string for DB + ->withUpdateFunction(fn($value) => $value->format('Y-m-d H:i:s')) + // Transform string from DB to DateTime + ->withSelectFunction(fn($value) => new \DateTimeImmutable($value)) + ); -// Optionally you can define table mappings between the propoerties -// and the database fields; -// The example below will map the property 'createdate' to the database field 'created'; -$mapper->addFieldMapping(FieldMap::create('createdate')->withFieldName('created')); + return $mapper; + } +} ``` -Then you need to create the dataset object and the repository: +#### Repository in Infrastructure Layer ```php repository = new Repository($executor, UserMapper::create()); + } + + public function findById(string $id): ?User + { + return $this->repository->get($id); + } + + public function save(User $user): void + { + $this->repository->save($user); + } +} ``` -Some examples with the repository: +**Benefits**: Pure domain model, testable without database, flexible to schema changes, clear architectural separation. + +## Creating Mappers Reference + +### Basic Mapper (Properties Match Column Names) ```php get(10); +### Mapper with Field Mappings -// Persist the entity into the database: -// Will INSERT if does not exists, and UPDATE if exists -$users->name = "New name"; -$repository->save($users); +```php +addFieldMapping( + FieldMapping::create('createdate')->withFieldName('created') +); ``` +## Summary + +| Feature | Attributes | Mapper Objects | +|-------------------------|--------------------------|-----------------------------------------| +| **Convenience** | ✅ Very convenient | ⚠️ More setup required | +| **Legacy Support** | ❌ Need to modify classes | ✅ No modification needed | +| **Clean Architecture** | ❌ Couples domain to ORM | ✅ Pure domain models | +| **Third-party Classes** | ❌ Can't add attributes | ✅ Works perfectly | +| **Multiple Mappings** | ❌ One mapping per class | ✅ Different mappings possible | +| **Best For** | New projects you control | Legacy, third-party, clean architecture | + +**Choose Mapper objects when you need complete decoupling between your domain models and database concerns.** + diff --git a/docs/using-with-recursive-sql-command.md b/docs/using-with-recursive-sql-command.md index 6ca4b0b..ac5c0bd 100644 --- a/docs/using-with-recursive-sql-command.md +++ b/docs/using-with-recursive-sql-command.md @@ -1,3 +1,7 @@ +--- +sidebar_position: 16 +--- + # Using With Recursive SQL Command ```php diff --git a/docs/uuid-support.md b/docs/uuid-support.md new file mode 100644 index 0000000..ebee9ef --- /dev/null +++ b/docs/uuid-support.md @@ -0,0 +1,457 @@ +--- +sidebar_position: 13 +--- + +# UUID Support + +MicroOrm provides comprehensive support for working with UUID fields across different database systems. This guide +explains how to set up and use UUIDs as primary keys or regular fields in your models. + +## Overview + +UUIDs (Universally Unique Identifiers) are 128-bit values typically represented as 32 hexadecimal characters. When used +as primary keys, they offer advantages like: + +- Globally unique identifiers without coordination between database servers +- No need for a centralized sequence or auto-increment field +- Improved security by avoiding sequential predictable IDs +- Distributed system-friendly identification + +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) +- Automatic formatting when reading from and writing to the database +- Support for UUID fields beyond just primary keys + +## Setting Up UUID Primary Keys + +### 1. Database Schema + +First, set up your database table with a binary field to store the UUID: + +```sql +-- MySQL example +CREATE TABLE my_table ( + id BINARY(16) PRIMARY KEY, -- Binary format is more efficient than VARCHAR + name VARCHAR(100), + -- other fields +); + +-- SQLite example +CREATE TABLE my_table ( + id BINARY(16) PRIMARY KEY, + name VARCHAR(100), + -- other fields +); + +-- PostgreSQL example (can use native UUID type) +CREATE TABLE my_table ( + id UUID PRIMARY KEY, + name VARCHAR(100), + -- other fields +); +``` + +### 2. Model Definition + +Then define your model using the appropriate UUID attributes: + +```php +id; + } + + public function setId(?string $id): void + { + $this->id = $id; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } +} +``` + +## Available UUID Attributes + +### Table Attributes + +MicroOrm provides several table attributes for UUID primary keys: + +1. `TableUuidPKAttribute` - Base class for UUID tables (uses generic implementation) +2. `TableMySqlUuidPKAttribute` - MySQL-specific implementation (uses MySQL's UUID functions) +3. `TableSqliteUuidPKAttribute` - SQLite-specific implementation + +Each attribute automatically configures a primary key seed function that generates appropriate UUID values for the +specific database. + +### Field Attribute + +The `FieldUuidAttribute` is used to mark UUID fields (both primary keys and regular fields). It internally configures: + +- `FormatUpdateUuidMapper` - Handles converting string UUIDs to `HexUuidLiteral` (binary format) when saving to database +- `FormatSelectUuidMapper` - Handles converting binary UUIDs from database to formatted string (with dashes) when + reading + +These mapper functions ensure that: + +- Your application code works with human-readable UUID strings (e.g., `'550e8400-e29b-41d4-a716-446655440000'`) +- The database stores UUIDs efficiently as 16-byte binary values +- Conversions happen automatically during save and retrieve operations + +## Using UUID Fields Beyond Primary Keys + +You can use `FieldUuidAttribute` on any UUID field, not just primary keys: + +```php +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\Attributes\FieldUuidAttribute; +use ByJG\MicroOrm\Attributes\TableAttribute; + +#[TableAttribute(tableName: 'users')] +class User +{ + #[FieldAttribute(primaryKey: true)] + protected int $id; + + #[FieldUuidAttribute(fieldName: 'external_id')] + protected ?string $externalId = null; + + #[FieldUuidAttribute(fieldName: 'tenant_id')] + protected ?string $tenantId = null; + + #[FieldAttribute] + protected ?string $name = null; + + // Getters and setters work with string UUIDs + public function getExternalId(): ?string + { + return $this->externalId; + } + + public function setExternalId(?string $externalId): void + { + $this->externalId = $externalId; + } +} + +// Usage +$user = new User(); +$user->setExternalId('550e8400-e29b-41d4-a716-446655440000'); // String format +$repository->save($user); // Automatically converted to binary for storage + +// When retrieved, automatically converted back to string +$retrieved = $repository->get($user->getId()); +echo $retrieved->getExternalId(); // '550E8400-E29B-41D4-A716-446655440000' +``` + +## Querying by UUID + +### Primary Key Queries + +When querying by UUID primary keys using `get()` or `delete()`, the conversion is **automatic**: + +```php +use ByJG\MicroOrm\Repository; + +$repository = new Repository($dbDriver, MyModel::class); +$uuid = '550e8400-e29b-41d4-a716-446655440000'; + +// For UUID primary keys, just pass the string directly - NO need to wrap in HexUuidLiteral +$entity = $repository->get($uuid); // ✓ Automatically converted via updateFunction + +// Delete also works automatically +$repository->delete($uuid); // ✓ Automatically converted via updateFunction +``` + +The repository automatically applies the `updateFunction` (in this case `FormatUpdateUuidMapper`) to convert the string +UUID to a `HexUuidLiteral` before querying. + +### Manual Queries with WHERE Clauses + +When using manual queries with `where()` clauses, you **must** explicitly use UUID literals: + +```php +use ByJG\MicroOrm\Literal\HexUuidLiteral; + +$uuid = '550e8400-e29b-41d4-a716-446655440000'; + +// ✓ CORRECT - Wrap in HexUuidLiteral for WHERE clauses +$query = $repository->getMapper()->getQuery(); +$query->where('id = :id', ['id' => new HexUuidLiteral($uuid)]); +$results = $repository->getByQuery($query); + +// ✓ Also correct for non-primary key UUID fields +$query->where('user_id = :user_id', ['user_id' => new HexUuidLiteral($userId)]); + +// ✗ INCORRECT - This won't work correctly +$query->where('id = :id', ['id' => $uuid]); // String won't match binary in DB +``` + +### Why the Difference? + +- **`get()` / `delete()`**: These methods know they're working with primary keys, so they automatically apply the + `updateFunction` from the field mapping +- **`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: + +- `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 + +### Example Usage + +```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 +``` + +## Format Helpers + +The `HexUuidLiteral` class provides helper methods for formatting and validating UUIDs: + +```php +use ByJG\MicroOrm\Literal\HexUuidLiteral; + +// Format a binary UUID (16 bytes) to a formatted string +$binaryUuid = hex2bin('550E8400E29B41D4A716446655440000'); +$formatted = HexUuidLiteral::getFormattedUuid($binaryUuid); +// Returns: '550E8400-E29B-41D4-A716-446655440000' + +// Format a hex string (32 chars) to formatted UUID +$hexString = '550E8400E29B41D4A716446655440000'; +$formatted = HexUuidLiteral::getFormattedUuid($hexString); +// Returns: '550E8400-E29B-41D4-A716-446655440000' + +// Already formatted UUIDs are returned as-is +$alreadyFormatted = '550E8400-E29B-41D4-A716-446655440000'; +$result = HexUuidLiteral::getFormattedUuid($alreadyFormatted); +// Returns: '550E8400-E29B-41D4-A716-446655440000' + +// Handle invalid UUIDs gracefully +$invalid = 'not-a-uuid'; +$result = HexUuidLiteral::getFormattedUuid($invalid, throwErrorIfInvalid: false); +// Returns: null + +// Or with a default value +$result = HexUuidLiteral::getFormattedUuid($invalid, throwErrorIfInvalid: false, default: 'N/A'); +// Returns: 'N/A' + +// Extract UUID from a literal object +$literal = new HexUuidLiteral('550E8400-E29B-41D4-A716-446655440000'); +$uuid = HexUuidLiteral::getUuidFromLiteral($literal); +// Returns: '550E8400-E29B-41D4-A716-446655440000' +``` + +### Supported Input Formats + +`HexUuidLiteral::getFormattedUuid()` can handle various UUID formats: + +- **Binary (16 bytes)**: `hex2bin('550E8400E29B41D4A716446655440000')` +- **Hex string (32 chars)**: `'550E8400E29B41D4A716446655440000'` +- **Formatted (36 chars with dashes)**: `'550E8400-E29B-41D4-A716-446655440000'` +- **Literal format**: `X'550E8400E29B41D4A716446655440000'` +- **Prefixed hex**: `0x550E8400E29B41D4A716446655440000` + +All formats are automatically detected and converted to the standard formatted string with dashes. + +## Database-Specific Notes + +### MySQL + +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. + +### SQLite + +SQLite also doesn't have a native UUID type. The `TableSqliteUuidPKAttribute` uses SQLite's `randomblob()` function to +generate UUIDs. + +### PostgreSQL + +PostgreSQL has a native UUID type. The library provides support through the `PostgresUuidLiteral` class. + +## Performance Considerations + +Storing UUIDs as BINARY(16) is more efficient than VARCHAR(36) both in terms of storage and indexing performance: + +- **Storage**: BINARY(16) uses 16 bytes vs VARCHAR(36) which uses 36 bytes +- **Indexing**: Binary indexes are smaller and faster to traverse +- **Comparison**: Binary comparisons are faster than string comparisons + +MicroOrm handles all conversions between string representations and binary storage automatically through the mapper +functions. + +## Advanced: Custom UUID Mapper Functions + +If you need custom UUID handling, you can create your own mapper functions: + +```php +use ByJG\MicroOrm\Interface\MapperFunctionInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Literal\HexUuidLiteral; + +class CustomUuidUpdateMapper implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($value)) { + return null; + } + + // Your custom logic here + // Convert string UUID to your preferred format + return new HexUuidLiteral($value); + } +} + +class CustomUuidSelectMapper implements MapperFunctionInterface +{ + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($value)) { + return $value; + } + + // Your custom logic here + // Convert binary UUID from database to your preferred format + return HexUuidLiteral::getFormattedUuid($value, throwErrorIfInvalid: false); + } +} + +// Use in your model +#[FieldAttribute( + fieldName: 'custom_uuid', + updateFunction: CustomUuidUpdateMapper::class, + selectFunction: CustomUuidSelectMapper::class +)] +protected ?string $customUuid = null; +``` + +## Complete Example + +Here's a complete example showing a table with multiple UUID fields: + +```php +use ByJG\MicroOrm\Attributes\FieldAttribute; +use ByJG\MicroOrm\Attributes\FieldUuidAttribute; +use ByJG\MicroOrm\Attributes\TableUuidPKAttribute; +use ByJG\MicroOrm\Repository; + +#[TableUuidPKAttribute(tableName: 'orders')] +class Order +{ + #[FieldUuidAttribute(primaryKey: true)] + protected ?string $id = null; + + #[FieldUuidAttribute(fieldName: 'user_id')] + protected ?string $userId = null; + + #[FieldUuidAttribute(fieldName: 'session_id')] + protected ?string $sessionId = null; + + #[FieldAttribute] + protected ?string $status = null; + + #[FieldAttribute(fieldName: 'created_at')] + protected ?string $createdAt = null; + + // Getters and setters... +} + +// Database schema +/* +CREATE TABLE orders ( + id BINARY(16) PRIMARY KEY, + user_id BINARY(16) NOT NULL, + session_id BINARY(16), + status VARCHAR(50), + created_at DATETIME +); +*/ + +// Usage +$repository = new Repository($dbDriver, Order::class); + +// Create new order +$order = new Order(); +$order->setUserId('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); +$order->setSessionId('11111111-2222-3333-4444-555555555555'); +$order->setStatus('pending'); +$repository->save($order); + +// ID is automatically generated +echo $order->getId(); // e.g., '3B4E18A0-C719-4ED0-C9B2-026A91889DC0' + +// Query by UUID +$found = $repository->get($order->getId()); +echo $found->getUserId(); // 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890' + +// All UUIDs are automatically formatted when retrieved +``` \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 13600e9..a25ebca 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,12 +9,17 @@ and open the template in the editor. bootstrap="./vendor/autoload.php" colors="true" testdox="true" - convertErrorsToExceptions="true" - convertNoticesToExceptions="true" - convertWarningsToExceptions="true" - convertDeprecationsToExceptions="true" + displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnTestsThatTriggerErrors="true" + displayDetailsOnTestsThatTriggerNotices="true" + displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnPhpunitDeprecations="true" + failOnDeprecation="true" + failOnNotice="true" + failOnWarning="true" stopOnFailure="false" - xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"> @@ -22,11 +27,11 @@ and open the template in the editor. - + - ./src + ./src/ - + diff --git a/psalm.xml b/psalm.xml index ebabb1a..b208114 100644 --- a/psalm.xml +++ b/psalm.xml @@ -4,6 +4,7 @@ resolveFromConfigFile="true" findUnusedBaselineEntry="true" findUnusedCode="false" + 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" diff --git a/src/ActiveRecordQuery.php b/src/ActiveRecordQuery.php new file mode 100644 index 0000000..848b099 --- /dev/null +++ b/src/ActiveRecordQuery.php @@ -0,0 +1,101 @@ +repository = $repository; + + // Initialize with the table from the repository's mapper + $mapper = $repository->getMapper(); + $this->table($mapper->getTable(), $mapper->getTableAlias()); + } + + /** + * Get the iterator for this query with entity transformation + * + * @param CacheQueryResult|null $cache + * @return GenericIterator + */ + public function getIterator(?CacheQueryResult $cache = null): GenericIterator + { + return $this->repository->getIterator($this, $cache); + } + + /** + * Get the first entity matching the query, or null if not found + * + * @return mixed|null + */ + public function first(): mixed + { + return $this->getIterator()->first(); + } + + /** + * Get the first entity matching the query, or throw an exception if not found + * + * @return mixed + * @throws NotFoundException + */ + public function firstOrFail(): mixed + { + return $this->getIterator()->firstOrFail(); + } + + /** + * Check if any entities exist matching the query + * + * @return bool + */ + public function exists(): bool + { + return $this->getIterator()->exists(); + } + + /** + * Check if any entities exist matching the query, throw exception if not + * + * @return bool Always returns true (throws exception if no records exist) + * @throws NotFoundException + */ + public function existsOrFail(): bool + { + return $this->getIterator()->existsOrFail(); + } + + /** + * Get all entities matching the query as an array + * + * @return array + */ + public function toArray(): array + { + return $this->repository->getByQuery($this); + } + + /** + * Alias for where() to enable fluent syntax from Active Record + * This allows: Model::where('field = :value', ['value' => 123])->first() + * + * @param array|string $filter + * @param array $params + * @return static + */ + public static function createWhere(Repository $repository, array|string $filter, array $params = []): static + { + $query = new static($repository); + return $query->where($filter, $params); + } +} diff --git a/src/Attributes/FieldAttribute.php b/src/Attributes/FieldAttribute.php index 7489b26..42c5d44 100644 --- a/src/Attributes/FieldAttribute.php +++ b/src/Attributes/FieldAttribute.php @@ -4,41 +4,35 @@ use Attribute; use ByJG\MicroOrm\FieldMapping; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] class FieldAttribute { - private ?string $fieldName; - private mixed $updateFunction; - private mixed $selectFunction; - private mixed $insertFunction; - private ?string $fieldAlias; - private ?bool $syncWithDb; - private ?bool $primaryKey; private ?string $propertyName; - private ?string $parentTable; + /** + * @param bool|null $primaryKey + * @param string|null $fieldName + * @param string|null $fieldAlias + * @param bool|null $syncWithDb + * @param MapperFunctionInterface|string|null $updateFunction + * @param MapperFunctionInterface|string|null $selectFunction + * @param MapperFunctionInterface|string|null $insertFunction + * @param string|null $parentTable + */ public function __construct( - bool $primaryKey = null, - string $fieldName = null, - string $fieldAlias = null, - bool $syncWithDb = null, - callable $updateFunction = null, - callable $selectFunction = null, - callable $insertFunction = null, - string $parentTable = null + private ?bool $primaryKey = null, + private ?string $fieldName = null, + private ?string $fieldAlias = null, + private ?bool $syncWithDb = null, + private MapperFunctionInterface|string|null $updateFunction = null, + private MapperFunctionInterface|string|null $selectFunction = null, + private MapperFunctionInterface|string|null $insertFunction = null, + private ?string $parentTable = null ) { - $this->primaryKey = $primaryKey; - $this->fieldName = $fieldName; - $this->fieldAlias = $fieldAlias; - $this->insertFunction = $insertFunction; - $this->updateFunction = $updateFunction; - $this->selectFunction = $selectFunction; - $this->syncWithDb = $syncWithDb; - $this->parentTable = $parentTable; - if ($this->syncWithDb === false && !is_null($this->updateFunction)) { throw new InvalidArgumentException("You cannot have an updateFunction when syncWithDb is false"); } diff --git a/src/Attributes/FieldUuidAttribute.php b/src/Attributes/FieldUuidAttribute.php index 0b33c66..f4d1c1b 100644 --- a/src/Attributes/FieldUuidAttribute.php +++ b/src/Attributes/FieldUuidAttribute.php @@ -3,18 +3,19 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\MicroOrm\MapperFunctions; +use ByJG\MicroOrm\MapperFunctions\FormatSelectUuidMapper; +use ByJG\MicroOrm\MapperFunctions\FormatUpdateUuidMapper; #[Attribute(Attribute::TARGET_PROPERTY)] class FieldUuidAttribute extends FieldAttribute { public function __construct( - bool $primaryKey = null, - string $fieldName = null, - string $fieldAlias = null, - bool $syncWithDb = null + ?bool $primaryKey = null, + ?string $fieldName = null, + ?string $fieldAlias = null, + ?bool $syncWithDb = null ) { - parent::__construct($primaryKey, $fieldName, $fieldAlias, $syncWithDb, MapperFunctions::UPDATE_BINARY_UUID, MapperFunctions::SELECT_BINARY_UUID); + parent::__construct($primaryKey, $fieldName, $fieldAlias, $syncWithDb, FormatUpdateUuidMapper::class, FormatSelectUuidMapper::class); } } diff --git a/src/Attributes/TableAttribute.php b/src/Attributes/TableAttribute.php index 5c98873..d02fff9 100644 --- a/src/Attributes/TableAttribute.php +++ b/src/Attributes/TableAttribute.php @@ -3,21 +3,32 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; +use ByJG\MicroOrm\Interface\EntityProcessorInterface; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; #[Attribute(Attribute::TARGET_CLASS)] class TableAttribute { private string $tableName; - private mixed $primaryKeySeedFunction; + private string|MapperFunctionInterface|null $primaryKeySeedFunction; private ?string $tableAlias; - - public function __construct(string $tableName, callable $primaryKeySeedFunction = null, string $tableAlias = null) + private string|EntityProcessorInterface|null $beforeInsert = null; + private string|EntityProcessorInterface|null $beforeUpdate = null; + + public function __construct( + string $tableName, + string|MapperFunctionInterface|null $primaryKeySeedFunction = null, + ?string $tableAlias = null, + string|EntityProcessorInterface|null $beforeInsert = null, + string|EntityProcessorInterface|null $beforeUpdate = null + ) { - $this->tableName = $tableName; $this->tableAlias = $tableAlias; $this->primaryKeySeedFunction = $primaryKeySeedFunction; + $this->beforeInsert = $beforeInsert; + $this->beforeUpdate = $beforeUpdate; } public function getTableName(): string @@ -30,8 +41,18 @@ public function getTableAlias(): ?string return $this->tableAlias; } - public function getPrimaryKeySeedFunction(): ?callable + public function getPrimaryKeySeedFunction(): string|MapperFunctionInterface|null { return $this->primaryKeySeedFunction; } + + public function getBeforeInsert(): string|EntityProcessorInterface|null + { + return $this->beforeInsert; + } + + public function getBeforeUpdate(): string|EntityProcessorInterface|null + { + return $this->beforeUpdate; + } } diff --git a/src/Attributes/TableMySqlUuidPKAttribute.php b/src/Attributes/TableMySqlUuidPKAttribute.php index 5bdee44..e57c1a7 100644 --- a/src/Attributes/TableMySqlUuidPKAttribute.php +++ b/src/Attributes/TableMySqlUuidPKAttribute.php @@ -3,16 +3,22 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\MicroOrm\Literal\Literal; +use Override; #[Attribute(Attribute::TARGET_CLASS)] -class TableMySqlUuidPKAttribute extends TableAttribute +class TableMySqlUuidPKAttribute extends TableAttribute implements MapperFunctionInterface { public function __construct(string $tableName) { - parent::__construct($tableName, function (DbDriverInterface $dbDriver, object $entity) { - return new Literal("X'" . $dbDriver->getScalar("SELECT hex(uuid_to_bin(uuid()))") . "'"); - }); + parent::__construct($tableName, primaryKeySeedFunction: $this); + } + + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + 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 03a09dc..7caf84b 100644 --- a/src/Attributes/TablePgsqlUuidPKAttribute.php +++ b/src/Attributes/TablePgsqlUuidPKAttribute.php @@ -3,16 +3,22 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; +use Override; #[Attribute(Attribute::TARGET_CLASS)] -class TablePgsqlUuidPKAttribute extends TableAttribute +class TablePgsqlUuidPKAttribute extends TableAttribute implements MapperFunctionInterface { public function __construct(string $tableName) { - parent::__construct($tableName, function (DbDriverInterface $dbDriver, object $entity) { - $bytes = random_bytes(16); - return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); - }); + 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 0cfd73a..9c8c62e 100644 --- a/src/Attributes/TableSqliteUuidPKAttribute.php +++ b/src/Attributes/TableSqliteUuidPKAttribute.php @@ -3,16 +3,22 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\MicroOrm\Literal\Literal; +use Override; #[Attribute(Attribute::TARGET_CLASS)] -class TableSqliteUuidPKAttribute extends TableAttribute +class TableSqliteUuidPKAttribute extends TableAttribute implements MapperFunctionInterface { public function __construct(string $tableName) { - parent::__construct($tableName, function (DbDriverInterface $dbDriver, object $entity) { - return new Literal("X'" . $dbDriver->getScalar("SELECT hex(randomblob(16))") . "'"); - }); + parent::__construct($tableName, primaryKeySeedFunction: $this); + } + + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + return new Literal("X'" . $executor->getScalar("SELECT hex(randomblob(16))") . "'"); } } diff --git a/src/Attributes/TableUuidPKAttribute.php b/src/Attributes/TableUuidPKAttribute.php index 1aec3a7..67fba67 100644 --- a/src/Attributes/TableUuidPKAttribute.php +++ b/src/Attributes/TableUuidPKAttribute.php @@ -3,16 +3,22 @@ namespace ByJG\MicroOrm\Attributes; use Attribute; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\MicroOrm\Literal\Literal; +use Override; #[Attribute(Attribute::TARGET_CLASS)] -class TableUuidPKAttribute extends TableAttribute +class TableUuidPKAttribute extends TableAttribute implements MapperFunctionInterface { public function __construct(string $tableName) { - parent::__construct($tableName, function (DbDriverInterface $dbDriver, object $entity) { - return new Literal("X'" . bin2hex(random_bytes(16)) . "'"); - }); + parent::__construct($tableName, primaryKeySeedFunction: $this); + } + + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + return new Literal("X'" . bin2hex(random_bytes(16)) . "'"); } } diff --git a/src/Constraint/CustomConstraint.php b/src/Constraint/CustomConstraint.php new file mode 100644 index 0000000..2f4e7a3 --- /dev/null +++ b/src/Constraint/CustomConstraint.php @@ -0,0 +1,38 @@ +closure = $closure; + $this->errorMessage = $errorMessage; + } + + /** + * @inheritDoc + */ + #[Override] + public function check(mixed $oldInstance, mixed $newInstance): void + { + $result = ($this->closure)($oldInstance, $newInstance); + if ($result !== true) { + throw new UpdateConstraintException( + $this->errorMessage ?? "The Update Constraint validation failed" + ); + } + } +} \ No newline at end of file diff --git a/src/Constraint/RequireChangedValuesConstraint.php b/src/Constraint/RequireChangedValuesConstraint.php new file mode 100644 index 0000000..316fcc2 --- /dev/null +++ b/src/Constraint/RequireChangedValuesConstraint.php @@ -0,0 +1,40 @@ +properties = (array)$properties; + } + + /** + * @inheritDoc + */ + #[Override] + public function check(mixed $oldInstance, mixed $newInstance): void + { + foreach ($this->properties as $property) { + $method = "get" . ucfirst($property); + if (!method_exists($oldInstance, $method)) { + throw new RequireChangedValuesConstraintException("The property '$property' does not exist in the old instance."); + } + if (!method_exists($newInstance, $method)) { + throw new RequireChangedValuesConstraintException("The property '$property' does not exist in the new instance."); + } + if ($oldInstance->$method() == $newInstance->$method()) { + throw new RequireChangedValuesConstraintException("You are not updating the property '$property'"); + } + } + } +} \ No newline at end of file diff --git a/src/DeleteQuery.php b/src/DeleteQuery.php index e1c548c..d8b8fac 100644 --- a/src/DeleteQuery.php +++ b/src/DeleteQuery.php @@ -4,8 +4,10 @@ use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; +use Override; class DeleteQuery extends Updatable { @@ -14,7 +16,8 @@ public static function getInstance(): DeleteQuery return new DeleteQuery(); } - public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject + #[Override] + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement { $whereStr = $this->getWhere(); if (is_null($whereStr)) { @@ -28,10 +31,11 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel $sql = ORMHelper::processLiteral($sql, $params); - return new SqlObject($sql, $params, SqlObjectEnum::DELETE); + return new SqlStatement($sql, $params); } - public function convert(?DbFunctionsInterface $dbDriver = null): QueryBuilderInterface + #[Override] + public function convert(?DbFunctionsInterface $dbHelper = null): QueryBuilderInterface { $query = Query::getInstance() ->table($this->table); diff --git a/src/Enum/ObserverEvent.php b/src/Enum/ObserverEvent.php new file mode 100644 index 0000000..7fbd6bc --- /dev/null +++ b/src/Enum/ObserverEvent.php @@ -0,0 +1,10 @@ +fieldAlias = $propertyName; $this->propertyName = $propertyName; - $this->selectFunction = MapperFunctions::STANDARD; - $this->updateFunction = MapperFunctions::STANDARD; - $this->insertFunction = MapperFunctions::READ_ONLY; + $this->selectFunction = StandardMapper::class; + $this->updateFunction = StandardMapper::class; + $this->insertFunction = ReadOnlyMapper::class; } - public function withUpdateFunction(callable $updateFunction): static + private function checkIfStringImplementsMapperInterface(string $className): void { + if (!class_exists($className)) { + throw new InvalidArgumentException("The class '$className' does not exist"); + } + + if (!in_array(MapperFunctionInterface::class, class_implements($className))) { + throw new InvalidArgumentException("The class '$className' must implement MapperFunctionInterface"); + } + } + + public function withUpdateFunction(MapperFunctionInterface|string $updateFunction): static + { + if (is_string($updateFunction)) { + $this->checkIfStringImplementsMapperInterface($updateFunction); + } + $this->updateFunction = $updateFunction; return $this; } - public function withSelectFunction(callable $selectFunction): static + public function withSelectFunction(MapperFunctionInterface|string $selectFunction): static { + if (is_string($selectFunction)) { + $this->checkIfStringImplementsMapperInterface($selectFunction); + } + $this->selectFunction = $selectFunction; return $this; } - public function withInsertFunction(callable $insertFunction): static + public function withInsertFunction(MapperFunctionInterface|string $insertFunction): static { + if (is_string($insertFunction)) { + $this->checkIfStringImplementsMapperInterface($insertFunction); + } + $this->insertFunction = $insertFunction; return $this; } @@ -100,7 +126,7 @@ public function withParentTable(string $parentTable): static public function dontSyncWithDb(): static { $this->syncWithDb = false; - $this->withUpdateFunction(MapperClosure::readOnly()); + $this->withUpdateFunction(ReadOnlyMapper::class); return $this; } @@ -113,17 +139,17 @@ public function getFieldName(): string } /** - * @return Closure + * @return MapperFunctionInterface|string */ - public function getUpdateFunction(): Closure + public function getUpdateFunction(): mixed { return $this->updateFunction; } /** - * @return Closure + * @return MapperFunctionInterface|string */ - public function getSelectFunction(): Closure + public function getSelectFunction(): mixed { return $this->selectFunction; } @@ -140,17 +166,29 @@ public function getFieldAlias(): ?string public function getSelectFunctionValue(mixed $value, mixed $instance): mixed { - return call_user_func_array($this->selectFunction, [$value, $instance]); + $function = $this->selectFunction; + if (is_string($function)) { + $function = new $function(); + } + return $function->processedValue($value, $instance, null); } - public function getUpdateFunctionValue(mixed $value, mixed $instance, DbFunctionsInterface $helper): mixed + public function getUpdateFunctionValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { - return call_user_func_array($this->updateFunction, [$value, $instance, $helper]); + $function = $this->updateFunction; + if (is_string($function)) { + $function = new $function(); + } + return $function->processedValue($value, $instance, $executor); } - public function getInsertFunctionValue(mixed $value, mixed $instance, DbFunctionsInterface $helper): mixed + public function getInsertFunctionValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed { - return call_user_func_array($this->insertFunction, [$value, $instance, $helper]); + $function = $this->insertFunction; + if (is_string($function)) { + $function = new $function(); + } + return $function->processedValue($value, $instance, $executor); } public function isSyncWithDb(): bool diff --git a/src/InsertBulkQuery.php b/src/InsertBulkQuery.php index a41247e..b990065 100644 --- a/src/InsertBulkQuery.php +++ b/src/InsertBulkQuery.php @@ -4,10 +4,12 @@ use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\MicroOrm\Literal\Literal; use InvalidArgumentException; +use Override; class InsertBulkQuery extends Updatable { @@ -15,8 +17,6 @@ class InsertBulkQuery extends Updatable protected ?QueryBuilderInterface $query = null; - protected ?SqlObject $sqlObject = null; - protected bool $safe = false; public function __construct(string $table, array $fieldNames) @@ -62,10 +62,11 @@ public function values(array $values, bool $allowNonMatchFields = true): static /** * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper - * @return SqlObject + * @return SqlStatement * @throws OrmInvalidFieldsException */ - public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject + #[Override] + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement { if (empty($this->fields)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); @@ -101,7 +102,7 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel } else { $value = str_replace("'", "''", $this->fields[$col][$i]); if (!is_numeric($value)) { - $value = $dbDriverOrHelper?->delimiterField($value) ?? "'{$value}'"; + $value = "'{$value}'"; } $params[$paramKey] = new Literal($value); // Map parameter key to value } @@ -122,10 +123,11 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel ); $sql = ORMHelper::processLiteral($sql, $params); - return new SqlObject($sql, $params); + return new SqlStatement($sql, $params); } - public function convert(?DbFunctionsInterface $dbDriver = null): QueryBuilderInterface + #[Override] + public function convert(?DbFunctionsInterface $dbHelper = null): QueryBuilderInterface { throw new InvalidArgumentException('It is not possible to convert an InsertBulkQuery to a Query'); } diff --git a/src/InsertQuery.php b/src/InsertQuery.php index 08b305b..d02fe43 100644 --- a/src/InsertQuery.php +++ b/src/InsertQuery.php @@ -4,16 +4,18 @@ use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\MicroOrm\Literal\LiteralInterface; +use Override; class InsertQuery extends Updatable { protected array $values = []; - public static function getInstance(string $table = null, array $fieldsAndValues = []): self + public static function getInstance(?string $table = null, array $fieldsAndValues = []): self { $query = new InsertQuery(); if (!is_null($table)) { @@ -67,10 +69,11 @@ public function defineFields(array $fields): static /** * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper - * @return SqlObject + * @return SqlStatement * @throws OrmInvalidFieldsException */ - public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject + #[Override] + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement { if (empty($this->values)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); @@ -98,14 +101,15 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel $params = $this->values; $sql = ORMHelper::processLiteral($sql, $params); - return new SqlObject($sql, $params, SqlObjectEnum::INSERT); + return new SqlStatement($sql, $params); } /** * @throws InvalidArgumentException * @throws \ByJG\Serializer\Exception\InvalidArgumentException */ - public function convert(?DbFunctionsInterface $dbDriver = null): QueryBuilderInterface + #[Override] + public function convert(?DbFunctionsInterface $dbHelper = null): QueryBuilderInterface { $query = Query::getInstance() ->fields(array_keys($this->values)) diff --git a/src/InsertSelectQuery.php b/src/InsertSelectQuery.php index 315209d..a43e080 100644 --- a/src/InsertSelectQuery.php +++ b/src/InsertSelectQuery.php @@ -4,9 +4,11 @@ use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use InvalidArgumentException; +use Override; class InsertSelectQuery extends Updatable { @@ -14,10 +16,10 @@ class InsertSelectQuery extends Updatable protected ?QueryBuilderInterface $query = null; - protected ?SqlObject $sqlObject = null; + protected ?SqlStatement $sqlStatement = null; - public static function getInstance(string $table = null, array $fields = []): self + public static function getInstance(?string $table = null, array $fields = []): self { $query = new InsertSelectQuery(); if (!is_null($table)) { @@ -42,19 +44,20 @@ public function fromQuery(QueryBuilderInterface $query): static return $this; } - public function fromSqlObject(SqlObject $sqlObject): static + public function fromSqlStatement(SqlStatement $sqlStatement): static { - $this->sqlObject = $sqlObject; + $this->sqlStatement = $sqlStatement; return $this; } /** * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper - * @return SqlObject + * @return SqlStatement * @throws OrmInvalidFieldsException */ - public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject + #[Override] + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement { if (empty($this->fields)) { throw new OrmInvalidFieldsException('You must specify the fields for insert'); @@ -67,9 +70,9 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel $dbHelper = $dbDriverOrHelper->getDbHelper(); } - if (empty($this->query) && empty($this->sqlObject)) { + if (empty($this->query) && empty($this->sqlStatement)) { throw new OrmInvalidFieldsException('You must specify the query for insert'); - } elseif (!empty($this->query) && !empty($this->sqlObject)) { + } elseif (!empty($this->query) && !empty($this->sqlStatement)) { throw new OrmInvalidFieldsException('You must specify only one query for insert'); } @@ -87,16 +90,17 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel . $tableStr . ' ( ' . implode(', ', $fieldsStr) . ' ) '; - if (!is_null($this->sqlObject)) { - $fromObj = $this->sqlObject; + if (!is_null($this->sqlStatement)) { + $fromObj = $this->sqlStatement; } else { $fromObj = $this->query->build($dbDriver); } - return new SqlObject($sql . $fromObj->getSql(), $fromObj->getParameters()); + return new SqlStatement($sql . $fromObj->getSql(), $fromObj->getParams()); } - public function convert(?DbFunctionsInterface $dbDriver = null): QueryBuilderInterface + #[Override] + public function convert(?DbFunctionsInterface $dbHelper = null): QueryBuilderInterface { throw new InvalidArgumentException('It is not possible to convert an InsertSelectQuery to a Query'); } diff --git a/src/Interface/EntityProcessorInterface.php b/src/Interface/EntityProcessorInterface.php new file mode 100644 index 0000000..09a29be --- /dev/null +++ b/src/Interface/EntityProcessorInterface.php @@ -0,0 +1,17 @@ +literalValue; @@ -27,6 +30,7 @@ public function getLiteralValue(): mixed /** * @param mixed $literalValue */ + #[Override] public function setLiteralValue(mixed $literalValue): void { $this->literalValue = $literalValue; diff --git a/src/Mapper.php b/src/Mapper.php index 6396014..9794746 100644 --- a/src/Mapper.php +++ b/src/Mapper.php @@ -2,14 +2,17 @@ namespace ByJG\MicroOrm; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Attributes\FieldAttribute; use ByJG\MicroOrm\Attributes\TableAttribute; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Exception\OrmModelInvalidException; +use ByJG\MicroOrm\Interface\EntityProcessorInterface; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\MicroOrm\Literal\LiteralInterface; +use ByJG\MicroOrm\PropertyHandler\MapFromDbToInstanceHandler; use ByJG\Serializer\ObjectCopy; -use Closure; +use ByJG\Serializer\Serialize; use ReflectionAttribute; use ReflectionClass; use ReflectionException; @@ -21,13 +24,17 @@ class Mapper private string $table; private array $primaryKey; private array $primaryKeyModel; - private mixed $primaryKeySeedFunction = null; + private string|MapperFunctionInterface|null $primaryKeySeedFunction = null; private bool $softDelete = false; + private string|EntityProcessorInterface|null $beforeInsert = null; + private string|EntityProcessorInterface|null $beforeUpdate = null; /** * @var FieldMapping[] */ private array $fieldMap = []; + + private array $fieldToProperty = []; private bool $preserveCaseName = false; private ?string $tableAlias = null; @@ -37,6 +44,7 @@ class Mapper * @param string $entity * @param string|null $table * @param string|array|null $primaryKey + * @param string|null $tableAlias * @throws OrmModelInvalidException * @throws ReflectionException */ @@ -83,6 +91,12 @@ protected function processAttribute(string $entity): void if (!empty($tableAttribute->getPrimaryKeySeedFunction())) { $this->withPrimaryKeySeedFunction($tableAttribute->getPrimaryKeySeedFunction()); } + if (!empty($tableAttribute->getBeforeInsert())) { + $this->withBeforeInsert($tableAttribute->getBeforeInsert()); + } + if (!empty($tableAttribute->getBeforeUpdate())) { + $this->withBeforeUpdate($tableAttribute->getBeforeUpdate()); + } ORM::addMapper($this); $this->primaryKey = []; @@ -107,7 +121,7 @@ protected function processAttribute(string $entity): void } } - public function withPrimaryKeySeedFunction(callable $primaryKeySeedFunction): static + public function withPrimaryKeySeedFunction(string|MapperFunctionInterface $primaryKeySeedFunction): static { $this->primaryKeySeedFunction = $primaryKeySeedFunction; return $this; @@ -119,6 +133,18 @@ public function withPreserveCaseName(): static return $this; } + public function withBeforeInsert(EntityProcessorInterface|string $processor): static + { + $this->beforeInsert = $processor; + return $this; + } + + public function withBeforeUpdate(EntityProcessorInterface|string $processor): static + { + $this->beforeUpdate = $processor; + return $this; + } + public function fixFieldName(?string $field): ?string { if (is_null($field)) { @@ -150,6 +176,8 @@ public function addFieldMapping(FieldMapping $fieldMapping): static ->withFieldName($fieldName) ->withFieldAlias($fieldAlias) ; + $this->fieldToProperty[$fieldName] = $propertyName; + $this->fieldToProperty[$fieldAlias] = $propertyName; if ($fieldName === 'deleted_at') { $this->softDelete = true; @@ -163,27 +191,9 @@ public function addFieldMapping(FieldMapping $fieldMapping): static return $this; } - /** - * @param string $property - * @param string $fieldName - * @param Closure|null $updateFunction - * @param Closure|null $selectFunction - * @return $this - * @deprecated Use addFieldMapping instead - */ - public function addFieldMap(string $property, string $fieldName, Closure $updateFunction = null, Closure $selectFunction = null): static + public function getEntityClass(): string { - $fieldMapping = FieldMapping::create($property) - ->withFieldName($fieldName); - - if (!is_null($updateFunction)) { - $fieldMapping->withUpdateFunction($updateFunction); - } - if (!is_null($selectFunction)) { - $fieldMapping->withSelectFunction($selectFunction); - } - - return $this->addFieldMapping($fieldMapping); + return $this->entity; } /** @@ -195,27 +205,11 @@ public function getEntity(array $fieldValues = []): object $class = $this->entity; $instance = new $class(); - if (empty($fieldValues)) { - return $instance; - } - - foreach ((array)$this->getFieldMap() as $property => $fieldMap) { - if (!empty($fieldMap->getFieldAlias()) && isset($fieldValues[$fieldMap->getFieldAlias()])) { - $fieldValues[$fieldMap->getFieldName()] = $fieldValues[$fieldMap->getFieldAlias()]; - } - if ($property != $fieldMap->getFieldName() && isset($fieldValues[$fieldMap->getFieldName()])) { - $fieldValues[$property] = $fieldValues[$fieldMap->getFieldName()]; - unset($fieldValues[$fieldMap->getFieldName()]); - } - } - ObjectCopy::copy($fieldValues, $instance); - - foreach ((array)$this->getFieldMap() as $property => $fieldMap) { - $fieldValues[$property] = $fieldMap->getSelectFunctionValue($fieldValues[$property] ?? "", $instance); - } - if (count($this->getFieldMap()) > 0) { - ObjectCopy::copy($fieldValues, $instance); - } + // The command below is to get all properties of the class. + // This will allow to process all properties, even if they are not in the $fieldValues array. + // Particularly useful for processing the selectFunction. + $fieldValues = array_merge(Serialize::from($instance)->withStopAtFirstLevel()->toArray(), $fieldValues); + ObjectCopy::copy($fieldValues, $instance, new MapFromDbToInstanceHandler($this)); return $instance; } @@ -313,7 +307,7 @@ public function isPreserveCaseName(): bool * @param string|null $property * @return FieldMapping[]|FieldMapping|null */ - public function getFieldMap(string $property = null): array|FieldMapping|null + public function getFieldMap(?string $property = null): array|FieldMapping|null { if (empty($property)) { return $this->fieldMap; @@ -328,6 +322,11 @@ public function getFieldMap(string $property = null): array|FieldMapping|null return $this->fieldMap[$property]; } + public function getPropertyName(string $fieldName): string + { + return $this->fieldToProperty[$fieldName] ?? $fieldName; + } + /** * @param string|null $fieldName * @return string|null @@ -343,20 +342,42 @@ public function getFieldAlias(?string $fieldName = null): ?string } /** - * @param DbDriverInterface $dbDriver + * @param DatabaseExecutor $executor + * @param object $instance * @return mixed|null */ - public function generateKey(DbDriverInterface $dbDriver, object $instance): mixed + public function generateKey(DatabaseExecutor $executor, object $instance): mixed { if (empty($this->primaryKeySeedFunction)) { return null; } - return call_user_func_array($this->primaryKeySeedFunction, [$dbDriver, $instance]); + $primaryKeyFunction = $this->primaryKeySeedFunction; + if (is_string($this->primaryKeySeedFunction)) { + $primaryKeyFunction = new $this->primaryKeySeedFunction(); + } + + return $primaryKeyFunction->processedValue(null, $instance, $executor); } public function isSoftDeleteEnabled(): bool { return $this->softDelete; } + + /** + * @return mixed + */ + public function getBeforeInsert(): mixed + { + return $this->beforeInsert; + } + + /** + * @return mixed + */ + public function getBeforeUpdate(): mixed + { + return $this->beforeUpdate; + } } diff --git a/src/MapperClosure.php b/src/MapperClosure.php deleted file mode 100644 index 6ca7ce6..0000000 --- a/src/MapperClosure.php +++ /dev/null @@ -1,41 +0,0 @@ -sqlDate('Y-m-d H:i:s')); - } - + const STANDARD = StandardMapper::class; + const READ_ONLY = ReadOnlyMapper::class; + const UPDATE_BINARY_UUID = FormatUpdateUuidMapper::class; + const SELECT_BINARY_UUID = FormatSelectUuidMapper::class; + const NOW_UTC = NowUtcMapper::class; } \ No newline at end of file diff --git a/src/MapperFunctions/FormatSelectUuidMapper.php b/src/MapperFunctions/FormatSelectUuidMapper.php new file mode 100644 index 0000000..6f4de92 --- /dev/null +++ b/src/MapperFunctions/FormatSelectUuidMapper.php @@ -0,0 +1,23 @@ +getHelper()->sqlDate('Y-m-d H:i:s')); + } +} \ No newline at end of file diff --git a/src/MapperFunctions/ReadOnlyMapper.php b/src/MapperFunctions/ReadOnlyMapper.php new file mode 100644 index 0000000..4d74043 --- /dev/null +++ b/src/MapperFunctions/ReadOnlyMapper.php @@ -0,0 +1,16 @@ +getTable()] = $mainMapper; } @@ -54,7 +54,7 @@ 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 - foreach (static::$incompleteRelationships as $key => $relationship) { + foreach (static::$incompleteRelationships as $relationship) { if (isset(static::$mapper[$relationship["parent"]])) { continue; } @@ -174,12 +174,13 @@ private static function saveRelationShip(string $parentTable, string $childTable } } - public static function clearRelationships(): void + public static function resetMemory(): void { static::$relationships = []; static::$incompleteRelationships = []; + static::$executor = null; // Reset the default DB driver foreach (static::$mapper as $mapper) { - // Reset the ActiveRecord DbDriver + // Reset the ActiveRecord DatabaseExecutor if (method_exists($mapper->getEntity(), 'reset')) { call_user_func([$mapper->getEntity(), 'reset']); } @@ -187,16 +188,16 @@ public static function clearRelationships(): void static::$mapper = []; } - public static function defaultDbDriver(?DbDriverInterface $dbDriver = null): DbDriverInterface + public static function defaultDbDriver(?DatabaseExecutor $executor = null): DatabaseExecutor { - if (is_null($dbDriver)) { - if (is_null(static::$dbDriver)) { - throw new InvalidArgumentException("You must initialize the ORM with a DbDriverInterface"); + if (is_null($executor)) { + if (is_null(static::$executor)) { + throw new InvalidArgumentException("You must initialize the ORM with a DatabaseExecutor"); } - return static::$dbDriver; + return static::$executor; } - static::$dbDriver = $dbDriver; - return $dbDriver; + static::$executor = $executor; + return $executor; } } diff --git a/src/ORMHelper.php b/src/ORMHelper.php index 1cc5726..b92e0d0 100644 --- a/src/ORMHelper.php +++ b/src/ORMHelper.php @@ -11,7 +11,7 @@ class ORMHelper * @param array|null $params * @return string */ - public static function processLiteral(string $sql, array &$params = null): string + public static function processLiteral(string $sql, ?array &$params = null): string { if (empty($params)) { return $sql; @@ -35,6 +35,6 @@ public static function processLiteral(string $sql, array &$params = null): strin } } - return $sql; + return $sql ?? ""; } } diff --git a/src/ORMSubject.php b/src/ORMSubject.php index 2b1863c..824eeb2 100644 --- a/src/ORMSubject.php +++ b/src/ORMSubject.php @@ -2,15 +2,13 @@ namespace ByJG\MicroOrm; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\ObserverProcessorInterface; use Throwable; class ORMSubject { - const EVENT_INSERT = 'insert'; - const EVENT_UPDATE = 'update'; - const EVENT_DELETE = 'delete'; // Define a singleton instance private static ?ORMSubject $instance = null; @@ -32,27 +30,27 @@ public static function getInstance(): ?ORMSubject */ protected array $observers = []; - public function addObserver(ObserverProcessorInterface $observerProcessor, Repository $observer_in): void + public function addObserver(ObserverProcessorInterface $observerProcessor, Repository $repoObserverIn): void { - $observer_in->getDbDriver()->log("Observer: entity " . $observer_in->getMapper()->getTable() . ", listening for {$observerProcessor->getObservedTable()}"); + $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($observer_in)) { + 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, $observer_in); + $this->observers[$observerProcessor->getObservedTable()][] = new ObserverProcessorInternal($observerProcessor, $repoObserverIn); } - public function notify($entitySource, $event, $data, $oldData = null): void + public function notify(string $entitySource, ObserverEvent $event, mixed $data, mixed $oldData = null): void { if (!isset($this->observers[$entitySource])) { return; } - foreach ((array)$this->observers[$entitySource] as $observer) { + 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()); diff --git a/src/ObserverData.php b/src/ObserverData.php index dcc284a..f7879ca 100644 --- a/src/ObserverData.php +++ b/src/ObserverData.php @@ -2,13 +2,15 @@ namespace ByJG\MicroOrm; +use ByJG\MicroOrm\Enum\ObserverEvent; + class ObserverData { // The table name that was affected protected string $table; - // The event that was triggered. Can be 'insert', 'update' or 'delete' - protected string $event; + // The event that was triggered + protected ObserverEvent $event; // The data that was inserted or updated. It is null in case of delete. protected mixed $data; @@ -19,7 +21,7 @@ class ObserverData // The repository is listening to the event (the same as $myRepository) protected Repository $repository; - public function __construct(string $table, string $event, mixed $data, mixed $oldData, Repository $repository) + public function __construct(string $table, ObserverEvent $event, mixed $data, mixed $oldData, Repository $repository) { $this->table = $table; $this->event = $event; @@ -37,9 +39,9 @@ public function getTable(): string } /** - * @return string + * @return ObserverEvent */ - public function getEvent(): string + public function getEvent(): ObserverEvent { return $this->event; } diff --git a/src/ObserverProcessorInternal.php b/src/ObserverProcessorInternal.php index cbc57ff..eb435ee 100644 --- a/src/ObserverProcessorInternal.php +++ b/src/ObserverProcessorInternal.php @@ -28,7 +28,7 @@ public function getRepository(): Repository public function log($message): void { - $this->repository->getDbDriver()->log($message); + $this->repository->getExecutor()->getDriver()->log($message); } public function getMapper(): Mapper @@ -38,6 +38,6 @@ public function getMapper(): Mapper public function getDbDriver(): DbDriverInterface { - return $this->repository->getDbDriver(); + return $this->repository->getExecutor()->getDriver(); } } diff --git a/src/PropertyHandler/MapFromDbToInstanceHandler.php b/src/PropertyHandler/MapFromDbToInstanceHandler.php new file mode 100644 index 0000000..1f05fa6 --- /dev/null +++ b/src/PropertyHandler/MapFromDbToInstanceHandler.php @@ -0,0 +1,47 @@ +mapper->getPropertyName($property); + $this->fieldMap = $this->mapper->getFieldMap($propertyName); + return $propertyName; + } + + /** + * + * @param string $propertyName In this context is the field name + * @param string $targetName In this context is the property name + * @param mixed $value The value to be transformed + * @param mixed|null $instance This is an array with the values from the database + * @return mixed + */ + #[Override] + public function transformValue(string $propertyName, string $targetName, mixed $value, mixed $instance = null): mixed + { + return $this->fieldMap?->getSelectFunctionValue($value, $instance) ?? $value; + } +} \ No newline at end of file diff --git a/src/PropertyHandler/PrepareToUpdateHandler.php b/src/PropertyHandler/PrepareToUpdateHandler.php new file mode 100644 index 0000000..444aae0 --- /dev/null +++ b/src/PropertyHandler/PrepareToUpdateHandler.php @@ -0,0 +1,37 @@ +mapper = $mapper; + $this->instance = $instance; + $this->executor = $executor; + } + + #[Override] + public function mapName(string $property): string + { + $sourcePropertyName = $this->mapper->fixFieldName($property); + return $this->mapper->getFieldMap($sourcePropertyName)?->getFieldName() ?? $sourcePropertyName ?? $property; + } + + #[Override] + public function transformValue(string $propertyName, string $targetName, mixed $value, mixed $instance = null): mixed + { + $fieldMap = $this->mapper->getFieldMap($propertyName); + return $fieldMap?->getUpdateFunctionValue($value, $this->instance, $this->executor) ?? $value; + } +} diff --git a/src/Query.php b/src/Query.php index c6b46bc..d7196f7 100644 --- a/src/Query.php +++ b/src/Query.php @@ -3,49 +3,24 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; +use Override; class Query extends QueryBasic { - protected array $groupBy = []; - protected array $having = []; protected array $orderBy = []; protected ?int $limitStart = null; protected ?int $limitEnd = null; protected ?int $top = null; protected bool $forUpdate = false; + #[Override] public static function getInstance(): Query { return new Query(); } - /** - * Example: - * $query->groupBy(['name']); - * - * @param array $fields - * @return $this - */ - public function groupBy(array $fields): static - { - $this->groupBy = array_merge($this->groupBy, $fields); - - return $this; - } - - /** - * Example: - * $query->having('count(price) > 10'); - * - * @param string $filter - * @return $this - */ - public function having(string $filter): static - { - $this->having[] = $filter; - return $this; - } /** * Example: @@ -101,18 +76,15 @@ public function top(int $top): static /** * @param DbDriverInterface|null $dbDriver - * @return SqlObject + * @return SqlStatement * @throws InvalidArgumentException */ - public function build(?DbDriverInterface $dbDriver = null): SqlObject + #[Override] + public function build(?DbDriverInterface $dbDriver = null): SqlStatement { $buildResult = parent::build($dbDriver); $sql = $buildResult->getSql(); - $params = $buildResult->getParameters(); - - $sql .= $this->addGroupBy(); - - $sql .= $this->addHaving(); + $params = $buildResult->getParams(); $sql .= $this->addOrderBy(); @@ -124,7 +96,7 @@ public function build(?DbDriverInterface $dbDriver = null): SqlObject $sql = ORMHelper::processLiteral($sql, $params); - return new SqlObject($sql, $params); + return new SqlStatement($sql, $params); } protected function addOrderBy(): string @@ -135,21 +107,6 @@ protected function addOrderBy(): string return ' ORDER BY ' . implode(', ', $this->orderBy); } - protected function addGroupBy(): string - { - if (empty($this->groupBy)) { - return ""; - } - return ' GROUP BY ' . implode(', ', $this->groupBy); - } - - protected function addHaving(): string - { - if (empty($this->having)) { - return ""; - } - return ' HAVING ' . implode(' AND ', $this->having); - } /** * @param DbDriverInterface|null $dbDriver @@ -242,6 +199,14 @@ public function getQueryBasic(): QueryBasic $queryBasic->distinct(); } + if (!empty($this->groupBy)) { + $queryBasic->groupBy($this->groupBy); + } + + foreach ($this->having as $having) { + $queryBasic->having($having); + } + return $queryBasic; } } diff --git a/src/QueryBasic.php b/src/QueryBasic.php index 97ff974..fbcb9de 100644 --- a/src/QueryBasic.php +++ b/src/QueryBasic.php @@ -3,11 +3,13 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Core\GenericIterator; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\Serializer\Serialize; +use Override; class QueryBasic implements QueryBuilderInterface { @@ -20,6 +22,8 @@ class QueryBasic implements QueryBuilderInterface protected DbDriverInterface|null $dbDriver = null; protected ?Recursive $recursive = null; protected bool $distinct = false; + protected array $groupBy = []; + protected array $having = []; public static function getInstance(): QueryBasic { @@ -77,7 +81,7 @@ protected function addFieldFromMapper(Mapper $mapper): void { $entityClass = $mapper->getEntity(); $entity = new $entityClass(); - $serialized = Serialize::from($entity)->toArray(); + $serialized = Serialize::from($entity)->withStopAtFirstLevel()->toArray(); foreach (array_keys($serialized) as $fieldName) { $fieldMapping = $mapper->getFieldMap($fieldName); @@ -183,6 +187,33 @@ public function distinct(): static return $this; } + /** + * Example: + * $query->groupBy(['name']); + * + * @param array $fields + * @return $this + */ + public function groupBy(array $fields): static + { + $this->groupBy = array_merge($this->groupBy, $fields); + + return $this; + } + + /** + * Example: + * $query->having('count(price) > 10'); + * + * @param string $filter + * @return $this + */ + public function having(string $filter): static + { + $this->having[] = $filter; + return $this; + } + /** * @throws InvalidArgumentException */ @@ -203,7 +234,7 @@ protected function getFields(): array } elseif ($field instanceof QueryBasic) { $subQuery = $field->build($this->dbDriver); $fieldList .= '(' . $subQuery->getSql() . ') as ' . $alias; - $params = array_merge($params, $subQuery->getParameters()); + $params = array_merge($params, $subQuery->getParams()); } else { $fieldList .= $field . ' as ' . $alias; } @@ -238,24 +269,41 @@ protected function buildTable(QueryBasic|string $table, QueryBasic|string|null $ $params = []; if ($table instanceof QueryBasic) { $subQuery = $table->build($this->dbDriver); - if (!empty($subQuery->getParameters()) && !$supportParams) { + if (!empty($subQuery->getParams()) && !$supportParams) { throw new InvalidArgumentException("SubQuery does not support filters"); } if (empty($alias) || $alias instanceof QueryBasic) { throw new InvalidArgumentException("SubQuery requires you define an alias"); } $table = "({$subQuery->getSql()})"; - $params = $subQuery->getParameters(); + $params = $subQuery->getParams(); } return [ $table . (!empty($alias) && $table != $alias ? " as " . $alias : ""), $params ]; - } + } + + protected function addGroupBy(): string + { + if (empty($this->groupBy)) { + return ""; + } + return ' GROUP BY ' . implode(', ', $this->groupBy); + } + + protected function addHaving(): string + { + if (empty($this->having)) { + return ""; + } + return ' HAVING ' . implode(' AND ', $this->having); + } /** * @param DbDriverInterface|null $dbDriver - * @return SqlObject + * @return SqlStatement * @throws InvalidArgumentException */ - public function build(?DbDriverInterface $dbDriver = null): SqlObject + #[Override] + public function build(?DbDriverInterface $dbDriver = null): SqlStatement { $this->dbDriver = $dbDriver; @@ -280,18 +328,22 @@ public function build(?DbDriverInterface $dbDriver = null): SqlObject $params = array_merge($params, $whereStr[1]); } + $sql .= $this->addGroupBy(); + + $sql .= $this->addHaving(); + $sql = ORMHelper::processLiteral($sql, $params); - return new SqlObject($sql, $params); + return new SqlStatement($sql, $params); } - public function buildAndGetIterator(?DbDriverInterface $dbDriver = null, ?CacheQueryResult $cache = null): GenericIterator + #[Override] + public function buildAndGetIterator(DatabaseExecutor $executor, ?CacheQueryResult $cache = null): GenericIterator { - $sqlObject = $this->build($dbDriver); - $sqlStatement = new SqlStatement($sqlObject->getSql()); + $sqlStatement = $this->build($executor->getDriver()); if (!empty($cache)) { - $sqlStatement->withCache($cache->getCache(), $cache->getCacheKey(), $cache->getTtl()); + $sqlStatement = $sqlStatement->withCache($cache->getCache(), $cache->getCacheKey(), $cache->getTtl()); } - return $sqlStatement->getIterator($dbDriver, $sqlObject->getParameters()); + return $executor->getIterator($sqlStatement); } } diff --git a/src/QueryRaw.php b/src/QueryRaw.php index 2a3adf8..9bb2cd3 100644 --- a/src/QueryRaw.php +++ b/src/QueryRaw.php @@ -3,9 +3,13 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Core\GenericIterator; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Interface\QueryBuilderInterface; +use Override; -class QueryRaw implements Interface\QueryBuilderInterface +class QueryRaw implements QueryBuilderInterface { protected function __construct(protected string $sql, protected array $parameters = []) @@ -17,13 +21,15 @@ public static function getInstance(string $sql, array $parameters = []): QueryRa return new self($sql, $parameters); } - public function build(?DbDriverInterface $dbDriver = null): SqlObject + #[Override] + public function build(?DbDriverInterface $dbDriver = null): SqlStatement { - return new SqlObject($this->sql, $this->parameters); + return new SqlStatement($this->sql, $this->parameters); } - public function buildAndGetIterator(?DbDriverInterface $dbDriver = null, ?CacheQueryResult $cache = null): GenericIterator + #[Override] + public function buildAndGetIterator(DatabaseExecutor $executor, ?CacheQueryResult $cache = null): GenericIterator { - return $dbDriver->getIterator($this->sql, $this->parameters); + return $executor->getIterator($this->build($executor->getDriver())); } } \ No newline at end of file diff --git a/src/Recursive.php b/src/Recursive.php index 627f2df..242016e 100644 --- a/src/Recursive.php +++ b/src/Recursive.php @@ -3,6 +3,7 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Literal\LiteralInterface; class Recursive @@ -50,7 +51,7 @@ public function where(string $filter): static return $this; } - public function build(DbDriverInterface $dbDriver = null): SqlObject + public function build(?DbDriverInterface $dbDriver = null): SqlStatement { $this->dbDriver = $dbDriver; @@ -59,7 +60,7 @@ public function build(DbDriverInterface $dbDriver = null): SqlObject $sql .= " UNION ALL "; $sql .= $this->getRecursion(); $sql .= ") "; - return new SqlObject($sql); + return new SqlStatement($sql); } protected function getBase(): string diff --git a/src/Repository.php b/src/Repository.php index 5f85f5f..775f819 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -2,31 +2,38 @@ namespace ByJG\MicroOrm; +use ByJG\AnyDataset\Core\AnyDataset; use ByJG\AnyDataset\Core\Enum\Relation; +use ByJG\AnyDataset\Core\Exception\DatabaseException; use ByJG\AnyDataset\Core\GenericIterator; use ByJG\AnyDataset\Core\IteratorFilter; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\Exception\DbDriverNotConnected; use ByJG\AnyDataset\Db\IsolationLevelEnum; use ByJG\AnyDataset\Db\IteratorFilterSqlFormatter; use ByJG\AnyDataset\Db\SqlStatement; -use ByJG\AnyDataset\Lists\ArrayDataset; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Exception\OrmBeforeInvalidException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Exception\OrmModelInvalidException; use ByJG\MicroOrm\Exception\RepositoryReadOnlyException; use ByJG\MicroOrm\Exception\UpdateConstraintException; +use ByJG\MicroOrm\Interface\EntityProcessorInterface; use ByJG\MicroOrm\Interface\ObserverProcessorInterface; use ByJG\MicroOrm\Interface\QueryBuilderInterface; +use ByJG\MicroOrm\Interface\UpdateConstraintInterface; use ByJG\MicroOrm\Literal\Literal; use ByJG\MicroOrm\Literal\LiteralInterface; +use ByJG\MicroOrm\PropertyHandler\MapFromDbToInstanceHandler; +use ByJG\MicroOrm\PropertyHandler\PrepareToUpdateHandler; use ByJG\Serializer\ObjectCopy; use ByJG\Serializer\Serialize; -use Closure; +use ByJG\XmlUtil\Exception\FileException; +use ByJG\XmlUtil\Exception\XmlUtilException; use Exception; use ReflectionException; use stdClass; -use Throwable; class Repository { @@ -37,51 +44,54 @@ class Repository protected Mapper $mapper; /** - * @var DbDriverInterface + * @var DatabaseExecutor */ - protected DbDriverInterface $dbDriver; + protected DatabaseExecutor $dbDriver; /** - * @var DbDriverInterface|null + * @var DatabaseExecutor|null */ - protected ?DbDriverInterface $dbDriverWrite = null; + protected ?DatabaseExecutor $dbDriverWrite = null; /** - * @var Closure|null + * @var EntityProcessorInterface|null */ - protected ?Closure $beforeUpdate = null; + protected EntityProcessorInterface|null $beforeUpdate = null; /** - * @var Closure|null + * @var EntityProcessorInterface|null */ - protected ?Closure $beforeInsert = null; + protected EntityProcessorInterface|null $beforeInsert = null; /** * Repository constructor. - * @param DbDriverInterface $dbDataset + * @param DatabaseExecutor $executor * @param string|Mapper $mapperOrEntity + * @throws InvalidArgumentException * @throws OrmModelInvalidException * @throws ReflectionException */ - public function __construct(DbDriverInterface $dbDataset, string|Mapper $mapperOrEntity) + public function __construct(DatabaseExecutor $executor, string|Mapper $mapperOrEntity) { - $this->dbDriver = $dbDataset; - $this->dbDriverWrite = $dbDataset; + $this->dbDriver = $executor; + $this->dbDriverWrite = $executor; if (is_string($mapperOrEntity)) { $mapperOrEntity = new Mapper($mapperOrEntity); } $this->mapper = $mapperOrEntity; - $this->beforeInsert = function ($instance) { - return $instance; - }; - $this->beforeUpdate = function ($instance) { - return $instance; - }; + + // Set beforeInsert and beforeUpdate from mapper if available + if (!empty($this->mapper->getBeforeInsert())) { + $this->setBeforeInsert($this->mapper->getBeforeInsert()); + } + if (!empty($this->mapper->getBeforeUpdate())) { + $this->setBeforeUpdate($this->mapper->getBeforeUpdate()); + } } - public function addDbDriverForWrite(DbDriverInterface $dbDriver): void + public function addDbDriverForWrite(DatabaseExecutor $executor): void { - $this->dbDriverWrite = $dbDriver; + $this->dbDriverWrite = $executor; } public function setRepositoryReadOnly(): void @@ -97,12 +107,22 @@ public function getMapper(): Mapper return $this->mapper; } + public function getExecutor(): DatabaseExecutor + { + return $this->dbDriver; + + } + /** - * @return DbDriverInterface + * @return DatabaseExecutor + * @throws RepositoryReadOnlyException */ - public function getDbDriver(): DbDriverInterface + public function getExecutorWrite(): DatabaseExecutor { - return $this->dbDriver; + if (empty($this->dbDriverWrite)) { + throw new RepositoryReadOnlyException('Repository is ReadOnly'); + } + return $this->dbDriverWrite; } public function entity(array $values): mixed @@ -110,7 +130,7 @@ public function entity(array $values): mixed return $this->getMapper()->getEntity($values); } - public function queryInstance(object $model = null, string ...$tales): Query + public function queryInstance(?object $model = null): Query { $query = Query::getInstance() ->table($this->mapper->getTable(), $this->mapper->getTableAlias()) @@ -123,6 +143,7 @@ public function queryInstance(object $model = null, string ...$tales): Query } $array = Serialize::from($model) + ->withStopAtFirstLevel() ->withDoNotParseNullValues() ->toArray(); @@ -139,48 +160,88 @@ public function queryInstance(object $model = null, string ...$tales): Query } /** - * @return DbDriverInterface|null - * @throws RepositoryReadOnlyException + * Apply updateFunction to primary key values if configured in the field mapping. + * This allows automatic conversion of values (e.g., UUID strings to HexUuidLiteral). + * + * @param array|string|int|LiteralInterface $pkId + * @return array|string|int|LiteralInterface Processed primary key value(s) */ - public function getDbDriverWrite(): ?DbDriverInterface + private function applyPkUpdateFunction(array|string|int|LiteralInterface $pkId): array|string|int|LiteralInterface { - if (empty($this->dbDriverWrite)) { - throw new RepositoryReadOnlyException('Repository is ReadOnly'); + // Ensure we have an array to work with + if (!is_array($pkId)) { + $pkId = [$pkId]; } - return $this->dbDriverWrite; + + $pkList = $this->mapper->getPrimaryKey(); + + // Apply updateFunction to each primary key value + foreach ($pkId as $index => $value) { + $pkName = null; + if (is_numeric($index)) { + $pkName = $pkList[$index] ?? null; + } else { + $pkName = in_array($index, $pkList) ? $index : null; + } + + if ($pkName === null) { + throw new OrmInvalidFieldsException('Primary field key not found'); + } + + $fieldMap = $this->mapper->getFieldMap($this->mapper->getPropertyName($pkName)); + if ($fieldMap && $fieldMap->getUpdateFunction()) { + $value = $fieldMap->getUpdateFunctionValue($value, null, $this->getExecutor()); + } + $pkId[$index] = $value; + } + + return $pkId; } /** * @param array|string|int|LiteralInterface $pkId * @return mixed|null + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException * @throws InvalidArgumentException + * @throws OrmInvalidFieldsException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ public function get(array|string|int|LiteralInterface $pkId): mixed { - [$filterList, $filterKeys] = $this->mapper->getPkFilter($pkId); - $result = $this->getByFilter($filterList, $filterKeys); + $processedPkId = $this->applyPkUpdateFunction($pkId); - if (count($result) === 1) { - return $result[0]; + [$filterList, $filterKeys] = $this->mapper->getPkFilter($processedPkId); + + $query = $this->getMapper()->getQuery(); + if (!empty($filterList)) { + $query->where($filterList, $filterKeys); } - return null; + return $this->getIterator($query)->first(); } /** * @param array|string|int|LiteralInterface $pkId * @return bool + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws InvalidArgumentException + * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ public function delete(array|string|int|LiteralInterface $pkId): bool { - [$filterList, $filterKeys] = $this->mapper->getPkFilter($pkId); + $processedPkId = $this->applyPkUpdateFunction($pkId); + + [$filterList, $filterKeys] = $this->mapper->getPkFilter($processedPkId); if ($this->mapper->isSoftDeleteEnabled()) { $updatable = UpdateQuery::getInstance() ->table($this->mapper->getTable()) - ->set('deleted_at', new Literal($this->getDbDriverWrite()->getDbHelper()->sqlDate('Y-m-d H:i:s'))) + ->set('deleted_at', new Literal($this->getExecutorWrite()->getHelper()->sqlDate('Y-m-d H:i:s'))) ->where($filterList, $filterKeys); $this->update($updatable); return true; @@ -200,9 +261,12 @@ public function delete(array|string|int|LiteralInterface $pkId): bool * @param array $queries List of queries to be executed in bulk * @param IsolationLevelEnum|null $isolationLevel * @return GenericIterator|null + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException * @throws InvalidArgumentException - * @throws RepositoryReadOnlyException - * @throws Throwable + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel = null): ?GenericIterator { @@ -210,7 +274,7 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel throw new InvalidArgumentException('You pass an empty array to bulk'); } - $dbDriver = $this->getDbDriverWrite(); + $dbDriver = $this->getExecutor()->getDriver(); $bigSqlWrites = ''; $selectSql = null; @@ -223,9 +287,9 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel } // Build SQL object using the write driver to ensure correct helper/dialect - $sqlObject = $query->build($dbDriver); - $sql = $sqlObject->getSql(); - $params = $sqlObject->getParameters(); + $sqlStatement = $query->build($dbDriver); + $sql = $sqlStatement->getSql(); + $params = $sqlStatement->getParams(); $isSelect = str_starts_with(strtoupper(ltrim($sql)), 'SELECT'); if ($isSelect && $i === array_key_last($queries)) { @@ -259,14 +323,14 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel // First execute all writes (if any) in a single batch using direct PDO exec if (trim($bigSqlWrites) !== '') { // Use direct PDO to ensure multi-statement execution across drivers like SQLite - $dbDriver->execute($bigSqlWrites, $bigParams); + $this->getExecutor()->execute($bigSqlWrites, $bigParams); } // If there is a trailing SELECT, fetch it and return its iterator. Otherwise return an empty iterator if (!empty($selectSql)) { - $it = $dbDriver->getIterator($selectSql, $selectParams); + $it = $this->getExecutor()->getIterator(new SqlStatement($selectSql, $selectParams)); } else { - $it = (new ArrayDataset([]))->getIterator(); + $it = (new AnyDataset())->getIterator(); } $dbDriver->commitTransaction(); @@ -283,14 +347,16 @@ public function bulkExecute(array $queries, ?IsolationLevelEnum $isolationLevel * @return bool * @throws InvalidArgumentException * @throws RepositoryReadOnlyException + * @throws DatabaseException + * @throws DbDriverNotConnected */ public function deleteByQuery(DeleteQuery $updatable): bool { - $sqlObject = $updatable->build(); + $sqlStatement = $updatable->build(); - $this->getDbDriverWrite()->execute($sqlObject->getSql(), $sqlObject->getParameters()); + $this->getExecutorWrite()->execute($sqlStatement); - ORMSubject::getInstance()->notify($this->mapper->getTable(), ORMSubject::EVENT_DELETE, null, $sqlObject->getParameters()); + ORMSubject::getInstance()->notify($this->mapper->getTable(), ObserverEvent::Delete, null, $sqlStatement->getParams()); return true; } @@ -299,7 +365,15 @@ public function deleteByQuery(DeleteQuery $updatable): bool * @param string|IteratorFilter $filter * @param array $params * @param bool $forUpdate + * @param int $page + * @param int|null $limit * @return array + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws InvalidArgumentException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ public function getByFilter(string|IteratorFilter $filter = "", array $params = [], bool $forUpdate = false, int $page = 0, ?int $limit = null): array { @@ -328,6 +402,7 @@ public function getByFilter(string|IteratorFilter $filter = "", array $params = * @param array|string|int|LiteralInterface $arrValues * @param string $field * @return array + * @throws InvalidArgumentException */ public function filterIn(array|string|int|LiteralInterface $arrValues, string $field = ""): array { @@ -338,7 +413,7 @@ public function filterIn(array|string|int|LiteralInterface $arrValues, string $f } $iteratorFilter = new IteratorFilter(); - $iteratorFilter->addRelation($field, Relation::IN, $arrValues); + $iteratorFilter->and($field, Relation::IN, $arrValues); return $this->getByFilter($iteratorFilter); } @@ -349,31 +424,98 @@ public function filterIn(array|string|int|LiteralInterface $arrValues, string $f */ public function getScalar(QueryBuilderInterface $query): mixed { - $sqlBuild = $query->build($this->getDbDriver()); + $sqlBuild = $query->build($this->getExecutor()->getDriver()); + return $this->getExecutor()->getScalar($sqlBuild); + } + + /** + * Execute a query and return an iterator with automatic entity transformation. + * + * This is the PREFERRED method for application/business logic when working with domain entities. + * The iterator automatically transforms database rows into entity instances using the repository's mapper. + * + * @param QueryBuilderInterface|SqlStatement $query The query to execute + * @param CacheQueryResult|null $cache Optional cache configuration + * @return GenericIterator Iterator with entity transformation enabled + * + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException + * @see Query::buildAndGetIterator() For infrastructure-level raw data access + * @see getByQuery() For multi-mapper queries with JOIN operations + */ + public function getIterator(QueryBuilderInterface|SqlStatement $query, ?CacheQueryResult $cache = null): GenericIterator + { + if ($query instanceof QueryBuilderInterface) { + $sqlStatement = $query->build($this->getExecutor()->getDriver()) + ->withEntityClass($this->mapper->getEntityClass()) + ->withEntityTransformer(new MapFromDbToInstanceHandler($this->mapper)); + } else { + $sqlStatement = $query; + } - $params = $sqlBuild->getParameters(); - $sql = $sqlBuild->getSql(); - return $this->getDbDriver()->getScalar($sql, $params); + if (!empty($cache)) { + $sqlStatement = $sqlStatement->withCache($cache->getCache(), $cache->getCacheKey(), $cache->getTtl()); + } + + return $this->getExecutor()->getIterator($sqlStatement); } /** - * @param QueryBuilderInterface $query - * @param Mapper[] $mapper - * @return array + * Execute a query and return entities, with support for complex JOIN queries using multiple mappers. + * + * This method intelligently handles both single-mapper and multi-mapper scenarios: + * + * @param QueryBuilderInterface $query The query to execute (typically with JOINs for multi-mapper) + * @param Mapper[] $mapper Additional mappers for JOIN queries (repository's mapper is always included first) + * @param CacheQueryResult|null $cache Optional cache configuration + * @return array For single mapper: Entity[]. For multi-mapper: Array + * + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException + * @see getIterator() For simple single-entity queries (more efficient) + * + * Example (single mapper): + * ```php + * $query = $userRepo->queryInstance()->where('status = :status', ['status' => 'active']); + * $users = $userRepo->getByQuery($query); // Returns User[] + * ``` + * + * Example (multi-mapper JOIN): + * ```php + * $query = Query::getInstance() + * ->table('users') + * ->join('info', 'users.id = info.iduser') + * ->where('users.id = :id', ['id' => 1]); + * $result = $userRepo->getByQuery($query, [$infoMapper]); + * // Returns [[$userEntity, $infoEntity], [$userEntity, $infoEntity], ...] + * ``` */ public function getByQuery(QueryBuilderInterface $query, array $mapper = [], ?CacheQueryResult $cache = null): array { $mapper = array_merge([$this->mapper], $mapper); - $sqlBuild = $query->build($this->getDbDriver()); - $params = $sqlBuild->getParameters(); - $sql = new SqlStatement($sqlBuild->getSql()); + // If only one mapper, use entity transformation in iterator + if (count($mapper) === 1) { + $iterator = $this->getIterator($query, $cache); + return $iterator->toEntities(); + } + + // For multiple mappers, get raw data without transformation + // (Entity transformation would fail because joined rows don't match single entity structure) + $sqlBuild = $query->build($this->getExecutor()->getDriver()); if (!empty($cache)) { - $sql->withCache($cache->getCache(), $cache->getCacheKey(), $cache->getTtl()); + $sqlBuild = $sqlBuild->withCache($cache->getCache(), $cache->getCacheKey(), $cache->getTtl()); } - $result = []; - $iterator = $sql->getIterator($this->getDbDriver(), $params); + $iterator = $this->getExecutor()->getIterator($sqlBuild); + // Manually map each row to multiple entities + $result = []; foreach ($iterator as $row) { $collection = []; foreach ($mapper as $item) { @@ -391,33 +533,45 @@ public function getByQuery(QueryBuilderInterface $query, array $mapper = [], ?Ca * * @param Query $query * @return array + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException * @throws InvalidArgumentException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ public function getByQueryRaw(QueryBuilderInterface $query): array { - $sqlObject = $query->build($this->getDbDriver()); - $iterator = $this->getDbDriver()->getIterator($sqlObject->getSql(), $sqlObject->getParameters()); + $sqlStatement = $query->build($this->getExecutor()->getDriver()); + $iterator = $this->getExecutor()->getIterator($sqlStatement); return $iterator->toArray(); } /** - * @param mixed $instance - * @param UpdateConstraint|null $updateConstraint - * @return mixed + * Save an instance to the database + * + * @param mixed $instance The instance to save + * @param UpdateConstraintInterface|UpdateConstraintInterface[]|null $updateConstraints One or more constraints to apply + * @return mixed The saved instance + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException * @throws InvalidArgumentException * @throws OrmBeforeInvalidException * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException * @throws UpdateConstraintException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ - public function save(mixed $instance, UpdateConstraint $updateConstraint = null): mixed + public function save(mixed $instance, UpdateConstraintInterface|array|null $updateConstraints = null): mixed { // Build the updatable without executing [$updatable, $array, $fieldToProperty, $isInsert, $oldInstance, $pkList] = $this->saveUpdatableInternal($instance); // Execute the Insert or Update if ($isInsert) { - $keyGen = $this->getMapper()->generateKey($this->getDbDriver(), $instance) ?? []; + $keyGen = $this->getMapper()->generateKey($this->getExecutorWrite(), $instance) ?? []; if (!empty($keyGen) && !is_array($keyGen)) { $keyGen = [$keyGen]; } @@ -430,20 +584,31 @@ public function save(mixed $instance, UpdateConstraint $updateConstraint = null) if (count($pkList) == 1 && !empty($keyReturned)) { $array[$pkList[0]] = $keyReturned; } - } else { - if (!empty($updateConstraint)) { - $updateConstraint->check($oldInstance, $this->getMapper()->getEntity($array)); + } + + // The command below is to get all properties of the class. + // This will allow to process all properties, even if they are not in the $fieldValues array. + // Particularly useful for processing the selectFunction. + $array = array_merge(Serialize::from($instance)->withStopAtFirstLevel()->toArray(), $array); + ObjectCopy::copy($array, $instance, new MapFromDbToInstanceHandler($this->mapper)); + + if (!$isInsert) { + if (!empty($updateConstraints)) { + // Convert single constraint to array for uniform processing + $constraints = is_array($updateConstraints) ? $updateConstraints : [$updateConstraints]; + + // Apply all constraints + foreach ($constraints as $constraint) { + $constraint->check($oldInstance, $instance); + } } $this->update($updatable); } - ObjectCopy::copy($array, $instance, function ($sourcePropertyName) use ($fieldToProperty) { - return $fieldToProperty[$sourcePropertyName] ?? $sourcePropertyName; - }); ORMSubject::getInstance()->notify( $this->mapper->getTable(), - $isInsert ? ORMSubject::EVENT_INSERT : ORMSubject::EVENT_UPDATE, + $isInsert ? ObserverEvent::Insert : ObserverEvent::Update, $instance, $oldInstance ); @@ -473,9 +638,15 @@ public function saveUpdatable(mixed $instance): Updatable * * @param mixed $instance * @return array [Updatable $updatable, array $array, array $fieldToProperty, bool $isInsert, mixed $oldInstance, array $pkList] + * @throws DatabaseException + * @throws DbDriverNotConnected + * @throws FileException * @throws InvalidArgumentException * @throws OrmBeforeInvalidException + * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException + * @throws XmlUtilException + * @throws \Psr\SimpleCache\InvalidArgumentException */ protected function saveUpdatableInternal(mixed $instance): array { @@ -488,19 +659,11 @@ protected function saveUpdatableInternal(mixed $instance): array // Copy the values to the instance $valuesToUpdate = new stdClass(); + ObjectCopy::copy( $array, $valuesToUpdate, - function ($sourcePropertyName) use ($mapper, &$fieldToProperty) { - $sourcePropertyName = $mapper->fixFieldName($sourcePropertyName); - $fieldName = $mapper->getFieldMap($sourcePropertyName)?->getFieldName() ?? $sourcePropertyName; - $fieldToProperty[$fieldName] = $sourcePropertyName; - return $fieldName; - }, - function ($propName, $targetName, $value) use ($mapper, $instance) { - $fieldMap = $mapper->getFieldMap($propName); - return $fieldMap?->getUpdateFunctionValue($value, $instance, $this->getDbDriverWrite()->getDbHelper()) ?? $value; - } + new PrepareToUpdateHandler($mapper, $instance, $this->getExecutorWrite()) ); $array = array_filter((array)$valuesToUpdate, fn($value) => $value !== false); @@ -524,18 +687,20 @@ function ($propName, $targetName, $value) use ($mapper, $instance) { // Execute Before Statements if ($isInsert) { - $closure = $this->beforeInsert; - $array = $closure($array); - foreach ($this->getMapper()->getFieldMap() as $mapItem) { - $fieldValue = $mapItem->getInsertFunctionValue($array[$mapItem->getFieldName()] ?? null, $instance, $this->getDbDriverWrite()->getDbHelper()); + if (!empty($this->beforeInsert)) { + $array = $this->beforeInsert->process($array); + } + foreach ($this->getMapper()->getFieldMap() as $fieldMap) { + $fieldValue = $fieldMap->getInsertFunctionValue($array[$fieldMap->getFieldName()] ?? null, $instance, $this->getExecutorWrite()); if ($fieldValue !== false) { - $array[$mapItem->getFieldName()] = $fieldValue; + $array[$fieldMap->getFieldName()] = $fieldValue; } } $updatable = InsertQuery::getInstance($this->mapper->getTable(), $array); } else { - $closure = $this->beforeUpdate; - $array = $closure($array); + if (!empty($this->beforeUpdate)) { + $array = $this->beforeUpdate->process($array); + } $updatable = UpdateQuery::getInstance($array, $this->mapper); } @@ -558,11 +723,13 @@ public function addObserver(ObserverProcessorInterface $observerProcessor): void /** * @param InsertQuery $updatable * @param mixed $keyGen - * @return mixed + * @return int|null + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insert(InsertQuery $updatable, mixed $keyGen): mixed + protected function insert(InsertQuery $updatable, mixed $keyGen): ?int { if (empty($keyGen)) { return $this->insertWithAutoinc($updatable); @@ -578,44 +745,83 @@ protected function insert(InsertQuery $updatable, mixed $keyGen): mixed * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ - protected function insertWithAutoinc(InsertQuery $updatable): int + protected function insertWithAutoInc(InsertQuery $updatable): int { - $sqlObject = $updatable->build($this->getDbDriverWrite()->getDbHelper()); - $dbFunctions = $this->getDbDriverWrite()->getDbHelper(); - return $dbFunctions->executeAndGetInsertedId($this->getDbDriverWrite(), $sqlObject->getSql(), $sqlObject->getParameters()); + $dbFunctions = $this->getExecutorWrite()->getHelper(); + $sqlStatement = $updatable->build($dbFunctions); + return $dbFunctions->executeAndGetInsertedId($this->getExecutorWrite(), $sqlStatement); } /** * @param InsertQuery $updatable * @return void + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws OrmInvalidFieldsException * @throws RepositoryReadOnlyException */ protected function insertWithKeyGen(InsertQuery $updatable): void { - $sqlObject = $updatable->build($this->getDbDriverWrite()->getDbHelper()); - $this->getDbDriverWrite()->execute($sqlObject->getSql(), $sqlObject->getParameters()); + $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); + $this->getExecutorWrite()->execute($sqlStatement); } /** * @param UpdateQuery $updatable + * @throws DatabaseException + * @throws DbDriverNotConnected * @throws InvalidArgumentException * @throws RepositoryReadOnlyException */ protected function update(UpdateQuery $updatable): void { - $sqlObject = $updatable->build($this->getDbDriverWrite()->getDbHelper()); - - $this->getDbDriverWrite()->execute($sqlObject->getSql(), $sqlObject->getParameters()); + $sqlStatement = $updatable->build($this->getExecutorWrite()->getHelper()); + $this->getExecutorWrite()->execute($sqlStatement); } - public function setBeforeUpdate(Closure $closure): void + /** + * Sets a processor to be executed before updating an entity + * + * @param EntityProcessorInterface|string $processor The processor to execute + * @return void + * @throws InvalidArgumentException + */ + public function setBeforeUpdate(EntityProcessorInterface|string $processor): void { - $this->beforeUpdate = $closure; + if (is_string($processor)) { + if (!class_exists($processor)) { + throw new InvalidArgumentException("The class '$processor' does not exist"); + } + + if (!in_array(EntityProcessorInterface::class, class_implements($processor))) { + throw new InvalidArgumentException("The class '$processor' must implement EntityProcessorInterface"); + } + + $processor = new $processor(); + } + $this->beforeUpdate = $processor; } - public function setBeforeInsert(Closure $closure): void + /** + * Sets a processor to be executed before inserting an entity + * + * @param EntityProcessorInterface|string $processor The processor to execute + * @return void + * @throws InvalidArgumentException + */ + public function setBeforeInsert(EntityProcessorInterface|string $processor): void { - $this->beforeInsert = $closure; + if (is_string($processor)) { + if (!class_exists($processor)) { + throw new InvalidArgumentException("The class '$processor' does not exist"); + } + + if (!in_array(EntityProcessorInterface::class, class_implements($processor))) { + throw new InvalidArgumentException("The class '$processor' must implement EntityProcessorInterface"); + } + + $processor = new $processor(); + } + $this->beforeInsert = $processor; } } diff --git a/src/SqlObject.php b/src/SqlObject.php deleted file mode 100644 index 592deef..0000000 --- a/src/SqlObject.php +++ /dev/null @@ -1,39 +0,0 @@ -sql = $sql; - $this->parameters = $parameters; - $this->type = $type; - } - - public function getSql(): string - { - return $this->sql; - } - - public function getParameters(): array - { - return $this->parameters; - } - - public function getParameter($key) - { - return $this->parameters[$key] ?? null; - } - - public function getType(): SqlObjectEnum - { - return $this->type; - } -} \ No newline at end of file diff --git a/src/SqlObjectEnum.php b/src/SqlObjectEnum.php deleted file mode 100644 index 4f6218c..0000000 --- a/src/SqlObjectEnum.php +++ /dev/null @@ -1,11 +0,0 @@ -entity(Serialize::from($data)->toArray()); + return self::$repository->entity(Serialize::from($data)->withStopAtFirstLevel()->toArray()); } public static function get(mixed ...$pk) @@ -104,6 +106,7 @@ public function refresh() * @param int $page * @param int $limit * @return static[] + * @throws InvalidArgumentException */ public static function filter(IteratorFilter $filter, int $page = 0, int $limit = 50): array { @@ -126,11 +129,12 @@ public static function joinWith(string ...$tables): Query public function toArray(bool $includeNullValue = false): array { + $serialize = Serialize::from($this)->withStopAtFirstLevel(); if ($includeNullValue) { - return Serialize::from($this)->toArray(); + return $serialize->toArray(); } - return Serialize::from($this)->withDoNotParseNullValues()->toArray(); + return $serialize->withDoNotParseNullValues()->toArray(); } /** @@ -143,6 +147,32 @@ public static function query(Query $query): array return self::$repository->getByQuery($query); } + /** + * Create a new query builder for this Active Record model + * + * @return ActiveRecordQuery + */ + public static function newQuery(): ActiveRecordQuery + { + self::initialize(); + return new ActiveRecordQuery(self::$repository); + } + + /** + * Create a new query with an initial WHERE clause for fluent syntax + * + * Example: User::where('email = :email', ['email' => 'test@example.com'])->first() + * + * @param array|string $filter + * @param array $params + * @return ActiveRecordQuery + */ + public static function where(array|string $filter, array $params = []): ActiveRecordQuery + { + self::initialize(); + return ActiveRecordQuery::createWhere(self::$repository, $filter, $params); + } + // Override this method to create a custom mapper instead of discovering by attributes in the class protected static function discoverClass(): string|Mapper { diff --git a/src/Trait/CreatedAt.php b/src/Trait/CreatedAt.php index 0507475..7b2eb2d 100644 --- a/src/Trait/CreatedAt.php +++ b/src/Trait/CreatedAt.php @@ -3,14 +3,15 @@ namespace ByJG\MicroOrm\Trait; use ByJG\MicroOrm\Attributes\FieldAttribute; -use ByJG\MicroOrm\MapperFunctions; +use ByJG\MicroOrm\MapperFunctions\NowUtcMapper; +use ByJG\MicroOrm\MapperFunctions\ReadOnlyMapper; trait CreatedAt { /** * @var string|null */ - #[FieldAttribute(fieldName: "created_at", updateFunction: MapperFunctions::READ_ONLY, insertFunction: MapperFunctions::NOW_UTC)] + #[FieldAttribute(fieldName: "created_at", updateFunction: ReadOnlyMapper::class, insertFunction: NowUtcMapper::class)] protected ?string $createdAt = null; public function getCreatedAt(): ?string diff --git a/src/Trait/UpdatedAt.php b/src/Trait/UpdatedAt.php index 836ec69..4744bb0 100644 --- a/src/Trait/UpdatedAt.php +++ b/src/Trait/UpdatedAt.php @@ -3,14 +3,14 @@ namespace ByJG\MicroOrm\Trait; use ByJG\MicroOrm\Attributes\FieldAttribute; -use ByJG\MicroOrm\MapperFunctions; +use ByJG\MicroOrm\MapperFunctions\NowUtcMapper; trait UpdatedAt { /** * @var string|null */ - #[FieldAttribute(fieldName: "updated_at", updateFunction: MapperFunctions::NOW_UTC)] + #[FieldAttribute(fieldName: "updated_at", updateFunction: NowUtcMapper::class)] protected ?string $updatedAt = null; public function getUpdatedAt(): ?string diff --git a/src/TransactionManager.php b/src/TransactionManager.php index 4ea6636..3bb0b6a 100644 --- a/src/TransactionManager.php +++ b/src/TransactionManager.php @@ -52,7 +52,7 @@ public function addDbDriver(DbDriverInterface $dbDriver): void public function addRepository(Repository $repository): void { - $this->addDbDriver($repository->getDbDriver()); + $this->addDbDriver($repository->getExecutor()->getDriver()); } /** diff --git a/src/Union.php b/src/Union.php index c5b38c5..019cd91 100644 --- a/src/Union.php +++ b/src/Union.php @@ -3,10 +3,12 @@ namespace ByJG\MicroOrm; use ByJG\AnyDataset\Core\GenericIterator; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\DbDriverInterface; use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Interface\QueryBuilderInterface; +use Override; class Union implements QueryBuilderInterface { @@ -25,12 +27,15 @@ public static function getInstance(): Union } /** + * @param Query|QueryBasic $query + * @return Union * @throws InvalidArgumentException + * @throws \ByJG\Serializer\Exception\InvalidArgumentException */ - public function addQuery(QueryBasic $query): Union + public function addQuery(Query|QueryBasic $query): Union { - if (get_class($query) !== QueryBasic::class) { - throw new InvalidArgumentException("The query must be an instance of " . QueryBasic::class); + if (get_class($query) === Query::class) { + $query = $query->getQueryBasic(); } $this->queryList[] = $query; @@ -84,14 +89,15 @@ public function top(int $top): Union /** * @throws InvalidArgumentException */ - public function build(?DbDriverInterface $dbDriver = null): SqlObject + #[Override] + public function build(?DbDriverInterface $dbDriver = null): SqlStatement { $unionQuery = []; $params = []; foreach ($this->queryList as $query) { $build = $query->build($dbDriver); $unionQuery[] = $build->getSql(); - $params = array_merge($params, $build->getParameters()); + $params = array_merge($params, $build->getParams()); } $unionQuery = implode(" UNION ", $unionQuery); @@ -100,17 +106,17 @@ public function build(?DbDriverInterface $dbDriver = null): SqlObject $unionQuery = trim($unionQuery . " " . substr($build->getSql(), strpos($build->getSql(), "__TMP__") + 8)); - return new SqlObject($unionQuery, $params); + return new SqlStatement($unionQuery, $params); } - public function buildAndGetIterator(?DbDriverInterface $dbDriver = null, ?CacheQueryResult $cache = null): GenericIterator + #[Override] + public function buildAndGetIterator(DatabaseExecutor $executor, ?CacheQueryResult $cache = null): GenericIterator { - $sqlObject = $this->build($dbDriver); - $sqlStatement = new SqlStatement($sqlObject->getSql()); + $sqlStatement = $this->build($executor->getDriver()); if (!empty($cache)) { - $sqlStatement->withCache($cache->getCache(), $cache->getCacheKey(), $cache->getTtl()); + $sqlStatement = $sqlStatement->withCache($cache->getCache(), $cache->getCacheKey(), $cache->getTtl()); } - return $sqlStatement->getIterator($dbDriver, $sqlObject->getParameters()); + return $executor->getIterator($sqlStatement); } } \ No newline at end of file diff --git a/src/Updatable.php b/src/Updatable.php index 22adb8c..f86aace 100644 --- a/src/Updatable.php +++ b/src/Updatable.php @@ -2,9 +2,9 @@ namespace ByJG\MicroOrm; -use ByJG\AnyDataset\Db\DbDriverInterface; -use ByJG\AnyDataset\Db\DbFunctionsInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Interface\UpdateBuilderInterface; +use Override; abstract class Updatable implements UpdateBuilderInterface { @@ -26,9 +26,13 @@ public function table(string $table): static return $this; } - public function buildAndExecute(DbDriverInterface $dbDriver, $params = [], ?DbFunctionsInterface $dbHelper = null): bool + #[Override] + public function buildAndExecute(DatabaseExecutor $executor, $params = []): bool { - $sqlObject = $this->build($dbHelper); - return $dbDriver->execute($sqlObject->getSql(), array_merge($sqlObject->getParameters(), $params)); + $sqlStatement = $this->build($executor->getHelper()); + if (!empty($params)) { + $sqlStatement = $sqlStatement->withParams($params); + } + return $executor->execute($sqlStatement); } } diff --git a/src/UpdateConstraint.php b/src/UpdateConstraint.php deleted file mode 100644 index d892040..0000000 --- a/src/UpdateConstraint.php +++ /dev/null @@ -1,55 +0,0 @@ -withClosureValidation(function($oldInstance, $newInstance) use ($properties) { - foreach ((array)$properties as $property) { - $method = "get" . ucfirst($property); - if (!method_exists($oldInstance, $method)) { - throw new AllowOnlyNewValuesConstraintException("The property '$property' does not exists in the old instance."); - } - if (!method_exists($newInstance, $method)) { - throw new AllowOnlyNewValuesConstraintException("The property '$property' does not exists in the new instance."); - } - if ($oldInstance->$method() == $newInstance->$method()) { - throw new AllowOnlyNewValuesConstraintException("You are not updating the property '$property' "); - } - } - return true; - }); - return $this; - } - - public function withClosureValidation(Closure $closure): self - { - $this->closureValidation[] = $closure; - return $this; - } - - /** - * @throws UpdateConstraintException - */ - public function check(mixed $oldInstance, mixed $newInstance): void - { - foreach ($this->closureValidation as $closure) { - if ($closure($oldInstance, $newInstance) !== true) { - throw new UpdateConstraintException("The Update Constraint validation failed"); - } - } - } -} \ No newline at end of file diff --git a/src/UpdateQuery.php b/src/UpdateQuery.php index 5b072ef..93cdc1e 100644 --- a/src/UpdateQuery.php +++ b/src/UpdateQuery.php @@ -9,6 +9,7 @@ use ByJG\MicroOrm\Interface\QueryBuilderInterface; use ByJG\MicroOrm\Literal\Literal; use ByJG\MicroOrm\Literal\LiteralInterface; +use Override; class UpdateQuery extends Updatable { @@ -19,7 +20,7 @@ class UpdateQuery extends Updatable /** * @throws InvalidArgumentException */ - public static function getInstance(array $fields = [], Mapper $mapper = null): UpdateQuery + public static function getInstance(array $fields = [], ?Mapper $mapper = null): UpdateQuery { $updatable = new UpdateQuery(); @@ -67,7 +68,7 @@ public function setLiteral(string $field, mixed $value): UpdateQuery return $this; } - protected function getJoinTables(DbFunctionsInterface|DbDriverInterface $dbDriverOrHelper = null): array + protected function getJoinTables(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): array { $dbDriver = null; $dbHelper = $dbDriverOrHelper; @@ -102,10 +103,11 @@ public function join(QueryBasic|string $table, string $joinCondition, ?string $a /** * @param DbDriverInterface|DbFunctionsInterface|null $dbDriverOrHelper - * @return SqlObject + * @return SqlStatement * @throws InvalidArgumentException */ - public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlObject + #[Override] + public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHelper = null): SqlStatement { if (empty($this->set)) { throw new InvalidArgumentException('You must specify the fields for update'); @@ -118,7 +120,7 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel } else { $dbHelper = $dbDriverOrHelper; } - + $fieldsStr = []; $params = []; foreach ($this->set as $field => $value) { @@ -158,9 +160,11 @@ public function build(DbFunctionsInterface|DbDriverInterface|null $dbDriverOrHel $params = array_merge($params, $whereStr[1]); $sql = ORMHelper::processLiteral($sql, $params); - return new SqlObject($sql, $params, SqlObjectEnum::UPDATE); + return new SqlStatement($sql, $params); } - public function convert(?DbFunctionsInterface $dbDriver = null): QueryBuilderInterface + + #[Override] + public function convert(?DbFunctionsInterface $dbHelper = null): QueryBuilderInterface { $query = Query::getInstance() ->fields(array_keys($this->set)) diff --git a/src/WhereTrait.php b/src/WhereTrait.php index 94f3374..66355c5 100644 --- a/src/WhereTrait.php +++ b/src/WhereTrait.php @@ -30,6 +30,7 @@ public function where(string|IteratorFilter $filter, array $params = []): static return $this; } + /** @psalm-suppress UndefinedThisPropertyFetch,RedundantCondition This is a Trait, and $this->join is defined elsewhere */ protected function getWhere(): ?array { $where = $this->where; @@ -42,7 +43,6 @@ protected function getWhere(): ?array $where[] = ["filter" => "{$from}.deleted_at is null", "params" => []]; } - /** @psalm-suppress RedundantCondition This is a Trait, and $this->join is defined elsewhere */ if (isset($this->join)) { foreach ($this->join as $item) { if ($item['table'] instanceof QueryBasic) { @@ -128,7 +128,7 @@ public function whereIn(string $field, array $values): static return $this; } - public function unsafe() + public function unsafe(): void { $this->unsafe = true; } diff --git a/test.php b/test.php new file mode 100644 index 0000000..fd91504 --- /dev/null +++ b/test.php @@ -0,0 +1,16 @@ +executor = DatabaseExecutor::using($dbDriver); + + $this->executor->execute('create table users ( + id integer primary key autoincrement, + name varchar(45), + createdate datetime);' + ); + $insertBulk = InsertBulkQuery::getInstance('users', ['name', 'createdate']); + $insertBulk->values(['name' => 'John Doe', 'createdate' => '2015-05-02']); + $insertBulk->values(['name' => 'Jane Doe', 'createdate' => '2017-01-04']); + $insertBulk->buildAndExecute($this->executor); + + ORM::defaultDbDriver($this->executor); + ActiveRecordWithProcessors::reset(); + } + + #[Override] + public function tearDown(): void + { + ORM::resetMemory(); // This also clears the default DB driver + $uri = new Uri(self::URI); + unlink($uri->getPath()); + } + + public function testActiveRecordWithProcessors() + { + // Test Insert with processor + $model = ActiveRecordWithProcessors::new(); + $model->name = 'Test User'; + $model->createdate = '2023-05-01'; + + $model->save(); + + $savedModel = ActiveRecordWithProcessors::get($model->getId()); + + // Verify processor was applied + $this->assertEquals('Test User-processed', $savedModel->name); + $this->assertEquals('2023-01-15', $savedModel->createdate); + + // Test Update with processor + $savedModel->name = 'Updated User'; + $savedModel->createdate = '2023-06-01'; + + $savedModel->save(); + + $updatedModel = ActiveRecordWithProcessors::get($savedModel->getId()); + + // Verify processor was applied + $this->assertEquals('Updated User-updated', $updatedModel->name); + $this->assertEquals('2023-02-20', $updatedModel->createdate); + } + + public function testInitializeActiveRecordDefaultDbDriverError() + { + // Clear ORM state completely + ORM::resetMemory(); // This method also clears the default DB driver + // Reset ActiveRecordWithProcessors static properties + ActiveRecordWithProcessors::reset(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("You must initialize the ORM with a DatabaseExecutor"); + ActiveRecordWithProcessors::initialize(); + } +} \ No newline at end of file diff --git a/tests/ActiveRecordQueryTest.php b/tests/ActiveRecordQueryTest.php new file mode 100644 index 0000000..b86b998 --- /dev/null +++ b/tests/ActiveRecordQueryTest.php @@ -0,0 +1,322 @@ +executor = DatabaseExecutor::using($dbDriver); + + $this->executor->execute('create table info ( + id integer primary key auto_increment, + iduser INTEGER, + property decimal(10, 2), + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at datetime);' + ); + + $insertBulk = InsertBulkQuery::getInstance('info', ['iduser', 'property']); + $insertBulk->values(['iduser' => 1, 'property' => 30.4]); + $insertBulk->values(['iduser' => 1, 'property' => 1250.96]); + $insertBulk->values(['iduser' => 2, 'property' => 45.50]); + $insertBulk->values(['iduser' => 3, 'property' => 99.99]); + $insertBulk->buildAndExecute($this->executor); + + ORM::defaultDbDriver($this->executor); + ActiveRecordModel::reset(); + } + + #[Override] + public function tearDown(): void + { + $this->executor->execute('drop table if exists info;'); + ORM::resetMemory(); + } + + // Test newQuery() method + + public function testNewQueryReturnsActiveRecordQuery() + { + $query = ActiveRecordModel::newQuery(); + + $this->assertInstanceOf(ActiveRecordQuery::class, $query); + } + + public function testNewQueryWithWhereAndFirst() + { + $result = ActiveRecordModel::newQuery() + ->where('iduser = :iduser', ['iduser' => 1]) + ->orderBy(['property']) + ->first(); + + $this->assertInstanceOf(ActiveRecordModel::class, $result); + $this->assertEquals(1, $result->getIdUser()); + $this->assertEquals(30.4, $result->getValue()); + } + + public function testNewQueryWithToArray() + { + $results = ActiveRecordModel::newQuery() + ->where('iduser = :iduser', ['iduser' => 1]) + ->orderBy(['property']) + ->toArray(); + + $this->assertCount(2, $results); + $this->assertEquals(30.4, $results[0]->getValue()); + $this->assertEquals(1250.96, $results[1]->getValue()); + } + + // Test where() method + + public function testWhereWithFirst() + { + $result = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 2]) + ->first(); + + $this->assertInstanceOf(ActiveRecordModel::class, $result); + $this->assertEquals(2, $result->getIdUser()); + $this->assertEquals(45.50, $result->getValue()); + } + + public function testWhereWithMultipleConditions() + { + $result = ActiveRecordModel::where('iduser = :iduser AND property > :min', [ + 'iduser' => 1, + 'min' => 100 + ])->first(); + + $this->assertInstanceOf(ActiveRecordModel::class, $result); + $this->assertEquals(1250.96, $result->getValue()); + } + + public function testWhereChainedWithAdditionalWhere() + { + $result = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 1]) + ->where('property > :min', ['min' => 100]) + ->first(); + + $this->assertInstanceOf(ActiveRecordModel::class, $result); + $this->assertEquals(1250.96, $result->getValue()); + } + + // Test first() method + + public function testFirstReturnsFirstRecord() + { + $result = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 1]) + ->orderBy(['property']) + ->first(); + + $this->assertInstanceOf(ActiveRecordModel::class, $result); + $this->assertEquals(30.4, $result->getValue()); + } + + public function testFirstReturnsNullWhenNoRecords() + { + $result = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 999]) + ->first(); + + $this->assertNull($result); + } + + // Test firstOrFail() method + + public function testFirstOrFailReturnsRecord() + { + $result = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 3]) + ->firstOrFail(); + + $this->assertInstanceOf(ActiveRecordModel::class, $result); + $this->assertEquals(3, $result->getIdUser()); + } + + public function testFirstOrFailThrowsExceptionWhenNoRecords() + { + $this->expectException(NotFoundException::class); + + ActiveRecordModel::where('iduser = :iduser', ['iduser' => 999]) + ->firstOrFail(); + } + + // Test exists() method + + public function testExistsReturnsTrueWhenRecordsExist() + { + $result = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 1]) + ->exists(); + + $this->assertTrue($result); + } + + public function testExistsReturnsFalseWhenNoRecords() + { + $result = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 999]) + ->exists(); + + $this->assertFalse($result); + } + + // Test existsOrFail() method + + public function testExistsOrFailReturnsTrueWhenRecordsExist() + { + $result = ActiveRecordModel::where('property > :min', ['min' => 50]) + ->existsOrFail(); + + $this->assertTrue($result); + } + + public function testExistsOrFailThrowsExceptionWhenNoRecords() + { + $this->expectException(NotFoundException::class); + + ActiveRecordModel::where('property > :max', ['max' => 10000]) + ->existsOrFail(); + } + + // Test toArray() method + + public function testToArrayReturnsAllMatchingRecords() + { + $results = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 1]) + ->toArray(); + + $this->assertCount(2, $results); + $this->assertContainsOnlyInstancesOf(ActiveRecordModel::class, $results); + } + + public function testToArrayWithOrderBy() + { + $results = ActiveRecordModel::newQuery() + ->whereIn('iduser', [1, 2]) + ->orderBy(['property DESC']) + ->toArray(); + + $this->assertCount(3, $results); + $this->assertEquals(1250.96, $results[0]->getValue()); + $this->assertEquals(45.50, $results[1]->getValue()); + $this->assertEquals(30.4, $results[2]->getValue()); + } + + public function testToArrayWithLimit() + { + $results = ActiveRecordModel::newQuery() + ->orderBy(['property DESC']) + ->limit(0, 2) + ->toArray(); + + $this->assertCount(2, $results); + $this->assertEquals(1250.96, $results[0]->getValue()); + } + + // Test complex queries + + public function testComplexQueryWithMultipleConditionsAndOrdering() + { + $result = ActiveRecordModel::newQuery() + ->where('property > :min', ['min' => 40]) + ->where('property < :max', ['max' => 100]) + ->orderBy(['property']) + ->first(); + + $this->assertInstanceOf(ActiveRecordModel::class, $result); + $this->assertEquals(45.50, $result->getValue()); + } + + public function testQueryWithNoWhereClause() + { + $results = ActiveRecordModel::newQuery() + ->orderBy(['id']) + ->toArray(); + + $this->assertCount(4, $results); + } + + public function testWhereWithNoResults() + { + $result = ActiveRecordModel::where('1 = 0')->first(); + + $this->assertNull($result); + } + + // Test method chaining + + public function testMethodChainingReadability() + { + $user = ActiveRecordModel::where('iduser = :id', ['id' => 1]) + ->where('property > :min', ['min' => 1000]) + ->orderBy(['property DESC']) + ->first(); + + $this->assertInstanceOf(ActiveRecordModel::class, $user); + $this->assertEquals(1250.96, $user->getValue()); + } + + // Test edge cases + + public function testFirstWithEmptyTable() + { + // Delete all records + $this->executor->execute('DELETE FROM info'); + + $result = ActiveRecordModel::newQuery()->first(); + + $this->assertNull($result); + } + + public function testExistsWithEmptyTable() + { + // Delete all records + $this->executor->execute('DELETE FROM info'); + + $result = ActiveRecordModel::newQuery()->exists(); + + $this->assertFalse($result); + } + + public function testToArrayWithEmptyTable() + { + // Delete all records + $this->executor->execute('DELETE FROM info'); + + $results = ActiveRecordModel::newQuery()->toArray(); + + $this->assertEmpty($results); + } + + // Test comparison with old query() method + + public function testNewApiCompatibleWithOldQueryMethod() + { + // Old way + $oldQuery = ActiveRecordModel::joinWith() + ->where('iduser = :iduser', ['iduser' => 1]); + $oldResults = ActiveRecordModel::query($oldQuery); + + // New way + $newResults = ActiveRecordModel::where('iduser = :iduser', ['iduser' => 1]) + ->toArray(); + + $this->assertCount(count($oldResults), $newResults); + $this->assertEquals($oldResults[0]->getId(), $newResults[0]->getId()); + } +} diff --git a/tests/BulkTest.php b/tests/BulkTest.php index d54185f..729083e 100644 --- a/tests/BulkTest.php +++ b/tests/BulkTest.php @@ -2,7 +2,7 @@ namespace Tests; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\InsertBulkQuery; @@ -12,21 +12,25 @@ use ByJG\MicroOrm\QueryRaw; use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\UpdateQuery; +use Override; use PDOException; use PHPUnit\Framework\TestCase; use Tests\Model\Users; class BulkTest extends TestCase { - protected DbDriverInterface $dbDriver; protected Repository $repository; + #[Override] protected function setUp(): void { - $this->dbDriver = ConnectionUtil::getConnection('testmicroorm'); + $dbDriver = ConnectionUtil::getConnection('testmicroorm'); + + $mapper = new Mapper(Users::class, 'users', 'Id'); + $this->repository = new Repository(DatabaseExecutor::using($dbDriver), $mapper); // Create table and seed data similar to RepositoryTest - $this->dbDriver->execute('create table users ( + $this->repository->getExecutor()->execute('create table users ( id integer primary key auto_increment, name varchar(45), createdate datetime);' @@ -35,15 +39,13 @@ protected function setUp(): void $insertBulk->values(['name' => 'John Doe', 'createdate' => '2017-01-02']); $insertBulk->values(['name' => 'Jane Doe', 'createdate' => '2017-01-04']); $insertBulk->values(['name' => 'JG', 'createdate' => '1974-01-26']); - $insertBulk->buildAndExecute($this->dbDriver); - - $mapper = new Mapper(Users::class, 'users', 'Id'); - $this->repository = new Repository($this->dbDriver, $mapper); + $insertBulk->buildAndExecute($this->repository->getExecutor()); } + #[Override] protected function tearDown(): void { - $this->dbDriver->execute('drop table users'); + $this->repository->getExecutor()->execute('drop table users'); } public function testBulkMixedQueriesWithParamCollision(): void @@ -149,7 +151,7 @@ public function testBulkInsertAndSelectLastInsertedId(): void ]); // Outer query selects last_insert_rowid() from the single-row subquery - $selectLastId = QueryRaw::getInstance($this->repository->getDbDriver()->getDbHelper()->getSqlLastInsertId()); + $selectLastId = QueryRaw::getInstance($this->repository->getExecutor()->getHelper()->getSqlLastInsertId()); $it = $this->repository->bulkExecute([$insert, $selectLastId], null); $result = $it->toArray(); @@ -170,7 +172,7 @@ public function testBulkInsertNoParams(): void $insert = QueryRaw::getInstance("insert into users (name, createdate) values ('Charlie', '2025-01-01')"); // Outer query selects last_insert_rowid() from the single-row subquery - $selectLastId = QueryRaw::getInstance($this->repository->getDbDriver()->getDbHelper()->getSqlLastInsertId()); + $selectLastId = QueryRaw::getInstance($this->repository->getExecutor()->getHelper()->getSqlLastInsertId()); $it = $this->repository->bulkExecute([$insert, $selectLastId], null); $result = $it->toArray(); diff --git a/tests/DeleteQueryTest.php b/tests/DeleteQueryTest.php index 24ecf6a..6c242e5 100644 --- a/tests/DeleteQueryTest.php +++ b/tests/DeleteQueryTest.php @@ -2,11 +2,11 @@ namespace Tests; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; -use ByJG\MicroOrm\SqlObject; -use ByJG\MicroOrm\SqlObjectEnum; use ByJG\MicroOrm\Updatable; +use Override; use PHPUnit\Framework\TestCase; class DeleteQueryTest extends TestCase @@ -16,11 +16,13 @@ class DeleteQueryTest extends TestCase */ protected $object; + #[Override] protected function setUp(): void { $this->object = new DeleteQuery(); } + #[Override] protected function tearDown(): void { $this->object = null; @@ -31,10 +33,10 @@ public function testDelete() $this->object->table('test'); $this->object->where('fld1 = :id', ['id' => 10]); - $sqlObject = $this->object->build(); + $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlObject('DELETE FROM test WHERE fld1 = :id', [ 'id' => 10 ], SqlObjectEnum::DELETE), - $sqlObject + new SqlStatement('DELETE FROM test WHERE fld1 = :id', ['id' => 10]), + $sqlStatement ); } @@ -52,12 +54,12 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlObject('SELECT * FROM test'), + new SqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); $this->assertEquals( - new SqlObject('SELECT * FROM test'), + new SqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -65,7 +67,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE fld2 = :teste', [ 'teste' => 10 ]), + new SqlStatement('SELECT * FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -73,7 +75,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20', [ 'teste' => 10 ]), + new SqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -81,7 +83,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', [ 'teste' => 10, 'teste2' => 40 ]), + new SqlStatement('SELECT * FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } diff --git a/tests/HexUuidLiteralTest.php b/tests/HexUuidLiteralTest.php index e1fa02e..893442b 100644 --- a/tests/HexUuidLiteralTest.php +++ b/tests/HexUuidLiteralTest.php @@ -8,12 +8,14 @@ use ByJG\MicroOrm\Literal\PostgresUuidLiteral; use ByJG\MicroOrm\Literal\SqliteUuidLiteral; use ByJG\MicroOrm\Literal\SqlServerUuidLiteral; +use Override; use PHPUnit\Framework\TestCase; class HexUuidLiteralTest extends TestCase { protected $class; + #[Override] public function setUp(): void { $this->class = HexUuidLiteral::class; @@ -172,4 +174,12 @@ public function testEmpty() $this->assertNull(HexUuidLiteral::create(null)); } + + public function testCreateWithInvalidUuidBinary2() + { + $this->assertEquals('123E4567-E89B-12D3-A456-426614174000', HexUuidLiteral::getFormattedUuid(hex2bin('123E4567E89B12D3A456426614174000'))); + $this->assertEquals('123E4567-E89B-12D3-A456-426614174000', (new HexUuidLiteral(hex2bin('123E4567E89B12D3A456426614174000')))->formatUuid()); + $this->assertEquals('123E4567-E89B-12D3-A456-426614174000', HexUuidLiteral::create(hex2bin('123E4567E89B12D3A456426614174000'))->formatUuid()); + $this->assertEquals('not-uuid', HexUuidLiteral::create("not-uuid")); + } } \ No newline at end of file diff --git a/tests/HexUuidMapperFunctionTest.php b/tests/HexUuidMapperFunctionTest.php new file mode 100644 index 0000000..5112e19 --- /dev/null +++ b/tests/HexUuidMapperFunctionTest.php @@ -0,0 +1,338 @@ +executor = DatabaseExecutor::using($dbDriver); + $this->repository = new Repository($this->executor, HexUuidEntity::class); + + $this->executor->execute('create table hex_uuid_test ( + id binary(16) primary key, + uuid_field binary(16), + name varchar(100), + description varchar(255) + );'); + } + + #[Override] + public function tearDown(): void + { + $this->executor->execute('drop table if exists hex_uuid_test;'); + } + + public function testInsertWithHexUuidPrimaryKey() + { + $entity = new HexUuidEntity(); + $entity->setName('Test Entity'); + $entity->setDescription('Test Description'); + + // Before save, ID should be null + $this->assertNull($entity->getId()); + + // Save the entity + $this->repository->save($entity); + + // After save, ID should be populated with a HexUuidLiteral + /** @var string $id */ + $id = $entity->getId(); + $this->assertNotNull($id); + $this->assertMatchesRegularExpression('/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', $id); + } + + public function testSelectWithHexUuidPrimaryKey() + { + // Insert an entity + $entity = new HexUuidEntity(); + $entity->setName('Select Test'); + $entity->setDescription('Testing SELECT with UUID'); + $this->repository->save($entity); + + /** @var string $savedId */ + $savedId = $entity->getId(); + $this->assertMatchesRegularExpression('/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', $savedId); + + + // Query the entity back + $result = $this->repository->get($savedId); + + $this->assertInstanceOf(HexUuidEntity::class, $result); + $this->assertEquals('Select Test', $result->getName()); + $this->assertEquals('Testing SELECT with UUID', $result->getDescription()); + + // Verify the ID is properly formatted + $retrievedId = $result->getId(); + $this->assertNotNull($retrievedId); + + // The ID should be formatted as a string UUID + $this->assertIsString($retrievedId); + $this->assertMatchesRegularExpression('/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', $retrievedId); + } + + public function testUpdateWithHexUuidPrimaryKey() + { + // Insert an entity + $entity = new HexUuidEntity(); + $entity->setName('Original Name'); + $entity->setDescription('Original Description'); + $this->repository->save($entity); + + $savedId = $entity->getId(); + + // Update the entity + $entity->setName('Updated Name'); + $entity->setDescription('Updated Description'); + $this->repository->save($entity); + + // Verify the ID hasn't changed + $this->assertEquals($savedId, $entity->getId()); + + // Query back to verify update + $query = $this->repository->getMapper()->getQuery(); + $query->where('id = :id', ['id' => new HexUuidLiteral($savedId)]); + $result = $this->repository->getByQuery($query); + + $this->assertCount(1, $result); + $this->assertEquals('Updated Name', $result[0]->getName()); + $this->assertEquals('Updated Description', $result[0]->getDescription()); + } + + public function testUpdateFunctionWithSecondaryUuidField() + { + // Insert an entity with a secondary UUID field + $entity = new HexUuidEntity(); + $entity->setName('UUID Field Test'); + $entity->setDescription('Testing secondary UUID field'); + $entity->setUuidField('F47AC10B-58CC-4372-A567-0E02B2C3D479'); + + $this->repository->save($entity); + + // Query back to verify the secondary UUID field + $query = $this->repository->getMapper()->getQuery(); + $query->where('id = :id', ['id' => new HexUuidLiteral($entity->getId())]); + $result = $this->repository->getByQuery($query); + + $this->assertCount(1, $result); + + // Verify the secondary UUID field is properly formatted on select + $retrievedUuidField = $result[0]->getUuidField(); + $this->assertEquals('F47AC10B-58CC-4372-A567-0E02B2C3D479', $retrievedUuidField); + } + + public function testUpdateSecondaryUuidField() + { + // Insert an entity + $entity = new HexUuidEntity(); + $entity->setName('Update UUID Field Test'); + $entity->setUuidField('123E4567-E89B-12D3-A456-426614174000'); + $this->repository->save($entity); + + $savedId = $entity->getId(); + + // Update the secondary UUID field + $entity->setUuidField('987FED65-4321-BA98-7654-321098765432'); + $this->repository->save($entity); + + // Query back to verify the update + $query = $this->repository->getMapper()->getQuery(); + $query->where('id = :id', ['id' => new HexUuidLiteral($savedId)]); + $result = $this->repository->getByQuery($query); + + $this->assertCount(1, $result); + $this->assertEquals('987FED65-4321-BA98-7654-321098765432', $result[0]->getUuidField()); + } + + public function testSelectWithNullUuidField() + { + // Insert an entity without a secondary UUID field + $entity = new HexUuidEntity(); + $entity->setName('Null UUID Test'); + $entity->setDescription('Testing null UUID field'); + $this->repository->save($entity); + + // Query back + $result = $this->repository->get($entity->getId()); + + $this->assertNull($result->getUuidField()); + } + + public function testMultipleEntitiesWithDifferentUuids() + { + // Insert multiple entities + $entity1 = new HexUuidEntity(); + $entity1->setName('Entity 1'); + $entity1->setUuidField('11111111-1111-1111-1111-111111111111'); + $this->repository->save($entity1); + + $entity2 = new HexUuidEntity(); + $entity2->setName('Entity 2'); + $entity2->setUuidField('22222222-2222-2222-2222-222222222222'); + $this->repository->save($entity2); + + $entity3 = new HexUuidEntity(); + $entity3->setName('Entity 3'); + $entity3->setUuidField('33333333-3333-3333-3333-333333333333'); + $this->repository->save($entity3); + + // Query all entities + $query = $this->repository->getMapper()->getQuery(); + $query->orderBy(['name']); + $result = $this->repository->getByQuery($query); + + $this->assertCount(3, $result); + $this->assertEquals('Entity 1', $result[0]->getName()); + $this->assertEquals('11111111-1111-1111-1111-111111111111', $result[0]->getUuidField()); + $this->assertEquals('Entity 2', $result[1]->getName()); + $this->assertEquals('22222222-2222-2222-2222-222222222222', $result[1]->getUuidField()); + $this->assertEquals('Entity 3', $result[2]->getName()); + $this->assertEquals('33333333-3333-3333-3333-333333333333', $result[2]->getUuidField()); + } + + public function testDeleteWithHexUuidPrimaryKey() + { + // Insert an entity + $entity = new HexUuidEntity(); + $entity->setName('To Delete'); + $this->repository->save($entity); + + $savedId = $entity->getId(); + + // Verify it exists + $query = $this->repository->getMapper()->getQuery(); + $query->where('id = :id', ['id' => new HexUuidLiteral($savedId)]); + $result = $this->repository->getByQuery($query); + $this->assertCount(1, $result); + + // Delete the entity + $this->repository->delete($entity->getId()); + + // Verify it's deleted + $result = $this->repository->getByQuery($query); + $this->assertCount(0, $result); + } + + public function testBinaryUuidStorageAndRetrieval() + { + // Insert an entity + $entity = new HexUuidEntity(); + $entity->setName('Binary Test'); + $entity->setUuidField('F47AC10B-58CC-4372-A567-0E02B2C3D479'); + $this->repository->save($entity); + + // Query the raw database to verify binary storage + $rawQuery = "SELECT id, uuid_field, name FROM hex_uuid_test WHERE name = 'Binary Test'"; + $iterator = $this->executor->getIterator($rawQuery); + $this->assertTrue($iterator->valid()); + + $row = $iterator->current(); + + // Verify the UUID is stored as binary (16 bytes) + $this->assertEquals(16, strlen($row->get('id'))); + $this->assertEquals(16, strlen($row->get('uuid_field'))); + + // Verify we can convert it back to the correct format + $formattedId = HexUuidLiteral::getFormattedUuid($row->get('id')); + $this->assertMatchesRegularExpression('/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i', $formattedId); + + $formattedUuidField = HexUuidLiteral::getFormattedUuid($row->get('uuid_field')); + $this->assertEquals('F47AC10B-58CC-4372-A567-0E02B2C3D479', $formattedUuidField); + } +} + +/** + * UUID Generator for the test entity + */ +class HexUuidGenerator implements MapperFunctionInterface +{ + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + return new HexUuidLiteral(bin2hex(random_bytes(16))); + } +} + +/** + * Test entity with HexUuidLiteral as primary key and mapper functions + */ +#[TableAttribute(tableName: 'hex_uuid_test', primaryKeySeedFunction: HexUuidGenerator::class)] +class HexUuidEntity +{ + #[FieldAttribute( + primaryKey: true, + updateFunction: FormatUpdateUuidMapper::class, + selectFunction: FormatSelectUuidMapper::class + )] + protected string|HexUuidLiteral|Literal|null $id = null; + + #[FieldUuidAttribute(fieldName: 'uuid_field')] + protected ?string $uuidField = null; + + #[FieldAttribute] + protected ?string $name = null; + + #[FieldAttribute] + protected ?string $description = null; + + public function getId(): string|HexUuidLiteral|Literal|null + { + return $this->id; + } + + public function setId(string|HexUuidLiteral|null $id): void + { + $this->id = $id; + } + + public function getUuidField(): ?string + { + return $this->uuidField; + } + + public function setUuidField(?string $uuidField): void + { + $this->uuidField = $uuidField; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): void + { + $this->name = $name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } +} diff --git a/tests/InsertBulkQueryTest.php b/tests/InsertBulkQueryTest.php index 9790e41..608a5a5 100644 --- a/tests/InsertBulkQueryTest.php +++ b/tests/InsertBulkQueryTest.php @@ -15,10 +15,10 @@ public function testInsert() $insertBulk->values(['fld1' => 'D', 'fld2' => new Literal("E")]); $insertBulk->values(['fld1' => "G'1", 'fld2' => 'H']); - $sqlObject = $insertBulk->build(); + $sqlStatement = $insertBulk->build(); - $this->assertEquals("INSERT INTO test (fld1, fld2) VALUES ('A', 'B'), ('D', 'E'), ('G''1', 'H')", $sqlObject->getSql()); - $this->assertEquals([], $sqlObject->getParameters()); + $this->assertEquals("INSERT INTO test (fld1, fld2) VALUES ('A', 'B'), ('D', 'E'), ('G''1', 'H')", $sqlStatement->getSql()); + $this->assertEquals([], $sqlStatement->getParams()); } public function testInsertSafe() @@ -29,9 +29,9 @@ public function testInsertSafe() $insertBulk->values(['fld1' => 'G', 'fld2' => 'H']); $insertBulk->withSafeParameters(); - $sqlObject = $insertBulk->build(); + $sqlStatement = $insertBulk->build(); - $this->assertEquals('INSERT INTO test (fld1, fld2) VALUES (:p0_0, :p0_1), (:p1_0, :p1_1), (:p2_0, :p2_1)', $sqlObject->getSql()); + $this->assertEquals('INSERT INTO test (fld1, fld2) VALUES (:p0_0, :p0_1), (:p1_0, :p1_1), (:p2_0, :p2_1)', $sqlStatement->getSql()); $this->assertEquals([ 'p0_0' => 'A', 'p0_1' => 'B', @@ -39,7 +39,7 @@ public function testInsertSafe() 'p1_1' => 'E', 'p2_0' => 'G', 'p2_1' => 'H', - ], $sqlObject->getParameters()); + ], $sqlStatement->getParams()); } @@ -50,10 +50,10 @@ public function testInsertDifferentOrder() $insertBulk->values(['fld3' => 'F', 'fld1' => 'D', 'fld2' => 'E', 'fld4' => 'X']); $insertBulk->values(['fld2' => 'H', 'fld3' => 'I', 'fld1' => 'G']); - $sqlObject = $insertBulk->build(); + $sqlStatement = $insertBulk->build(); - $this->assertEquals("INSERT INTO test (fld1, fld2, fld3) VALUES ('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'H', 'I')", $sqlObject->getSql()); - $this->assertEquals([], $sqlObject->getParameters()); + $this->assertEquals("INSERT INTO test (fld1, fld2, fld3) VALUES ('A', 'B', 'C'), ('D', 'E', 'F'), ('G', 'H', 'I')", $sqlStatement->getSql()); + $this->assertEquals([], $sqlStatement->getParams()); } public function testWrongFieldsValuesCount() diff --git a/tests/InsertQueryTest.php b/tests/InsertQueryTest.php index 2fe199d..79b208f 100644 --- a/tests/InsertQueryTest.php +++ b/tests/InsertQueryTest.php @@ -3,9 +3,9 @@ namespace Tests; use ByJG\AnyDataset\Db\Helpers\DbSqliteFunctions; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\InsertQuery; -use ByJG\MicroOrm\SqlObject; -use ByJG\MicroOrm\SqlObjectEnum; +use Override; use PHPUnit\Framework\TestCase; class InsertQueryTest extends TestCase @@ -15,11 +15,13 @@ class InsertQueryTest extends TestCase */ protected ?InsertQuery $object; + #[Override] protected function setUp(): void { $this->object = new InsertQuery(); } + #[Override] protected function tearDown(): void { $this->object = null; @@ -33,16 +35,16 @@ public function testInsert() $this->object->set('fld2', 'B'); $this->object->set('fld3', 'C'); - $sqlObject = $this->object->build(); + $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlObject('INSERT INTO test( fld1, fld2, fld3 ) values ( :fld1, :fld2, :fld3 ) ', [ 'fld1' => 'A', 'fld2'=> 'B', 'fld3' => 'C' ], SqlObjectEnum::INSERT), - $sqlObject + new SqlStatement('INSERT INTO test( fld1, fld2, fld3 ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C']), + $sqlStatement ); - $sqlObject = $this->object->build(new DbSqliteFunctions()); + $sqlStatement = $this->object->build(new DbSqliteFunctions()); $this->assertEquals( - new SqlObject('INSERT INTO `test`( `fld1`, `fld2`, `fld3` ) values ( :fld1, :fld2, :fld3 ) ', [ 'fld1' => 'A', 'fld2'=> 'B', 'fld3' => 'C' ], SqlObjectEnum::INSERT), - $sqlObject + new SqlStatement('INSERT INTO `test`( `fld1`, `fld2`, `fld3` ) values ( :fld1, :fld2, :fld3 ) ', ['fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C']), + $sqlStatement ); } @@ -50,7 +52,7 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlObject('SELECT * FROM test'), + new SqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -59,7 +61,7 @@ public function testQueryUpdatable() $this->object->set('fld3', 'C'); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test'), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->convert()->build() ); @@ -67,7 +69,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', [ 'teste' => 10 ]), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -75,7 +77,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', [ 'teste' => 10 ]), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -83,7 +85,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', [ 'teste' => 10, 'teste2' => 40 ]), + new SqlStatement('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/InsertSelectQueryTest.php b/tests/InsertSelectQueryTest.php index ce6c5d4..29d9659 100644 --- a/tests/InsertSelectQueryTest.php +++ b/tests/InsertSelectQueryTest.php @@ -2,9 +2,11 @@ namespace Tests; +use ByJG\AnyDataset\Db\SqlStatement; +use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\InsertSelectQuery; use ByJG\MicroOrm\QueryBasic; -use ByJG\MicroOrm\SqlObject; +use Override; use PHPUnit\Framework\TestCase; class InsertSelectQueryTest extends TestCase @@ -14,11 +16,13 @@ class InsertSelectQueryTest extends TestCase */ protected $object; + #[Override] protected function setUp(): void { $this->object = new InsertSelectQuery(); } + #[Override] protected function tearDown(): void { $this->object = null; @@ -36,9 +40,9 @@ public function testInsert() $query->field('fldC'); $this->object->fromQuery($query); - $sqlObject = $this->object->build(); + $sqlStatement = $this->object->build(); - $this->assertEquals('INSERT INTO test ( fld1, fld2, fld3 ) SELECT fldA, fldB, fldC FROM table2', $sqlObject->getSql()); + $this->assertEquals('INSERT INTO test ( fld1, fld2, fld3 ) SELECT fldA, fldB, fldC FROM table2', $sqlStatement->getSql()); } public function testInsertSelectParam() @@ -54,13 +58,13 @@ public function testInsertSelectParam() $query->where('fldA = :valueA', ['valueA' => 1]); $this->object->fromQuery($query); - $sqlObject = $this->object->build(); + $sqlStatement = $this->object->build(); - $this->assertEquals('INSERT INTO test ( fld1, fld2, fld3 ) SELECT fldA, fldB, fldC FROM table2 WHERE fldA = :valueA', $sqlObject->getSql()); - $this->assertEquals(['valueA' => 1], $sqlObject->getParameters()); + $this->assertEquals('INSERT INTO test ( fld1, fld2, fld3 ) SELECT fldA, fldB, fldC FROM table2 WHERE fldA = :valueA', $sqlStatement->getSql()); + $this->assertEquals(['valueA' => 1], $sqlStatement->getParams()); } - public function testInsertSqlObject() + public function testInsertSqlStatement() { $this->object->table('test'); $this->object->fields(['fld1', 'fld2', 'fld3']); @@ -73,11 +77,11 @@ public function testInsertSqlObject() $query->where('fldA = :valueA', ['valueA' => 1]); $fromObject = $query->build(); - $this->object->fromSqlObject($fromObject); - $sqlObject = $this->object->build(); + $this->object->fromSqlStatement($fromObject); + $sqlStatement = $this->object->build(); - $this->assertEquals('INSERT INTO test ( fld1, fld2, fld3 ) SELECT fldA, fldB, fldC FROM table2 WHERE fldA = :valueA', $sqlObject->getSql()); - $this->assertEquals(['valueA' => 1], $sqlObject->getParameters()); + $this->assertEquals('INSERT INTO test ( fld1, fld2, fld3 ) SELECT fldA, fldB, fldC FROM table2 WHERE fldA = :valueA', $sqlStatement->getSql()); + $this->assertEquals(['valueA' => 1], $sqlStatement->getParams()); } public function testWithoutQuery() @@ -85,19 +89,19 @@ public function testWithoutQuery() $this->object->table('test'); $this->object->fields(['fld1', 'fld2', 'fld3']); - $this->expectException(\ByJG\MicroOrm\Exception\OrmInvalidFieldsException::class); + $this->expectException(OrmInvalidFieldsException::class); $this->expectExceptionMessage('You must specify the query for insert'); $this->object->build(); } - public function testWithBothQueryandSqlObject() + public function testWithBothQueryandSqlStatement() { $this->object->table('test'); $this->object->fields(['fld1', 'fld2', 'fld3']); $this->object->fromQuery(new QueryBasic()); - $this->object->fromSqlObject(new SqlObject("SELECT * FROM table2")); + $this->object->fromSqlStatement(new SqlStatement("SELECT * FROM table2")); - $this->expectException(\ByJG\MicroOrm\Exception\OrmInvalidFieldsException::class); + $this->expectException(OrmInvalidFieldsException::class); $this->expectExceptionMessage('You must specify only one query for insert'); $this->object->build(); } diff --git a/tests/MapperFunctionsTest.php b/tests/MapperFunctionsTest.php new file mode 100644 index 0000000..c123194 --- /dev/null +++ b/tests/MapperFunctionsTest.php @@ -0,0 +1,238 @@ +processedValue('test value', new stdClass()); + + $this->assertEquals('test value', $result); + } + + public function testStandardMapperWithEmptyString() + { + $mapper = new StandardMapper(); + $result = $mapper->processedValue('', new stdClass()); + + $this->assertNull($result); + } + + public function testStandardMapperWithNull() + { + $mapper = new StandardMapper(); + $result = $mapper->processedValue(null, new stdClass()); + + $this->assertNull($result); + } + + public function testStandardMapperWithZeroInteger() + { + $mapper = new StandardMapper(); + $result = $mapper->processedValue(0, new stdClass()); + + $this->assertSame(0, $result); + } + + public function testStandardMapperWithZeroString() + { + $mapper = new StandardMapper(); + $result = $mapper->processedValue('0', new stdClass()); + + $this->assertSame('0', $result); + } + + public function testStandardMapperWithFalse() + { + $mapper = new StandardMapper(); + $result = $mapper->processedValue(false, new stdClass()); + + $this->assertFalse($result); + } + + // ReadOnlyMapper Tests + + public function testReadOnlyMapperAlwaysReturnsFalse() + { + $mapper = new ReadOnlyMapper(); + $result = $mapper->processedValue('any value', new stdClass()); + + $this->assertFalse($result); + } + + public function testReadOnlyMapperWithNullValue() + { + $mapper = new ReadOnlyMapper(); + $result = $mapper->processedValue(null, new stdClass()); + + $this->assertFalse($result); + } + + public function testReadOnlyMapperWithNumericValue() + { + $mapper = new ReadOnlyMapper(); + $result = $mapper->processedValue(123, new stdClass()); + + $this->assertFalse($result); + } + + // NowUtcMapper Tests + + public function testNowUtcMapperReturnsLiteral() + { + $dbHelperMock = $this->createMock(DbFunctionsInterface::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); + + $this->assertInstanceOf(Literal::class, $result); + $this->assertEquals('2024-01-15 12:30:45', $result->getLiteralValue()); + } + + public function testNowUtcMapperIgnoresInputValue() + { + $dbHelperMock = $this->createMock(DbFunctionsInterface::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); + + $this->assertInstanceOf(Literal::class, $result); + $this->assertEquals('2024-01-15 12:30:45', $result->getLiteralValue()); + } + + // FormatUpdateUuidMapper Tests + + public function testFormatUpdateUuidMapperWithEmptyValue() + { + $mapper = new FormatUpdateUuidMapper(); + $result = $mapper->processedValue('', new stdClass()); + + $this->assertNull($result); + } + + public function testFormatUpdateUuidMapperWithNullValue() + { + $mapper = new FormatUpdateUuidMapper(); + $result = $mapper->processedValue(null, new stdClass()); + + $this->assertNull($result); + } + + public function testFormatUpdateUuidMapperWithStringUuid() + { + $mapper = new FormatUpdateUuidMapper(); + $uuid = 'F47AC10B-58CC-4372-A567-0E02B2C3D479'; + $result = $mapper->processedValue($uuid, new stdClass()); + + $this->assertInstanceOf(HexUuidLiteral::class, $result); + $this->assertEquals("X'" . str_replace("-", "", $uuid) . "'", $result->getLiteralValue()); + } + + public function testFormatUpdateUuidMapperWithLiteralValue() + { + $mapper = new FormatUpdateUuidMapper(); + $literal = new Literal('some value'); + $result = $mapper->processedValue($literal, new stdClass()); + + $this->assertSame($literal, $result); + } + + public function testFormatUpdateUuidMapperWithHexUuidLiteral() + { + $mapper = new FormatUpdateUuidMapper(); + $uuid = new HexUuidLiteral('F47AC10B-58CC-4372-A567-0E02B2C3D479'); + $result = $mapper->processedValue($uuid, new stdClass()); + + $this->assertSame($uuid, $result); + } + + // FormatSelectUuidMapper Tests + + public function testFormatSelectUuidMapperWithBinaryUuid() + { + $mapper = new FormatSelectUuidMapper(); + $binaryUuid = hex2bin('F47AC10B58CC4372A5670E02B2C3D479'); + + $result = $mapper->processedValue($binaryUuid, []); + + $this->assertEquals('F47AC10B-58CC-4372-A567-0E02B2C3D479', $result); + } + + public function testFormatSelectUuidMapperWithHexString() + { + $mapper = new FormatSelectUuidMapper(); + $hexUuid = 'F47AC10B58CC4372A5670E02B2C3D479'; + + $result = $mapper->processedValue($hexUuid, []); + + $this->assertEquals('F47AC10B-58CC-4372-A567-0E02B2C3D479', $result); + } + + public function testFormatSelectUuidMapperWithFormattedUuid() + { + $mapper = new FormatSelectUuidMapper(); + $formattedUuid = 'F47AC10B-58CC-4372-A567-0E02B2C3D479'; + + $result = $mapper->processedValue($formattedUuid, []); + + $this->assertEquals('F47AC10B-58CC-4372-A567-0E02B2C3D479', $result); + } + + public function testFormatSelectUuidMapperWithEmptyValue() + { + $mapper = new FormatSelectUuidMapper(); + + $result = $mapper->processedValue('', []); + + $this->assertEquals('', $result); + } + + public function testFormatSelectUuidMapperWithNullValue() + { + $mapper = new FormatSelectUuidMapper(); + + $result = $mapper->processedValue(null, []); + + $this->assertNull($result); + } + + public function testFormatSelectUuidMapperWithInvalidValue() + { + $mapper = new FormatSelectUuidMapper(); + + $result = $mapper->processedValue('invalid-uuid', []); + + // Should return null when invalid and throwErrorIfInvalid is false + $this->assertNull($result); + } +} diff --git a/tests/Model/ActiveRecordWithProcessors.php b/tests/Model/ActiveRecordWithProcessors.php new file mode 100644 index 0000000..84a7456 --- /dev/null +++ b/tests/Model/ActiveRecordWithProcessors.php @@ -0,0 +1,42 @@ +id; + } + + /** + * @param mixed $id + */ + public function setId($id) + { + $this->id = $id; + } +} \ No newline at end of file diff --git a/tests/Model/ModelWithAttributes.php b/tests/Model/ModelWithAttributes.php index 0da2fbb..48081c4 100644 --- a/tests/Model/ModelWithAttributes.php +++ b/tests/Model/ModelWithAttributes.php @@ -39,4 +39,28 @@ public function setPk($pk) $this->pk = $pk; } + /** + * @return mixed + */ + public function getId() + { + return $this->pk; + } + + /** + * @return mixed + */ + public function getIdUser() + { + return $this->iduser; + } + + /** + * @return mixed + */ + public function getValue() + { + return $this->value; + } + } \ No newline at end of file diff --git a/tests/Model/ModelWithProcessors.php b/tests/Model/ModelWithProcessors.php new file mode 100644 index 0000000..4f2d89e --- /dev/null +++ b/tests/Model/ModelWithProcessors.php @@ -0,0 +1,39 @@ +id; + } + + /** + * @param mixed $id + */ + public function setId($id) + { + $this->id = $id; + } +} \ No newline at end of file diff --git a/tests/Model/TableSeedAttribute.php b/tests/Model/TableSeedAttribute.php index 18584dd..a33f196 100644 --- a/tests/Model/TableSeedAttribute.php +++ b/tests/Model/TableSeedAttribute.php @@ -3,15 +3,22 @@ namespace Tests\Model; use Attribute; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Attributes\TableAttribute; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; +use Override; #[Attribute(Attribute::TARGET_CLASS)] -class TableSeedAttribute extends TableAttribute +class TableSeedAttribute extends TableAttribute implements MapperFunctionInterface { public function __construct() { - parent::__construct('users', function ($instance) { - return 50; - }); + parent::__construct('users', TableSeedAttribute::class); + } + + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + return 50; } } diff --git a/tests/Model/TestInsertProcessor.php b/tests/Model/TestInsertProcessor.php new file mode 100644 index 0000000..a0977a6 --- /dev/null +++ b/tests/Model/TestInsertProcessor.php @@ -0,0 +1,17 @@ +class = MySqlUuidLiteral::class; diff --git a/tests/ORMTest.php b/tests/ORMTest.php index 7bc2035..a083596 100644 --- a/tests/ORMTest.php +++ b/tests/ORMTest.php @@ -8,6 +8,7 @@ use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\ORM; use ByJG\MicroOrm\ORMHelper; +use Override; use PHPUnit\Framework\TestCase; use Tests\Model\Class1; use Tests\Model\Class2; @@ -21,9 +22,10 @@ class ORMTest extends TestCase private Mapper $mapper3; private Mapper $mapper4; + #[Override] public function setUp(): void { - ORM::clearRelationships(); + ORM::resetMemory(); $this->mapper1 = new Mapper(Class1::class, 'table1', 'id'); $this->mapper2 = new Mapper(Class2::class, 'table2', 'id'); $this->mapper2->addFieldMapping(FieldMapping::create('idTable1')->withFieldName('id_table1')->withParentTable('table1')); @@ -31,9 +33,10 @@ public function setUp(): void $this->mapper4 = new Mapper(Class4::class); } + #[Override] public function tearDown(): void { - ORM::clearRelationships(); + ORM::resetMemory(); } public function testSanityCheck() @@ -145,9 +148,9 @@ public function testProcessLiteralUnsafe() $query = ORM::getQueryInstance('table1'); $query->where('field1 = :value', ['value' => new Literal(10)]); - $sqlObject = $query->build(); - $sql = $sqlObject->getSql(); - $params = $sqlObject->getParameters(); + $sqlStatement = $query->build(); + $sql = $sqlStatement->getSql(); + $params = $sqlStatement->getParams(); $sql = ORMHelper::processLiteral($sql, $params); $this->assertEquals("SELECT * FROM table1 WHERE field1 = 10", $sql); @@ -159,9 +162,9 @@ public function testProcessLiteralString() $query->where('field1 = :value', ['value' => new Literal("'testando'")]); $query->where('field2 = :value2', ['value2' => new Literal("'Joana D''Arc'")]); - $sqlObject = $query->build(); - $sql = $sqlObject->getSql(); - $params = $sqlObject->getParameters(); + $sqlStatement = $query->build(); + $sql = $sqlStatement->getSql(); + $params = $sqlStatement->getParams(); $sql = ORMHelper::processLiteral($sql, $params); $this->assertEquals("SELECT * FROM table1 WHERE field1 = 'testando' AND field2 = 'Joana D''Arc'", $sql); diff --git a/tests/PostgresUuidLiteralTest.php b/tests/PostgresUuidLiteralTest.php index ca597e9..a8df3fc 100644 --- a/tests/PostgresUuidLiteralTest.php +++ b/tests/PostgresUuidLiteralTest.php @@ -4,14 +4,17 @@ use ByJG\MicroOrm\Literal\PostgresUuidLiteral; +use Override; class PostgresUuidLiteralTest extends HexUuidLiteralTest { + #[Override] public function setUp(): void { $this->class = PostgresUuidLiteral::class; } + #[Override] public function testBinaryString() { $value = 'F47AC10B-58CC-4372-A567-0E02B2C3D479'; @@ -22,6 +25,7 @@ public function testBinaryString() $this->assertEquals($expectedBinaryString, $hexUuidLiteral->binaryString($value)); } + #[Override] public function testFormatUuidFromBinRepresent() { $value = '\xF47AC10B58CC4372A5670E02B2C3D479'; diff --git a/tests/QueryTest.php b/tests/QueryTest.php index bb29c88..74757d2 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -4,6 +4,7 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\DeleteQuery; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Literal\Literal; @@ -12,9 +13,8 @@ use ByJG\MicroOrm\Query; use ByJG\MicroOrm\QueryBasic; use ByJG\MicroOrm\Recursive; -use ByJG\MicroOrm\SqlObject; -use ByJG\MicroOrm\SqlObjectEnum; use ByJG\MicroOrm\UpdateQuery; +use Override; use PHPUnit\Framework\TestCase; use Tests\Model\ModelWithAttributes; @@ -25,22 +25,24 @@ class QueryTest extends TestCase */ protected $object; + #[Override] protected function setUp(): void { $this->object = new Query(); } + #[Override] protected function tearDown(): void { $this->object = null; - ORM::clearRelationships(); + ORM::resetMemory(); } public function testQueryBasic() { $this->object->table('test'); $this->assertEquals( - new SqlObject('SELECT * FROM test'), + new SqlStatement('SELECT * FROM test'), $this->object->build() ); @@ -50,7 +52,7 @@ public function testQueryBasic() ->fields(['fld2', 'fld3']); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test'), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->build() ); @@ -58,7 +60,7 @@ public function testQueryBasic() ->orderBy(['fld1']); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test ORDER BY fld1'), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test ORDER BY fld1'), $this->object->build() ); @@ -66,7 +68,7 @@ public function testQueryBasic() ->groupBy(['fld1', 'fld2', 'fld3']); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test GROUP BY fld1, fld2, fld3 ORDER BY fld1'), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test GROUP BY fld1, fld2, fld3 ORDER BY fld1'), $this->object->build() ); @@ -74,7 +76,7 @@ public function testQueryBasic() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste GROUP BY fld1, fld2, fld3 ORDER BY fld1', [ 'teste' => 10 ]), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste GROUP BY fld1, fld2, fld3 ORDER BY fld1', ['teste' => 10]), $this->object->build() ); @@ -82,7 +84,7 @@ public function testQueryBasic() ->where('fld3 = 20'); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 GROUP BY fld1, fld2, fld3 ORDER BY fld1', [ 'teste' => 10 ]), + new SqlStatement('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() ); @@ -90,7 +92,7 @@ public function testQueryBasic() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlObject('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 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]), $this->object->build() ); @@ -98,7 +100,7 @@ public function testQueryBasic() ->having('count(fld1) > 1'); $this->assertEquals( - new SqlObject('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 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]), $this->object->build() ); @@ -106,7 +108,7 @@ public function testQueryBasic() $iteratorFilter->and('fld4', Relation::EQUAL, 40); $this->object->where($iteratorFilter); $this->assertEquals( - new SqlObject('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 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]), $this->object->build() ); } @@ -124,7 +126,7 @@ public function testQueryWhereIteratorFilter() $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE fld1 = :fld1 and fld2 = :fld2 or fld1 = :fld10 ', [ 'fld1' => 10, 'fld2' => '20', 'fld10' => '30' ]), + new SqlStatement('SELECT * FROM test WHERE fld1 = :fld1 and fld2 = :fld2 or fld1 = :fld10 ', ['fld1' => 10, 'fld2' => '20', 'fld10' => '30']), $this->object->build() ); } @@ -141,6 +143,7 @@ public function testConvertQueryToQueryBasic() ->fields(['fld2', 'fld3']) ->orderBy(['fld1']) ->groupBy(['fld1', 'fld2', 'fld3']) + ->having('count(fld1) > 1') ->join('table2', 'table2.id = test.id') ->leftJoin('table3', 'table3.id = test.id') ->rightJoin('table4', 'table4.id = test.id') @@ -150,18 +153,17 @@ public function testConvertQueryToQueryBasic() ->where('fld3 = 20') ->where('fld1 = :teste2', [ 'teste2' => 40 ]); - $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 ORDER BY fld1'; + $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 SqlObject($expectedSql, [ 'teste' => 10, 'teste2' => 40 ]), + new SqlStatement($expectedSql, ['teste' => 10, 'teste2' => 40]), $query->build() ); $queryBasic = $query->getQueryBasic(); - /** @psalm-suppress InvalidLiteralArgument */ - $expectedSql2 = substr($expectedSql, 0, strpos($expectedSql, ' GROUP')); + $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 SqlObject($expectedSql2, [ 'teste' => 10, 'teste2' => 40 ]), + new SqlStatement($expectedSql2, ['teste' => 10, 'teste2' => 40]), $queryBasic->build() ); } @@ -175,7 +177,7 @@ public function testLiteral() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE field = ABC', []), + new SqlStatement('SELECT * FROM test WHERE field = ABC', []), $result ); } @@ -190,7 +192,7 @@ public function testLiteral2() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE field = ABC AND other = :other', ['other' => 'test']), + new SqlStatement('SELECT * FROM test WHERE field = ABC AND other = :other', ['other' => 'test']), $result ); } @@ -200,7 +202,7 @@ public function testTableAlias() $this->object->table('test', "t"); $this->object->where("1 = 1"); $this->assertEquals( - new SqlObject('SELECT * FROM test as t WHERE 1 = 1', []), + new SqlStatement('SELECT * FROM test as t WHERE 1 = 1', []), $this->object->build() ); } @@ -216,7 +218,7 @@ public function testJoin() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo INNER JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new SqlStatement('SELECT * FROM foo INNER JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -232,7 +234,7 @@ public function testJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo INNER JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + 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']), $result ); } @@ -248,7 +250,7 @@ public function testLeftJoin() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo LEFT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new SqlStatement('SELECT * FROM foo LEFT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -264,7 +266,7 @@ public function testLeftJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo LEFT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + 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']), $result ); } @@ -280,7 +282,7 @@ public function testRightJoin() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo RIGHT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new SqlStatement('SELECT * FROM foo RIGHT JOIN bar ON foo.id = bar.id WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -296,7 +298,7 @@ public function testRightJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo RIGHT JOIN bar as b ON foo.id = b.id WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + 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']), $result ); } @@ -312,7 +314,7 @@ public function testCrossJoin() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo CROSS JOIN bar WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), + new SqlStatement('SELECT * FROM foo CROSS JOIN bar WHERE foo.field = ABC AND bar.other = :other', ['other' => 'test']), $result ); } @@ -328,7 +330,7 @@ public function testCrossJoinAlias() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM foo CROSS JOIN bar as b WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), + new SqlStatement('SELECT * FROM foo CROSS JOIN bar as b WHERE foo.field = ABC AND b.other = :other', ['other' => 'test']), $result ); } @@ -353,7 +355,7 @@ public function testSubQueryTable() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM (SELECT id, max(date) as date FROM subtest GROUP BY id) as sq WHERE sq.date < :date', ['date' => '2020-06-01']), + 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']), $result ); @@ -411,7 +413,7 @@ public function testSubQueryTableWithFilter() $result = $query->build(); $this->assertEquals( - new SqlObject('SELECT * FROM (SELECT id, max(date) as date FROM subtest WHERE date > :test GROUP BY id) as sq WHERE sq.date < :date', [ + new SqlStatement('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', ]), @@ -440,7 +442,7 @@ public function testSubQueryJoin() $result = $query->build(); $this->assertEquals( - new SqlObject('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 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']), $result ); @@ -529,7 +531,7 @@ public function testSubQueryField() $result = $query->build(); $this->assertEquals( - new SqlObject('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 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']), $result ); @@ -542,7 +544,7 @@ public function testSoftDeleteQuery() ->table('info') ->where('iduser = :id', ['id' => 3]) ->orderBy(['property']); - $this->assertEquals(new SqlObject("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); @@ -550,11 +552,11 @@ public function testSoftDeleteQuery() ->table('info') ->where('iduser = :id', ['id' => 3]) ->orderBy(['property']); - $this->assertEquals(new SqlObject("SELECT * FROM info WHERE iduser = :id AND info.deleted_at is null ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new SqlStatement("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 SqlObject("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); + $this->assertEquals(new SqlStatement("SELECT * FROM info WHERE iduser = :id ORDER BY property", ["id" => 3]), $query->build()); } public function testSoftDeleteUpdate() @@ -564,7 +566,7 @@ public function testSoftDeleteUpdate() ->table('info') ->set('property', 'value') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlObject("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3], type: SqlObjectEnum::UPDATE), $query->build()); + $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3]), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); @@ -572,11 +574,11 @@ public function testSoftDeleteUpdate() ->table('info') ->set('property', 'value') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlObject("UPDATE info SET property = :property WHERE iduser = :id AND info.deleted_at is null", ["property" => "value", "id" => 3], type: SqlObjectEnum::UPDATE), $query->build()); + $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id AND info.deleted_at is null", ["property" => "value", "id" => 3]), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlObject("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3], type: SqlObjectEnum::UPDATE), $query->build()); + $this->assertEquals(new SqlStatement("UPDATE info SET property = :property WHERE iduser = :id", ["property" => "value", "id" => 3]), $query->build()); } public function testSoftDeleteDelete() @@ -585,18 +587,18 @@ public function testSoftDeleteDelete() $query = DeleteQuery::getInstance() ->table('info') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlObject("DELETE FROM info WHERE iduser = :id", ["id" => 3], type: SqlObjectEnum::DELETE), $query->build()); + $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3]), $query->build()); // Test with soft delete new Mapper(ModelWithAttributes::class); $query = DeleteQuery::getInstance() ->table('info') ->where('iduser = :id', ['id' => 3]); - $this->assertEquals(new SqlObject("DELETE FROM info WHERE iduser = :id AND info.deleted_at is null", ["id" => 3], type: SqlObjectEnum::DELETE), $query->build()); + $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id AND info.deleted_at is null", ["id" => 3]), $query->build()); // Test Unsafe $query->unsafe(); - $this->assertEquals(new SqlObject("DELETE FROM info WHERE iduser = :id", ["id" => 3], type: SqlObjectEnum::DELETE), $query->build()); + $this->assertEquals(new SqlStatement("DELETE FROM info WHERE iduser = :id", ["id" => 3]), $query->build()); } public function testQueryBasicDistinct() @@ -607,7 +609,7 @@ public function testQueryBasicDistinct() ->fields(['fld1', 'fld2']); $this->assertEquals( - new SqlObject('SELECT fld1, fld2 FROM test'), + new SqlStatement('SELECT fld1, fld2 FROM test'), $queryBasic->build() ); @@ -618,7 +620,7 @@ public function testQueryBasicDistinct() ->distinct(); $this->assertEquals( - new SqlObject('SELECT DISTINCT fld1, fld2 FROM test'), + new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $queryBasic->build() ); } @@ -631,7 +633,7 @@ public function testQueryDistinct() ->fields(['fld1', 'fld2']); $this->assertEquals( - new SqlObject('SELECT fld1, fld2 FROM test'), + new SqlStatement('SELECT fld1, fld2 FROM test'), $query->build() ); @@ -642,14 +644,14 @@ public function testQueryDistinct() ->distinct(); $this->assertEquals( - new SqlObject('SELECT DISTINCT fld1, fld2 FROM test'), + new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $query->build() ); // Test getQueryBasic preserves distinct $queryBasic = $query->getQueryBasic(); $this->assertEquals( - new SqlObject('SELECT DISTINCT fld1, fld2 FROM test'), + new SqlStatement('SELECT DISTINCT fld1, fld2 FROM test'), $queryBasic->build() ); } diff --git a/tests/RecursiveTest.php b/tests/RecursiveTest.php index 63f7da5..daee710 100644 --- a/tests/RecursiveTest.php +++ b/tests/RecursiveTest.php @@ -2,9 +2,9 @@ namespace Tests; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Recursive; -use ByJG\MicroOrm\SqlObject; use PHPUnit\Framework\TestCase; class RecursiveTest extends TestCase @@ -26,7 +26,7 @@ public function testRecursive() $expected = $expected . "SELECT start, end FROM test"; $this->assertEquals( - new SqlObject($expected), + new SqlStatement($expected), $query->build() ); diff --git a/tests/RepositoryAliasTest.php b/tests/RepositoryAliasTest.php index 6b5552c..ed547f0 100644 --- a/tests/RepositoryAliasTest.php +++ b/tests/RepositoryAliasTest.php @@ -2,58 +2,52 @@ namespace Tests; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\FieldMapping; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Repository; +use Override; use PHPUnit\Framework\TestCase; use Tests\Model\Customer; class RepositoryAliasTest extends TestCase { - /** - * @var Mapper - */ - protected $customerMapper; - - /** - * @var DbDriverInterface - */ - protected $dbDriver; /** * @var Repository */ - protected $repository; + protected Repository $repository; + #[Override] public function setUp(): void { - $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); - - $this->dbDriver->execute('create table customers ( - id integer primary key auto_increment, - customer_name varchar(45), - customer_age int);' - ); - $this->dbDriver->execute("insert into customers (customer_name, customer_age) values ('John Doe', 40)"); - $this->dbDriver->execute("insert into customers (customer_name, customer_age) values ('Jane Doe', 37)"); - $this->customerMapper = new Mapper(Customer::class, 'customers', 'id'); - $this->customerMapper->addFieldMapping(FieldMapping::create('customerName')->withFieldName('customer_Name')); - $this->customerMapper->addFieldMapping(FieldMapping::create('notInTable')->dontSyncWithDb()); + $dbDriver = ConnectionUtil::getConnection("testmicroorm"); + $customerMapper = new Mapper(Customer::class, 'customers', 'id'); + $customerMapper->addFieldMapping(FieldMapping::create('customerName')->withFieldName('customer_Name')); + $customerMapper->addFieldMapping(FieldMapping::create('notInTable')->dontSyncWithDb()); $fieldMap = FieldMapping::create('Age') ->withFieldName('customer_Age') ->withFieldAlias('custAge'); - $this->customerMapper->addFieldMapping($fieldMap); + $customerMapper->addFieldMapping($fieldMap); - $this->repository = new Repository($this->dbDriver, $this->customerMapper); + $this->repository = new Repository(DatabaseExecutor::using($dbDriver), $customerMapper); + + $this->repository->getExecutor()->execute('create table customers ( + id integer primary key auto_increment, + customer_name varchar(45), + customer_age int);' + ); + $this->repository->getExecutor()->execute("insert into customers (customer_name, customer_age) values ('John Doe', 40)"); + $this->repository->getExecutor()->execute("insert into customers (customer_name, customer_age) values ('Jane Doe', 37)"); } + #[Override] public function tearDown(): void { - $this->dbDriver->execute('drop table if exists customers;'); + $this->repository->getExecutor()->execute('drop table if exists customers;'); } public function testGet() diff --git a/tests/RepositoryPkListTest.php b/tests/RepositoryPkListTest.php index 40f5e11..87846ea 100644 --- a/tests/RepositoryPkListTest.php +++ b/tests/RepositoryPkListTest.php @@ -2,52 +2,43 @@ namespace Tests; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Repository; +use Override; use PHPUnit\Framework\TestCase; use Tests\Model\Items; class RepositoryPkListTest extends TestCase { - /** - * @var Mapper - */ - protected $itemsMapper; - - /** - * @var DbDriverInterface - */ - protected $dbDriver; - /** * @var Repository */ - protected $repository; + protected Repository $repository; + #[Override] public function setUp(): void { - $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); + $dbDriver = ConnectionUtil::getConnection("testmicroorm"); + $itemsMapper = new Mapper(Items::class, 'items', ['storeid', 'itemid']); + $this->repository = new Repository(DatabaseExecutor::using($dbDriver), $itemsMapper); - $this->dbDriver->execute('CREATE TABLE items ( + $this->repository->getExecutor()->execute('CREATE TABLE items ( storeid INTEGER, itemid INTEGER, qty INTEGER, PRIMARY KEY (storeid, itemid) );' ); - $this->dbDriver->execute("insert into items (storeid, itemid, qty) values (1, 1, 10)"); - $this->dbDriver->execute("insert into items (storeid, itemid, qty) values (1, 2, 15)"); - $this->dbDriver->execute("insert into items (storeid, itemid, qty) values (2, 1, 20)"); - - $this->itemsMapper = new Mapper(Items::class, 'items', ['storeid', 'itemid']); - - $this->repository = new Repository($this->dbDriver, $this->itemsMapper); + $this->repository->getExecutor()->execute("insert into items (storeid, itemid, qty) values (1, 1, 10)"); + $this->repository->getExecutor()->execute("insert into items (storeid, itemid, qty) values (1, 2, 15)"); + $this->repository->getExecutor()->execute("insert into items (storeid, itemid, qty) values (2, 1, 20)"); } + #[Override] public function tearDown(): void { - $this->dbDriver->execute('drop table if exists items;'); + $this->repository->getExecutor()->execute('drop table if exists items;'); } public function testGet() @@ -91,518 +82,4 @@ public function testInsert() $items = $this->repository->get([5, 3]); $this->assertEquals(200, $items->getQty()); } - - // public function testGetSelectFunction() - // { - // $this->itemsMapper = new Mapper(Items::class, 'items', 'itemid'); - // $this->repository = new Repository($this->dbDriver, $this->itemsMapper); - - // $this->itemsMapper->addFieldMap( - // 'name', - // 'name', - // null, - // function ($value, $instance) { - // return '[' . strtoupper($value) . '] - ' . $instance->getCreatedate(); - // } - // ); - - // $this->itemsMapper->addFieldMap( - // 'year', - // 'createdate', - // null, - // function ($value, $instance) { - // $date = new \DateTime($value); - // return $date->format('Y'); - // } - // ); - - // $items = $this->repository->get(1); - // $this->assertEquals(1, $items->getItemId()); - // $this->assertEquals('[JOHN DOE] - 2017-01-02', $items->getName()); - // $this->assertEquals('2017-01-02', $items->getCreatedate()); - // $this->assertEquals('2017', $items->getYear()); - - // $items = $this->repository->get(2); - // $this->assertEquals(2, $items->getItemId()); - // $this->assertEquals('[JANE DOE] - 2017-01-04', $items->getName()); - // $this->assertEquals('2017-01-04', $items->getCreatedate()); - // $this->assertEquals('2017', $items->getYear()); - // } - - // public function testInsert() - // { - // $items = new items(); - // $items->setName('Bla99991919'); - // $items->setCreatedate('2015-08-09'); - - // $this->assertEquals(null, $items->getId()); - // $this->repository->save($items); - // $this->assertEquals(4, $items->getId()); - - // $items2 = $this->repository->get(4); - - // $this->assertEquals(4, $items2->getId()); - // $this->assertEquals('Bla99991919', $items2->getName()); - // $this->assertEquals('2015-08-09', $items2->getCreatedate()); - // } - - // public function testInsert_beforeInsert() - // { - // $items = new items(); - // $items->setName('Bla'); - - // $this->repository->setBeforeInsert(function ($instance) { - // $instance['name'] .= "-add"; - // $instance['createdate'] .= "2017-12-21"; - // return $instance; - // }); - - // $this->assertEquals(null, $items->getId()); - // $this->repository->save($items); - // $this->assertEquals(4, $items->getId()); - - // $items2 = $this->repository->get(4); - - // $this->assertEquals(4, $items2->getId()); - // $this->assertEquals('Bla-add', $items2->getName()); - // $this->assertEquals('2017-12-21', $items2->getCreatedate()); - // } - - // public function testInsertLiteral() - // { - // $items = new items(); - // $items->setName(new Literal("X'6565'")); - // $items->setCreatedate('2015-08-09'); - - // $this->assertEquals(null, $items->getId()); - // $this->repository->save($items); - // $this->assertEquals(4, $items->getId()); - - // $items2 = $this->repository->get(4); - - // $this->assertEquals(4, $items2->getId()); - // $this->assertEquals('ee', $items2->getName()); - // $this->assertEquals('2015-08-09', $items2->getCreatedate()); - // } - - // public function testInsertKeyGen() - // { - // $this->infoMapper = new Mapper( - // items::class, - // 'items', - // 'id', - // function () { - // return 50; - // } - // ); - // $this->repository = new Repository($this->dbDriver, $this->infoMapper); - - // $items = new items(); - // $items->setName('Bla99991919'); - // $items->setCreatedate('2015-08-09'); - // $this->assertEquals(null, $items->getId()); - // $this->repository->save($items); - // $this->assertEquals(50, $items->getId()); - - // $items2 = $this->repository->get(50); - - // $this->assertEquals(50, $items2->getId()); - // $this->assertEquals('Bla99991919', $items2->getName()); - // $this->assertEquals('2015-08-09', $items2->getCreatedate()); - // } - - // public function testInsertUpdateFunction() - // { - // $this->itemsMapper = new Mapper(itemsMap::class, 'items', 'id'); - // $this->repository = new Repository($this->dbDriver, $this->itemsMapper); - - // $this->itemsMapper->addFieldMap( - // 'name', - // 'name', - // function ($value, $instance) { - // return 'Sr. ' . $value . ' - ' . $instance->getCreatedate(); - // } - // ); - - // $this->itemsMapper->addFieldMap( - // 'year', - // 'createdate', - // Mapper::doNotUpdateClosure(), - // function ($value, $instance) { - // $date = new \DateTime($value); - // return $date->format('Y'); - // } - // ); - - // $items = new itemsMap(); - // $items->setName('John Doe'); - // $items->setCreatedate('2015-08-09'); - // $items->setYear('NOT USED!'); - - // $this->assertEquals(null, $items->getId()); - // $this->repository->save($items); - // $this->assertEquals(4, $items->getId()); - - // $items2 = $this->repository->get(4); - - // $this->assertEquals(4, $items2->getId()); - // $this->assertEquals('2015-08-09', $items2->getCreatedate()); - // $this->assertEquals('2015', $items2->getYear()); - // $this->assertEquals('Sr. John Doe - 2015-08-09', $items2->getName()); - // } - - // public function testUpdate() - // { - // $items = $this->repository->get(1); - - // $items->setName('New Name'); - // $items->setCreatedate('2016-01-09'); - // $this->repository->save($items); - - // $items2 = $this->repository->get(1); - // $this->assertEquals(1, $items2->getId()); - // $this->assertEquals('New Name', $items2->getName()); - // $this->assertEquals('2016-01-09', $items2->getCreatedate()); - - // $items2 = $this->repository->get(2); - // $this->assertEquals(2, $items2->getId()); - // $this->assertEquals('Jane Doe', $items2->getName()); - // $this->assertEquals('2017-01-04', $items2->getCreatedate()); - // } - - // public function testUpdate_beforeUpdate() - // { - // $items = $this->repository->get(1); - - // $items->setName('New Name'); - - // $this->repository->setBeforeUpdate(function ($instance) { - // $instance['name'] .= "-upd"; - // $instance['createdate'] = "2017-12-21"; - // return $instance; - // }); - - // $this->repository->save($items); - - // $items2 = $this->repository->get(1); - // $this->assertEquals(1, $items2->getId()); - // $this->assertEquals('New Name-upd', $items2->getName()); - // $this->assertEquals('2017-12-21', $items2->getCreatedate()); - - // $items2 = $this->repository->get(2); - // $this->assertEquals(2, $items2->getId()); - // $this->assertEquals('Jane Doe', $items2->getName()); - // $this->assertEquals('2017-01-04', $items2->getCreatedate()); - // } - - // public function testUpdateLiteral() - // { - // $items = $this->repository->get(1); - // $items->setName(new Literal("X'6565'")); - // $this->repository->save($items); - - // $items2 = $this->repository->get(1); - - // $this->assertEquals(1, $items2->getId()); - // $this->assertEquals('ee', $items2->getName()); - // $this->assertEquals('2017-01-02', $items2->getCreatedate()); - // } - - // public function testUpdateFunction() - // { - // $this->itemsMapper = new Mapper(itemsMap::class, 'items', 'id'); - // $this->repository = new Repository($this->dbDriver, $this->itemsMapper); - - // $this->itemsMapper->addFieldMap( - // 'name', - // 'name', - // function ($value, $instance) { - // return 'Sr. ' . $value; - // } - // ); - - // $this->itemsMapper->addFieldMap( - // 'year', - // 'createdate', - // Mapper::doNotUpdateClosure(), - // function ($value, $instance) { - // $date = new \DateTime($value); - // return $date->format('Y'); - // } - // ); - - // $items = $this->repository->get(1); - - // $items->setName('New Name'); - // $items->setCreatedate('2016-01-09'); - // $this->repository->save($items); - - // $items2 = $this->repository->get(1); - // $this->assertEquals(1, $items2->getId()); - // $this->assertEquals('Sr. New Name', $items2->getName()); - // $this->assertEquals('2016-01-09', $items2->getCreatedate()); - // $this->assertEquals('2016', $items2->getYear()); - - // $items2 = $this->repository->get(2); - // $this->assertEquals(2, $items2->getId()); - // $this->assertEquals('Jane Doe', $items2->getName()); - // $this->assertEquals('2017-01-04', $items2->getCreatedate()); - // $this->assertEquals('2017', $items2->getYear()); - // } - - - // public function testDelete() - // { - // $this->repository->delete(1); - // $this->assertEmpty($this->repository->get(1)); - - // $items = $this->repository->get(2); - // $this->assertEquals(2, $items->getId()); - // $this->assertEquals('Jane Doe', $items->getName()); - // $this->assertEquals('2017-01-04', $items->getCreatedate()); - // } - - // public function testDeleteLiteral() - // { - // $this->repository->delete(new Literal(1)); - // $this->assertEmpty($this->repository->get(1)); - - // $items = $this->repository->get(2); - // $this->assertEquals(2, $items->getId()); - // $this->assertEquals('Jane Doe', $items->getName()); - // $this->assertEquals('2017-01-04', $items->getCreatedate()); - // } - - // public function testDelete2() - // { - // $query = Updatable::getInstance() - // ->table($this->itemsMapper->getTable()) - // ->where('name like :name', ['name'=>'Jane%']); - - // $this->repository->deleteByQuery($query); - - // $items = $this->repository->get(1); - // $this->assertEquals(1, $items->getId()); - // $this->assertEquals('John Doe', $items->getName()); - // $this->assertEquals('2017-01-02', $items->getCreatedate()); - - // $items = $this->repository->get(2); - // $this->assertEmpty($items); - // } - - // public function testGetByQueryNone() - // { - // $query = new Query(); - // $query->table($this->infoMapper->getTable()) - // ->where('iduser = :id', ['id'=>1000]) - // ->orderBy(['property']); - - // $infoRepository = new Repository($this->dbDriver, $this->infoMapper); - // $result = $infoRepository->getByQuery($query); - - // $this->assertEquals(count($result), 0); - // } - - // public function testGetByQueryOne() - // { - // $query = new Query(); - // $query->table($this->infoMapper->getTable()) - // ->where('iduser = :id', ['id'=>3]) - // ->orderBy(['property']); - - // $infoRepository = new Repository($this->dbDriver, $this->infoMapper); - // $result = $infoRepository->getByQuery($query); - - // $this->assertEquals(count($result), 1); - - // $this->assertEquals(3, $result[0]->getId()); - // $this->assertEquals(3, $result[0]->getIduser()); - // $this->assertEquals('bbb', $result[0]->getValue()); - // } - - // public function testFilterInNone() - // { - // $result = $this->repository->filterIn([1000, 1002]); - - // $this->assertEquals(count($result), 0); - // } - - // public function testFilterInOne() - // { - // $result = $this->repository->filterIn(2); - - // $this->assertEquals(count($result), 1); - - // $this->assertEquals(2, $result[0]->getId()); - // $this->assertEquals('Jane Doe', $result[0]->getName()); - // $this->assertEquals('2017-01-04', $result[0]->getCreatedate()); - // } - - // public function testFilterInTwo() - // { - // $result = $this->repository->filterIn([2, 3, 1000, 1001]); - - // $this->assertEquals(count($result), 2); - - // $this->assertEquals(2, $result[0]->getId()); - // $this->assertEquals('Jane Doe', $result[0]->getName()); - // $this->assertEquals('2017-01-04', $result[0]->getCreatedate()); - - // $this->assertEquals(3, $result[1]->getId()); - // $this->assertEquals('JG', $result[1]->getName()); - // $this->assertEquals('1974-01-26', $result[1]->getCreatedate()); - // } - - // /** - // * @throws \Exception - // */ - // public function testGetScalar() - // { - // $query = new Query(); - // $query->table($this->infoMapper->getTable()) - // ->fields(['property']) - // ->where('iduser = :id', ['id'=>3]); - - // $infoRepository = new Repository($this->dbDriver, $this->infoMapper); - // $result = $infoRepository->getScalar($query); - - // $this->assertEquals('bbb', $result); - // } - - // public function testGetByQueryMoreThanOne() - // { - // $query = new Query(); - // $query->table($this->infoMapper->getTable()) - // ->where('iduser = :id', ['id'=>1]) - // ->orderBy(['property']); - - // $infoRepository = new Repository($this->dbDriver, $this->infoMapper); - // $result = $infoRepository->getByQuery($query); - - // $this->assertEquals(2, $result[0]->getId()); - // $this->assertEquals(1, $result[0]->getIduser()); - // $this->assertEquals('ggg', $result[0]->getValue()); - - // $this->assertEquals(1, $result[1]->getId()); - // $this->assertEquals(1, $result[1]->getIduser()); - // $this->assertEquals('xxx', $result[1]->getValue()); - // } - - // public function testJoin() - // { - // $query = new Query(); - // $query->table($this->itemsMapper->getTable()) - // ->fields([ - // 'items.id', - // 'items.name', - // 'items.createdate', - // 'info.property' - // ]) - // ->join($this->infoMapper->getTable(), 'items.id = info.iduser') - // ->where('items.id = :id', ['id'=>1]) - // ->orderBy(['items.id']); - - // $result = $this->repository->getByQuery($query, [$this->infoMapper]); - - // $this->assertEquals(1, $result[0][0]->getId()); - // $this->assertEquals('John Doe', $result[0][0]->getName()); - // $this->assertEquals('2017-01-02', $result[0][0]->getCreatedate()); - - // $this->assertEquals(1, $result[1][0]->getId()); - // $this->assertEquals('John Doe', $result[1][0]->getName()); - // $this->assertEquals('2017-01-02', $result[1][0]->getCreatedate()); - - // // - ------------------ - - // $this->assertEmpty($result[0][1]->getIduser()); - // $this->assertEquals('xxx', $result[0][1]->getValue()); - - // $this->assertEmpty($result[1][1]->getIduser()); - // $this->assertEquals('ggg', $result[1][1]->getValue()); - - // } - - // public function testLeftJoin() - // { - // $query = new Query(); - // $query->table($this->itemsMapper->getTable()) - // ->fields([ - // 'items.id', - // 'items.name', - // 'items.createdate', - // 'info.property' - // ]) - // ->leftJoin($this->infoMapper->getTable(), 'items.id = info.iduser') - // ->where('items.id = :id', ['id'=>2]) - // ->orderBy(['items.id']); - - // $result = $this->repository->getByQuery($query, [$this->infoMapper]); - - // $this->assertEquals(2, $result[0][0]->getId()); - // $this->assertEquals('Jane Doe', $result[0][0]->getName()); - // $this->assertEquals('2017-01-04', $result[0][0]->getCreatedate()); - - // // - ------------------ - - // $this->assertEmpty($result[0][1]->getIduser()); - // $this->assertEmpty($result[0][1]->getValue()); - // } - - // public function testTop() - // { - // $query = Query::getInstance() - // ->table($this->itemsMapper->getTable()) - // ->top(1); - - // $result = $this->repository->getByQuery($query); - - // $this->assertEquals(1, $result[0]->getId()); - // $this->assertEquals('John Doe', $result[0]->getName()); - // $this->assertEquals('2017-01-02', $result[0]->getCreatedate()); - - // $this->assertEquals(1, count($result)); - // } - - // public function testLimit() - // { - // $query = Query::getInstance() - // ->table($this->itemsMapper->getTable()) - // ->limit(1, 1); - - // $result = $this->repository->getByQuery($query); - - // $this->assertEquals(2, $result[0]->getId()); - // $this->assertEquals('Jane Doe', $result[0]->getName()); - // $this->assertEquals('2017-01-04', $result[0]->getCreatedate()); - - // $this->assertEquals(1, count($result)); - // } - - // public function testQueryRaw() - // { - // $query = Query::getInstance() - // ->fields([ - // "name", - // "julianday('2020-06-28') - julianday(createdate) as days" - // ]) - // ->table($this->itemsMapper->getTable()) - // ->limit(1, 1); - - // $result = $this->repository->getByQuery($query); - - // $this->assertEquals(null, $result[0]->getId()); - // $this->assertEquals('Jane Doe', $result[0]->getName()); - // $this->assertEquals(null, $result[0]->getCreatedate()); - - // $this->assertEquals(1, count($result)); - - // $result = $this->repository->getByQueryRaw($query); - // $this->assertEquals([ - // [ - // "name" => "Jane Doe", - // "days" => 1271.0 - // ] - // ], $result); - // } - } diff --git a/tests/RepositoryTest.php b/tests/RepositoryTest.php index 3fb912c..07cb0c1 100644 --- a/tests/RepositoryTest.php +++ b/tests/RepositoryTest.php @@ -4,38 +4,44 @@ use ByJG\AnyDataset\Core\Enum\Relation; use ByJG\AnyDataset\Core\IteratorFilter; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\Cache\Psr16\ArrayCacheEngine; use ByJG\MicroOrm\CacheQueryResult; +use ByJG\MicroOrm\Constraint\CustomConstraint; +use ByJG\MicroOrm\Constraint\RequireChangedValuesConstraint; use ByJG\MicroOrm\DeleteQuery; -use ByJG\MicroOrm\Exception\AllowOnlyNewValuesConstraintException; +use ByJG\MicroOrm\Enum\ObserverEvent; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Exception\OrmInvalidFieldsException; use ByJG\MicroOrm\Exception\RepositoryReadOnlyException; +use ByJG\MicroOrm\Exception\RequireChangedValuesConstraintException; +use ByJG\MicroOrm\Exception\UpdateConstraintException; use ByJG\MicroOrm\FieldMapping; use ByJG\MicroOrm\InsertBulkQuery; use ByJG\MicroOrm\InsertQuery; +use ByJG\MicroOrm\Interface\EntityProcessorInterface; +use ByJG\MicroOrm\Interface\MapperFunctionInterface; use ByJG\MicroOrm\Interface\ObserverProcessorInterface; use ByJG\MicroOrm\Literal\Literal; use ByJG\MicroOrm\Mapper; -use ByJG\MicroOrm\MapperClosure; use ByJG\MicroOrm\ObserverData; use ByJG\MicroOrm\ORM; use ByJG\MicroOrm\ORMSubject; use ByJG\MicroOrm\Query; use ByJG\MicroOrm\Repository; -use ByJG\MicroOrm\SqlObject; -use ByJG\MicroOrm\SqlObjectEnum; use ByJG\MicroOrm\Union; -use ByJG\MicroOrm\UpdateConstraint; use ByJG\MicroOrm\UpdateQuery; use DateTime; use Exception; +use Override; use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; use Tests\Model\ActiveRecordModel; use Tests\Model\Info; use Tests\Model\ModelWithAttributes; +use Tests\Model\TestInsertProcessor; +use Tests\Model\TestUpdateProcessor; use Tests\Model\Users; use Tests\Model\UsersMap; use Tests\Model\UsersWithAttribute; @@ -46,41 +52,42 @@ class RepositoryTest extends TestCase /** * @var Mapper */ - protected $userMapper; + protected Mapper $userMapper; /** * @var Mapper */ - protected $infoMapper; - - /** - * @var DbDriverInterface - */ - protected $dbDriver; + protected Mapper $infoMapper; /** * @var Repository */ - protected $repository; + protected Repository $repository; + #[Override] public function setUp(): void { - $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); + $dbDriver = ConnectionUtil::getConnection("testmicroorm"); + $executor = DatabaseExecutor::using($dbDriver); + $this->userMapper = new Mapper(Users::class, 'users', 'Id'); + $this->infoMapper = new Mapper(Info::class, 'info', 'id'); + $this->infoMapper->addFieldMapping(FieldMapping::create('value')->withFieldName('property')); - $this->dbDriver->execute('create table users ( + $this->repository = new Repository($executor, $this->userMapper); + ORMSubject::getInstance()->clearObservers(); + + $executor->execute('create table users ( id integer primary key auto_increment, name varchar(45), createdate datetime);' ); $insertBulk = InsertBulkQuery::getInstance('users', ['name', 'createdate']); - $insertBulk->values(['name' => 'John Doe', 'createdate' => '2017-01-02']); + $insertBulk->values(['name' => 'John Doe', 'createdate' => '2015-05-02']); $insertBulk->values(['name' => 'Jane Doe', 'createdate' => '2017-01-04']); $insertBulk->values(['name' => 'JG', 'createdate' => '1974-01-26']); - $insertBulk->buildAndExecute($this->dbDriver); - $this->userMapper = new Mapper(Users::class, 'users', 'Id'); + $insertBulk->buildAndExecute($executor); - - $this->dbDriver->execute('create table info ( + $executor->execute('create table info ( id integer primary key auto_increment, iduser INTEGER, property decimal(10, 2), @@ -92,19 +99,16 @@ public function setUp(): void $insertMultiple->values(['iduser' => 1, 'property' => 30.4]); $insertMultiple->values(['iduser' => 1, 'property' => 1250.96]); $insertMultiple->values(['iduser' => 3, 'property' => 3.5]); - $insertMultiple->buildAndExecute($this->dbDriver); - $this->infoMapper = new Mapper(Info::class, 'info', 'id'); - $this->infoMapper->addFieldMapping(FieldMapping::create('value')->withFieldName('property')); + $insertMultiple->buildAndExecute($executor); - $this->repository = new Repository($this->dbDriver, $this->userMapper); - ORMSubject::getInstance()->clearObservers(); } + #[Override] public function tearDown(): void { - $this->dbDriver->execute('drop table if exists users;'); - $this->dbDriver->execute('drop table if exists info;'); - ORM::clearRelationships(); + $this->repository->getExecutor()->execute('drop table if exists users;'); + $this->repository->getExecutor()->execute('drop table if exists info;'); + ORM::resetMemory(); } public function testGet() @@ -112,7 +116,7 @@ public function testGet() $users = $this->repository->get(1); $this->assertEquals(1, $users->getId()); $this->assertEquals('John Doe', $users->getName()); - $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $users->getCreatedate()); $users = $this->repository->get("2"); $this->assertEquals(2, $users->getId()); @@ -122,7 +126,7 @@ public function testGet() $users = $this->repository->get(new Literal(1)); $this->assertEquals(1, $users->getId()); $this->assertEquals('John Doe', $users->getName()); - $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $users->getCreatedate()); } public function testGetByFilter() @@ -131,7 +135,7 @@ public function testGetByFilter() $this->assertCount(1, $users); $this->assertEquals(1, $users[0]->getId()); $this->assertEquals('John Doe', $users[0]->getName()); - $this->assertEquals('2017-01-02 00:00:00', $users[0]->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $users[0]->getCreatedate()); $filter = new IteratorFilter(); $filter->and('id', Relation::EQUAL, 2); @@ -145,27 +149,41 @@ public function testGetByFilter() public function testGetSelectFunction() { $this->userMapper = new Mapper(UsersMap::class, 'users', 'id'); - $this->repository = new Repository($this->dbDriver, $this->userMapper); + $this->repository = new Repository($this->repository->getExecutor(), $this->userMapper); $this->userMapper->addFieldMapping(FieldMapping::create('name') - ->withSelectFunction(function ($value, $instance) { - return '[' . strtoupper($value) . '] - ' . $instance->getCreatedate(); - } - ) + ->withSelectFunction(new class implements MapperFunctionInterface { + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($value)) { + return null; + } + return '[' . strtoupper($value) . '] - ' . $instance["createdate"]; + } + }) ); $this->userMapper->addFieldMapping(FieldMapping::create('year') - ->withSelectFunction(function ($value, $instance) { - $date = new DateTime($instance->getCreatedate()); - return intval($date->format('Y')); + ->withSelectFunction(new class implements MapperFunctionInterface { + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($instance["createdate"])) { + return null; + } + $date = new DateTime($instance["createdate"]); + return intval($date->format('Y')); + } }) + ->dontSyncWithDb() ); $users = $this->repository->get(1); $this->assertEquals(1, $users->getId()); - $this->assertEquals('[JOHN DOE] - 2017-01-02 00:00:00', $users->getName()); - $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); - $this->assertEquals(2017, $users->getYear()); + $this->assertEquals('[JOHN DOE] - 2015-05-02 00:00:00', $users->getName()); + $this->assertEquals('2015-05-02 00:00:00', $users->getCreatedate()); + $this->assertEquals(2015, $users->getYear()); $users = $this->repository->get(2); $this->assertEquals(2, $users->getId()); @@ -180,7 +198,7 @@ public function testBuildAndGetIterator() ->table('users') ->where('id = :id', ['id' => 1]); - $iterator = $query->buildAndGetIterator($this->repository->getDbDriver(), null)->toArray(); + $iterator = $query->buildAndGetIterator($this->repository->getExecutor(), null)->toArray(); $this->assertEquals(1, count($iterator)); } @@ -193,27 +211,27 @@ public function testBuildAndGetIteratorWithCache() $cacheEngine = new ArrayCacheEngine(); // Sanity test - $result = $query->buildAndGetIterator($this->repository->getDbDriver())->toArray(); + $result = $query->buildAndGetIterator($this->repository->getExecutor())->toArray(); $this->assertCount(1, $result); $cacheObject = new CacheQueryResult($cacheEngine, "mykey", 120); // Get the result and save to cache - $result = $query->buildAndGetIterator($this->repository->getDbDriver(), cache: $cacheObject)->toArray(); + $result = $query->buildAndGetIterator($this->repository->getExecutor(), cache: $cacheObject)->toArray(); $this->assertCount(1, $result); // Delete the record $deleteQuery = DeleteQuery::getInstance() ->table('users') ->where('id = :id', ['id' => 1]); - $deleteQuery->buildAndExecute($this->repository->getDbDriver()); + $deleteQuery->buildAndExecute($this->repository->getExecutor()); // Check if query with no cache the record is not found - $result = $query->buildAndGetIterator($this->repository->getDbDriver())->toArray(); + $result = $query->buildAndGetIterator($this->repository->getExecutor())->toArray(); $this->assertCount(0, $result); // Check if query with cache the record is found - $result = $query->buildAndGetIterator($this->repository->getDbDriver(), cache: $cacheObject)->toArray(); + $result = $query->buildAndGetIterator($this->repository->getExecutor(), cache: $cacheObject)->toArray(); $this->assertCount(1, $result); } @@ -250,15 +268,19 @@ public function testInsertReadOnly() public function testInsert_beforeInsert() { + $this->repository->setBeforeInsert(new class implements EntityProcessorInterface { + #[Override] + public function process(array $instance): array + { + $instance['name'] .= "-add"; + $instance['createdate'] .= "2017-12-21"; + return $instance; + } + }); + $users = new Users(); $users->setName('Bla'); - $this->repository->setBeforeInsert(function ($instance) { - $instance['name'] .= "-add"; - $instance['createdate'] .= "2017-12-21"; - return $instance; - }); - $this->assertEquals(null, $users->getId()); $this->repository->save($users); $this->assertEquals(4, $users->getId()); @@ -301,14 +323,14 @@ public function testInsertFromObject() ] ); - $sqlObject = $insertQuery->build(); + $sqlStatement = $insertQuery->build(); $this->assertEquals( - new SqlObject("INSERT INTO users( name, createdate ) values ( X'6565', :createdate ) ", ["createdate" => "2015-08-09"], SqlObjectEnum::INSERT), - $sqlObject + new SqlStatement("INSERT INTO users( name, createdate ) values ( X'6565', :createdate ) ", ["createdate" => "2015-08-09"]), + $sqlStatement ); - $this->repository->getDbDriverWrite()->execute($sqlObject->getSql(), $sqlObject->getParameters()); + $this->repository->getExecutor()->execute($sqlStatement); $users2 = $this->repository->get(4); @@ -327,7 +349,7 @@ public function testInsertKeyGen() // $this->infoMapper->withPrimaryKeySeedFunction(function ($instance) { // return 50; // }); - $this->repository = new Repository($this->dbDriver, UsersWithAttribute::class); + $this->repository = new Repository($this->repository->getExecutor(), UsersWithAttribute::class); $users = new Users(); $users->setName('Bla99991919'); @@ -346,20 +368,34 @@ public function testInsertKeyGen() public function testInsertUpdateFunction() { $this->userMapper = new Mapper(UsersMap::class, 'users', 'id'); - $this->repository = new Repository($this->dbDriver, $this->userMapper); + $this->repository = new Repository($this->repository->getExecutor(), $this->userMapper); $this->userMapper->addFieldMapping(FieldMapping::create('name') - ->withUpdateFunction(function ($value, $instance) { - return 'Sr. ' . $value . ' - ' . $instance->getCreatedate(); + ->withUpdateFunction(new class implements MapperFunctionInterface { + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($value)) { + return null; + } + return '[' . strtoupper($value) . '] - ' . $instance->getCreatedate(); + } }) ); $this->userMapper->addFieldMapping(FieldMapping::create('year') - ->withUpdateFunction(MapperClosure::readOnly()) - ->withSelectFunction(function ($value, $instance) { - $date = new DateTime($instance->getCreateDate()); - return intval($date->format('Y')); + ->withSelectFunction(new class implements MapperFunctionInterface { + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($instance["createdate"])) { + return null; + } + $date = new DateTime($instance["createdate"]); + return intval($date->format('Y')); + } }) + ->dontSyncWithDb() ); $users = new UsersMap(); @@ -371,12 +407,12 @@ public function testInsertUpdateFunction() $this->repository->save($users); $this->assertEquals(4, $users->getId()); - $users2 = $this->repository->get(4); + $users2 = $this->repository->get($users->getId()); $this->assertEquals(4, $users2->getId()); $this->assertEquals('2015-08-09 00:00:00', $users2->getCreatedate()); $this->assertEquals(2015, $users2->getYear()); - $this->assertEquals('Sr. John Doe - 2015-08-09', $users2->getName()); + $this->assertEquals('[JOHN DOE] - 2015-08-09', $users2->getName()); } public function testUpdate() @@ -420,7 +456,7 @@ public function testUpdateBuildAndExecute() ->table('users') ->set('name', 'New Name') ->where('id = :id', ['id' => 1]); - $updateQuery->buildAndExecute($this->repository->getDbDriver()); + $updateQuery->buildAndExecute($this->repository->getExecutor()); $users = $this->repository->get(1); $this->assertEquals('New Name', $users->getName()); @@ -437,7 +473,7 @@ public function testInsertBuildAndExecute() 'name', 'createdate' ]); - $insertQuery->buildAndExecute($this->repository->getDbDriver(), ['name' => 'inserted name', 'createdate' => '2024-09-03']); + $insertQuery->buildAndExecute($this->repository->getExecutor(), ['name' => 'inserted name', 'createdate' => '2024-09-03']); $users = $this->repository->get(4); $this->assertEquals('inserted name', $users->getName()); @@ -454,7 +490,7 @@ public function testInsertBuildAndExecute2() ->set('name', 'inserted name') ->set('createdate', '2024-09-03') ; - $insertQuery->buildAndExecute($this->repository->getDbDriver()); + $insertQuery->buildAndExecute($this->repository->getExecutor()); $users = $this->repository->get(4); $this->assertEquals('inserted name', $users->getName()); @@ -469,7 +505,7 @@ public function testDeleteBuildAndExecute() $updateQuery = DeleteQuery::getInstance() ->table('users') ->where('id = :id', ['id' => 1]); - $updateQuery->buildAndExecute($this->repository->getDbDriver()); + $updateQuery->buildAndExecute($this->repository->getExecutor()); $users = $this->repository->get(1); $this->assertEmpty($users); @@ -481,10 +517,14 @@ public function testUpdate_beforeUpdate() $users->setName('New Name'); - $this->repository->setBeforeUpdate(function ($instance) { - $instance['name'] .= "-upd"; - $instance['createdate'] = "2017-12-21"; - return $instance; + $this->repository->setBeforeUpdate(new class implements EntityProcessorInterface { + #[Override] + public function process(array $instance): array + { + $instance['name'] .= "-upd"; + $instance['createdate'] = "2017-12-21"; + return $instance; + } }); $this->repository->save($users); @@ -510,7 +550,7 @@ public function testUpdateLiteral() $this->assertEquals(1, $users2->getId()); $this->assertEquals('ee', $users2->getName()); - $this->assertEquals('2017-01-02 00:00:00', $users2->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $users2->getCreatedate()); } public function testUpdateObject() @@ -524,13 +564,13 @@ public function testUpdateObject() $this->userMapper, ); - $sqlObject = $updateQuery->build(); + $sqlStatement = $updateQuery->build(); $this->assertEquals( - new SqlObject("UPDATE users SET name = X'6565' , createdate = :createdate WHERE id = :pkid", ["createdate" => "2020-01-02", "pkid" => 1], SqlObjectEnum::UPDATE), - $sqlObject + new SqlStatement("UPDATE users SET name = X'6565' , createdate = :createdate WHERE id = :pkid", ["createdate" => "2020-01-02", "pkid" => 1]), + $sqlStatement ); - $this->repository->getDbDriverWrite()->execute($sqlObject->getSql(), $sqlObject->getParameters()); + $this->repository->getExecutor()->execute($sqlStatement); $users2 = $this->repository->get(1); @@ -543,31 +583,45 @@ public function testUpdateObject() public function testUpdateFunction() { $this->userMapper = new Mapper(UsersMap::class, 'users', 'id'); - $this->repository = new Repository($this->dbDriver, $this->userMapper); + $this->repository = new Repository($this->repository->getExecutor(), $this->userMapper); $this->userMapper->addFieldMapping(FieldMapping::create('name') - ->withUpdateFunction(function ($value, $instance) { - return 'Sr. ' . $value; + ->withUpdateFunction(new class implements MapperFunctionInterface { + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($value)) { + return null; + } + return '[' . strtoupper($value) . ']'; + } }) ); $this->userMapper->addFieldMapping(FieldMapping::create('year') - ->withUpdateFunction(MapperClosure::readOnly()) - ->withSelectFunction(function ($value, $instance) { - $date = new DateTime($instance->getCreateDate()); - return intval($date->format('Y')); + ->withSelectFunction(new class implements MapperFunctionInterface { + #[Override] + public function processedValue(mixed $value, mixed $instance, ?DatabaseExecutor $executor = null): mixed + { + if (empty($instance["createdate"])) { + return null; + } + $date = new DateTime($instance["createdate"]); + return intval($date->format('Y')); + } }) + ->dontSyncWithDb() ); $users = $this->repository->get(1); - $users->setName('New Name'); + $users->setName('John Doe Updated'); $users->setCreatedate('2016-01-09'); $this->repository->save($users); $users2 = $this->repository->get(1); $this->assertEquals(1, $users2->getId()); - $this->assertEquals('Sr. New Name', $users2->getName()); + $this->assertEquals('[JOHN DOE UPDATED]', $users2->getName()); $this->assertEquals('2016-01-09 00:00:00', $users2->getCreatedate()); $this->assertEquals(2016, $users2->getYear()); @@ -619,7 +673,7 @@ public function testDelete2() $users = $this->repository->get(1); $this->assertEquals(1, $users->getId()); $this->assertEquals('John Doe', $users->getName()); - $this->assertEquals('2017-01-02 00:00:00', $users->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $users->getCreatedate()); $users = $this->repository->get(2); $this->assertEmpty($users); @@ -631,7 +685,7 @@ public function testGetByQueryNone() ->where('iduser = :id', ['id'=>1000]) ->orderBy(['property']); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($query); $this->assertEquals(count($result), 0); @@ -643,7 +697,7 @@ public function testGetByQueryOne() ->where('iduser = :id', ['id'=>3]) ->orderBy(['property']); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($query); $this->assertEquals(count($result), 1); @@ -709,7 +763,7 @@ public function testGetScalar() ->fields(['property']) ->where('iduser = :id', ['id'=>3]); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getScalar($query); $this->assertEquals(3.5, $result); @@ -721,7 +775,7 @@ public function testGetByQueryMoreThanOne() ->where('iduser = :id', ['id'=>1]) ->orderBy(['property']); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($query); $this->assertEquals(1, $result[0]->getId()); @@ -750,11 +804,11 @@ public function testJoin() $this->assertEquals(1, $result[0][0]->getId()); $this->assertEquals('John Doe', $result[0][0]->getName()); - $this->assertEquals('2017-01-02 00:00:00', $result[0][0]->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $result[0][0]->getCreatedate()); $this->assertEquals(1, $result[1][0]->getId()); $this->assertEquals('John Doe', $result[1][0]->getName()); - $this->assertEquals('2017-01-02 00:00:00', $result[1][0]->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $result[1][0]->getCreatedate()); // - ------------------ @@ -800,7 +854,7 @@ public function testTop() $this->assertEquals(1, $result[0]->getId()); $this->assertEquals('John Doe', $result[0]->getName()); - $this->assertEquals('2017-01-02 00:00:00', $result[0]->getCreatedate()); + $this->assertEquals('2015-05-02 00:00:00', $result[0]->getCreatedate()); $this->assertEquals(1, count($result)); } @@ -866,11 +920,12 @@ public function __construct($table, $parentRepository, $parent) $this->parentRepository = $parentRepository; } + #[Override] public function process(ObserverData $observerData): void { $this->parent->test = true; $this->parent->assertEquals('info', $observerData->getTable()); - $this->parent->assertEquals(ORMSubject::EVENT_UPDATE, $observerData->getEvent()); + $this->parent->assertEquals(ObserverEvent::Update, $observerData->getEvent()); $this->parent->assertInstanceOf(Info::class, $observerData->getData()); $this->parent->assertEquals(0, $observerData->getData()->getValue()); $this->parent->assertInstanceOf(Info::class, $observerData->getOldData()); @@ -878,6 +933,7 @@ public function process(ObserverData $observerData): void $this->parent->assertEquals($this->parentRepository, $observerData->getRepository()); } + #[Override] public function onError(Throwable $exception, ObserverData $observerData) : void { $this->parent->onError = true; @@ -886,6 +942,7 @@ public function onError(Throwable $exception, ObserverData $observerData) : void $this->parent->assertInstanceOf(Info::class, $observerData->getData()); } + #[Override] public function getObservedTable(): string { return $this->table; @@ -904,12 +961,12 @@ public function getObservedTable(): string // This update has an observer, and you change the `test` variable - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $query = $infoRepository->queryInstance() ->where('iduser = :id', ['id'=>3]) ->orderBy(['property']); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($query); // Set Zero @@ -937,11 +994,12 @@ public function __construct($table, $parentRepository, $parent) $this->parentRepository = $parentRepository; } + #[Override] public function process(ObserverData $observerData): void { $this->parent->test = true; $this->parent->assertEquals('info', $observerData->getTable()); - $this->parent->assertEquals(ORMSubject::EVENT_UPDATE, $observerData->getEvent()); + $this->parent->assertEquals(ObserverEvent::Update, $observerData->getEvent()); $this->parent->assertInstanceOf(Info::class, $observerData->getData()); $this->parent->assertEquals(0, $observerData->getData()->getValue()); $this->parent->assertInstanceOf(Info::class, $observerData->getOldData()); @@ -949,6 +1007,7 @@ public function process(ObserverData $observerData): void $this->parent->assertEquals($this->parentRepository, $observerData->getRepository()); } + #[Override] public function onError(Throwable $exception, ObserverData $observerData) : void { $this->parent->onError = true; @@ -957,6 +1016,7 @@ public function onError(Throwable $exception, ObserverData $observerData) : void $this->parent->assertInstanceOf(Info::class, $observerData->getData()); } + #[Override] public function getObservedTable(): string { return $this->table; @@ -975,7 +1035,7 @@ public function getObservedTable(): string // This update has an observer, and you change the `test` variable - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $query = $infoRepository->queryInstance() ->where('iduser = :id', ['id'=>3]) ->orderBy(['property']); @@ -1006,16 +1066,18 @@ public function __construct($table, $parentRepository, $parent) $this->parentRepository = $parentRepository; } + #[Override] public function process(ObserverData $observerData): void { $this->parent->test = true; $this->parent->assertEquals('info', $observerData->getTable()); - $this->parent->assertEquals(ORMSubject::EVENT_DELETE, $observerData->getEvent()); + $this->parent->assertEquals(ObserverEvent::Delete, $observerData->getEvent()); $this->parent->assertNull($observerData->getData()); $this->parent->assertEquals(["pkid" => 3], $observerData->getOldData()); $this->parent->assertEquals($this->parentRepository, $observerData->getRepository()); } + #[Override] public function onError(Throwable $exception, ObserverData $observerData) : void { $this->parent->onError = true; @@ -1024,6 +1086,7 @@ public function onError(Throwable $exception, ObserverData $observerData) : void $this->parent->assertInstanceOf(Info::class, $observerData->getData()); } + #[Override] public function getObservedTable(): string { return $this->table; @@ -1032,7 +1095,7 @@ public function getObservedTable(): string $this->assertNull($this->test); $this->assertNull($this->onError); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->delete(3); $this->assertTrue($this->test); $this->assertNull($this->onError); @@ -1055,11 +1118,12 @@ public function __construct($table, $parentRepository, $parent) $this->parentRepository = $parentRepository; } + #[Override] public function process(ObserverData $observerData): void { $this->parent->test = true; $this->parent->assertEquals('info', $observerData->getTable()); - $this->parent->assertEquals(ORMSubject::EVENT_INSERT, $observerData->getEvent()); + $this->parent->assertEquals(ObserverEvent::Insert, $observerData->getEvent()); $this->parent->assertInstanceOf(Info::class, $observerData->getData()); $this->parent->assertEquals(4, $observerData->getData()->getId()); $this->parent->assertEquals(1, $observerData->getData()->getIdUser()); @@ -1068,14 +1132,16 @@ public function process(ObserverData $observerData): void $this->parent->assertEquals($this->parentRepository, $observerData->getRepository()); } + #[Override] public function onError(Throwable $exception, ObserverData $observerData) : void { $this->parent->onError = true; - $this->parent->assertEquals(null, $exception); + $this->parent->assertNull($exception); $this->parent->assertInstanceOf(Info::class, $observerData->getOldData()); $this->parent->assertInstanceOf(Info::class, $observerData->getData()); } + #[Override] public function getObservedTable(): string { return $this->table; @@ -1088,7 +1154,7 @@ public function getObservedTable(): string $this->assertNull($this->test); $this->assertNull($this->onError); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $infoRepository->save($info); $this->assertTrue($this->test); $this->assertNull($this->onError); @@ -1107,14 +1173,17 @@ public function __construct($table, $parentRepository, $parent) $this->table = $table; } + #[Override] public function process(ObserverData $observerData): void { } + #[Override] public function onError(Throwable $exception, ObserverData $observerData): void { } + #[Override] public function getObservedTable(): string { return $this->table; @@ -1127,29 +1196,25 @@ public function getObservedTable(): string $this->repository->addObserver($class); } - public function testConstraintDifferentValues() + public function testConstraintAllow() { // This update has an observer, and you change the `test` variable $query = $this->infoMapper->getQuery() ->where('iduser = :id', ['id'=>3]) ->orderBy(['property']); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($query); - // Define Constraint - $updateConstraint = UpdateConstraint::instance() - ->withAllowOnlyNewValuesForFields('value'); - // Set Zero $result[0]->setValue(2); - $newInstance = $infoRepository->save($result[0], $updateConstraint); + $newInstance = $infoRepository->save($result[0], new RequireChangedValuesConstraint('value')); $this->assertEquals(2, $newInstance->getValue()); } - public function testConstraintSameValues() + public function testConstraintNotAllow() { - $this->expectException(AllowOnlyNewValuesConstraintException::class); + $this->expectException(RequireChangedValuesConstraintException::class); $this->expectExceptionMessage("You are not updating the property 'value'"); // This update has an observer, and you change the `test` variable @@ -1157,24 +1222,57 @@ public function testConstraintSameValues() ->where('iduser = :id', ['id'=>3]) ->orderBy(['property']); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($query); - // Define Constraint - $updateConstraint = UpdateConstraint::instance() - ->withAllowOnlyNewValuesForFields('value'); - // Set Zero $result[0]->setValue(3.5); - $newInstance = $infoRepository->save($result[0], $updateConstraint); + $newInstance = $infoRepository->save($result[0], new RequireChangedValuesConstraint('value')); + } + + public function testConstraintCustomAllow() + { + // This update has an observer, and you change the `test` variable + $query = $this->infoMapper->getQuery() + ->where('iduser = :id', ['id' => 3]) + ->orderBy(['property']); + + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); + $result = $infoRepository->getByQuery($query); + + // Set Zero + $result[0]->setValue(2); + $newInstance = $infoRepository->save($result[0], new CustomConstraint(function ($oldInstance, $newInstance) { + return $newInstance->getValue() != 3.5; + })); + $this->assertEquals(2, $newInstance->getValue()); } + public function testConstraintCustomNotAllow() + { + $this->expectException(UpdateConstraintException::class); + $this->expectExceptionMessage("The Update Constraint validation failed"); + + // This update has an observer, and you change the `test` variable + $query = $this->infoMapper->getQuery() + ->where('iduser = :id', ['id' => 3]) + ->orderBy(['property']); + + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); + $result = $infoRepository->getByQuery($query); + + // Set Zero + $result[0]->setValue(3.5); + $newInstance = $infoRepository->save($result[0], new CustomConstraint(function ($oldInstance, $newInstance) { + return $newInstance->getValue() != 3.5; + })); + } public function testQueryBasic() { $query = $this->infoMapper->getQueryBasic() ->where('iduser = :id', ['id'=>3]); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($query); $this->assertEquals(count($result), 1); @@ -1198,7 +1296,7 @@ public function testUnion() ->addQuery($query2) ->orderBy(['iduser']); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $result = $infoRepository->getByQuery($union); $this->assertEquals(count($result), 2); @@ -1219,7 +1317,7 @@ public function testMappingAttribute() ->where('iduser = :id', ['id'=>3]) ->orderBy(['property']); - $infoRepository = new Repository($this->dbDriver, ModelWithAttributes::class); + $infoRepository = new Repository($this->repository->getExecutor(), ModelWithAttributes::class); /** @var ModelWithAttributes[] $result */ $result = $infoRepository->getByQuery($query); @@ -1237,7 +1335,7 @@ public function testMappingAttribute() public function testMappingAttributeInsert() { - $infoRepository = new Repository($this->dbDriver, ModelWithAttributes::class); + $infoRepository = new Repository($this->repository->getExecutor(), ModelWithAttributes::class); // Sanity check $query = new Query(); @@ -1287,7 +1385,7 @@ public function testMappingAttributeInsert() public function testMappingAttributeSoftDeleteAndGetByQuery() { - $infoRepository = new Repository($this->dbDriver, ModelWithAttributes::class); + $infoRepository = new Repository($this->repository->getExecutor(), ModelWithAttributes::class); $query = $infoRepository->queryInstance() ->where('iduser = :id', ['id' => 3]) @@ -1314,7 +1412,7 @@ public function testMappingAttributeSoftDeleteAndGetByQuery() public function testMappingAttributeSoftDeleteAndGetByFilter() { - $infoRepository = new Repository($this->dbDriver, ModelWithAttributes::class); + $infoRepository = new Repository($this->repository->getExecutor(), ModelWithAttributes::class); $iteratorFilter = new IteratorFilter(); $iteratorFilter->and('iduser', Relation::EQUAL, 3); @@ -1335,7 +1433,7 @@ public function testMappingAttributeSoftDeleteAndGetByFilter() public function testMappingAttributeSoftDeleteAndGetByPK() { - $infoRepository = new Repository($this->dbDriver, ModelWithAttributes::class); + $infoRepository = new Repository($this->repository->getExecutor(), ModelWithAttributes::class); $result = $infoRepository->get(3); @@ -1415,7 +1513,7 @@ public function testQueryWithCache() $query = $this->infoMapper->getQueryBasic() ->where('id = :id1', ['id1'=>3]); - $infoRepository = new Repository($this->dbDriver, $this->infoMapper); + $infoRepository = new Repository($this->repository->getExecutor(), $this->infoMapper); $cacheEngine = new ArrayCacheEngine(); // Sanity test @@ -1432,7 +1530,7 @@ public function testQueryWithCache() $deleteQuery = DeleteQuery::getInstance() ->table($this->infoMapper->getTable()) ->where('id = :id', ['id' => 3]); - $deleteQuery->buildAndExecute($this->repository->getDbDriver()); + $deleteQuery->buildAndExecute($this->repository->getExecutor()); // Check if query with no cache the record is not found $result = $infoRepository->getByQuery($query); @@ -1445,7 +1543,7 @@ public function testQueryWithCache() public function testActiveRecordGet() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); $this->assertEquals('info', ActiveRecordModel::tableName()); @@ -1462,7 +1560,7 @@ public function testActiveRecordGet() public function testActiveRecordRefresh() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); $this->assertEquals('info', ActiveRecordModel::tableName()); @@ -1483,7 +1581,7 @@ public function testActiveRecordRefresh() $this->assertNull($deletedAt); // Update the record OUTSIDE the Active Record - $this->dbDriver->execute("UPDATE info SET iduser = 4, property = 44.44 WHERE id = 3"); + $this->repository->getExecutorWrite()->execute("UPDATE info SET iduser = 4, property = 44.44 WHERE id = 3"); // Check model isn't updated (which is the expected behavior) $this->assertEquals(3, $model->getPk()); @@ -1507,7 +1605,7 @@ public function testActiveRecordRefresh() public function testActiveRecordRefreshError() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); $this->expectException(OrmInvalidFieldsException::class); $this->expectExceptionMessage("Primary key 'pk' is null"); @@ -1518,7 +1616,7 @@ public function testActiveRecordRefreshError() public function testActiveRecordFill() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); $model = ActiveRecordModel::get(3); @@ -1545,7 +1643,7 @@ public function testActiveRecordFill() public function testActiveRecordFilter() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); $model = ActiveRecordModel::filter((new IteratorFilter())->and('iduser', Relation::EQUAL, 1)); @@ -1567,7 +1665,7 @@ public function testActiveRecordFilter() public function testActiveRecordEmptyFilter() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); $model = ActiveRecordModel::filter(new IteratorFilter()); @@ -1576,7 +1674,7 @@ public function testActiveRecordEmptyFilter() public function testActiveRecordAll() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); $model = ActiveRecordModel::all(); @@ -1585,7 +1683,7 @@ public function testActiveRecordAll() public function testActiveRecordNew() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); // $model = ActiveRecordModel::get(4); @@ -1615,7 +1713,7 @@ public function testActiveRecordNew() public function testActiveRecordUpdate() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); // $model = ActiveRecordModel::get(3); @@ -1641,7 +1739,7 @@ public function testActiveRecordUpdate() public function testActiveRecordDelete() { - ActiveRecordModel::initialize($this->dbDriver); + ActiveRecordModel::initialize($this->repository->getExecutor()); // $model = ActiveRecordModel::get(3); @@ -1656,21 +1754,60 @@ public function testActiveRecordDelete() public function testInitializeActiveRecordDefaultDbDriverError() { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("You must initialize the ORM with a DbDriverInterface"); + $this->expectExceptionMessage("You must initialize the ORM with a DatabaseExecutor"); ActiveRecordModel::initialize(); } public function testInitializeActiveRecordDefaultDbDriver() { - ORM::defaultDbDriver($this->dbDriver); + ORM::defaultDbDriver($this->repository->getExecutor()); ActiveRecordModel::initialize(); $this->assertTrue(true); } public function testInitializeActiveRecordDefaultDbDriver2() { - ORM::defaultDbDriver($this->dbDriver); + ORM::defaultDbDriver($this->repository->getExecutor()); $model = ActiveRecordModel::get(1); $this->assertNotNull($model); } + + public function testInsert_beforeInsert_Interface() + { + $users = new Users(); + $users->setName('User'); + + $this->repository->setBeforeInsert(new TestInsertProcessor()); + + $this->assertEquals(null, $users->getId()); + $this->repository->save($users); + $this->assertEquals(4, $users->getId()); + + $users2 = $this->repository->get(4); + + $this->assertEquals(4, $users2->getId()); + $this->assertEquals('User-processed', $users2->getName()); + $this->assertEquals('2023-01-15 00:00:00', $users2->getCreatedate()); + } + + public function testUpdate_beforeUpdate_Interface() + { + $users = $this->repository->get(1); + + $users->setName('Updated'); + + $this->repository->setBeforeUpdate(new TestUpdateProcessor()); + + $this->repository->save($users); + + $users2 = $this->repository->get(1); + $this->assertEquals(1, $users2->getId()); + $this->assertEquals('Updated-updated', $users2->getName()); + $this->assertEquals('2023-02-20 00:00:00', $users2->getCreatedate()); + + $users2 = $this->repository->get(2); + $this->assertEquals(2, $users2->getId()); + $this->assertEquals('Jane Doe', $users2->getName()); + $this->assertEquals('2017-01-04 00:00:00', $users2->getCreatedate()); + } } diff --git a/tests/RepositoryUuidTest.php b/tests/RepositoryUuidTest.php index bf2bce2..092a4b9 100644 --- a/tests/RepositoryUuidTest.php +++ b/tests/RepositoryUuidTest.php @@ -2,38 +2,36 @@ namespace Tests; -use ByJG\AnyDataset\Db\DbDriverInterface; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\MicroOrm\Literal\HexUuidLiteral; use ByJG\MicroOrm\Repository; +use Override; use PHPUnit\Framework\TestCase; use Tests\Model\UsersWithUuidKey; class RepositoryUuidTest extends TestCase { - /** - * @var DbDriverInterface - */ - protected $dbDriver; - /** * @var Repository */ - protected $repository; + protected Repository $repository; + #[Override] public function setUp(): void { - $this->dbDriver = ConnectionUtil::getConnection("testmicroorm"); + $dbDriver = ConnectionUtil::getConnection("testmicroorm"); + $this->repository = new Repository(DatabaseExecutor::using($dbDriver), UsersWithUuidKey::class); - $this->dbDriver->execute('create table usersuuid ( + $this->repository->getExecutor()->execute('create table usersuuid ( id binary(16) primary key, name varchar(45));' ); - $this->repository = new Repository($this->dbDriver, UsersWithUuidKey::class); } + #[Override] public function tearDown(): void { - $this->dbDriver->execute('drop table if exists usersuuid;'); + $this->repository->getExecutor()->execute('drop table if exists usersuuid;'); } public function testGet() diff --git a/tests/SqlServerUuidLiteralTest.php b/tests/SqlServerUuidLiteralTest.php index d68f5cd..8da1f4a 100644 --- a/tests/SqlServerUuidLiteralTest.php +++ b/tests/SqlServerUuidLiteralTest.php @@ -3,14 +3,17 @@ namespace Tests; use ByJG\MicroOrm\Literal\SqlServerUuidLiteral; +use Override; -class SqlServerUuidLiteralTest extends \Tests\HexUuidLiteralTest +class SqlServerUuidLiteralTest extends HexUuidLiteralTest { + #[Override] public function setUp(): void { $this->class = SqlServerUuidLiteral::class; } + #[Override] public function testBinaryString() { $value = 'F47AC10B-58CC-4372-A567-0E02B2C3D479'; @@ -21,6 +24,7 @@ public function testBinaryString() $this->assertEquals($expectedBinaryString, $hexUuidLiteral->binaryString($value)); } + #[Override] public function testFormatUuidFromBinRepresent() { $value = '0xF47AC10B58CC4372A5670E02B2C3D479'; diff --git a/tests/SqliteUuidLiteralTest.php b/tests/SqliteUuidLiteralTest.php index 4c220ea..c9c90cd 100644 --- a/tests/SqliteUuidLiteralTest.php +++ b/tests/SqliteUuidLiteralTest.php @@ -3,10 +3,11 @@ namespace Tests; use ByJG\MicroOrm\Literal\SqliteUuidLiteral; -use ByJG\MicroOrm\Literal\SqlServerUuidLiteral; +use Override; -class SqliteUuidLiteralTest extends \Tests\HexUuidLiteralTest +class SqliteUuidLiteralTest extends HexUuidLiteralTest { + #[Override] public function setUp(): void { $this->class = SqliteUuidLiteral::class; diff --git a/tests/TableAttributeTest.php b/tests/TableAttributeTest.php new file mode 100644 index 0000000..e3c886b --- /dev/null +++ b/tests/TableAttributeTest.php @@ -0,0 +1,76 @@ +dbDriver = Factory::getDbInstance(self::URI); + $this->executor = DatabaseExecutor::using($this->dbDriver); + + $this->executor->execute('create table users ( + id integer primary key autoincrement, + name varchar(45), + createdate datetime);' + ); + $insertBulk = InsertBulkQuery::getInstance('users', ['name', 'createdate']); + $insertBulk->values(['name' => 'John Doe', 'createdate' => '2015-05-02']); + $insertBulk->values(['name' => 'Jane Doe', 'createdate' => '2017-01-04']); + $insertBulk->buildAndExecute($this->executor); + } + + #[Override] + public function tearDown(): void + { + $uri = new Uri(self::URI); + unlink($uri->getPath()); + } + + public function testModelWithTableAttributeAndProcessors() + { + $repository = new Repository($this->executor, ModelWithProcessors::class); + + // Test Insert with processor + $model = new ModelWithProcessors(); + $model->name = 'Test User'; + $model->createdate = '2023-05-01'; + + $repository->save($model); + + $savedModel = $repository->get($model->getId()); + + // Verify processor was applied + $this->assertEquals('Test User-processed', $savedModel->name); + $this->assertEquals('2023-01-15', $savedModel->createdate); + + // Test Update with processor + $savedModel->name = 'Updated User'; + $savedModel->createdate = '2023-06-01'; + + $repository->save($savedModel); + + $updatedModel = $repository->get($savedModel->getId()); + + // Verify processor was applied + $this->assertEquals('Updated User-updated', $updatedModel->name); + $this->assertEquals('2023-02-20', $updatedModel->createdate); + } +} \ No newline at end of file diff --git a/tests/TransactionManagerTest.php b/tests/TransactionManagerTest.php index fc5fa4e..06beb1c 100644 --- a/tests/TransactionManagerTest.php +++ b/tests/TransactionManagerTest.php @@ -2,12 +2,14 @@ namespace Tests; +use ByJG\AnyDataset\Db\DatabaseExecutor; use ByJG\AnyDataset\Db\Factory; use ByJG\MicroOrm\Exception\TransactionException; use ByJG\MicroOrm\Mapper; use ByJG\MicroOrm\Repository; use ByJG\MicroOrm\TransactionManager; use InvalidArgumentException; +use Override; use PHPUnit\Framework\TestCase; use Tests\Model\Users; @@ -18,6 +20,7 @@ class TransactionManagerTest extends TestCase */ protected $object; + #[Override] public function setUp(): void { $this->object = new TransactionManager(); @@ -28,6 +31,7 @@ public function setUp(): void ConnectionUtil::getConnection("d"); } + #[Override] public function tearDown(): void { $this->object->destroy(); @@ -98,17 +102,18 @@ public function testAddDbDriver() public function testAddRepository() { $dbDriver = ConnectionUtil::getConnection("a"); + $userMapper = new Mapper(Users::class, 'users', 'Id'); + $repository = new Repository(DatabaseExecutor::using($dbDriver), $userMapper); - $dbDriver->execute('create table users ( + $repository->getExecutor()->execute('create table users ( id integer primary key auto_increment, name varchar(45), createdate datetime);' ); - $dbDriver->execute("insert into users (name, createdate) values ('John Doe', '2017-01-02')"); - $dbDriver->execute("insert into users (name, createdate) values ('Jane Doe', '2017-01-04')"); - $dbDriver->execute("insert into users (name, createdate) values ('JG', '1974-01-26')"); - $userMapper = new Mapper(Users::class, 'users', 'Id'); - $repository = new Repository($dbDriver, $userMapper); + $repository->getExecutor()->execute("insert into users (name, createdate) values ('John Doe', '2015-05-02')"); + $repository->getExecutor()->execute("insert into users (name, createdate) values ('Jane Doe', '2017-01-04')"); + $repository->getExecutor()->execute("insert into users (name, createdate) values ('JG', '1974-01-26')"); + $this->object->addRepository($repository); $this->assertEquals(1, $this->object->count()); diff --git a/tests/UnionTest.php b/tests/UnionTest.php index bb52612..bbe2eb6 100644 --- a/tests/UnionTest.php +++ b/tests/UnionTest.php @@ -3,8 +3,6 @@ namespace Tests; use ByJG\AnyDataset\Db\Factory; -use ByJG\MicroOrm\Exception\InvalidArgumentException; -use ByJG\MicroOrm\Query; use ByJG\MicroOrm\QueryBasic; use ByJG\MicroOrm\Union; use ByJG\Util\Uri; @@ -21,7 +19,7 @@ public function testAddQuery() $build = $union->build(); $this->assertEquals("SELECT name, price FROM table1 WHERE name like :name UNION SELECT name, price FROM table2 WHERE price > :price", $build->getSql()); - $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParameters()); + $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParams()); } public function testAddQueryWithTop() @@ -34,7 +32,7 @@ public function testAddQueryWithTop() $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 0, 10", $build->getSql()); - $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParameters()); + $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParams()); } public function testAddQueryWithOrderBy() @@ -47,7 +45,7 @@ public function testAddQueryWithOrderBy() $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 ORDER BY name", $build->getSql()); - $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParameters()); + $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParams()); } public function testAddQueryWithGroupBy() @@ -60,15 +58,6 @@ public function testAddQueryWithGroupBy() $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 GROUP BY name", $build->getSql()); - $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParameters()); - } - - public function testInvalidArgument() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("The query must be an instance of " . QueryBasic::class); - - $union = new Union(); - $union->addQuery(Query::getInstance()); + $this->assertEquals(["name" => 'a%', 'price' => 10], $build->getParams()); } } diff --git a/tests/UpdateQueryTest.php b/tests/UpdateQueryTest.php index 09a76c1..5e84669 100644 --- a/tests/UpdateQueryTest.php +++ b/tests/UpdateQueryTest.php @@ -2,18 +2,17 @@ namespace Tests; -use ByJG\AnyDataset\Db\Factory; use ByJG\AnyDataset\Db\Helpers\DbMysqlFunctions; use ByJG\AnyDataset\Db\Helpers\DbPgsqlFunctions; use ByJG\AnyDataset\Db\Helpers\DbSqliteFunctions; use ByJG\AnyDataset\Db\PdoMysql; use ByJG\AnyDataset\Db\PdoObj; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\Exception\InvalidArgumentException; use ByJG\MicroOrm\Query; -use ByJG\MicroOrm\SqlObject; -use ByJG\MicroOrm\SqlObjectEnum; use ByJG\MicroOrm\UpdateQuery; use ByJG\Util\Uri; +use Override; use PHPUnit\Framework\TestCase; class UpdateQueryTest extends TestCase @@ -23,11 +22,13 @@ class UpdateQueryTest extends TestCase */ protected $object; + #[Override] protected function setUp(): void { $this->object = new UpdateQuery(); } + #[Override] protected function tearDown(): void { $this->object = null; @@ -43,24 +44,22 @@ public function testUpdate() $this->object->where('fld1 = :id', ['id' => 10]); - $sqlObject = $this->object->build(); + $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlObject( + new SqlStatement( 'UPDATE test SET fld1 = :fld1 , fld2 = :fld2 , fld3 = :fld3 WHERE fld1 = :id', - [ 'id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C' ], - SqlObjectEnum::UPDATE + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] ), - $sqlObject + $sqlStatement ); - $sqlObject = $this->object->build(new DbSqliteFunctions()); + $sqlStatement = $this->object->build(new DbSqliteFunctions()); $this->assertEquals( - new SqlObject( + new SqlStatement( 'UPDATE `test` SET `fld1` = :fld1 , `fld2` = :fld2 , `fld3` = :fld3 WHERE fld1 = :id', - [ 'id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C' ], - SqlObjectEnum::UPDATE + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] ), - $sqlObject + $sqlStatement ); } @@ -81,7 +80,7 @@ public function testQueryUpdatable() { $this->object->table('test'); $this->assertEquals( - new SqlObject('SELECT * FROM test'), + new SqlStatement('SELECT * FROM test'), $this->object->convert()->build() ); @@ -92,7 +91,7 @@ public function testQueryUpdatable() ->set('fld3', 'C'); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test'), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test'), $this->object->convert()->build() ); @@ -100,7 +99,7 @@ public function testQueryUpdatable() ->where('fld2 = :teste', [ 'teste' => 10 ]); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', [ 'teste' => 10 ]), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste', ['teste' => 10]), $this->object->convert()->build() ); @@ -108,7 +107,7 @@ public function testQueryUpdatable() ->where('fld3 = 20'); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', [ 'teste' => 10 ]), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20', ['teste' => 10]), $this->object->convert()->build() ); @@ -116,7 +115,7 @@ public function testQueryUpdatable() ->where('fld1 = :teste2', [ 'teste2' => 40 ]); $this->assertEquals( - new SqlObject('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', [ 'teste' => 10, 'teste2' => 40 ]), + new SqlStatement('SELECT fld1, fld2, fld3 FROM test WHERE fld2 = :teste AND fld3 = 20 AND fld1 = :teste2', ['teste' => 10, 'teste2' => 40]), $this->object->convert()->build() ); } @@ -138,14 +137,13 @@ public function testUpdateJoinMySQl() $this->object->set('fld3', 'C'); $this->object->where('fld1 = :id', ['id' => 10]); - $sqlObject = $this->object->build(new DbMysqlFunctions()); + $sqlStatement = $this->object->build(new DbMysqlFunctions()); $this->assertEquals( - new SqlObject( + new SqlStatement( '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'], - SqlObjectEnum::UPDATE + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] ), - $sqlObject + $sqlStatement ); } @@ -160,10 +158,9 @@ public function testUpdateJoinMySQlAlias() $sqlObject = $this->object->build(new DbMysqlFunctions()); $this->assertEquals( - new SqlObject( + new SqlStatement( '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'], - SqlObjectEnum::UPDATE + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] ), $sqlObject ); @@ -192,10 +189,9 @@ public function __construct() $sqlObject = $this->object->build($dbDriver); $this->assertEquals( - new SqlObject( + new SqlStatement( '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'], - SqlObjectEnum::UPDATE + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] ), $sqlObject ); @@ -210,14 +206,13 @@ public function testUpdateJoinPostgres() $this->object->set('fld3', 'C'); $this->object->where('fld1 = :id', ['id' => 10]); - $sqlObject = $this->object->build(new DbPgsqlFunctions()); + $sqlStatement = $this->object->build(new DbPgsqlFunctions()); $this->assertEquals( - new SqlObject( + new SqlStatement( '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'], - SqlObjectEnum::UPDATE + ['id' => 10, 'fld1' => 'A', 'fld2' => 'B', 'fld3' => 'C'] ), - $sqlObject + $sqlStatement ); } @@ -227,25 +222,23 @@ public function testSetLiteral() $this->object->setLiteral('counter', 'counter + 1'); $this->object->where('id = :id', ['id' => 10]); - $sqlObject = $this->object->build(); + $sqlStatement = $this->object->build(); $this->assertEquals( - new SqlObject( + new SqlStatement( 'UPDATE test SET counter = counter + 1 WHERE id = :id', - ['id' => 10], - SqlObjectEnum::UPDATE + ['id' => 10] ), - $sqlObject + $sqlStatement ); // Test with database helper - $sqlObject = $this->object->build(new DbMysqlFunctions()); + $sqlStatement = $this->object->build(new DbMysqlFunctions()); $this->assertEquals( - new SqlObject( + new SqlStatement( 'UPDATE `test` SET `counter` = counter + 1 WHERE id = :id', - ['id' => 10], - SqlObjectEnum::UPDATE + ['id' => 10] ), - $sqlObject + $sqlStatement ); } } diff --git a/tests/WhereTraitTest.php b/tests/WhereTraitTest.php index b7ee11c..2409c87 100644 --- a/tests/WhereTraitTest.php +++ b/tests/WhereTraitTest.php @@ -2,8 +2,8 @@ namespace Tests; +use ByJG\AnyDataset\Db\SqlStatement; use ByJG\MicroOrm\QueryBasic; -use ByJG\MicroOrm\SqlObject; use PHPUnit\Framework\TestCase; class WhereTraitTest extends TestCase @@ -15,7 +15,7 @@ public function testWhereIsNull() ->whereIsNull('test.field'); $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE test.field IS NULL', []), + new SqlStatement('SELECT * FROM test WHERE test.field IS NULL', []), $query->build() ); } @@ -27,7 +27,7 @@ public function testWhereIsNotNull() ->whereIsNotNull('test.field'); $this->assertEquals( - new SqlObject('SELECT * FROM test WHERE test.field IS NOT NULL', []), + new SqlStatement('SELECT * FROM test WHERE test.field IS NOT NULL', []), $query->build() ); } @@ -40,7 +40,7 @@ public function testWhereIn() $sql = $query->build(); $sqlString = $sql->getSql(); - $params = $sql->getParameters(); + $params = $sql->getParams(); // We need to check the SQL and parameters separately because the parameter names are dynamic $this->assertStringContainsString('SELECT * FROM test WHERE test.field IN (:', $sqlString); @@ -62,7 +62,7 @@ public function testWhereInEmpty() ->whereIn('test.field', []); $this->assertEquals( - new SqlObject('SELECT * FROM test', []), + new SqlStatement('SELECT * FROM test', []), $query->build() ); } @@ -78,7 +78,7 @@ public function testCombinedWhereClauses() $sql = $query->build(); $sqlString = $sql->getSql(); - $params = $sql->getParameters(); + $params = $sql->getParams(); // Check that the SQL contains all our where clauses $this->assertStringContainsString('test.name = :name', $sqlString);