diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..05f00f8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +/tests export-ignore +/.github export-ignore +/examples export-ignore +/.gitignore export-ignore +/.junie export-ignore +/phpunit.xml.dist export-ignore +/composer.lock export-ignore +/CHANGELOG.md export-ignore +/MILESTONES.md export-ignore +/SPECS.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4582b95..380efe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,42 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: [ '8.2', '8.3' ] - services: - mysql: - image: mysql:8 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: pairity - ports: - - 3306:3306 - options: >- - --health-cmd "mysqladmin ping -h 127.0.0.1 -proot" - --health-interval 10s - --health-timeout 5s - --health-retries 20 - mongo: - image: mongo:6 - ports: - - 27017:27017 - options: >- - --health-cmd "mongosh --eval 'db.runCommand({ ping: 1 })' || exit 1" - --health-interval 10s - --health-timeout 5s - --health-retries 30 - postgres: - image: postgres:15 - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: pairity - POSTGRES_USER: postgres - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 10s - --health-timeout 5s - --health-retries 20 + php: [ '8.2', '8.3', '8.4' ] steps: - name: Checkout uses: actions/checkout@v4 @@ -55,56 +20,17 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - # Pin ext-mongodb to a 1.21+ line compatible with mongodb/mongodb ^1.20 (which resolves to 1.21+) - extensions: pdo, pdo_mysql, pdo_sqlite, pdo_pgsql, mongodb-1.21.0 + extensions: pdo, pdo_sqlite, pdo_mysql, pdo_pgsql, pdo_sqlsrv coverage: none - name: Install dependencies run: | composer install --no-interaction --prefer-dist - - name: Prepare MySQL + - name: Initialize Pairity run: | - sudo apt-get update - # wait for mysql to be healthy - for i in {1..30}; do - if mysqladmin ping -h 127.0.0.1 -proot --silent; then - break - fi - sleep 2 - done - mysql -h 127.0.0.1 -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS pairity;' - - - name: Prepare Postgres - run: | - for i in {1..30}; do - if pg_isready -h 127.0.0.1 -p 5432 -U postgres; then - break - fi - sleep 2 - done + bin/pairity init - name: Run tests - env: - MYSQL_HOST: 127.0.0.1 - MYSQL_PORT: 3306 - MYSQL_DB: pairity - MYSQL_USER: root - MYSQL_PASS: root - MONGO_HOST: 127.0.0.1 - MONGO_PORT: 27017 - POSTGRES_HOST: 127.0.0.1 - POSTGRES_PORT: 5432 - POSTGRES_DB: pairity - POSTGRES_USER: postgres - POSTGRES_PASS: postgres run: | vendor/bin/phpunit --colors=always - - - name: Static analysis (PHPStan) - run: | - if [ -f phpstan.neon.dist ]; then vendor/bin/phpstan analyse --no-progress || true; fi - - - name: Style check (PHPCS) - run: | - if [ -f phpcs.xml.dist ]; then vendor/bin/phpcs || true; fi diff --git a/.gitignore b/.gitignore index afafe53..85a16e7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,10 @@ crashlytics.properties crashlytics-build.properties fabric.properties .junie/ -.phpunit.result.cache \ No newline at end of file +.phpunit.result.cache +LOG.md +/schema/ +/storage/ +/src/Models/DTO/ +/src/Models/DAO/ +/src/Models/Hydrators/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bdaf10b..93f500e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,3 @@ ### Changelog All notable changes to this project will be documented in this file. - -#### [0.1.1] - 2026-01-07 - -##### Changed -- **MongoDB Downgrade**: Stepped back MongoDB support from `mongodb/mongodb` ^2.0 to ^1.20 for broader compatibility with older `ext-mongodb` environments. -- **Refactoring**: Simplified `normalizeFilter` in `MongoClientConnection` for better maintainability. -- **Features**: Added `count()` method to `MongoConnectionInterface` and implementations. - -#### [0.1.0] - 2026-01-06 - -##### Added -- **Caching Layer**: Integrated PSR-16 (Simple Cache) support into `AbstractDao` and `AbstractMongoDao`. - - Automated cache invalidation on write operations. - - Identity Map synchronization for cached objects. - - Customizable TTL and prefix per DAO. -- **CLI Enhancements**: - - Extracted CLI logic into dedicated Command classes in `Pairity\Console`. - - Added `--pretend` flag to `migrate`, `rollback`, and `reset` commands for dry-run support. - - Added `--template` flag to `make:migration` for custom migration boilerplate. -- **MongoDB Refinements**: - - Production-ready `MongoClientConnection` wrapping `mongodb/mongodb` ^2.0. - - Integrated caching into `AbstractMongoDao`. - - Improved `_id` normalization and BSON type handling. -- **Improved Testing**: Added `PretendTest`, `MigrationGeneratorTest`, and `CachingTest`. - -##### Changed -- Updated `AbstractDto` to support `\Serializable` and PHP 8.1+ `__serialize()`/`__unserialize()` for better cache persistence. -- Refactored `bin/pairity` to use the new Console Command structure. - -#### [0.0.1] - 2025-12-11 -- Initial development version with core ORM, Relations, Migrations, and basic MongoDB support. diff --git a/MILESTONES.md b/MILESTONES.md index 0c1d799..99fd458 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -1,62 +1,117 @@ # Milestones ## Table of Contents -1. [Milestone 1: Core DTO/DAO & Persistence](#milestone-1-core-dtodao--persistence) -2. [Milestone 2: Basic Relations & Eager Loading](#milestone-2-basic-relations--eager-loading) -3. [Milestone 3: Attribute Accessors/Mutators & Custom Casters](#milestone-3-attribute-accessorsmutators--custom-casters) -4. [Milestone 4: Unit of Work & Identity Map](#milestone-4-unit-of-work--identity-map) -5. [Milestone 5: Pagination & Query Scopes](#milestone-5-pagination--query-scopes) -6. [Milestone 6: Event System](#milestone-6-event-system) -7. [Milestone 7: Performance Knobs](#milestone-7-performance-knobs) -8. [Milestone 8: Road Ahead](#milestone-8-road-ahead) [x] +- [x] Milestone 1: Planning & Specification +- [x] Milestone 2: CLI Infrastructure & Service Container +- [x] Milestone 3: Error Handling & Localization +- [x] Milestone 4: Connection Management & Drivers +- [x] Milestone 5: Advanced Database Features +- [x] Milestone 6: YAML Schema & Fluent Builder +- [x] Milestone 7: Metadata Management & Caching +- [x] Milestone 8: DTO/DAO Code Generation +- [x] Milestone 9: Performance & State Management +- [x] Milestone 10: Basic Query Builder & CRUD +- [x] Milestone 11: Relationships & Eager Loading +- [x] Milestone 12: Unit of Work & Concurrency +- [x] Milestone 13: Lifecycle Events & Auditing +- [x] Milestone 14: Enterprise Features & Tooling +- [x] Milestone 15: Project Clean Up --- -## Milestone 1: Core DTO/DAO & Persistence -- [x] Basic AbstractDto and AbstractDao -- [x] CRUD operations (Insert, Update, Delete, Find) -- [x] Schema metadata (Casts, Timestamps, Soft Deletes) -- [x] Dynamic DAO methods -- [x] Basic SQL and SQLite support +## Milestone 1: Planning & Specification +- [x] Define Core Concepts and Architecture in SPECS.md +- [x] Establish CLI Tool as a first-class citizen +- [x] Minimal composer.json setup +- [x] Project cleanup and reset to PLANNING status -## Milestone 2: Basic Relations & Eager Loading -- [x] `hasOne`, `hasMany`, `belongsTo` -- [x] Batched eager loading (IN lookup) -- [x] Join-based eager loading (SQL) -- [x] Nested eager loading (dot notation) -- [x] `belongsToMany` and Pivot Helpers +## Milestone 2: CLI Infrastructure & Service Container +- [x] Implement basic CLI structure (`bin/pairity`) +- [x] Define internal command registration system +- [x] Implement Service Container / Dependency Injection base +- [x] Verify binary exposure via Composer (`bin` entry in `composer.json`) -## Milestone 3: Attribute Accessors/Mutators & Custom Casters -- [x] DTO accessors and mutators -- [x] Pluggable per-column custom casters -- [x] Casting integration with hydration and storage +## Milestone 3: Error Handling & Localization +- [x] Implement Exception Hierarchy +- [x] Implement i18n Translation Layer for CLI and Exceptions -## Milestone 4: Unit of Work & Identity Map -- [x] Identity Map implementation -- [x] Deferred mutations (updates/deletes) -- [x] Transactional/Atomic commits -- [x] Relation-aware delete cascades -- [x] Optimistic Locking (SQL & Mongo) +## Milestone 4: Connection Management & Drivers +- [x] Implement `DatabaseManager` +- [x] Implement Driver abstractions (MySQL, Postgres, SQLite, etc.) +- [x] Support for multiple named connections +- [x] Implement Connection Heartbeat and Health Check logic -## Milestone 5: Pagination & Query Scopes -- [x] `paginate` and `simplePaginate` for SQL and Mongo -- [x] Ad-hoc and Named Query Scopes +## Milestone 5: Advanced Database Features +- [x] Implement Read/Write splitting logic +- [x] Implement Manual Transaction and Savepoint support +- [x] Implement Database Interceptors (Middleware) -## Milestone 6: Event System -- [x] Dispatcher and Subscriber interfaces -- [x] DAO lifecycle events -- [x] Unit of Work commit events +## Milestone 6: YAML Schema & Fluent Builder +- [x] Define YAML Schema format for table definitions (including Tenancy, Prefix, Inheritance, and Morph) +- [x] Implement Fluent Schema Builder API +- [x] Implement YAML parser and validator (supporting Enum casting, attribute visibility, Custom Types, and Encrypted Attributes) +- [x] Implement JSON Schema for YAML autocompletion (Opportunity Radar) +- [x] Implement Schema Linting CLI command (Opportunity Radar) +- [x] Implement Type Mapping Registry (Opportunity Radar) -## Milestone 7: Performance Knobs -- [x] PDO prepared-statement cache -- [x] Query timing hooks -- [x] Eager loader IN-batching -- [x] Metadata memoization +## Milestone 7: Metadata Management & Caching +- [x] Implement Metadata Cache (PSR-16 integration) +- [x] Support for Database View mapping +- [x] Implement Schema Snapshotting (`make:yaml:snapshot`) and Blueprint exporting + +## Milestone 8: DTO/DAO Code Generation +- [x] Implement Code Generator for DTOs (supporting serialization, visibility, Virtual Properties, Inheritance, and Encrypted Attributes) +- [x] Implement Code Generator for DAOs +- [x] Implement basic CRUD and Mass Operations (Insert, Update, Delete) in DAOs + +## Milestone 9: Performance & State Management +- [x] Implement Pre-Generated Hydrators for performance +- [x] Implement Identity Map pattern for DTO tracking +- [x] Implement Lazy Loading via Ghost Objects (Proxies) + +## Milestone 10: Basic Query Builder & CRUD +- [x] Implement fluent Query Builder +- [x] Implement Subquery support in FROM and WHERE IN (Advanced Features) +- [x] Implement Advanced Subquery Features (Joins, Existence Checks, Selects) +- [x] Implement Query Result Normalization (Consistent object syntax for raw results) +- [x] Implement Raw SQL Expressions support +- [x] Implement Driver-Specific Hints and Options +- [x] Implement Query Logging and Profiling +- [x] Implement Standardized Paginator (Offset and Cursor) +- [x] Safety Check: Prevent unconstrained Mass Update/Delete via configuration + +## Milestone 11: Relationships & Eager Loading +- [x] Implement Nested, Constrained, and Polymorphic Eager Loading +- [x] Support for Polymorphic relations in DAOs + +## Milestone 12: Unit of Work & Concurrency +- [x] Implement Unit of Work for transactional tracking +- [x] Implement Optimistic Locking (versioning) +- [x] Support for Pessimistic Locking (`sharedLock`, `lockForUpdate`) +- [x] Implement Upsert logic + +## Milestone 13: Lifecycle Events & Auditing +- [x] Implement Event Dispatcher and lifecycle hooks +- [x] Implement Auditing System + +## Milestone 14: Enterprise Features & Tooling +- [x] Implement Automatic Multi-Tenancy scoping +- [x] Implement Data Seeding system +- [x] Implement Procedural Data Migration System (`migration:data`) +- [x] Implement Model Factories +- [x] Implement Seed Squashing (`db:seed:squash`) +- [x] Implement manual migration support +- [x] Implement Schema Introspection (`make:yaml:fromdb`) +- [x] Implement Database Health Check commands (`db:check:health`, `db:check:sync`) +- [x] Implement Query Result Caching +- [x] Implement Async/Parallel Query Execution logic +- [x] Implement Set Operations (Unions, Intersect, Except) + +## Milestone 15: Project Clean Up +- [x] Update all thrown exceptions in codebase to use the i18n localization for exception messages +- [x] Exception Context Standardization: Ensure all exceptions leverage the context array in PairityException to pass structured data +- [x] Dead Code Elimination: Identify and remove any unused traits or helper methods +- [x] Create Translation files for Spanish (es.php), French (fr.php), German (de.php), and Italian (it.php) +- [x] Implement Task Priority rule [R21] and Definition of Done guidelines +- [x] Update README to be a normal project type README. Make sure all necessary Features, Installation examples, Usage examples (both DAO/DTO and Query Builder), and anything else typically found in a project README are present. -## Milestone 8: Road Ahead [x] -- [x] Broader Schema Builder ALTER coverage -- [x] More dialect nuances for SQL Server/Oracle -- [x] Enhanced CLI commands -- [x] Caching layer -- [x] Production-ready MongoDB adapter refinements -- [x] Documentation and expanded examples diff --git a/README.md b/README.md index bb73588..7e71156 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,138 @@ -# Pairity +# Pairity ORM -A partitioned‑model PHP ORM (DTO/DAO) with Query Builder, relations, raw SQL helpers, and a portable migrations + schema builder. +[![Latest Version on Packagist](https://img.shields.io/packagist/v/getphred/pairity.svg?style=flat-square)](https://packagist.org/packages/getphred/pairity) +[![Total Downloads](https://img.shields.io/packagist/dt/getphred/pairity.svg?style=flat-square)](https://packagist.org/packages/getphred/pairity) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) -![CI](https://github.com/getphred/pairity/actions/workflows/ci.yml/badge.svg) -![Packagist](https://img.shields.io/packagist/v/getphred/pairity.svg) +Pairity is a high-performance, partitioned-model PHP ORM that strictly separates data representation (DTO) from persistence logic (DAO). It provides a fluent Query Builder, robust relationship management, and enterprise-grade features like automatic multi-tenancy, auditing, and transparent attribute encryption. + +## Key Features + +- **Partitioned Model**: Clean separation between DTOs (Data) and DAOs (Persistence). +- **YAML-Driven Schema**: Define your tables in YAML; generate migrations and models automatically. +- **Fluent Query Builder**: Database-agnostic query construction with support for subqueries, joins, and set operations. +- **Relationships & Eager Loading**: Efficiently handle BelongsTo, HasOne, HasMany, and Polymorphic relations with N+1 prevention. +- **Unit of Work**: Coordinate atomic updates across multiple connections with centralized transaction management. +- **Enterprise Features**: + - **Automatic Multi-Tenancy**: Transparent data isolation via tenant scoping. + - **Auditing**: Automatic change tracking for sensitive models. + - **Concurrency Control**: Built-in Optimistic and Pessimistic locking. + - **Attribute Encryption**: Transparent AES-256 encryption for PII data. +- **Developer Tooling**: First-class CLI (`pairity`) for code generation, migrations, seeding, and health checks. +- **Internationalization (i18n)**: Localized exception messages and CLI output (EN, ES, FR, DE, IT). ## Installation -- **Requirements**: PHP >= 8.2, PDO extension for your database(s) -- **Install via Composer**: +Install Pairity via Composer: ```bash composer require getphred/pairity ``` -After install, you can use the CLI at `vendor/bin/pairity`. +Initialize the project structure (creates `schema/`, `src/Models/`, etc.): -## Testing - -This project uses PHPUnit 10. The default test suite excludes MongoDB integration tests by default for portability. - -- **Install dev dependencies**: ```bash -composer install -``` - -- **Run the default suite** (SQLite + unit tests; Mongo tests excluded by default): -```bash -vendor/bin/phpunit -``` - -- **Run MongoDB integration tests** (requires `ext-mongodb >= 1.21` and a reachable server): -```bash -MONGO_HOST=127.0.0.1 MONGO_PORT=27017 vendor/bin/phpunit --group mongo-integration +vendor/bin/pairity init ``` ## Quick Start -Minimal example with SQLite and a simple `users` DAO/DTO. +### 1. Define your Schema -```php -use Pairity\Database\ConnectionManager; -use Pairity\Model\AbstractDto; -use Pairity\Model\AbstractDao; +Create a YAML file in `schema/users.yaml` (Note: the `schema/` directory is automatically created by the `init` command): -// 1) Connect -$conn = ConnectionManager::make([ - 'driver' => 'sqlite', - 'path' => __DIR__ . '/db.sqlite', -]); +```yaml +columns: + id: + type: bigIncrements + email: + type: string + unique: true + password: + type: string + encrypted: true + active: + type: boolean + default: true -// 2) Define DTO + DAO -class UserDto extends AbstractDto {} -class UserDao extends AbstractDao { - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return UserDto::class; } -} - -// 3) CRUD -$dao = new UserDao($conn); -$created = $dao->insert(['email' => 'a@b.com', 'name' => 'Alice']); -$user = $dao->findById($created->id); +relations: + posts: + type: hasMany + target: posts ``` -For more detailed usage and technical specifications, see [SPECS.md](SPECS.md). +### 2. Generate Models + +```bash +vendor/bin/pairity make:model +``` + +### 3. Run Migrations + +```bash +vendor/bin/pairity migrate +``` + +### 4. Usage + +#### Basic CRUD + +```php +use App\Models\DTO\UsersDTO; +use App\Models\DAO\UsersDAO; + +$userDao = $container->get(UsersDAO::class); + +// Create +$user = new UsersDTO(['email' => 'jane@example.com', 'password' => 'secret']); +$userDao->save($user); + +// Read +$user = $userDao->find(1); +echo $user->email; + +// Update +$user->setAttribute('active', false); +$userDao->save($user); +``` + +#### Query Builder + +```php +$users = $userDao->query() + ->where('active', true) + ->whereIn('role', ['admin', 'editor']) + ->orderBy('created_at', 'desc') + ->limit(10) + ->get(); +``` + +#### Eager Loading + +```php +$users = $userDao->query() + ->with('posts.comments') + ->get(); + +foreach ($users as $user) { + foreach ($user->posts as $post) { + // Posts are already loaded! + } +} +``` ## Documentation -- [Specifications](SPECS.md) -- [Milestones & Roadmap](MILESTONES.md) -- [Examples](examples/) -## Contributing +For detailed documentation, please refer to the [SPECS.md](SPECS.md) file. -This is an early foundation. Contributions, discussions, and design proposals are welcome. Please open an issue to coordinate larger features. +## Testing + +Run the test suite using PHPUnit: + +```bash +vendor/bin/phpunit +``` ## License -MIT \ No newline at end of file +The MIT License (MIT). Please see [LICENSE.md](LICENSE.md) for more information. diff --git a/SPECS.md b/SPECS.md index ee8cae6..ed1e837 100644 --- a/SPECS.md +++ b/SPECS.md @@ -1,7 +1,105 @@ # Pairity Specifications +**Status**: IMPLEMENTATION + ## Architecture -Pairity is a partitioned‑model PHP ORM (DTO/DAO) that separates data representation (DTO) from persistence logic (DAO). It includes a Query Builder, relation management, raw SQL helpers, and a portable migrations + schema builder. +Pairity is a partitioned‑model PHP ORM (DTO/DAO) that separates data representation (DTO) from persistence logic (DAO). It includes a Query Builder, relation management, raw SQL helpers, and a user friendly migration system. + +- **Current DB Support**: + - MySQL/MariaDB + - PostgreSQL + - SQLite + - SQL Server + - Oracle + +- **Table Definitions** + - Create YAML file to create new table. + - file names are used as table names. + - `relations` key in the YAML file can be used to define relationships. + - `validation` key in the YAML file can be used to define validation rules. + - `indexes` key in the YAML file can be used to define indexes. + - `primary` key can define composite primary keys. + - `prefix` can define table-specific prefixes. + - `tenancy` key to enable automatic tenant scoping. + - `inheritance` key to define Single Table (STI) or Class Table (CTI) inheritance. + - `morph` key to define polymorphic relationship targets. + - `encrypted` flag on columns for transparent AES-256 encryption. + - `auditable` key to enable automatic change tracking/auditing. + - `timestamps` key in the YAML file can be used to enable timestamps. + - `softDeletes` key in the YAML file can be used to enable soft deletes. + - Support for **Database Views** (mapping DTOs to SQL views). + - Update YAML file to modify existing tables. + - Run `pairity migrate` to apply changes. + - Run `pairity make:model` to generate DTO and DAO classes from YAML files. +- **Schema Builder**: + - Fluent PHP API for programmatic table creation and alteration. + - Acts as the underlying engine for YAML-driven migrations. + - Support for **Schema Blueprints** (PHP-based schema snapshots). + - Support for **Schema Snapshotting** (`make:yaml:snapshot`) for baseline deployments. + - Support for **Dry Run** previews via `migrate:dryrun`. +- **Auditing & Change Tracking** + - Built-in auditing system that automatically tracks changes to DTOs. + - Enabled via `auditable: true` in YAML schema. + - Records previous and current values, event type, and timestamps. + - Integration with `AuditListener` and `Auditor` service. +- **Event Dispatcher & Lifecycle Hooks** + - Robust event system for model lifecycle: `retrieved`, `creating`, `created`, `updating`, `updated`, `saving`, `saved`, `deleting`, `deleted`. + - Supports halting operations (e.g., validation) via "before" events. + - Wildcard listener support (e.g., `pairity.model.*`). +- **Models**: + - **DTOs / Data Transfer Objects**: + - immutable by default. + - Supports accessors and mutators. + - Supports validation. + - Supports casting (including native PHP Enums, **Custom Cast Types**, and **Encrypted Attributes**). + - Supports relations (including **Lazy Loading via Ghost Objects** and **Polymorphism**). + - Supports serialization (`toArray`, `toJson`). + - Supports hidden/exposed attributes for serialization. + - Supports **Virtual/Computed Properties**. + - Supports **Inheritance Mapping** (STI/CTI). + - Managed via **Identity Map**. + - Performance boosted by **Pre-Generated Hydrators**. + - **DAOs / Data Access Objects**: + - Provide CRUD and **Mass Operations** (Insert, Update, Delete). + - Support for **Upserts** (Insert or Update). + - Support for relations (including **Polymorphic relations**). + - Support for dynamic helpers. + - Support for Scopes (including Global Scopes, Soft Deletes, **Automatic Multi-Tenancy**, and **Polymorphic Scopes**). + - Support for Unit of Work (with **Automatic Auditing**). + - Support for Event System. + - Support for Migrations. + - Support for Schema Builder. + - Returns DTO, **Pairity Collection**, or **Pairity Paginator**. +- **Query Builder**: + - Supports raw SQL. + - Supports joins. + - Supports eager loading (Nested, Constrained, and **Polymorphic**). + - Supports pagination (Offset and Cursor). + - Supports **Concurrency Control** (Shared and Exclusive Locks). + - Supports **Query Result Caching**. + - Supports **Async/Parallel Query Execution**. + - Supports scopes. + - Supports casts. + - Supports custom helpers. + - Supports **Read/Write Splitting**. + - Supports **Query Logging & Profiling**. + - Supports **Set Operations** (Unions, Intersect, Except). + - Supports **Driver-Specific Hints & Options**. + - DOES NOT support NoSQL. +- **Framework Integration**: + - **Connection Management**: + - Multi-database support with named connections. + - Explicit transaction control (`beginTransaction`, `commit`, `rollBack`). + - Support for **Savepoints** (nested transactions). + - Support for **Connection Heartbeats** and health checks via `db:check:health`. + - Support for **Database Interceptors (Middleware)**. + - **Seeding & Factories**: Built-in support for dummy data and test state generation. + - **Localization (i18n)**: + - Environment-driven language selection (`PAIRITY_LOCALE`). + - Localized exception messages and CLI output. + - **Metadata Caching**: Caching of hydrated schema to avoid repeated YAML parsing. + - **Service Provider**: Standardized entry point for framework integration. + ### Namespace `Pairity\` @@ -11,115 +109,110 @@ Pairity is a partitioned‑model PHP ORM (DTO/DAO) that separates data represent ## Core Concepts -### DTO (Data Transfer Object) -A lightweight data bag. -- Extend `Pairity\Model\AbstractDto`. -- Convert to arrays via `toArray(bool $deep = true)`. -- Support for accessors and mutators. +### 1. Partitioned Model (DTO/DAO Separation) +Pairity strictly separates data state from persistence logic. +- **DTO (Data Transfer Object)**: Pure, immutable (by default) data carriers representing a single record. They are responsible for data integrity, casting, and validation, but have no knowledge of the database. +- **DAO (Data Access Object)**: The persistence engine for a model. DAOs handle all database communication (CRUD, queries) and act as factories that produce DTOs. -### DAO (Data Access Object) -Table‑focused persistence and relations. -- Extend `Pairity\Model\AbstractDao`. -- Required implementations: - - `getTable(): string` - - `dtoClass(): string` (class-string of the DTO) -- Optional implementations: - - `schema()`: for casts, timestamps, soft deletes, and locking. - - `relations()`: for defining `hasOne`, `hasMany`, `belongsTo`, and `belongsToMany`. +### 2. YAML-Driven Schema +The database schema is defined in YAML files, which serve as the single source of truth. These files are used to: +- Generate and execute migrations. +- Generate DTO and DAO class code to ensure type safety and consistency. -## Features +### 3. Unified Query Builder +A fluent PHP interface for constructing SQL queries that works across all supported relational databases. It abstracts driver-specific syntax while allowing raw SQL for complex, non-portable optimizations. -### Persistence & CRUD -- `insert(array $data)`: Immediate execution to return real IDs. -- `update($id, array $data)`: Updates by primary key. -- `updateBy(array $criteria, array $data)`: Bulk updates. -- `deleteById($id)`: Deletes by primary key (supports soft deletes). -- `deleteBy(array $criteria)`: Bulk deletes (supports soft deletes). -- `findById($id)`: Find a single DTO by ID. -- `findOneBy(array $criteria)`: Find a single DTO by criteria. -- `findAllBy(array $criteria)`: Find all matching DTOs. +### 4. Unit of Work +The Unit of Work tracks all changes made to DTOs during a business transaction. It coordinates the writing out of these changes and resolves concurrency issues, ensuring that the database remains in a consistent state. -### Dynamic DAO Methods -`AbstractDao` supports dynamic helpers mapped to column names (Studly/camel to snake_case): -- `findOneBy($value): ?DTO` -- `findAllBy($value): DTO[]` -- `updateBy($value, array $data): int` -- `deleteBy($value): int` +### 5. Event-Driven Architecture +A robust event system that allows developers to hook into the lifecycle of DTOs and DAOs (e.g., `beforeSave`, `afterUpdate`, `onDelete`). This facilitates decoupled logic for auditing, cache invalidation, and complex validation. -### Projection -- Default projection is `SELECT *`. -- Use `fields(...$fields)` to limit columns. Supports dot-notation for related selects (e.g., `posts.title`). -- `fields()` affects only the next find call and then resets. +### 6. CLI Tool (`pairity`) +The `pairity` CLI is a first-class citizen of the framework, providing essential developer tools for schema management, code generation, and maintenance. It includes specialized commands for schema evolution (`migrate:dryrun`), availability (`db:check:health`), and state synchronization (`db:check:sync`). -### Relations -- Relation types: `hasOne`, `hasMany`, `belongsTo`, `belongsToMany`. -- **Eager Loading**: - - Default: Batched queries using `IN (...)` lookups. - - Opt-in: Join-based (`useJoinEager()`) for single-level SQL relations. - - Nested: Supported via dot notation (e.g., `posts.comments`). -- **Lazy Loading**: Via `load()` or `loadMany()` methods. -- **Cascades**: `cascadeDelete` supported for `hasOne` and `hasMany` within a Unit of Work. -- **Pivot Helpers**: `attach`, `detach`, and `sync` for `belongsToMany`. +### 7. Identity Map +To maintain data consistency, Pairity uses an Identity Map to track all instantiated DTOs within a single request cycle. This ensures that fetching the same record multiple times returns the same object instance. -### Model Metadata & Schema Mapping -Defined via `schema()` method in DAO. -- **Casting**: `int`, `float`, `bool`, `string`, `datetime`, `json`, or custom `CasterInterface`. -- **Timestamps**: `createdAt` and `updatedAt` filled automatically. Uses UTC `Y-m-d H:i:s`. -- **Soft Deletes**: - - Enabled via `softDeletes` configuration. - - Query scopes: `withTrashed()`, `onlyTrashed()`. - - Helpers: `restoreById()`, `forceDeleteById()`. -- **Locking**: Optimistic locking supported via `version` or `timestamp` strategies. +### 8. Connection Management +A centralized `DatabaseManager` handles multiple PDO connections, allowing the ORM to communicate with different databases or read/write replicas seamlessly. -### Unit of Work (UoW) -Optional batching and identity map. -- Identity Map: Same DTO instance per `[DAO + ID]` within the UoW scope. -- Deferred mutations: `update` and `delete` are queued until commit. -- Atomicity: Transactional commit per connection. +### 9. Metadata Caching +To optimize performance, Pairity caches the processed YAML schema and table metadata using a PSR-16 cache. This minimizes filesystem I/O and YAML parsing during runtime. -### Event System -Lightweight hook system for DAO operations and UoW commits. -- Events: `dao.before*`, `dao.after*`, `uow.beforeCommit`, `uow.afterCommit`. -- Dispatcher and Subscriber interfaces. +### 10. Data Seeding & Factories +Pairity provides a robust system for seeding the database with initial data and generating complex DTO states via Factories, facilitating both development and testing. -### Pagination -- `paginate(page, perPage, criteria)`: Returns data with total, lastPage, etc. -- `simplePaginate(page, perPage, criteria)`: Returns data without total (uses nextPage detection). +### 11. Query Logging & Profiling +A centralized logging mechanism to capture all executed SQL queries, their execution time, and bound parameters. This is essential for debugging, performance optimization, and security audits. -### Query Scopes -- Ad-hoc: `scope(callable $fn)`. -- Named: Registered via `registerScope()`. +### 12. Standardized Pagination +Pairity provides a dedicated `Paginator` object for paginated results. This object encapsulates the DTO collection along with metadata like total records, current page, and navigation helpers, ensuring a consistent API response. -## Databases +### 13. Structured Exception Hierarchy +Pairity uses a custom exception hierarchy to provide clear, actionable error feedback. This avoids leaking raw database driver exceptions and allows developers to handle specific scenarios (e.g., `RecordNotFoundException`, `QueryException`) gracefully. -### Supported SQL -- MySQL / MariaDB -- SQLite (including table rebuild fallback for legacy versions) -- PostgreSQL -- SQL Server -- Oracle +### 14. Localization (i18n) +To support global development, Pairity includes a localization layer. Exception messages and CLI output are translated based on the environment configuration (`PAIRITY_LOCALE`), facilitating better accessibility and debugging for developers across different regions. -### NoSQL (MongoDB) -- Production adapter: Wraps `mongodb/mongodb` library. -- Stub adapter: In-memory for experimentation. -- Supports aggregation pipelines, pagination, and optimistic locking. +### 15. DTO Serialization & Transformation +Pairity DTOs provide built-in support for transformation into array or JSON formats, making them ideal for use in modern APIs. This includes the ability to define "hidden" fields (e.g., sensitive data) that are excluded by default, and "exposed" fields that are included during serialization. -## Migrations & Schema Builder -Lightweight runner and portable builder. -- Operations: `create`, `drop`, `dropIfExists`, `table` (ALTER). -- Column types: `increments`, `string`, `text`, `integer`, `boolean`, `json`, `datetime`, `decimal`, `timestamps`. -- Indexing: `primary`, `unique`, `index`. -- CLI: `pairity` binary for `migrate`, `rollback`, `status`, `reset`, `make:migration`. - - Supports `--pretend` flag for dry-runs (migrate, rollback, reset). - - Supports `--template` flag for custom migration templates (make:migration). +### 16. Concurrency Control (Locking) +To handle high-concurrency environments, Pairity provides both Optimistic and Pessimistic locking. +- **Optimistic Locking**: Automatic versioning via a `version` column. +- **Pessimistic Locking**: Support for Shared (Read) and Exclusive (Write) locks via the Query Builder (`sharedLock()`, `lockForUpdate()`). -## Performance -- PDO prepared-statement cache (LRU). -- Query timing hooks. -- Eager loader IN-batching. -- Metadata memoization. -- **Caching Layer**: PSR-16 (Simple Cache) integration for DAO-level caching. - - Optional per-DAO cache configuration. - - Automatic invalidation on write operations. - - Identity Map synchronization for cached DTOs. - - Support for bulk invalidation (configurable). +### 17. Multi-Tenancy & Data Isolation +Pairity supports enterprise-grade multi-tenancy through automatic scoping. By defining a tenant identifier in the YAML schema, the ORM automatically injects the necessary filters into all queries and inserts, ensuring strict data isolation between tenants with zero developer overhead. + +### 18. Performance Optimization (Hydrators & Ghost Objects) +To achieve maximum performance, Pairity employs advanced optimization techniques: +- **Pre-Generated Hydrators**: Code-generated classes specialized for populating DTOs, bypassing slow reflection-based instantiation. +- **Lazy Loading via Ghost Objects**: Utilizing the Proxy pattern to instantiate DTOs for relations that only trigger a database query when a property is accessed. + +### 19. Custom Extension & Interceptors +Developers can extend the ORM's core behavior through: +- **Custom Cast Types**: Defining reusable casting logic for unique data types (e.g., encryption, spatial data). +- **Database Interceptors (Middleware)**: Hooking into the `DatabaseManager` to intercept and modify raw PDO calls for auditing, security, or custom logging. +- **Virtual Properties**: Defining computed DTO attributes in YAML that behave as first-class fields. + +### 20. Data Migrations & Procedural Transforms +Beyond schema changes, Pairity supports procedural data migrations. These are PHP-based scripts used to transform or move data using the ORM's business logic, ensuring complex data transitions are handled safely within the application's domain layer. + +### 21. Polymorphic Relationships +Pairity allows a model to belong to more than one other type of model on a single association. This is implemented via `morphTo`, `morphOne`, and `morphMany`, allowing for flexible data structures like shared comment or attachment systems. + +### 22. Table Inheritance Mapping +To support domain-driven design, Pairity provides two primary inheritance patterns: +- **Single Table Inheritance (STI)**: Mapping multiple DTO types to a single database table using a discriminator column. +- **Class Table Inheritance (CTI)**: Mapping a class hierarchy across multiple tables, with a base table for common attributes and specialized tables for extended data. + +### 23. Auditing & Change Tracking +Pairity includes a built-in auditing system that automatically tracks changes to DTOs. When enabled in the YAML schema, the ORM records the previous and current values of attributes, the timestamp, and the user responsible for the change, facilitating compliance and security auditing. + +### 24. Async & Parallel Query Execution +For high-performance applications using PHP Fibers or asynchronous runtimes (e.g., Swoole, RoadRunner), Pairity supports dispatching multiple independent queries in parallel. This maximizes database throughput and reduces total response time for data-heavy operations. + +### 25. Attribute Encryption +Pairity provides native support for transparent attribute encryption. Sensitive data (PII) can be marked as `encrypted` in the YAML schema, and the ORM will automatically handle AES-256 encryption/decryption before storage and after retrieval, ensuring data-at-rest security. + +### 26. Seed Squashing +To facilitate rapid environment setup, Pairity supports squashing individual seed files into a single, optimized baseline seed. This reduces the overhead of executing hundreds of separate seeder classes and provides a clean starting point for new development or staging environments. + +## CLI Actions +List of actions that the `pairity` tool can perform: +- `db:check:health`: Verify database connection health and heartbeat. +- `db:check:sync`: Verify synchronization of manual migration files and seed files (excludes YAML to DB direct). +- `db:seed`: Seed the database with records. +- `db:seed:squash`: Squash all individual seed files into a single baseline seed for new environments. +- `make:factory`: Create a new Factory class for a model. +- `make:migration`: Create a manual migration file for custom SQL or data changes. +- `make:model`: Generate DTO and DAO classes from YAML schema definitions. +- `make:seeder`: Create a new Seeder class. +- `make:yaml:fromdb`: Reverse-engineer an existing database to generate YAML schema files. +- `make:yaml:snapshot`: Export the current YAML source of truth into a single SQL or PHP baseline snapshot. +- `migrate`: Apply pending schema changes defined in YAML files. +- `migrate:dryrun`: Preview all changes that would be run with `migrate` (YAML to DB direct only). +- `migrate:rollback`: Roll back the last database migration. +- `migration:data`: Execute procedural PHP data migrations. diff --git a/bin/pairity b/bin/pairity old mode 100644 new mode 100755 index a25eaed..ba67c6a --- a/bin/pairity +++ b/bin/pairity @@ -3,86 +3,117 @@ declare(strict_types=1); -require __DIR__ . '/../vendor/autoload.php'; +/** + * Pairity CLI Entry Point + * + * This script bootstraps the Pairity ORM CLI environment, + * initializes the service container, and runs the console application. + */ -use Pairity\Console\MigrateCommand; -use Pairity\Console\RollbackCommand; -use Pairity\Console\StatusCommand; -use Pairity\Console\ResetCommand; -use Pairity\Console\MakeMigrationCommand; -use Pairity\Console\MongoIndexEnsureCommand; -use Pairity\Console\MongoIndexDropCommand; -use Pairity\Console\MongoIndexListCommand; +// Search for the autoloader. +$autoloadPath = __DIR__ . '/../vendor/autoload.php'; -function parseArgs(array $argv): array { - $args = ['_cmd' => $argv[1] ?? 'help']; - for ($i = 2; $i < count($argv); $i++) { - $a = $argv[$i]; - if (str_starts_with($a, '--')) { - $eq = strpos($a, '='); - if ($eq !== false) { - $key = substr($a, 2, $eq - 2); - $val = substr($a, $eq + 1); - $args[$key] = $val; - } else { - $key = substr($a, 2); - $args[$key] = true; - } - } else { - $args[] = $a; - } - } - return $args; -} - -function cmd_help(): void -{ - $help = << MigrateCommand::class, - 'rollback' => RollbackCommand::class, - 'status' => StatusCommand::class, - 'reset' => ResetCommand::class, - 'make:migration' => MakeMigrationCommand::class, - 'mongo:index:ensure' => MongoIndexEnsureCommand::class, - 'mongo:index:drop' => MongoIndexDropCommand::class, - 'mongo:index:list' => MongoIndexListCommand::class, -]; - -if ($cmd === 'help' || !isset($commands[$cmd])) { - cmd_help(); - exit($cmd === 'help' ? 0 : 1); -} - -try { - $class = $commands[$cmd]; - /** @var \Pairity\Console\CommandInterface $instance */ - $instance = new $class(); - $instance->execute($args); -} catch (\Throwable $e) { - fwrite(STDERR, 'Error: ' . $e->getMessage() . PHP_EOL); +if (!file_exists($autoloadPath)) { + // Translate if possible, otherwise show English + $msg = "Composer autoloader not found. Please run 'composer install'."; + fwrite(STDERR, $msg . "\n"); + exit(1); +} + +require $autoloadPath; + +use Pairity\Container\Container; +use Pairity\Console\Application; +use Pairity\Contracts\Translation\TranslatorInterface; +use Pairity\Translation\Translator; +use Pairity\Contracts\Database\DatabaseManagerInterface; +use Pairity\Database\DatabaseManager; +use Pairity\Console\Commands\DatabaseHealthCheckCommand; + +try { + // 1. Initialize the Service Container. + $container = new Container(); + + // 1.0 Register Identity Map (singleton). + $container->singleton(\Pairity\DTO\IdentityMap::class); + + // 1.0.1 Register Proxy Factory (singleton). + $container->singleton(\Pairity\DTO\ProxyFactory::class); + + // 1.1 Register Translator service (singleton) with path to translations. + $container->singleton(TranslatorInterface::class, function ($c) { + return new Translator(__DIR__ . '/../src/Translations'); + }); + + // 1.2 Register Database Manager (singleton). + $container->singleton(DatabaseManagerInterface::class, function ($c) { + // In a real app, this config would come from a file. + $config = [ + 'default' => 'sqlite', + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + ], + ]; + return new DatabaseManager($config); + }); + + // 1.3 Register Cache and Metadata services. + $container->singleton(\Psr\SimpleCache\CacheInterface::class, function ($c) { + return new \Pairity\Cache\FileCache(__DIR__ . '/../storage/cache'); + }); + + $container->singleton(\Pairity\Schema\MetadataManager::class, function ($c) { + return new \Pairity\Schema\MetadataManager( + $c->make(\Pairity\Schema\YamlSchemaParser::class), + $c->make(\Psr\SimpleCache\CacheInterface::class) + ); + }); + + $container->singleton(\Pairity\Schema\CodeGenerator::class, function ($c) { + return new \Pairity\Schema\CodeGenerator(__DIR__ . '/../src/Stubs'); + }); + + // 2. Initialize the Console Application. + $app = new Application($container); + + // 3. Register Core Commands. + $app->add(\Pairity\Console\Commands\InitCommand::class); + $app->add(DatabaseHealthCheckCommand::class); + $app->add(\Pairity\Console\Commands\SyncCheckCommand::class); + $app->add(\Pairity\Console\Commands\GenerateJsonSchemaCommand::class); + $app->add(\Pairity\Console\Commands\SchemaLintCommand::class); + $app->add(\Pairity\Console\Commands\GenerateSchemaSnapshotCommand::class); + $app->add(\Pairity\Console\Commands\GenerateModelsCommand::class); + $app->add(\Pairity\Console\Commands\SeedCommand::class); + $app->add(\Pairity\Console\Commands\MakeSeederCommand::class); + $app->add(\Pairity\Console\Commands\MakeFactoryCommand::class); + $app->add(\Pairity\Console\Commands\MakeMigrationCommand::class); + $app->add(\Pairity\Console\Commands\RunDataMigrationCommand::class); + $app->add(\Pairity\Console\Commands\IntrospectCommand::class); + $app->add(\Pairity\Console\Commands\CacheClearCommand::class); + // $app->add(\Pairity\Console\Commands\MigrateCommand::class); + + // 4. Run the Application. + $exitCode = $app->run($_SERVER['argv']); + + exit($exitCode); + +} catch (\Throwable $e) { + // Try to use translator if available + $message = 'Uncaught Exception: ' . $e->getMessage(); + try { + if (isset($container) && $container->has(TranslatorInterface::class)) { + /** @var TranslatorInterface $t */ + $t = $container->get(TranslatorInterface::class); + $message = $t->trans('error.uncaught_exception', ['message' => $e->getMessage()]); + } + } catch (\Throwable $ignored) { + // ignore translation errors in emergency handler + } + fwrite(STDERR, $message . "\n"); + fwrite(STDERR, $e->getTraceAsString() . "\n"); exit(1); } diff --git a/composer.json b/composer.json index 5a62958..deb149e 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,11 @@ { "name": "getphred/pairity", - "description": "DAO/DTO-centric PHP ORM with Query Builder, relations (SQL + MongoDB), migrations/CLI, Unit of Work, and event system. Supports MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, and MongoDB.", + "description": "DAO/DTO-centric PHP ORM with Query Builder, relations, migrations/CLI, Unit of Work, and event system. Supports MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, and Oracle.", "type": "library", "license": "MIT", + "bin": [ + "bin/pairity" + ], "authors": [ { "name": "Phred", @@ -29,8 +32,9 @@ "sqlite", "sqlserver", "oracle", - "mongodb", - "nosql" + "multi-tenancy", + "concurrency", + "performance" ], "homepage": "https://github.com/getphred/pairity", "support": { @@ -39,13 +43,14 @@ }, "require": { "php": "^8.2", - "ext-mongodb": "^1.21", - "mongodb/mongodb": "^1.21", - "psr/simple-cache": "^3.0" + "psr/simple-cache": "^3.0", + "symfony/yaml": "^8.0", + "ext-pdo": "*" }, "autoload": { "psr-4": { - "Pairity\\": "src/" + "Pairity\\": "src/", + "App\\": "src/" } }, "autoload-dev": { @@ -56,10 +61,14 @@ "require-dev": { "phpunit/phpunit": "^10.5" }, + "scripts": { + "post-install-cmd": [ + "bin/pairity init" + ], + "post-update-cmd": [ + "bin/pairity init" + ] + }, "minimum-stability": "dev", "prefer-stable": true - , - "bin": [ - "bin/pairity" - ] } diff --git a/composer.lock b/composer.lock index 0f6dd94..d8667c9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,134 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4b460634f481dac571848ddf91d287ae", + "content-hash": "82d818d72ff2ec7c8196017a7ac8d14d", "packages": [ - { - "name": "mongodb/mongodb", - "version": "1.21.3", - "source": { - "type": "git", - "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "b8f569ec52542d2f1bfca88286f20d14a7f72536" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/b8f569ec52542d2f1bfca88286f20d14a7f72536", - "reference": "b8f569ec52542d2f1bfca88286f20d14a7f72536", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2.0", - "ext-mongodb": "^1.21.0", - "php": "^8.1", - "psr/log": "^1.1.4|^2|^3" - }, - "replace": { - "mongodb/builder": "*" - }, - "require-dev": { - "doctrine/coding-standard": "^12.0", - "phpunit/phpunit": "^10.5.35", - "rector/rector": "^2.1.4", - "squizlabs/php_codesniffer": "^3.7", - "vimeo/psalm": "6.5.*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "files": [ - "src/functions.php" - ], - "psr-4": { - "MongoDB\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Andreas Braun", - "email": "andreas.braun@mongodb.com" - }, - { - "name": "Jeremy Mikola", - "email": "jmikola@gmail.com" - }, - { - "name": "Jérôme Tamarelle", - "email": "jerome.tamarelle@mongodb.com" - } - ], - "description": "MongoDB driver library", - "homepage": "https://jira.mongodb.org/browse/PHPLIB", - "keywords": [ - "database", - "driver", - "mongodb", - "persistence" - ], - "support": { - "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/1.21.3" - }, - "time": "2025-09-22T12:34:29+00:00" - }, - { - "name": "psr/log", - "version": "3.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", - "shasum": "" - }, - "require": { - "php": ">=8.0.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/3.0.2" - }, - "time": "2024-09-11T13:17:53+00:00" - }, { "name": "psr/simple-cache", "version": "3.0.0", @@ -182,6 +56,164 @@ "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" }, "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.0.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "reference": "7a1a90ba1df6e821a6b53c4cabdc32a56cabfb14", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.0.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-04T18:17:06+00:00" } ], "packages-dev": [ @@ -744,16 +776,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.60", + "version": "10.5.63", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f2e26f52f80ef77832e359205f216eeac00e320c" + "reference": "33198268dad71e926626b618f3ec3966661e4d90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c", - "reference": "f2e26f52f80ef77832e359205f216eeac00e320c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", "shasum": "" }, "require": { @@ -774,7 +806,7 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.4", + "sebastian/comparator": "^5.0.5", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", "sebastian/exporter": "^5.1.4", @@ -825,7 +857,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" }, "funding": [ { @@ -849,7 +881,7 @@ "type": "tidelift" } ], - "time": "2025-12-06T07:50:42+00:00" + "time": "2026-01-27T05:48:37+00:00" }, { "name": "sebastian/cli-parser", @@ -1021,16 +1053,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.4", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e" + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e", - "reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", "shasum": "" }, "require": { @@ -1086,7 +1118,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" }, "funding": [ { @@ -1106,7 +1138,7 @@ "type": "tidelift" } ], - "time": "2025-09-07T05:25:07+00:00" + "time": "2026-01-24T09:25:16+00:00" }, { "name": "sebastian/complexity", @@ -1862,7 +1894,7 @@ "prefer-lowest": false, "platform": { "php": "^8.2", - "ext-mongodb": "^1.21" + "ext-pdo": "*" }, "platform-dev": {}, "plugin-api-version": "2.6.0" diff --git a/examples/events_audit.php b/examples/events_audit.php deleted file mode 100644 index ee0cb7e..0000000 --- a/examples/events_audit.php +++ /dev/null @@ -1,67 +0,0 @@ - 'sqlite', - 'path' => __DIR__ . '/../db.sqlite', -]); - -// Ensure table -$conn->execute('CREATE TABLE IF NOT EXISTS audit_users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT, - name TEXT, - status TEXT -)'); - -class UserDto extends AbstractDto {} -class UserDao extends AbstractDao { - public function getTable(): string { return 'audit_users'; } - protected function dtoClass(): string { return UserDto::class; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>[ - 'id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string'],'status'=>['cast'=>'string'] - ]]; } -} - -// Simple audit buffer -$audit = []; - -// Register listeners -Events::dispatcher()->clear(); -Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) { - if (($p['table'] ?? '') === 'audit_users') { - // normalize - $p['data']['email'] = strtolower((string)($p['data']['email'] ?? '')); - } -}); -Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$audit) { - if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) { - $audit[] = '[afterInsert] id=' . ($p['dto']->toArray(false)['id'] ?? '?'); - } -}); -Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$audit) { - if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) { - $audit[] = '[afterUpdate] id=' . ($p['dto']->toArray(false)['id'] ?? '?'); - } -}); - -$dao = new UserDao($conn); - -// Clean for demo -foreach ($dao->findAllBy() as $row) { $dao->deleteById((int)$row->toArray(false)['id']); } - -// Perform some ops -$u = $dao->insert(['email' => 'AUDIT@EXAMPLE.COM', 'name' => 'Audit Me']); -$id = (int)($u->toArray(false)['id'] ?? 0); -$dao->update($id, ['name' => 'Audited']); - -echo "Audit log:\n" . implode("\n", $audit) . "\n"; diff --git a/examples/migrations/AlterUsersAddBio.php b/examples/migrations/AlterUsersAddBio.php deleted file mode 100644 index 0751fd6..0000000 --- a/examples/migrations/AlterUsersAddBio.php +++ /dev/null @@ -1,29 +0,0 @@ -table('users', function (Blueprint $t) { - // Add a new nullable column and an index (on status) - $t->string('bio', 500)->nullable(); - $t->index(['status'], 'users_status_index'); - }); - } - - public function down(ConnectionInterface $connection): void - { - $schema = SchemaManager::forConnection($connection); - $schema->table('users', function (Blueprint $t) { - $t->dropIndex('users_status_index'); - $t->dropColumn('bio'); - }); - } -}; diff --git a/examples/migrations/CreateUsersTable.php b/examples/migrations/CreateUsersTable.php deleted file mode 100644 index cdc354f..0000000 --- a/examples/migrations/CreateUsersTable.php +++ /dev/null @@ -1,30 +0,0 @@ -create('users', function (Blueprint $t) { - $t->increments('id'); - $t->string('email', 190); - $t->unique(['email']); - $t->string('name', 255)->nullable(); - $t->string('status', 50)->nullable(); - $t->timestamps(); - $t->datetime('deleted_at')->nullable(); - }); - } - - public function down(ConnectionInterface $connection): void - { - $schema = SchemaManager::forConnection($connection); - $schema->dropIfExists('users'); - } -}; diff --git a/examples/mysql_crud.php b/examples/mysql_crud.php deleted file mode 100644 index dc7a3cf..0000000 --- a/examples/mysql_crud.php +++ /dev/null @@ -1,72 +0,0 @@ - 'mysql', - 'host' => '127.0.0.1', - 'port' => 3306, - 'database' => 'app', - 'username' => 'root', - 'password' => 'secret', - 'charset' => 'utf8mb4', -]); - -// 2) Define DTO, DAO, and Repository for `users` table - -class UserDto extends AbstractDto {} - -class UserDao extends AbstractDao -{ - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return UserDto::class; } - - // Demonstrate schema metadata (casts) - protected function schema(): array - { - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'email' => ['cast' => 'string'], - 'name' => ['cast' => 'string'], - 'status' => ['cast' => 'string'], - ], - // Uncomment if your table has these columns - // 'timestamps' => [ 'createdAt' => 'created_at', 'updatedAt' => 'updated_at' ], - // 'softDeletes' => [ 'enabled' => true, 'deletedAt' => 'deleted_at' ], - ]; - } -} - -$dao = new UserDao($conn); - -// 3) Create (INSERT) -$user = new UserDto([ - 'email' => 'alice@example.com', - 'name' => 'Alice', - 'status'=> 'active', -]); -$created = $dao->insert($user->toArray()); -echo "Created user ID: " . ($created->toArray()['id'] ?? 'N/A') . PHP_EOL; - -// 4) Read (SELECT) -$found = $dao->findOneBy(['email' => 'alice@example.com']); -echo 'Found: ' . json_encode($found?->toArray()) . PHP_EOL; - -// 5) Update -$data = $found?->toArray() ?? []; -$data['name'] = 'Alice Updated'; -$updated = $dao->update($data['id'], ['name' => 'Alice Updated']); -echo 'Updated: ' . json_encode($updated->toArray()) . PHP_EOL; - -// 6) Delete -$deleted = $dao->deleteBy(['email' => 'alice@example.com']); -echo "Deleted rows: {$deleted}" . PHP_EOL; diff --git a/examples/mysql_join_eager_demo.php b/examples/mysql_join_eager_demo.php deleted file mode 100644 index 27712e1..0000000 --- a/examples/mysql_join_eager_demo.php +++ /dev/null @@ -1,106 +0,0 @@ - 'mysql', - 'host' => getenv('MYSQL_HOST') ?: '127.0.0.1', - 'port' => (int)(getenv('MYSQL_PORT') ?: 3306), - 'database' => getenv('MYSQL_DB') ?: 'app', - 'username' => getenv('MYSQL_USER') ?: 'root', - 'password' => getenv('MYSQL_PASS') ?: 'secret', - 'charset' => 'utf8mb4', -]); - -// Ensure demo tables (idempotent) -$conn->execute('CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(190) NOT NULL -)'); -$conn->execute('CREATE TABLE IF NOT EXISTS posts ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - title VARCHAR(190) NOT NULL, - deleted_at DATETIME NULL -)'); - -class UserDto extends AbstractDto {} -class PostDto extends AbstractDto {} - -class PostDao extends AbstractDao { - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return PostDto::class; } - protected function schema(): array { return [ - 'primaryKey' => 'id', - 'columns' => [ 'id'=>['cast'=>'int'], 'user_id'=>['cast'=>'int'], 'title'=>['cast'=>'string'], 'deleted_at'=>['cast'=>'datetime'] ], - 'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'], - ]; } -} - -class UserDao extends AbstractDao { - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return UserDto::class; } - protected function relations(): array { - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => PostDao::class, - 'foreignKey' => 'user_id', - 'localKey' => 'id', - ], - ]; - } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } -} - -$userDao = new UserDao($conn); -$postDao = new PostDao($conn); - -// Clean minimal (demo only) -foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); } -foreach ($postDao->findAllBy() as $p) { $postDao->deleteById((int)$p->toArray(false)['id']); } - -// Seed -$u1 = $userDao->insert(['name' => 'Alice']); -$u2 = $userDao->insert(['name' => 'Bob']); -$uid1 = (int)$u1->toArray(false)['id']; -$uid2 = (int)$u2->toArray(false)['id']; -$postDao->insert(['user_id' => $uid1, 'title' => 'P1']); -$postDao->insert(['user_id' => $uid1, 'title' => 'P2']); -$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]); - -// Baseline portable eager (batched IN) -$batched = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]); -echo "Batched eager: \n"; -foreach ($batched as $u) { - $arr = $u->toArray(false); - $titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []); - echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n"; -} - -// Join-based eager (global opt-in) -$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]); -echo "\nJoin eager (global): \n"; -foreach ($joined as $u) { - $arr = $u->toArray(false); - $titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []); - echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n"; -} - -// Per-relation join hint (equivalent in this single-rel case) -$hinted = $userDao->fields('id','name','posts.title') - ->with(['posts' => ['strategy' => 'join']]) - ->findAllBy([]); // will fallback to batched if conditions not met -echo "\nJoin eager (per-relation hint): \n"; -foreach ($hinted as $u) { - $arr = $u->toArray(false); - $titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []); - echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n"; -} diff --git a/examples/mysql_relations_pivot.php b/examples/mysql_relations_pivot.php deleted file mode 100644 index 9e451fc..0000000 --- a/examples/mysql_relations_pivot.php +++ /dev/null @@ -1,94 +0,0 @@ - 'mysql', - 'host' => '127.0.0.1', - 'port' => 3306, - 'database' => 'app', - 'username' => 'root', - 'password' => 'secret', - 'charset' => 'utf8mb4', -]); - -// Ensure demo tables (idempotent) -$conn->execute('CREATE TABLE IF NOT EXISTS users ( - id INT AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(190) NOT NULL -)'); -$conn->execute('CREATE TABLE IF NOT EXISTS roles ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(190) NOT NULL -)'); -$conn->execute('CREATE TABLE IF NOT EXISTS user_role ( - user_id INT NOT NULL, - role_id INT NOT NULL -)'); - -class UserDto extends AbstractDto {} -class RoleDto extends AbstractDto {} - -class RoleDao extends AbstractDao { - public function getTable(): string { return 'roles'; } - protected function dtoClass(): string { return RoleDto::class; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } -} - -class UserDao extends AbstractDao { - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return UserDto::class; } - protected function relations(): array { - return [ - 'roles' => [ - 'type' => 'belongsToMany', - 'dao' => RoleDao::class, - 'pivot' => 'user_role', - 'foreignPivotKey' => 'user_id', - 'relatedPivotKey' => 'role_id', - 'localKey' => 'id', - 'relatedKey' => 'id', - ], - ]; - } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; } -} - -$roleDao = new RoleDao($conn); -$userDao = new UserDao($conn); - -// Clean minimal (demo only) -foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); } -foreach ($roleDao->findAllBy() as $r) { $roleDao->deleteById((int)$r->toArray(false)['id']); } -$conn->execute('DELETE FROM user_role'); - -// Seed -$admin = $roleDao->insert(['name' => 'admin']); -$editor = $roleDao->insert(['name' => 'editor']); -$u = $userDao->insert(['email' => 'pivot@example.com']); -$uid = (int)$u->toArray(false)['id']; -$ridAdmin = (int)$admin->toArray(false)['id']; -$ridEditor = (int)$editor->toArray(false)['id']; - -// Attach roles -$userDao->attach('roles', $uid, [$ridAdmin, $ridEditor]); - -$with = $userDao->with(['roles'])->findById($uid); -echo "User with roles: " . json_encode($with?->toArray(true)) . "\n"; - -// Detach one role -$userDao->detach('roles', $uid, [$ridAdmin]); -$with = $userDao->with(['roles'])->findById($uid); -echo "After detach: " . json_encode($with?->toArray(true)) . "\n"; - -// Sync to only [editor] -$userDao->sync('roles', $uid, [$ridEditor]); -$with = $userDao->with(['roles'])->findById($uid); -echo "After sync: " . json_encode($with?->toArray(true)) . "\n"; diff --git a/examples/nosql/mongo_crud.php b/examples/nosql/mongo_crud.php deleted file mode 100644 index 201a0eb..0000000 --- a/examples/nosql/mongo_crud.php +++ /dev/null @@ -1,50 +0,0 @@ - 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin', - 'host' => '127.0.0.1', - 'port' => 27017, -]); - -$db = 'pairity_demo'; -$col = 'users'; - -// Clean collection for demo -foreach ($conn->find($db, $col, []) as $doc) { - $conn->deleteOne($db, $col, ['_id' => $doc['_id']]); -} - -// Insert -$id = $conn->insertOne($db, $col, [ - 'email' => 'mongo@example.com', - 'name' => 'Mongo User', - 'status'=> 'active', -]); -echo "Inserted _id={$id}\n"; - -// Find -$found = $conn->find($db, $col, ['_id' => $id]); -echo 'Found: ' . json_encode($found, JSON_UNESCAPED_SLASHES) . PHP_EOL; - -// Update -$conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['name' => 'Updated Mongo User']]); -$after = $conn->find($db, $col, ['_id' => $id]); -echo 'After update: ' . json_encode($after, JSON_UNESCAPED_SLASHES) . PHP_EOL; - -// Aggregate (simple match projection) -$agg = $conn->aggregate($db, $col, [ - ['$match' => ['status' => 'active']], - ['$project' => ['email' => 1, 'name' => 1]], -]); -echo 'Aggregate: ' . json_encode($agg, JSON_UNESCAPED_SLASHES) . PHP_EOL; - -// Delete -$deleted = $conn->deleteOne($db, $col, ['_id' => $id]); -echo "Deleted: {$deleted}\n"; diff --git a/examples/nosql/mongo_dao_crud.php b/examples/nosql/mongo_dao_crud.php deleted file mode 100644 index afdbd0f..0000000 --- a/examples/nosql/mongo_dao_crud.php +++ /dev/null @@ -1,62 +0,0 @@ - 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin', - 'host' => '127.0.0.1', - 'port' => 27017, -]); - -class UserDoc extends AbstractDto {} - -class UserMongoDao extends AbstractMongoDao -{ - protected function collection(): string { return 'pairity_demo.users'; } - protected function dtoClass(): string { return UserDoc::class; } -} - -$dao = new UserMongoDao($conn); - -// Clean for demo -foreach ($dao->findAllBy([]) as $dto) { - $id = (string)($dto->toArray(false)['_id'] ?? ''); - if ($id) { $dao->deleteById($id); } -} - -// Insert -$created = $dao->insert([ - 'email' => 'mongo@example.com', - 'name' => 'Mongo User', - 'status'=> 'active', -]); -echo 'Inserted: ' . json_encode($created->toArray(false)) . "\n"; - -// Find by dynamic helper -$found = $dao->findOneByEmail('mongo@example.com'); -echo 'Found: ' . json_encode($found?->toArray(false)) . "\n"; - -// Update -if ($found) { - $id = (string)$found->toArray(false)['_id']; - $updated = $dao->update($id, ['name' => 'Updated Mongo User']); - echo 'Updated: ' . json_encode($updated->toArray(false)) . "\n"; -} - -// Projection + sort + limit -$list = $dao->fields('email', 'name')->sort(['email' => 1])->limit(10)->findAllBy(['status' => 'active']); -echo 'List (projected): ' . json_encode(array_map(fn($d) => $d->toArray(false), $list)) . "\n"; - -// Delete -if ($found) { - $id = (string)$found->toArray(false)['_id']; - $deleted = $dao->deleteById($id); - echo "Deleted: {$deleted}\n"; -} diff --git a/examples/nosql/mongo_pagination.php b/examples/nosql/mongo_pagination.php deleted file mode 100644 index 840a069..0000000 --- a/examples/nosql/mongo_pagination.php +++ /dev/null @@ -1,74 +0,0 @@ - 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin', - 'host' => '127.0.0.1', - 'port' => 27017, -]); - -class UserDoc extends AbstractDto {} -class PostDoc extends AbstractDto {} - -class PostMongoDao extends AbstractMongoDao -{ - protected function collection(): string { return 'pairity_demo.pg_posts'; } - protected function dtoClass(): string { return PostDoc::class; } -} - -class UserMongoDao extends AbstractMongoDao -{ - protected function collection(): string { return 'pairity_demo.pg_users'; } - protected function dtoClass(): string { return UserDoc::class; } - protected function relations(): array - { - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => PostMongoDao::class, - 'foreignKey' => 'user_id', - 'localKey' => '_id', - ], - ]; - } -} - -$userDao = new UserMongoDao($conn); -$postDao = new PostMongoDao($conn); - -// Clean collections for demo -foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } } -foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } } - -// Seed 22 users; every 3rd has a post -for ($i=1; $i<=22; $i++) { - $status = $i % 2 === 0 ? 'active' : 'inactive'; - $u = $userDao->insert(['email' => "mp{$i}@example.com", 'status' => $status]); - $uid = (string)($u->toArray(false)['_id'] ?? ''); - if ($i % 3 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Post '.$i]); } -} - -// Paginate -$page1 = $userDao->paginate(1, 10, []); -echo "Page1 total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n"; - -// Simple paginate -$sp = $userDao->simplePaginate(3, 10, []); -echo 'Simple nextPage on page 3: ' . json_encode($sp['nextPage']) . "\n"; - -// Projection + sort + eager relation -$with = $userDao->fields('email','posts.title')->sort(['email' => 1])->with(['posts'])->paginate(1, 5, []); -echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n"; - -// Named scope example -$userDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; }); -$active = $userDao->active()->paginate(1, 100, []); -echo "Active total: {$active['total']}\n"; diff --git a/examples/nosql/mongo_relations_demo.php b/examples/nosql/mongo_relations_demo.php deleted file mode 100644 index 439aa54..0000000 --- a/examples/nosql/mongo_relations_demo.php +++ /dev/null @@ -1,75 +0,0 @@ - [ - 'type' => 'hasMany', - 'dao' => PostMongoDao::class, - 'foreignKey' => 'user_id', - 'localKey' => '_id', - ], - ]; - } -} - -class PostMongoDao extends AbstractMongoDao -{ - protected function collection(): string { return 'pairity_demo.posts'; } - protected function dtoClass(): string { return PostDoc::class; } - protected function relations(): array - { - return [ - 'user' => [ - 'type' => 'belongsTo', - 'dao' => UserMongoDao::class, - 'foreignKey' => 'user_id', - 'otherKey' => '_id', - ], - ]; - } -} - -$conn = MongoConnectionManager::make([ - 'host' => '127.0.0.1', - 'port' => 27017, -]); - -$userDao = new UserMongoDao($conn); -$postDao = new PostMongoDao($conn); - -// Clean -foreach ($postDao->findAllBy([]) as $p) { $postDao->deleteById((string)$p->toArray(false)['_id']); } -foreach ($userDao->findAllBy([]) as $u) { $userDao->deleteById((string)$u->toArray(false)['_id']); } - -// Seed -$u = $userDao->insert(['email' => 'mongo@example.com', 'name' => 'Alice']); -$uid = (string)$u->toArray(false)['_id']; -$p1 = $postDao->insert(['title' => 'First', 'user_id' => $uid]); -$p2 = $postDao->insert(['title' => 'Second', 'user_id' => $uid]); - -// Eager load posts on users -$users = $userDao->fields('email', 'name', 'posts.title')->with(['posts'])->findAllBy([]); -echo 'Users with posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $users)) . "\n"; - -// Lazy load user on a post -$onePost = $postDao->findOneBy(['title' => 'First']); -if ($onePost) { - $postDao->load($onePost, 'user'); - echo 'Post with user: ' . json_encode($onePost->toArray()) . "\n"; -} diff --git a/examples/run_migrations_sqlite.php b/examples/run_migrations_sqlite.php deleted file mode 100644 index 914c75e..0000000 --- a/examples/run_migrations_sqlite.php +++ /dev/null @@ -1,32 +0,0 @@ - 'sqlite', - 'path' => __DIR__ . '/../db.sqlite', -]); - -// Load migrations (here we just include a PHP file returning a MigrationInterface instance) -$createUsers = require __DIR__ . '/migrations/CreateUsersTable.php'; - -$migrator = new Migrator($conn); -$migrator->setRegistry([ - 'CreateUsersTable' => $createUsers, -]); - -// Apply outstanding migrations -$applied = $migrator->migrate([ - 'CreateUsersTable' => $createUsers, -]); -echo 'Applied: ' . json_encode($applied) . PHP_EOL; - -// To roll back last batch, uncomment: -// $rolled = $migrator->rollback(1); -// echo 'Rolled back: ' . json_encode($rolled) . PHP_EOL; diff --git a/examples/sqlite_crud.php b/examples/sqlite_crud.php deleted file mode 100644 index 631acb7..0000000 --- a/examples/sqlite_crud.php +++ /dev/null @@ -1,95 +0,0 @@ - 'sqlite', - 'path' => __DIR__ . '/../db.sqlite', -]); - -// Create table for demo if not exists -$conn->execute('CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, - name TEXT, - status TEXT, - created_at TEXT NULL, - updated_at TEXT NULL, - deleted_at TEXT NULL -)'); - -// 2) Define DTO, DAO, and Repository for `users` table - -class UserDto extends AbstractDto {} - -class UserDao extends AbstractDao -{ - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return UserDto::class; } - - // Demonstrate schema metadata (casts) - protected function schema(): array - { - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'email' => ['cast' => 'string'], - 'name' => ['cast' => 'string'], - 'status' => ['cast' => 'string'], - 'created_at' => ['cast' => 'datetime'], - 'updated_at' => ['cast' => 'datetime'], - 'deleted_at' => ['cast' => 'datetime'], - ], - 'timestamps' => [ 'createdAt' => 'created_at', 'updatedAt' => 'updated_at' ], - 'softDeletes' => [ 'enabled' => true, 'deletedAt' => 'deleted_at' ], - ]; - } -} - -$dao = new UserDao($conn); - -// 3) Create (INSERT) -$user = new UserDto([ - 'email' => 'bob@example.com', - 'name' => 'Bob', - 'status'=> 'active', -]); -$created = $dao->insert($user->toArray()); -echo "Created user ID: " . ($created->toArray()['id'] ?? 'N/A') . PHP_EOL; - -// 4) Read (SELECT) -$found = $dao->findOneBy(['email' => 'bob@example.com']); -echo 'Found: ' . json_encode($found?->toArray()) . PHP_EOL; - -// 5) Update -$data = $found?->toArray() ?? []; -$data['name'] = 'Bob Updated'; -$updated = $dao->update($data['id'], ['name' => 'Bob Updated']); -echo 'Updated: ' . json_encode($updated->toArray()) . PHP_EOL; - -// 6) Delete -// 6) Soft Delete -$deleted = $dao->deleteBy(['email' => 'bob@example.com']); -echo "Soft-deleted rows: {$deleted}" . PHP_EOL; - -// 7) Query scopes -$all = $dao->withTrashed()->findAllBy(); -echo 'All (with trashed): ' . count($all) . PHP_EOL; -$trashedOnly = $dao->onlyTrashed()->findAllBy(); -echo 'Only trashed: ' . count($trashedOnly) . PHP_EOL; - -// 8) Restore then force delete -if ($found) { - $dao->restoreById($found->toArray()['id']); - echo "Restored ID {$found->toArray()['id']}\n"; - $dao->forceDeleteById($found->toArray()['id']); - echo "Force-deleted ID {$found->toArray()['id']}\n"; -} diff --git a/examples/sqlite_pagination.php b/examples/sqlite_pagination.php deleted file mode 100644 index 697d48e..0000000 --- a/examples/sqlite_pagination.php +++ /dev/null @@ -1,83 +0,0 @@ - 'sqlite', - 'path' => __DIR__ . '/../db.sqlite', -]); - -// Demo tables -$conn->execute('CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT, - status TEXT -)'); -$conn->execute('CREATE TABLE IF NOT EXISTS posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - title TEXT -)'); - -class UserDto extends AbstractDto {} -class PostDto extends AbstractDto {} - -class PostDao extends AbstractDao { - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return PostDto::class; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; } -} - -class UserDao extends AbstractDao { - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return UserDto::class; } - protected function relations(): array { - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => PostDao::class, - 'foreignKey' => 'user_id', - 'localKey' => 'id', - ], - ]; - } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; } -} - -$userDao = new UserDao($conn); -$postDao = new PostDao($conn); - -// Seed a few users if table is empty -$hasAny = $userDao->findAllBy(); -if (!$hasAny) { - for ($i=1; $i<=25; $i++) { - $status = $i % 2 === 0 ? 'active' : 'inactive'; - $u = $userDao->insert(['email' => "p{$i}@example.com", 'status' => $status]); - $uid = (int)($u->toArray(false)['id'] ?? 0); - if ($i % 5 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Hello '.$i]); } - } -} - -// Paginate (page 1, perPage 10) -$page1 = $userDao->paginate(1, 10); -echo "Page 1: total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n"; - -// Simple paginate (detect next page) -$sp = $userDao->simplePaginate(1, 10); -echo 'Simple nextPage: ' . json_encode($sp['nextPage']) . "\n"; - -// Projection + eager load -$with = $userDao->fields('id','email','posts.title')->with(['posts'])->paginate(1, 5); -echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n"; - -// Named scope -$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; }); -$active = $userDao->active()->paginate(1, 50); -echo "Active total: {$active['total']}\n"; diff --git a/examples/uow_locking_snapshot.php b/examples/uow_locking_snapshot.php deleted file mode 100644 index 347eaac..0000000 --- a/examples/uow_locking_snapshot.php +++ /dev/null @@ -1,73 +0,0 @@ - 'sqlite', - 'path' => __DIR__ . '/../db.sqlite', -]); - -// Ensure table -$conn->execute('CREATE TABLE IF NOT EXISTS uow_users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - version INTEGER NOT NULL DEFAULT 0 -)'); - -class UserDto extends AbstractDto {} - -class UserDao extends AbstractDao { - public function getTable(): string { return 'uow_users'; } - protected function dtoClass(): string { return UserDto::class; } - protected function schema(): array - { - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'name' => ['cast' => 'string'], - 'version' => ['cast' => 'int'], - ], - // Enable optimistic locking on integer version column - 'locking' => ['type' => 'version', 'column' => 'version'], - ]; - } -} - -$dao = new UserDao($conn); - -// Clean for demo -foreach ($dao->findAllBy() as $row) { - $dao->deleteById((int)($row->toArray(false)['id'] ?? 0)); -} - -// Create one -$u = $dao->insert(['name' => 'Alice']); -$id = (int)($u->toArray(false)['id'] ?? 0); - -// Demonstrate UoW with snapshot diffing: modify DTO then commit -UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) { - // Enable snapshot diffing (optional) - $uow->enableSnapshots(true); - - // Load and modify the DTO directly (no explicit update call) - $user = $dao->findById($id); - if ($user) { - // mutate DTO attributes - $user->setRelation('name', 'Alice (uow)'); - } - - // Also stage an explicit update to show coalescing - $dao->update($id, ['name' => 'Alice (explicit)']); -}); - -$after = $dao->findById($id); -echo 'After UoW commit: ' . json_encode($after?->toArray(false)) . "\n"; diff --git a/pairity-schema.json b/pairity-schema.json new file mode 100644 index 0000000..4825c27 --- /dev/null +++ b/pairity-schema.json @@ -0,0 +1,161 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Pairity Table Definition", + "type": "object", + "properties": { + "prefix": { + "type": "string" + }, + "tenancy": { + "type": "boolean" + }, + "auditable": { + "type": "boolean" + }, + "timestamps": { + "type": "boolean" + }, + "softDeletes": { + "type": "boolean" + }, + "inheritance": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "sti", + "cti" + ] + }, + "discriminator": { + "type": "string" + }, + "parent": { + "type": "string" + } + } + }, + "morph": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + } + } + }, + "columns": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string" + }, + "length": { + "type": "integer" + }, + "precision": { + "type": "integer" + }, + "scale": { + "type": "integer" + }, + "nullable": { + "type": "boolean" + }, + "unique": { + "type": "boolean" + }, + "primary": { + "type": "boolean" + }, + "index": { + "type": "boolean" + }, + "encrypted": { + "type": "boolean" + }, + "unsigned": { + "type": "boolean" + }, + "autoIncrement": { + "type": "boolean" + }, + "default": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "comment": { + "type": "string" + } + } + } + ] + } + }, + "indexes": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "relations": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "type", + "model" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "belongsTo", + "hasOne", + "hasMany", + "belongsToMany", + "morphTo", + "morphOne", + "morphMany" + ] + }, + "model": { + "type": "string" + }, + "foreignKey": { + "type": "string" + }, + "localKey": { + "type": "string" + }, + "pivotTable": { + "type": "string" + } + } + } + } + }, + "required": [ + "columns" + ] +} \ No newline at end of file diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php new file mode 100644 index 0000000..2436415 --- /dev/null +++ b/src/Cache/FileCache.php @@ -0,0 +1,196 @@ +ensureDirectoryExists(); + } + + /** + * @inheritDoc + */ + public function get(string $key, mixed $default = null): mixed + { + $file = $this->getFilePath($key); + + if (!file_exists($file)) { + return $default; + } + + $content = file_get_contents($file); + if ($content === false) { + return $default; + } + + try { + $data = unserialize($content); + } catch (\Throwable) { + return $default; + } + + if ($data['expires_at'] !== null && $data['expires_at'] < time()) { + $this->delete($key); + return $default; + } + + return $data['value']; + } + + /** + * @inheritDoc + */ + public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool + { + $expiresAt = $this->calculateExpiry($ttl); + $file = $this->getFilePath($key); + + $data = [ + 'value' => $value, + 'expires_at' => $expiresAt, + ]; + + return file_put_contents($file, serialize($data)) !== false; + } + + /** + * @inheritDoc + */ + public function delete(string $key): bool + { + $file = $this->getFilePath($key); + + if (file_exists($file)) { + return unlink($file); + } + + return true; + } + + /** + * @inheritDoc + */ + public function clear(): bool + { + $files = glob($this->directory . '/*.cache'); + foreach ($files as $file) { + unlink($file); + } + return true; + } + + /** + * @inheritDoc + */ + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $results = []; + foreach ($keys as $key) { + $results[$key] = $this->get($key, $default); + } + return $results; + } + + /** + * @inheritDoc + */ + public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool + { + foreach ($values as $key => $value) { + if (!$this->set($key, $value, $ttl)) { + return false; + } + } + return true; + } + + /** + * @inheritDoc + */ + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete($key); + } + return true; + } + + /** + * @inheritDoc + */ + public function has(string $key): bool + { + return $this->get($key, $this) !== $this; + } + + /** + * Get the file path for a cache key. + * + * @param string $key + * @return string + */ + protected function getFilePath(string $key): string + { + return rtrim($this->directory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . md5($key) . '.cache'; + } + + /** + * Calculate the expiry timestamp. + * + * @param \DateInterval|int|null $ttl + * @return int|null + */ + protected function calculateExpiry(\DateInterval|int|null $ttl): ?int + { + if ($ttl === null) { + return null; + } + + if ($ttl instanceof \DateInterval) { + $now = new \DateTime(); + $now->add($ttl); + return $now->getTimestamp(); + } + + return time() + $ttl; + } + + /** + * Ensure the cache directory exists and is writable. + * + * @return void + * @throws RuntimeException + */ + protected function ensureDirectoryExists(): void + { + if (!is_dir($this->directory)) { + if (!mkdir($this->directory, 0755, true) && !is_dir($this->directory)) { + throw new RuntimeException("Directory [{$this->directory}] was not created."); + } + } + + if (!is_writable($this->directory)) { + throw new RuntimeException("Directory [{$this->directory}] is not writable."); + } + } +} diff --git a/src/Console/AbstractCommand.php b/src/Console/AbstractCommand.php deleted file mode 100644 index 4379b33..0000000 --- a/src/Console/AbstractCommand.php +++ /dev/null @@ -1,102 +0,0 @@ -loadConfig($args); - return ConnectionManager::make($config); - } - - protected function loadConfig(array $args): array - { - if (isset($args['config'])) { - $path = (string)$args['config']; - if (!is_file($path)) { - throw new \InvalidArgumentException("Config file not found: {$path}"); - } - $cfg = require $path; - if (!is_array($cfg)) { - throw new \InvalidArgumentException('Config file must return an array'); - } - return $cfg; - } - - $driver = getenv('DB_DRIVER') ?: null; - if ($driver) { - $driver = strtolower($driver); - $cfg = ['driver' => $driver]; - switch ($driver) { - case 'mysql': - case 'mariadb': - $cfg += [ - 'host' => getenv('DB_HOST') ?: '127.0.0.1', - 'port' => (int)(getenv('DB_PORT') ?: 3306), - 'database' => getenv('DB_DATABASE') ?: '', - 'username' => getenv('DB_USERNAME') ?: null, - 'password' => getenv('DB_PASSWORD') ?: null, - 'charset' => getenv('DB_CHARSET') ?: 'utf8mb4', - ]; - break; - case 'pgsql': - case 'postgres': - case 'postgresql': - $cfg += [ - 'host' => getenv('DB_HOST') ?: '127.0.0.1', - 'port' => (int)(getenv('DB_PORT') ?: 5432), - 'database' => getenv('DB_DATABASE') ?: '', - 'username' => getenv('DB_USERNAME') ?: null, - 'password' => getenv('DB_PASSWORD') ?: null, - ]; - break; - case 'sqlite': - $cfg += [ - 'path' => getenv('DB_PATH') ?: 'db.sqlite', - ]; - break; - case 'sqlsrv': - case 'mssql': - $cfg += [ - 'host' => getenv('DB_HOST') ?: '127.0.0.1', - 'port' => (int)(getenv('DB_PORT') ?: 1433), - 'database' => getenv('DB_DATABASE') ?: '', - 'username' => getenv('DB_USERNAME') ?: null, - 'password' => getenv('DB_PASSWORD') ?: null, - ]; - break; - } - return $cfg; - } - - // Fallback to SQLite in project root - return [ - 'driver' => 'sqlite', - 'path' => 'db.sqlite' - ]; - } - - protected function getMigrationsDir(array $args): string - { - $dir = $args['path'] ?? 'migrations'; - if (!str_starts_with($dir, '/') && !str_starts_with($dir, './')) { - $dir = getcwd() . DIRECTORY_SEPARATOR . $dir; - } - return $dir; - } -} diff --git a/src/Console/Application.php b/src/Console/Application.php new file mode 100644 index 0000000..3b48624 --- /dev/null +++ b/src/Console/Application.php @@ -0,0 +1,167 @@ + + */ + protected array $commands = []; + + /** + * Application constructor. + * + * @param ContainerInterface $container The service container instance. + * @param string $name The application name. + * @param string $version The application version. + */ + public function __construct( + protected ContainerInterface $container, + protected string $name = 'Pairity CLI', + protected string $version = '1.0.0' + ) { + } + + /** + * Register a command with the application. + * + * @param string|CommandInterface $command The command class name or instance. + * @return void + */ + public function add(string|CommandInterface $command): void + { + if (is_string($command)) { + $command = $this->container->make($command); + } + + if (!$command instanceof CommandInterface) { + throw new RuntimeException('Command must implement Pairity\Contracts\Console\CommandInterface'); + } + + $this->commands[$command->getName()] = $command; + } + + /** + * Run the application. + * + * @param array $argv The raw command line arguments (usually from $_SERVER['argv']). + * @return int The exit code. + */ + public function run(array $argv): int + { + // Remove the script name from argv + array_shift($argv); + + if (empty($argv)) { + $this->printHelp(); + return 0; + } + + $commandName = array_shift($argv); + + if ($commandName === '--version' || $commandName === '-V') { + $this->printVersion(); + return 0; + } + + if (!isset($this->commands[$commandName])) { + $message = $this->t('error.command_not_found', ['command' => $commandName]); + fwrite(STDERR, $message . "\n"); + return 1; + } + + $command = $this->commands[$commandName]; + + // Basic argument/option parsing (can be expanded later) + $args = []; + $options = []; + + foreach ($argv as $arg) { + if (str_starts_with($arg, '-')) { + $options[] = $arg; + } else { + $args[] = $arg; + } + } + + try { + return $command->execute($args, $options); + } catch (\Throwable $e) { + $message = $this->t('error.uncaught_exception', ['message' => $e->getMessage()]); + fwrite(STDERR, $message . "\n"); + return 1; + } + } + + /** + * Print the application version information. + * + * @return void + */ + protected function printVersion(): void + { + $line = $this->t('app.version', ['name' => $this->name, 'version' => $this->version]); + echo $line . "\n"; + } + + /** + * Print the help information including available commands. + * + * @return void + */ + protected function printHelp(): void + { + $this->printVersion(); + echo $this->t('app.usage'); + echo $this->t('app.available_commands') . "\n"; + + ksort($this->commands); + + foreach ($this->commands as $name => $command) { + printf(" %-20s %s\n", $name, $command->getDescription()); + } + } + + /** + * Translate a message if a Translator is available. + * Falls back to the key itself or plain text when not available. + * + * @param string $key + * @param array $replace + * @return string + */ + protected function t(string $key, array $replace = []): string + { + if ($this->container->has(TranslatorInterface::class)) { + /** @var TranslatorInterface $translator */ + $translator = $this->container->get(TranslatorInterface::class); + return $translator->trans($key, $replace); + } + + // Fallbacks for a few known keys to preserve previous behavior + return match ($key) { + 'app.usage' => "\nUsage:\n pairity [command] [arguments] [options]\n\n", + 'app.available_commands' => 'Available commands:', + 'app.version' => ($replace['name'] ?? 'Pairity CLI') . ' version ' . ($replace['version'] ?? '1.0.0'), + 'error.command_not_found' => "Command '" . ($replace['command'] ?? '') . "' not found.", + 'error.uncaught_exception' => 'Uncaught Exception: ' . ($replace['message'] ?? ''), + default => $key, + }; + } +} diff --git a/src/Console/CommandInterface.php b/src/Console/CommandInterface.php deleted file mode 100644 index d1d7d2d..0000000 --- a/src/Console/CommandInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - $args - */ - public function execute(array $args): void; -} diff --git a/src/Console/Commands/CacheClearCommand.php b/src/Console/Commands/CacheClearCommand.php new file mode 100644 index 0000000..c37284d --- /dev/null +++ b/src/Console/Commands/CacheClearCommand.php @@ -0,0 +1,84 @@ +t('command.cache_clear.description', 'Clear the Pairity metadata cache.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + echo $this->t('command.cache_clear.starting', 'Clearing metadata cache...') . "\n"; + + if ($this->metadataManager->clearCache()) { + echo $this->t('command.cache_clear.success', 'Metadata cache cleared successfully.') . "\n"; + return 0; + } + + echo $this->t('command.cache_clear.error', 'Failed to clear metadata cache.') . "\n"; + return 1; + } + + /** + * Translate a message if a Translator is available. + * + * @param string $key + * @param string $default + * @param array $replace + * @return string + */ + protected function t(string $key, string $default, array $replace = []): string + { + if ($this->translator) { + return $this->translator->trans($key, $replace); + } + + foreach ($replace as $placeholder => $value) { + $default = str_replace('{' . $placeholder . '}', (string) $value, $default); + } + + return $default; + } +} diff --git a/src/Console/Commands/DatabaseHealthCheckCommand.php b/src/Console/Commands/DatabaseHealthCheckCommand.php new file mode 100644 index 0000000..269aef5 --- /dev/null +++ b/src/Console/Commands/DatabaseHealthCheckCommand.php @@ -0,0 +1,94 @@ +t('command.db_health_check.description', 'Verify database connection health and heartbeat.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $connectionName = $args[0] ?? null; + + try { + $connection = $this->db->connection($connectionName); + $name = $connection->getName(); + + echo $this->t('command.db_health_check.checking', 'Checking health for connection: {name}...', ['name' => $name]) . "\n"; + + if ($connection->checkHealth()) { + echo $this->t('command.db_health_check.success', 'Connection {name} is healthy.', ['name' => $name]) . "\n"; + return 0; + } + + echo $this->t('command.db_health_check.failed', 'Connection {name} is unhealthy.', ['name' => $name]) . "\n"; + return 1; + } catch (\Throwable $e) { + echo $this->t('command.db_health_check.error', 'Error checking health: {message}', ['message' => $e->getMessage()]) . "\n"; + return 1; + } + } + + /** + * Translate a message if a Translator is available. + * + * @param string $key + * @param string $default + * @param array $replace + * @return string + */ + protected function t(string $key, string $default, array $replace = []): string + { + if ($this->translator) { + return $this->translator->trans($key, $replace); + } + + foreach ($replace as $placeholder => $value) { + $default = str_replace('{' . $placeholder . '}', (string) $value, $default); + } + + return $default; + } +} diff --git a/src/Console/Commands/GenerateJsonSchemaCommand.php b/src/Console/Commands/GenerateJsonSchemaCommand.php new file mode 100644 index 0000000..dbd8949 --- /dev/null +++ b/src/Console/Commands/GenerateJsonSchemaCommand.php @@ -0,0 +1,87 @@ +t('command.schema_json.description', 'Generate the JSON Schema for Pairity YAML table definitions.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $outputFile = $args[0] ?? 'pairity-schema.json'; + + try { + $json = $this->generator->generateJson(); + file_put_contents($outputFile, $json); + + echo $this->t('command.schema_json.success', 'JSON Schema generated successfully at: {path}', ['path' => $outputFile]) . "\n"; + return 0; + } catch (\Throwable $e) { + echo $this->t('command.schema_json.error', 'Error generating JSON Schema: {message}', ['message' => $e->getMessage()]) . "\n"; + return 1; + } + } + + /** + * Translate a message if a Translator is available. + * + * @param string $key + * @param string $default + * @param array $replace + * @return string + */ + protected function t(string $key, string $default, array $replace = []): string + { + if ($this->translator) { + return $this->translator->trans($key, $replace); + } + + foreach ($replace as $placeholder => $value) { + $default = str_replace('{' . $placeholder . '}', (string) $value, $default); + } + + return $default; + } +} diff --git a/src/Console/Commands/GenerateModelsCommand.php b/src/Console/Commands/GenerateModelsCommand.php new file mode 100644 index 0000000..c248b83 --- /dev/null +++ b/src/Console/Commands/GenerateModelsCommand.php @@ -0,0 +1,147 @@ +t('command.make_model.description', 'Generate DTO and DAO classes from YAML schema definitions.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $schemaPath = $args[0] ?? 'schema'; + $outputPath = $args[1] ?? 'src/Models'; // Default output path + + $namespace = 'App\\Models'; // Default namespace + + if (!is_dir($schemaPath)) { + echo $this->t('command.make_model.no_directory', 'Schema directory not found: {path}', ['path' => $schemaPath]) . "\n"; + return 1; + } + + $files = glob($schemaPath . '/*.yaml'); + if (empty($files)) { + echo $this->t('command.make_model.no_files', 'No YAML schema files found in: {path}', ['path' => $schemaPath]) . "\n"; + return 0; + } + + if (!is_dir($outputPath)) { + mkdir($outputPath, 0755, true); + } + + echo $this->t('command.make_model.starting', 'Generating DTO and DAO classes...') . "\n"; + + foreach ($files as $file) { + try { + $blueprint = $this->parser->parseFile($file); + + // Generate DTO + $dtoCode = $this->generator->generateDto($blueprint, $namespace . '\\DTO'); + $dtoPath = $outputPath . DIRECTORY_SEPARATOR . 'DTO'; + if (!is_dir($dtoPath)) mkdir($dtoPath, 0755, true); + $dtoFileName = $dtoPath . DIRECTORY_SEPARATOR . $this->studly($blueprint->getTableName()) . 'DTO.php'; + file_put_contents($dtoFileName, $dtoCode); + + // Generate DAO + $daoCode = $this->generator->generateDao($blueprint, $namespace . '\\DAO', $namespace . '\\DTO'); + $daoPath = $outputPath . DIRECTORY_SEPARATOR . 'DAO'; + if (!is_dir($daoPath)) mkdir($daoPath, 0755, true); + $daoFileName = $daoPath . DIRECTORY_SEPARATOR . $this->studly($blueprint->getTableName()) . 'DAO.php'; + file_put_contents($daoFileName, $daoCode); + + // Generate Hydrator + $hydratorCode = $this->generator->generateHydrator($blueprint, $namespace . '\\Hydrators', $namespace . '\\DTO\\' . $this->studly($blueprint->getTableName()) . 'DTO'); + $hydratorPath = $outputPath . DIRECTORY_SEPARATOR . 'Hydrators'; + if (!is_dir($hydratorPath)) mkdir($hydratorPath, 0755, true); + $hydratorFileName = $hydratorPath . DIRECTORY_SEPARATOR . $this->studly($blueprint->getTableName()) . 'Hydrator.php'; + file_put_contents($hydratorFileName, $hydratorCode); + + echo " - {$blueprint->getTableName()} -> " . $this->studly($blueprint->getTableName()) . " DTO/DAO/Hydrator\n"; + } catch (\Throwable $e) { + echo " - Error processing {$file}: {$e->getMessage()}\n"; + return 1; + } + } + + echo "\n" . $this->t('command.make_model.finished', 'Code generation completed successfully.') . "\n"; + return 0; + } + + /** + * Convert string to StudlyCase. + * + * @param string $value + * @return string + */ + protected function studly(string $value): string + { + $value = ucwords(str_replace(['-', '_'], ' ', $value)); + return str_replace(' ', '', $value); + } + + /** + * Translate a message if a Translator is available. + * + * @param string $key + * @param string $default + * @param array $replace + * @return string + */ + protected function t(string $key, string $default, array $replace = []): string + { + if ($this->translator) { + return $this->translator->trans($key, $replace); + } + + foreach ($replace as $placeholder => $value) { + $default = str_replace('{' . $placeholder . '}', (string) $value, $default); + } + + return $default; + } +} diff --git a/src/Console/Commands/GenerateSchemaSnapshotCommand.php b/src/Console/Commands/GenerateSchemaSnapshotCommand.php new file mode 100644 index 0000000..afc60ca --- /dev/null +++ b/src/Console/Commands/GenerateSchemaSnapshotCommand.php @@ -0,0 +1,117 @@ +t('command.schema_snapshot.description', 'Export the current YAML source of truth into a PHP baseline snapshot.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $schemaPath = $args[0] ?? 'schema'; + $snapshotPath = $args[1] ?? 'schema/snapshots'; + + if (!is_dir($schemaPath)) { + echo $this->t('command.schema_snapshot.no_directory', 'Schema directory not found: {path}', ['path' => $schemaPath]) . "\n"; + return 1; + } + + if (!is_dir($snapshotPath)) { + mkdir($snapshotPath, 0755, true); + } + + $files = glob($schemaPath . '/*.yaml'); + if (empty($files)) { + echo $this->t('command.schema_snapshot.no_files', 'No YAML schema files found in: {path}', ['path' => $schemaPath]) . "\n"; + return 0; + } + + echo $this->t('command.schema_snapshot.starting', 'Generating schema snapshots...') . "\n"; + + foreach ($files as $file) { + $tableName = pathinfo($file, PATHINFO_FILENAME); + try { + $blueprint = $this->parser->parseFile($file); + $phpCode = $this->serializer->toPhpCode($blueprint); + + $outputFile = $snapshotPath . DIRECTORY_SEPARATOR . $tableName . '.php'; + file_put_contents($outputFile, $phpCode); + + echo " - {$tableName} -> {$outputFile}\n"; + } catch (\Throwable $e) { + echo $this->t('command.schema_snapshot.error', 'Error processing {file}: {message}', ['file' => $file, 'message' => $e->getMessage()]) . "\n"; + return 1; + } + } + + echo "\n" . $this->t('command.schema_snapshot.finished', 'Snapshot generation completed successfully.') . "\n"; + return 0; + } + + /** + * Translate a message if a Translator is available. + * + * @param string $key + * @param string $default + * @param array $replace + * @return string + */ + protected function t(string $key, string $default, array $replace = []): string + { + if ($this->translator) { + return $this->translator->trans($key, $replace); + } + + foreach ($replace as $placeholder => $value) { + $default = str_replace('{' . $placeholder . '}', (string) $value, $default); + } + + return $default; + } +} diff --git a/src/Console/Commands/InitCommand.php b/src/Console/Commands/InitCommand.php new file mode 100644 index 0000000..7406b66 --- /dev/null +++ b/src/Console/Commands/InitCommand.php @@ -0,0 +1,98 @@ +t('command.init.description', 'Initialize the Pairity project structure.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $directories = [ + 'schema', + 'storage/cache', + 'src/Models/DTO', + 'src/Models/DAO', + 'src/Models/Hydrators', + 'src/Database/Migrations', + 'src/Database/Seeds', + 'src/Database/Factories', + ]; + + foreach ($directories as $dir) { + if (!is_dir($dir)) { + if (mkdir($dir, 0755, true)) { + echo "Created directory: {$dir}\n"; + } else { + echo "Failed to create directory: {$dir}\n"; + return 1; + } + } else { + echo "Directory already exists: {$dir}\n"; + } + } + + echo "Pairity initialization completed successfully.\n"; + return 0; + } + + /** + * Translate a message if a Translator is available. + * + * @param string $key + * @param string $default + * @param array $replace + * @return string + */ + protected function t(string $key, string $default, array $replace = []): string + { + if ($this->translator) { + return $this->translator->trans($key, $replace); + } + + foreach ($replace as $placeholder => $value) { + $default = str_replace('{' . $placeholder . '}', (string) $value, $default); + } + + return $default; + } +} diff --git a/src/Console/Commands/IntrospectCommand.php b/src/Console/Commands/IntrospectCommand.php new file mode 100644 index 0000000..930a744 --- /dev/null +++ b/src/Console/Commands/IntrospectCommand.php @@ -0,0 +1,82 @@ +translator->trans('command.make_yaml_fromdb.description', 'Reverse-engineer an existing database to generate YAML schema files.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $connectionName = $options['connection'] ?? $this->db->getDefaultConnection(); + $connection = $this->db->connection($connectionName); + $introspector = new Introspector($connection); + + $tables = $introspector->getTables(); + + if (empty($tables)) { + echo "No tables found in database [{$connectionName}].\n"; + return 0; + } + + $outputDir = 'schema'; + if (!is_dir($outputDir)) { + mkdir($outputDir, 0755, true); + } + + echo "Introspecting database [{$connectionName}]...\n"; + + foreach ($tables as $table) { + echo " - {$table}\n"; + $schema = $introspector->introspectTable($table); + $yaml = Yaml::dump($schema, 4); + file_put_contents($outputDir . '/' . $table . '.yaml', $yaml); + } + + echo "Schema files generated successfully in [{$outputDir}].\n"; + + return 0; + } +} diff --git a/src/Console/Commands/MakeFactoryCommand.php b/src/Console/Commands/MakeFactoryCommand.php new file mode 100644 index 0000000..51bf3b0 --- /dev/null +++ b/src/Console/Commands/MakeFactoryCommand.php @@ -0,0 +1,82 @@ +translator->trans('command.make_factory.description', 'Create a new factory class for a model.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $name = $args[0] ?? null; + + if (!$name) { + echo "Error: Factory name is required (e.g., UserFactory).\n"; + return 1; + } + + $path = 'src/Database/Factories/' . $name . '.php'; + + if (file_exists($path)) { + echo "Error: Factory [{$name}] already exists.\n"; + return 1; + } + + $directory = dirname($path); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + $modelName = str_replace('Factory', '', $name); + $dtoFqcn = 'App\\Models\\DTO\\' . $modelName . 'DTO'; + $daoFqcn = 'App\\Models\\DAO\\' . $modelName . 'DAO'; + + $content = "\n */\n public function definition(): array\n {\n return [\n // 'name' => 'John Doe',\n ];\n }\n\n /**\n * @inheritDoc\n */\n public function model(): string\n {\n return {$modelName}DTO::class;\n }\n\n /**\n * @inheritDoc\n */\n public function dao(): string\n {\n return {$modelName}DAO::class;\n }\n}\n"; + + file_put_contents($path, $content); + + echo "Factory created successfully at [{$path}].\n"; + + return 0; + } +} diff --git a/src/Console/Commands/MakeMigrationCommand.php b/src/Console/Commands/MakeMigrationCommand.php new file mode 100644 index 0000000..6e3046e --- /dev/null +++ b/src/Console/Commands/MakeMigrationCommand.php @@ -0,0 +1,75 @@ +translator->trans('command.make_migration.description', 'Create a new manual migration file for custom SQL or data changes.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $name = $args[0] ?? null; + + if (!$name) { + echo "Error: Migration name is required.\n"; + return 1; + } + + $timestamp = date('Y_m_d_His'); + $filename = $timestamp . '_' . $name . '.sql'; + $path = 'src/Database/Migrations/' . $filename; + + $directory = dirname($path); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + $content = "-- Pairity Migration: {$name}\n-- Created at: " . date('Y-m-d H:i:s') . "\n\n"; + + file_put_contents($path, $content); + + echo "Migration created successfully at [{$path}].\n"; + + return 0; + } +} diff --git a/src/Console/Commands/MakeSeederCommand.php b/src/Console/Commands/MakeSeederCommand.php new file mode 100644 index 0000000..a9d97ce --- /dev/null +++ b/src/Console/Commands/MakeSeederCommand.php @@ -0,0 +1,78 @@ +translator->trans('command.make_seeder.description', 'Create a new seeder class.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $name = $args[0] ?? null; + + if (!$name) { + echo "Error: Seeder name is required.\n"; + return 1; + } + + $path = 'src/Database/Seeds/' . $name . '.php'; + + if (file_exists($path)) { + echo "Error: Seeder [{$name}] already exists.\n"; + return 1; + } + + $directory = dirname($path); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + $content = "call(SomeOtherSeeder::class);\n }\n}\n"; + + file_put_contents($path, $content); + + echo "Seeder created successfully at [{$path}].\n"; + + return 0; + } +} diff --git a/src/Console/Commands/RunDataMigrationCommand.php b/src/Console/Commands/RunDataMigrationCommand.php new file mode 100644 index 0000000..38c21a7 --- /dev/null +++ b/src/Console/Commands/RunDataMigrationCommand.php @@ -0,0 +1,84 @@ +translator->trans('command.migration_data.description', 'Execute procedural PHP data migrations.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $class = $args[0] ?? null; + + if (!$class) { + echo "Error: Data migration class is required.\n"; + return 1; + } + + if (!str_contains($class, '\\')) { + $class = 'App\\Database\\Migrations\\' . $class; + } + + if (!class_exists($class)) { + echo "Error: Data migration class [{$class}] not found.\n"; + return 1; + } + + echo "Executing Data Migration: {$class}...\n"; + + /** @var \Pairity\Database\Migrations\DataMigration $migration */ + $migration = new $class($this->db); + + $rollback = in_array('--rollback', $options); + + if ($rollback) { + $migration->down(); + echo "Data Migration rolled back successfully.\n"; + } else { + $migration->up(); + echo "Data Migration executed successfully.\n"; + } + + return 0; + } +} diff --git a/src/Console/Commands/SchemaLintCommand.php b/src/Console/Commands/SchemaLintCommand.php new file mode 100644 index 0000000..22ac6fc --- /dev/null +++ b/src/Console/Commands/SchemaLintCommand.php @@ -0,0 +1,108 @@ +t('command.schema_lint.description', 'Lint Pairity YAML table definitions in the schema directory.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $schemaPath = $args[0] ?? 'schema'; + + if (!is_dir($schemaPath)) { + echo $this->t('command.schema_lint.no_directory', 'Schema directory not found: {path}', ['path' => $schemaPath]) . "\n"; + return 1; + } + + $files = glob($schemaPath . '/*.yaml'); + if (empty($files)) { + echo $this->t('command.schema_lint.no_files', 'No YAML schema files found in: {path}', ['path' => $schemaPath]) . "\n"; + return 0; + } + + $errors = 0; + foreach ($files as $file) { + try { + echo $this->t('command.schema_lint.checking', 'Linting {file}...', ['file' => $file]) . " "; + $this->parser->parseFile($file); + echo $this->t('command.schema_lint.ok', 'OK') . "\n"; + } catch (SchemaException $e) { + echo $this->t('command.schema_lint.error', 'ERROR: {message}', ['message' => $e->getMessage()]) . "\n"; + $errors++; + } + } + + if ($errors > 0) { + echo "\n" . $this->t('command.schema_lint.finished_errors', 'Linting finished with {count} error(s).', ['count' => $errors]) . "\n"; + return 1; + } + + echo "\n" . $this->t('command.schema_lint.finished_success', 'All schema files are valid.') . "\n"; + return 0; + } + + /** + * Translate a message if a Translator is available. + * + * @param string $key + * @param string $default + * @param array $replace + * @return string + */ + protected function t(string $key, string $default, array $replace = []): string + { + if ($this->translator) { + return $this->translator->trans($key, $replace); + } + + foreach ($replace as $placeholder => $value) { + $default = str_replace('{' . $placeholder . '}', (string) $value, $default); + } + + return $default; + } +} diff --git a/src/Console/Commands/SeedCommand.php b/src/Console/Commands/SeedCommand.php new file mode 100644 index 0000000..0e0bebf --- /dev/null +++ b/src/Console/Commands/SeedCommand.php @@ -0,0 +1,72 @@ +translator->trans('command.db_seed.description', 'Seed the database with records.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + $class = $args[0] ?? 'DatabaseSeeder'; + + if (!str_contains($class, '\\')) { + $class = 'App\\Database\\Seeds\\' . $class; + } + + if (!class_exists($class)) { + echo "Error: Seeder class [{$class}] not found.\n"; + return 1; + } + + echo "Seeding: {$class}...\n"; + + /** @var \Pairity\Database\Seeding\Seeder $seeder */ + $seeder = new $class($this->db); + $seeder->run(); + + echo "Database seeding completed successfully.\n"; + + return 0; + } +} diff --git a/src/Console/Commands/SyncCheckCommand.php b/src/Console/Commands/SyncCheckCommand.php new file mode 100644 index 0000000..314c939 --- /dev/null +++ b/src/Console/Commands/SyncCheckCommand.php @@ -0,0 +1,69 @@ +translator->trans('command.db_check_sync.description', 'Verify synchronization of manual migration files and seed files.'); + } + + /** + * @inheritDoc + */ + public function execute(array $args, array $options): int + { + echo "Checking synchronization...\n"; + + // This is a stub implementation as we don't have a migrations table yet. + // In a real implementation, this would compare files in filesystem with records in a meta-table. + + $migrationsDir = 'src/Database/Migrations'; + $seedsDir = 'src/Database/Seeds'; + + $migrations = is_dir($migrationsDir) ? glob($migrationsDir . '/*.sql') : []; + $seeds = is_dir($seedsDir) ? glob($seedsDir . '/*.php') : []; + + echo "Found " . count($migrations) . " manual migration(s).\n"; + echo "Found " . count($seeds) . " seed(s).\n"; + + echo "Synchronization check completed. (Note: Full tracking requires migration table implementation in next phases).\n"; + + return 0; + } +} diff --git a/src/Console/MakeMigrationCommand.php b/src/Console/MakeMigrationCommand.php deleted file mode 100644 index 5c8b584..0000000 --- a/src/Console/MakeMigrationCommand.php +++ /dev/null @@ -1,27 +0,0 @@ -stderr('Missing migration Name. Usage: pairity make:migration CreateUsersTable [--path=DIR]'); - exit(1); - } - - $dir = $this->getMigrationsDir($args); - if (!is_dir($dir)) { - mkdir($dir, 0755, true); - } - - $generator = new MigrationGenerator($args['template'] ?? null); - $file = $generator->generate($name, $dir); - - $this->stdout('Created: ' . $file); - } -} diff --git a/src/Console/MigrateCommand.php b/src/Console/MigrateCommand.php deleted file mode 100644 index 826ea68..0000000 --- a/src/Console/MigrateCommand.php +++ /dev/null @@ -1,38 +0,0 @@ -getConnection($args); - $dir = $this->getMigrationsDir($args); - $migrations = MigrationLoader::fromDirectory($dir); - - if (!$migrations) { - $this->stdout('No migrations found in ' . $dir); - return; - } - - $migrator = new Migrator($conn); - $migrator->setRegistry($migrations); - $pretend = isset($args['pretend']) && $args['pretend']; - $result = $migrator->migrate($migrations, $pretend); - - if ($pretend) { - $this->stdout('SQL to be executed:'); - foreach ($result as $log) { - $this->stdout($log['sql']); - if ($log['params']) { - $this->stdout(' Params: ' . json_encode($log['params'])); - } - } - } else { - $this->stdout('Applied: ' . json_encode($result)); - } - } -} diff --git a/src/Console/MongoCommands.php b/src/Console/MongoCommands.php deleted file mode 100644 index 495647e..0000000 --- a/src/Console/MongoCommands.php +++ /dev/null @@ -1,88 +0,0 @@ -loadConfig($args); - return MongoConnectionManager::make($config); - } -} - -class MongoIndexEnsureCommand extends AbstractMongoCommand -{ - public function execute(array $args): void - { - $db = $args[0] ?? null; - $col = $args[1] ?? null; - $keysJson = $args[2] ?? null; - - if (!$db || !$col || !$keysJson) { - $this->stderr('Usage: pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]'); - exit(1); - } - - $keys = json_decode($keysJson, true); - if (!is_array($keys)) { - $this->stderr('Invalid KEYS_JSON'); - exit(1); - } - - $options = []; - if (isset($args['unique']) && $args['unique']) { - $options['unique'] = true; - } - - $conn = $this->getMongoConnection($args); - $mgr = new IndexManager($conn, $db, $col); - $name = $mgr->ensureIndex($keys, $options); - - $this->stdout("Index created: {$name}"); - } -} - -class MongoIndexDropCommand extends AbstractMongoCommand -{ - public function execute(array $args): void - { - $db = $args[0] ?? null; - $col = $args[1] ?? null; - $name = $args[2] ?? null; - - if (!$db || !$col || !$name) { - $this->stderr('Usage: pairity mongo:index:drop DB COLLECTION NAME'); - exit(1); - } - - $conn = $this->getMongoConnection($args); - $mgr = new IndexManager($conn, $db, $col); - $mgr->dropIndex($name); - - $this->stdout("Index dropped: {$name}"); - } -} - -class MongoIndexListCommand extends AbstractMongoCommand -{ - public function execute(array $args): void - { - $db = $args[0] ?? null; - $col = $args[1] ?? null; - - if (!$db || !$col) { - $this->stderr('Usage: pairity mongo:index:list DB COLLECTION'); - exit(1); - } - - $conn = $this->getMongoConnection($args); - $mgr = new IndexManager($conn, $db, $col); - $indexes = $mgr->listIndexes(); - - $this->stdout(json_encode($indexes, JSON_PRETTY_PRINT)); - } -} diff --git a/src/Console/ResetCommand.php b/src/Console/ResetCommand.php deleted file mode 100644 index c2a3dc8..0000000 --- a/src/Console/ResetCommand.php +++ /dev/null @@ -1,42 +0,0 @@ -getConnection($args); - $dir = $this->getMigrationsDir($args); - $migrations = MigrationLoader::fromDirectory($dir); - - $migrator = new Migrator($conn); - $migrator->setRegistry($migrations); - - $pretend = isset($args['pretend']) && $args['pretend']; - $totalResult = []; - - while (true) { - $result = $migrator->rollback(1, $pretend); - if (!$result) { - break; - } - $totalResult = array_merge($totalResult, $result); - } - - if ($pretend) { - $this->stdout('SQL to be executed:'); - foreach ($totalResult as $log) { - $this->stdout($log['sql']); - if ($log['params']) { - $this->stdout(' Params: ' . json_encode($log['params'])); - } - } - } else { - $this->stdout('Reset complete. Rolled back: ' . json_encode($totalResult)); - } - } -} diff --git a/src/Console/RollbackCommand.php b/src/Console/RollbackCommand.php deleted file mode 100644 index f00f7ed..0000000 --- a/src/Console/RollbackCommand.php +++ /dev/null @@ -1,36 +0,0 @@ -getConnection($args); - $dir = $this->getMigrationsDir($args); - $migrations = MigrationLoader::fromDirectory($dir); - - $migrator = new Migrator($conn); - $migrator->setRegistry($migrations); - - $steps = isset($args['steps']) ? max(1, (int)$args['steps']) : 1; - $pretend = isset($args['pretend']) && $args['pretend']; - - $result = $migrator->rollback($steps, $pretend); - - if ($pretend) { - $this->stdout('SQL to be executed:'); - foreach ($result as $log) { - $this->stdout($log['sql']); - if ($log['params']) { - $this->stdout(' Params: ' . json_encode($log['params'])); - } - } - } else { - $this->stdout('Rolled back: ' . json_encode($result)); - } - } -} diff --git a/src/Console/StatusCommand.php b/src/Console/StatusCommand.php deleted file mode 100644 index be6b48e..0000000 --- a/src/Console/StatusCommand.php +++ /dev/null @@ -1,23 +0,0 @@ -getConnection($args); - $dir = $this->getMigrationsDir($args); - $migrations = array_keys(MigrationLoader::fromDirectory($dir)); - - $repo = new MigrationsRepository($conn); - $ran = $repo->getRan(); - $pending = array_values(array_diff($migrations, $ran)); - - $this->stdout('Ran: ' . json_encode($ran)); - $this->stdout('Pending: ' . json_encode($pending)); - } -} diff --git a/src/Container/Container.php b/src/Container/Container.php new file mode 100644 index 0000000..338116d --- /dev/null +++ b/src/Container/Container.php @@ -0,0 +1,213 @@ + + */ + protected array $bindings = []; + + /** + * The resolved singleton instances. + * + * @var array + */ + protected array $instances = []; + + /** + * @inheritDoc + */ + public function get(string $id): mixed + { + try { + return $this->make($id); + } catch (RuntimeException $e) { + throw $e; + } + } + + /** + * @inheritDoc + */ + public function has(string $id): bool + { + return isset($this->bindings[$id]) || isset($this->instances[$id]) || class_exists($id); + } + + /** + * @inheritDoc + */ + public function bind(string $abstract, mixed $concrete = null, bool $shared = false): void + { + if ($concrete === null) { + $concrete = $abstract; + } + + $this->bindings[$abstract] = [ + 'concrete' => $concrete, + 'shared' => $shared, + ]; + } + + /** + * @inheritDoc + */ + public function singleton(string $abstract, mixed $concrete = null): void + { + $this->bind($abstract, $concrete, true); + } + + /** + * @inheritDoc + */ + public function make(string $abstract): mixed + { + // If the instance is already resolved and shared, return it. + if (isset($this->instances[$abstract])) { + return $this->instances[$abstract]; + } + + $concrete = $this->getConcrete($abstract); + + // If the concrete is a closure or the same as abstract (needs instantiation) + if ($this->isBuildable($concrete, $abstract)) { + $object = $this->build($concrete); + } else { + $object = $this->make($concrete); + } + + // If it's a shared binding, store the instance. + if ($this->isShared($abstract)) { + $this->instances[$abstract] = $object; + } + + return $object; + } + + /** + * Get the concrete type for a given abstract. + * + * @param string $abstract + * @return mixed + */ + protected function getConcrete(string $abstract): mixed + { + if (isset($this->bindings[$abstract])) { + return $this->bindings[$abstract]['concrete']; + } + + return $abstract; + } + + /** + * Determine if the given concrete is buildable. + * + * @param mixed $concrete + * @param string $abstract + * @return bool + */ + protected function isBuildable(mixed $concrete, string $abstract): bool + { + return $concrete === $abstract || $concrete instanceof \Closure; + } + + /** + * Determine if a given abstract is shared. + * + * @param string $abstract + * @return bool + */ + protected function isShared(string $abstract): bool + { + return isset($this->bindings[$abstract]['shared']) && $this->bindings[$abstract]['shared'] === true; + } + + /** + * Instantiate a concrete instance of the given type. + * + * @param mixed $concrete + * @return mixed + * @throws RuntimeException + */ + protected function build(mixed $concrete): mixed + { + if ($concrete instanceof \Closure) { + return $concrete($this); + } + + try { + $reflector = new ReflectionClass($concrete); + } catch (ReflectionException $e) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new RuntimeException($translator->trans('error.container_class_not_found', ['class' => $concrete]), 0, $e); + } + + if (!$reflector->isInstantiable()) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new RuntimeException($translator->trans('error.container_not_instantiable', ['class' => $concrete])); + } + + $constructor = $reflector->getConstructor(); + + // If there is no constructor, we can just instantiate the class. + if ($constructor === null) { + return new $concrete(); + } + + $dependencies = $constructor->getParameters(); + $instances = $this->resolveDependencies($dependencies); + + return $reflector->newInstanceArgs($instances); + } + + /** + * Resolve all of the dependencies from the ReflectionParameters. + * + * @param array $dependencies + * @return array + */ + protected function resolveDependencies(array $dependencies): array + { + $results = []; + + foreach ($dependencies as $parameter) { + $type = $parameter->getType(); + + if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) { + if ($parameter->isDefaultValueAvailable()) { + $results[] = $parameter->getDefaultValue(); + continue; + } + + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new RuntimeException($translator->trans('error.container_unresolvable', [ + 'parameter' => $parameter->getName(), + 'class' => $parameter->getDeclaringClass()->getName() + ])); + } + + $results[] = $this->make($type->getName()); + } + + return $results; + } +} diff --git a/src/Contracts/CacheableDaoInterface.php b/src/Contracts/CacheableDaoInterface.php deleted file mode 100644 index 7714d7b..0000000 --- a/src/Contracts/CacheableDaoInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - $params - * @return array> - */ - public function query(string $sql, array $params = []): array; - - /** - * Execute a non-SELECT statement (INSERT/UPDATE/DELETE). - * - * @param string $sql - * @param array $params - * @return int affected rows - */ - public function execute(string $sql, array $params = []): int; - - /** - * Run a callback within a transaction. - * Rolls back on throwable and rethrows it. - * - * @template T - * @param callable($this):T $callback - * @return mixed - */ - public function transaction(callable $callback): mixed; - - /** - * Return the underlying driver connection (e.g., PDO). - * @return mixed - */ - public function getNative(): mixed; - - /** - * Get last inserted ID if supported. - */ - public function lastInsertId(): ?string; - - /** - * Run a callback without performing any persistent changes, returning the logged SQL. - * - * @param callable($this):void $callback - * @return array}> - */ - public function pretend(callable $callback): array; -} diff --git a/src/Contracts/Console/CommandInterface.php b/src/Contracts/Console/CommandInterface.php new file mode 100644 index 0000000..ee5f6d1 --- /dev/null +++ b/src/Contracts/Console/CommandInterface.php @@ -0,0 +1,41 @@ + - */ - public function toArray(bool $deep = true): array; -} diff --git a/src/Contracts/Events/DispatcherInterface.php b/src/Contracts/Events/DispatcherInterface.php new file mode 100644 index 0000000..1ee836f --- /dev/null +++ b/src/Contracts/Events/DispatcherInterface.php @@ -0,0 +1,40 @@ + */ - public function getBindings(): array; -} diff --git a/src/Contracts/Schema/HydratorInterface.php b/src/Contracts/Schema/HydratorInterface.php new file mode 100644 index 0000000..6e62ff2 --- /dev/null +++ b/src/Contracts/Schema/HydratorInterface.php @@ -0,0 +1,22 @@ + $data + * @param object $instance The DTO instance to hydrate. + * @return void + */ + public function hydrate(array $data, object $instance): void; +} diff --git a/src/Contracts/Translation/TranslatorInterface.php b/src/Contracts/Translation/TranslatorInterface.php new file mode 100644 index 0000000..7871241 --- /dev/null +++ b/src/Contracts/Translation/TranslatorInterface.php @@ -0,0 +1,40 @@ + $replace The values to replace in the message. + * @param string|null $locale The locale to use (defaults to the current locale). + * @return string The translated message. + */ + public function trans(string $key, array $replace = [], ?string $locale = null): string; + + /** + * Get the current locale. + * + * @return string + */ + public function getLocale(): string; + + /** + * Set the current locale. + * + * @param string $locale + * @return void + */ + public function setLocale(string $locale): void; +} diff --git a/src/DAO/BaseDAO.php b/src/DAO/BaseDAO.php new file mode 100644 index 0000000..c428f02 --- /dev/null +++ b/src/DAO/BaseDAO.php @@ -0,0 +1,317 @@ + + */ + protected array $options = []; + + /** + * @var \Pairity\Contracts\Schema\HydratorInterface|null + */ + protected ?\Pairity\Contracts\Schema\HydratorInterface $hydrator = null; + + /** + * @var string|null + */ + protected ?string $dtoClass = null; + + /** + * @var string|null + */ + protected ?string $lockingColumn = null; + + /** + * @var bool + */ + protected bool $auditable = false; + + /** + * BaseDAO constructor. + * + * @param DatabaseManagerInterface $db + * @param IdentityMap $identityMap + */ + public function __construct( + protected DatabaseManagerInterface $db, + protected IdentityMap $identityMap + ) { + } + + /** + * Set the hydrator for the DAO. + * + * @param \Pairity\Contracts\Schema\HydratorInterface $hydrator + * @return void + */ + public function setHydrator(\Pairity\Contracts\Schema\HydratorInterface $hydrator): void + { + $this->hydrator = $hydrator; + } + + /** + * Get the connection instance. + * + * @return \Pairity\Contracts\Database\ConnectionInterface + */ + protected function getConnection(): \Pairity\Contracts\Database\ConnectionInterface + { + return $this->db->connection($this->connection); + } + + /** + * Create a new query builder for the table. + * + * @return \Pairity\Database\Query\Builder + */ + public function query(): \Pairity\Database\Query\Builder + { + $connection = $this->getConnection(); + $grammar = $this->db->getQueryGrammar($connection->getDriver()->getName()); + + $builder = new \Pairity\Database\Query\Builder($this->db, $connection, $grammar); + + if ($this->dtoClass) { + $builder->setModel($this->dtoClass, $this); + } + + return $builder->from($this->table); + } + + /** + * @return string + */ + public function getTable(): string + { + return $this->table; + } + + /** + * Get the connection name. + * + * @return string + */ + public function getConnectionName(): string + { + return $this->connection; + } + + /** + * @return string|null + */ + public function getDtoClass(): ?string + { + return $this->dtoClass; + } + + /** + * @return string + */ + public function getPrimaryKey(): string + { + return $this->primaryKey; + } + + /** + * Get a DAO option. + * + * @param string $key + * @param mixed|null $default + * @return mixed + */ + public function getOption(string $key, mixed $default = null): mixed + { + return $this->options[$key] ?? $default; + } + + /** + * @return \Pairity\Contracts\Schema\HydratorInterface|null + */ + public function getHydrator(): ?\Pairity\Contracts\Schema\HydratorInterface + { + return $this->hydrator; + } + + /** + * @return IdentityMap + */ + public function getIdentityMap(): IdentityMap + { + return $this->identityMap; + } + + /** + * Delete a record by its primary key. + * + * @param mixed $id + * @return int + */ + public function delete(mixed $id): int + { + return $this->getConnection() + ->execute("DELETE FROM {$this->table} WHERE {$this->primaryKey} = ?", [$id]); + } + + /** + * Fire the given event for the model. + * + * @param string $event + * @param mixed $payload + * @param bool $halt + * @return mixed + */ + protected function fireModelEvent(string $event, mixed $payload = null, bool $halt = true): mixed + { + $dispatcher = $this->db->getDispatcher(); + $fullEventName = "pairity.model.{$event}: " . get_class($payload ?? $this); + + return $dispatcher->dispatch($fullEventName, $payload, $halt); + } + + /** + * Get the database manager. + * + * @return DatabaseManagerInterface + */ + public function getDb(): DatabaseManagerInterface + { + return $this->db; + } + + /** + * Save a DTO instance. + * + * @param \Pairity\DTO\BaseDTO $dto + * @return bool + * @throws \Pairity\Exceptions\DatabaseException + */ + public function save(\Pairity\DTO\BaseDTO $dto): bool + { + $data = $dto->toArray(); + $primaryKeyValue = $data[$this->primaryKey] ?? null; + + if ($primaryKeyValue === null) { + return $this->insert($data); + } + + return $this->update($primaryKeyValue, $data, $dto); + } + + /** + * Insert a new record. + * + * @param array $data + * @return bool + */ + protected function insert(array $data): bool + { + if ($this->getOption('tenancy', false)) { + $tenantColumn = \Pairity\Database\Query\Scopes\TenantScope::getTenantColumn(); + if (!isset($data[$tenantColumn]) && ($tenantId = \Pairity\Database\Query\Scopes\TenantScope::getTenantId())) { + $data[$tenantColumn] = $tenantId; + } + } + + if ($this->lockingColumn && !isset($data[$this->lockingColumn])) { + $data[$this->lockingColumn] = 1; + } + + $grammar = $this->db->getQueryGrammar($this->getConnection()->getDriver()->getName()); + $columns = implode(', ', array_map([$grammar, 'wrap'], array_keys($data))); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + + $sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})"; + + return $this->getConnection()->execute($sql, array_values($data)) > 0; + } + + /** + * Update an existing record. + * + * @param mixed $id + * @param array $data + * @param \Pairity\DTO\BaseDTO|null $dto + * @return bool + * @throws \Pairity\Exceptions\DatabaseException + */ + protected function update(mixed $id, array $data, ?\Pairity\DTO\BaseDTO $dto = null): bool + { + $sets = []; + $values = []; + $grammar = $this->db->getQueryGrammar($this->getConnection()->getDriver()->getName()); + + $currentVersion = null; + if ($this->lockingColumn) { + $currentVersion = $data[$this->lockingColumn] ?? null; + $data[$this->lockingColumn] = ($currentVersion ?: 1) + 1; + } + + foreach ($data as $column => $value) { + if ($column === $this->primaryKey) continue; + $sets[] = $grammar->wrap($column) . " = ?"; + $values[] = $value; + } + + $values[] = $id; + $sql = "UPDATE " . $grammar->wrapTable($this->table) . " SET " . implode(', ', $sets) . " WHERE " . $grammar->wrap($this->primaryKey) . " = ?"; + + if ($this->lockingColumn && $currentVersion !== null) { + $sql .= " AND " . $grammar->wrap($this->lockingColumn) . " = ?"; + $values[] = $currentVersion; + } + + $affected = $this->getConnection()->execute($sql, $values); + + if ($this->lockingColumn && $affected === 0) { + throw new \Pairity\Exceptions\DatabaseException("Optimistic locking failed for table [{$this->table}] and ID [{$id}]."); + } + + if ($dto && $this->lockingColumn) { + $dto->setAttribute($this->lockingColumn, $data[$this->lockingColumn]); + } + + return $affected > 0; + } + /** + * Update or insert records. + * + * @param array $values + * @param array $uniqueBy + * @param array|null $update + * @return int + */ + public function upsert(array $values, array $uniqueBy, array $update = null): int + { + return $this->query()->upsert($values, $uniqueBy, $update); + } +} diff --git a/src/DTO/BaseDTO.php b/src/DTO/BaseDTO.php new file mode 100644 index 0000000..d45e0db --- /dev/null +++ b/src/DTO/BaseDTO.php @@ -0,0 +1,146 @@ + + */ + protected array $attributes = []; + + /** + * @var bool + */ + protected bool $isProxy = false; + + /** + * @var array + */ + protected array $relations = []; + + /** + * @var \Pairity\DAO\BaseDAO|null + */ + protected ?\Pairity\DAO\BaseDAO $dao = null; + + /** + * BaseDTO constructor. + * + * @param array $attributes + */ + public function __construct(array $attributes = []) + { + $this->attributes = $attributes; + if ($this instanceof ProxyInterface) { + $this->isProxy = true; + } + } + + /** + * Ensure the object is fully loaded if it is a proxy. + * + * @return void + */ + protected function ensureLoaded(): void + { + if ($this->isProxy && $this instanceof ProxyInterface && !$this->__isInitialized()) { + $this->__load(); + } + } + + /** + * Set a relationship on the DTO. + * + * @param string $relation + * @param mixed $value + * @return void + */ + public function setRelation(string $relation, mixed $value): void + { + $this->relations[$relation] = $value; + } + + /** + * Get a relationship from the DTO. + * + * @param string $relation + * @return mixed + */ + public function getRelation(string $relation): mixed + { + if (!$this->relationLoaded($relation) && $this->dao && method_exists($this->dao, $relation)) { + $this->setRelation($relation, $this->dao->{$relation}($this)->getResults()); + } + + return $this->relations[$relation] ?? null; + } + + /** + * Check if a relationship is loaded. + * + * @param string $relation + * @return bool + */ + public function relationLoaded(string $relation): bool + { + return array_key_exists($relation, $this->relations); + } + + /** + * Set the DAO instance for the DTO. + * + * @param \Pairity\DAO\BaseDAO $dao + * @return void + */ + public function setDao(\Pairity\DAO\BaseDAO $dao): void + { + $this->dao = $dao; + } + + /** + * Get the DAO instance for the DTO. + * + * @return \Pairity\DAO\BaseDAO|null + */ + public function getDao(): ?\Pairity\DAO\BaseDAO + { + return $this->dao; + } + + /** + * Convert the DTO to an array. + * + * @return array + */ + public function toArray(): array + { + $this->ensureLoaded(); + return $this->attributes; + } + + /** + * Set an attribute on the DTO. + * + * @param string $key + * @param mixed $value + * @return void + */ + public function setAttribute(string $key, mixed $value): void + { + $this->attributes[$key] = $value; + + if ($this->dao) { + $this->dao->getDb()->unitOfWork()->registerDirty($this); + } + } +} diff --git a/src/DTO/IdentityMap.php b/src/DTO/IdentityMap.php new file mode 100644 index 0000000..ee9d3ca --- /dev/null +++ b/src/DTO/IdentityMap.php @@ -0,0 +1,65 @@ +> + */ + protected array $map = []; + + /** + * @var array> + */ + protected array $strongRefs = []; + + /** + * Get a DTO from the map. + * + * @param string $className + * @param string|int $id + * @return object|null + */ + public function get(string $className, string|int $id): ?object + { + return $this->strongRefs[$className][$id] ?? null; + } + + /** + * Add a DTO to the map. + * + * @param string $className + * @param string|int $id + * @param object $instance + * @return void + */ + public function add(string $className, string|int $id, object $instance): void + { + if (!isset($this->strongRefs[$className])) { + $this->strongRefs[$className] = []; + } + + $this->strongRefs[$className][$id] = $instance; + } + + /** + * Clear the identity map. + * + * @return void + */ + public function clear(): void + { + $this->strongRefs = []; + } +} diff --git a/src/DTO/ProxyFactory.php b/src/DTO/ProxyFactory.php new file mode 100644 index 0000000..4e7490b --- /dev/null +++ b/src/DTO/ProxyFactory.php @@ -0,0 +1,103 @@ + Cache of generated proxy class names. + */ + protected static array $proxyClasses = []; + + /** + * Create a new proxy instance. + * + * @param string $dtoClass + * @param BaseDAO $dao + * @param mixed $id + * @return object + */ + public function create(string $dtoClass, BaseDAO $dao, mixed $id): object + { + $proxyClass = $this->getProxyClass($dtoClass); + + return new $proxyClass($dao, $id); + } + + /** + * Get or generate the proxy class name for a DTO. + * + * @param string $dtoClass + * @return string + */ + protected function getProxyClass(string $dtoClass): string + { + if (isset(self::$proxyClasses[$dtoClass])) { + return self::$proxyClasses[$dtoClass]; + } + + $proxyClassName = 'Pairity_Proxy_' . md5($dtoClass); + + if (!class_exists($proxyClassName)) { + $this->generateProxyClass($dtoClass, $proxyClassName); + } + + return self::$proxyClasses[$dtoClass] = $proxyClassName; + } + + /** + * Generate the proxy class code and eval it. + * + * @param string $dtoClass + * @param string $proxyClassName + * @return void + */ + protected function generateProxyClass(string $dtoClass, string $proxyClassName): void + { + $code = << \$id]); + \$this->__dao = \$dao; + \$this->__id = \$id; + } + + public function __load(): void + { + if (\$this->__initialized) return; + + \$this->__initialized = true; + \$fullDto = \$this->__dao->find(\$this->__id); + + if (\$fullDto) { + foreach (\$fullDto->toArray() as \$key => \$value) { + \$this->\$key = \$value; + } + } + } + + public function __isInitialized(): bool + { + return \$this->__initialized; + } +} +PHP; + eval($code); + } +} diff --git a/src/DTO/ProxyInterface.php b/src/DTO/ProxyInterface.php new file mode 100644 index 0000000..fbe604c --- /dev/null +++ b/src/DTO/ProxyInterface.php @@ -0,0 +1,27 @@ +listen('pairity.model.created: *', [$this, 'onCreated']); + $dispatcher->listen('pairity.model.updated: *', [$this, 'onUpdated']); + $dispatcher->listen('pairity.model.deleted: *', [$this, 'onDeleted']); + } + + /** + * Handle model created event. + * + * @param BaseDTO $dto + * @return void + */ + public function onCreated(BaseDTO $dto): void + { + $this->audit($dto, 'created'); + } + + /** + * Handle model updated event. + * + * @param BaseDTO $dto + * @return void + */ + public function onUpdated(BaseDTO $dto): void + { + $this->audit($dto, 'updated'); + } + + /** + * Handle model deleted event. + * + * @param BaseDTO $dto + * @return void + */ + public function onDeleted(BaseDTO $dto): void + { + $this->audit($dto, 'deleted'); + } + + /** + * Trigger audit recording if the model is auditable. + * + * @param BaseDTO $dto + * @param string $event + * @return void + */ + protected function audit(BaseDTO $dto, string $event): void + { + $dao = $dto->getDao(); + + if ($dao && $dao->getOption('auditable', false)) { + $changes = $this->auditor->getChanges($dto); + $this->auditor->record($dto, $event, $changes['old'], $changes['new']); + } + } +} diff --git a/src/Database/Auditing/Auditor.php b/src/Database/Auditing/Auditor.php new file mode 100644 index 0000000..08860d8 --- /dev/null +++ b/src/Database/Auditing/Auditor.php @@ -0,0 +1,67 @@ + [], + 'new' => $dto->toArray() + ]; + } + + /** + * Record an audit entry. + * + * @param BaseDTO $dto + * @param string $event + * @param array $oldValues + * @param array $newValues + * @return void + */ + public function record(BaseDTO $dto, string $event, array $oldValues, array $newValues): void + { + $dao = $dto->getDao(); + if (!$dao) { + return; + } + + $db = $dao->getDb(); + $connection = $db->connection(); + + $primaryKey = $dao->getPrimaryKey(); + $auditableId = $dto->toArray()[$primaryKey] ?? null; + + $sql = "INSERT INTO audits (auditable_type, auditable_id, event, old_values, new_values, created_at) VALUES (?, ?, ?, ?, ?, ?)"; + + $connection->execute($sql, [ + get_class($dto), + $auditableId, + $event, + json_encode($oldValues), + json_encode($newValues), + date('Y-m-d H:i:s') + ]); + } +} diff --git a/src/Database/Connection.php b/src/Database/Connection.php new file mode 100644 index 0000000..546fa41 --- /dev/null +++ b/src/Database/Connection.php @@ -0,0 +1,343 @@ + Registered interceptors. + */ + protected array $interceptors = []; + + /** + * Connection constructor. + * + * @param string $name + * @param DriverInterface $driver + * @param array $config + */ + public function __construct( + protected string $name, + protected DriverInterface $driver, + protected array $config + ) { + } + + /** + * @inheritDoc + */ + public function getReadPdo(): PDO + { + if ($this->sticky && $this->writePdo) { + return $this->writePdo; + } + + if ($this->readPdo === null) { + $this->readPdo = $this->createPdo($this->getReadConfig()); + } + + return $this->readPdo; + } + + /** + * @inheritDoc + */ + public function getWritePdo(): PDO + { + if ($this->writePdo === null) { + $this->writePdo = $this->createPdo($this->getWriteConfig()); + } + + return $this->writePdo; + } + + /** + * Create a new PDO instance from the given config. + * + * @param array $config + * @return PDO + * @throws QueryException + */ + protected function createPdo(array $config): PDO + { + try { + return $this->driver->connect($config); + } catch (PDOException $e) { + throw new QueryException( + '', + [], + "Failed to connect to database: " . $e->getMessage(), + (int) $e->getCode(), + $e + ); + } + } + + /** + * Get the configuration for a read connection. + * + * @return array + */ + protected function getReadConfig(): array + { + $config = $this->config['read'] ?? $this->config; + + if (isset($config[0])) { + return array_merge($this->config, $config[array_rand($config)]); + } + + return array_merge($this->config, $config); + } + + /** + * Get the configuration for a write connection. + * + * @return array + */ + protected function getWriteConfig(): array + { + $config = $this->config['write'] ?? $this->config; + + if (isset($config[0])) { + return array_merge($this->config, $config[array_rand($config)]); + } + + return array_merge($this->config, $config); + } + + /** + * @inheritDoc + */ + public function execute(string $query, array $bindings = []): int + { + return $this->run($query, $bindings, function (PDOStatement $statement) { + $this->sticky = true; + return $statement->rowCount(); + }, 'write'); + } + + /** + * @inheritDoc + */ + public function select(string $query, array $bindings = []): array + { + return $this->run($query, $bindings, function (PDOStatement $statement) { + return $statement->fetchAll(PDO::FETCH_ASSOC); + }, 'read'); + } + + /** + * @inheritDoc + */ + public function query(string $query, array $bindings = []): PDOStatement + { + // For raw query, we default to write if we're not sure, + // but typically raw queries should be handled carefully. + // Let's stick with read by default if it's not a modifying query, + // but for now let's just use write to be safe or allow user to specify? + // SPECS say select for read, execute for write. query() is raw. + // Let's assume raw query is write. + return $this->run($query, $bindings, function (PDOStatement $statement) { + return $statement; + }, 'write'); + } + + /** + * Run a query and handle exceptions. + * + * @param string $query + * @param array $bindings + * @param callable $callback + * @param string $mode + * @return mixed + * @throws QueryException + */ + protected function run(string $query, array $bindings, callable $callback, string $mode = 'write'): mixed + { + $pipeline = $this->createPipeline($callback); + + return $pipeline($query, $bindings, $mode); + } + + /** + * Create the interceptor pipeline. + * + * @param callable $final + * @return callable + */ + protected function createPipeline(callable $final): callable + { + $pipeline = function (string $query, array $bindings, string $mode) use ($final) { + try { + $pdo = ($mode === 'write') ? $this->getWritePdo() : $this->getReadPdo(); + $statement = $pdo->prepare($query); + $statement->execute($bindings); + return $final($statement); + } catch (PDOException $e) { + throw new QueryException( + $query, + $bindings, + $e->getMessage(), + (int) $e->getCode(), + $e + ); + } + }; + + foreach (array_reverse($this->interceptors) as $interceptor) { + $next = $pipeline; + $pipeline = function (string $query, array $bindings, string $mode) use ($interceptor, $next) { + return $interceptor->intercept($query, $bindings, $mode, $next); + }; + } + + return $pipeline; + } + + /** + * @inheritDoc + */ + public function checkHealth(): bool + { + try { + $this->select('SELECT 1'); + return true; + } catch (PDOException|QueryException $e) { + return false; + } + } + + /** + * @inheritDoc + */ + public function beginTransaction(): void + { + $pdo = $this->getWritePdo(); + + if ($this->transactions === 0) { + $pdo->beginTransaction(); + } elseif ($this->transactions > 0) { + $pdo->exec("SAVEPOINT parity_{$this->transactions}"); + } + + $this->transactions++; + $this->sticky = true; + } + + /** + * @inheritDoc + */ + public function commit(): void + { + if ($this->transactions === 1) { + $this->getWritePdo()->commit(); + } elseif ($this->transactions > 1) { + $this->getWritePdo()->exec("RELEASE SAVEPOINT parity_" . ($this->transactions - 1)); + } + + $this->transactions = max(0, $this->transactions - 1); + } + + /** + * @inheritDoc + */ + public function rollBack(): void + { + if ($this->transactions === 1) { + $this->getWritePdo()->rollBack(); + } elseif ($this->transactions > 1) { + $this->getWritePdo()->exec("ROLLBACK TO SAVEPOINT parity_" . ($this->transactions - 1)); + } + + $this->transactions = max(0, $this->transactions - 1); + } + + /** + * @inheritDoc + */ + public function addInterceptor(InterceptorInterface $interceptor): void + { + $this->interceptors[] = $interceptor; + } + + /** + * @inheritDoc + */ + public function transactionLevel(): int + { + return $this->transactions; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return $this->name; + } + + /** + * @inheritDoc + */ + public function getDriver(): DriverInterface + { + return $this->driver; + } + + /** + * @inheritDoc + */ + public function getConfig(): array + { + return $this->config; + } + + /** + * Disconnect the PDO instances. + * + * @return void + */ + public function disconnect(): void + { + $this->readPdo = null; + $this->writePdo = null; + $this->sticky = false; + } +} diff --git a/src/Database/ConnectionManager.php b/src/Database/ConnectionManager.php deleted file mode 100644 index bbc9257..0000000 --- a/src/Database/ConnectionManager.php +++ /dev/null @@ -1,85 +0,0 @@ - $config - */ - public static function make(array $config): ConnectionInterface - { - $driver = strtolower((string)($config['driver'] ?? '')); - if ($driver === '') { - throw new \InvalidArgumentException('Database config must include a driver'); - } - - [$dsn, $username, $password, $options] = self::buildDsn($driver, $config); - $pdo = new PDO($dsn, $username, $password, $options); - return new PdoConnection($pdo); - } - - /** - * @param array $config - * @return array{0:string,1:?string,2:?string,3:array} - */ - private static function buildDsn(string $driver, array $config): array - { - $username = $config['username'] ?? null; - $password = $config['password'] ?? null; - $options = $config['options'] ?? []; - - switch ($driver) { - case 'mysql': - case 'mariadb': - $host = $config['host'] ?? '127.0.0.1'; - $port = (int)($config['port'] ?? 3306); - $db = $config['database'] ?? ''; - $charset = $config['charset'] ?? 'utf8mb4'; - $dsn = "mysql:host={$host};port={$port};dbname={$db};charset={$charset}"; - return [$dsn, $username, $password, $options]; - - case 'pgsql': - case 'postgres': - case 'postgresql': - $host = $config['host'] ?? '127.0.0.1'; - $port = (int)($config['port'] ?? 5432); - $db = $config['database'] ?? ''; - $dsn = "pgsql:host={$host};port={$port};dbname={$db}"; - return [$dsn, $username, $password, $options]; - - case 'sqlite': - $path = $config['path'] ?? ($config['database'] ?? ':memory:'); - $dsn = str_starts_with($path, 'memory') || $path === ':memory:' ? 'sqlite::memory:' : 'sqlite:' . $path; - // For SQLite, username/password are typically null - return [$dsn, null, null, $options]; - - case 'sqlsrv': - case 'mssql': - $host = $config['host'] ?? '127.0.0.1'; - $port = (int)($config['port'] ?? 1433); - $db = $config['database'] ?? ''; - $server = $port ? "$host,$port" : $host; - $dsn = "sqlsrv:Server={$server};Database={$db}"; - if (!isset($options[PDO::SQLSRV_ATTR_ENCODING])) { - $options[PDO::SQLSRV_ATTR_ENCODING] = PDO::SQLSRV_ENCODING_UTF8; - } - return [$dsn, $username, $password, $options]; - - case 'oci': - case 'oracle': - $host = $config['host'] ?? '127.0.0.1'; - $port = (int)($config['port'] ?? 1521); - $service = $config['service_name'] ?? ($config['sid'] ?? 'XE'); - $charset = $config['charset'] ?? 'AL32UTF8'; - $dsn = "oci:dbname=//{$host}:{$port}/{$service};charset={$charset}"; - return [$dsn, $username, $password, $options]; - - default: - throw new \InvalidArgumentException("Unsupported driver: {$driver}"); - } - } -} diff --git a/src/Database/DatabaseManager.php b/src/Database/DatabaseManager.php new file mode 100644 index 0000000..57e6adc --- /dev/null +++ b/src/Database/DatabaseManager.php @@ -0,0 +1,261 @@ + + */ + protected array $connections = []; + + /** + * @var array + */ + protected array $drivers = []; + + /** + * @var \Pairity\Contracts\Container\ContainerInterface|null + */ + protected ?\Pairity\Contracts\Container\ContainerInterface $container = null; + + /** + * @var UnitOfWork|null + */ + protected ?UnitOfWork $unitOfWork = null; + + /** + * @var \Pairity\Contracts\Events\DispatcherInterface|null + */ + protected ?\Pairity\Contracts\Events\DispatcherInterface $dispatcher = null; + + /** + * DatabaseManager constructor. + * + * @param array $config The database configuration. + * @param \Pairity\Contracts\Container\ContainerInterface|null $container + */ + public function __construct( + protected array $config, + ?\Pairity\Contracts\Container\ContainerInterface $container = null + ) { + $this->container = $container; + } + + /** + * @inheritDoc + */ + public function getDispatcher(): \Pairity\Contracts\Events\DispatcherInterface + { + if (!$this->dispatcher) { + $this->dispatcher = new \Pairity\Events\Dispatcher(); + } + + return $this->dispatcher; + } + + /** + * @inheritDoc + */ + public function setDispatcher(\Pairity\Contracts\Events\DispatcherInterface $dispatcher): void + { + $this->dispatcher = $dispatcher; + } + + /** + * Get the Unit of Work instance. + * + * @return UnitOfWork + */ + public function unitOfWork(): UnitOfWork + { + if (!$this->unitOfWork) { + $this->unitOfWork = new UnitOfWork($this); + } + + return $this->unitOfWork; + } + + /** + * Set the container instance. + * + * @param \Pairity\Contracts\Container\ContainerInterface $container + * @return void + */ + public function setContainer(\Pairity\Contracts\Container\ContainerInterface $container): void + { + $this->container = $container; + } + + /** + * @inheritDoc + */ + public function getContainer(): \Pairity\Contracts\Container\ContainerInterface + { + if (!$this->container) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new RuntimeException($translator->trans('error.container_not_set')); + } + return $this->container; + } + + /** + * @inheritDoc + */ + public function connection(?string $name = null): ConnectionInterface + { + $name = $name ?: $this->getDefaultConnection(); + + if (!isset($this->connections[$name])) { + $this->connections[$name] = $this->makeConnection($name); + } + + return $this->connections[$name]; + } + + /** + * @inheritDoc + */ + public function reconnect(?string $name = null): ConnectionInterface + { + $name = $name ?: $this->getDefaultConnection(); + + $this->disconnect($name); + + return $this->connection($name); + } + + /** + * @inheritDoc + */ + public function disconnect(?string $name = null): void + { + $name = $name ?: $this->getDefaultConnection(); + + if (isset($this->connections[$name])) { + $this->connections[$name]->disconnect(); + unset($this->connections[$name]); + } + } + + /** + * @inheritDoc + */ + public function getDefaultConnection(): string + { + return $this->config['default'] ?? 'default'; + } + + /** + * @inheritDoc + */ + public function setDefaultConnection(string $name): void + { + $this->config['default'] = $name; + } + + /** + * Resolve a connection instance. + * + * @param string $name + * @return ConnectionInterface + * @throws RuntimeException + */ + protected function makeConnection(string $name): ConnectionInterface + { + $config = $this->getConnectionConfig($name); + $driverName = $config['driver'] ?? 'sqlite'; + $driver = $this->resolveDriver($driverName); + + return new Connection($name, $driver, $config); + } + + /** + * Get the configuration for a connection. + * + * @param string $name + * @return array + * @throws RuntimeException + */ + protected function getConnectionConfig(string $name): array + { + $connections = $this->config['connections'] ?? []; + + if (!isset($connections[$name])) { + throw new RuntimeException("Database connection [{$name}] not configured."); + } + + return $connections[$name]; + } + + /** + * Resolve the driver instance. + * + * @param string $name + * @return DriverInterface + * @throws RuntimeException + */ + protected function resolveDriver(string $name): DriverInterface + { + if (isset($this->drivers[$name])) { + return $this->drivers[$name]; + } + + $driver = match ($name) { + 'sqlite' => new SQLiteDriver(), + 'mysql' => new MySQLDriver(), + 'pgsql', 'postgres' => new PostgresDriver(), + 'sqlsrv', 'sqlserver' => new SqlServerDriver(), + 'oci', 'oracle' => new OracleDriver(), + default => throw new RuntimeException("Database driver [{$name}] not supported."), + }; + + return $this->drivers[$name] = $driver; + } + + /** + * Get the query grammar for a driver. + * + * @param string $driver + * @return \Pairity\Database\Query\Grammar + */ + public function getQueryGrammar(string $driver): \Pairity\Database\Query\Grammar + { + return match ($driver) { + 'mysql' => new \Pairity\Database\Query\Grammars\MySqlGrammar(), + 'pgsql', 'postgres' => new \Pairity\Database\Query\Grammars\PostgresGrammar(), + 'sqlsrv', 'sqlserver' => new \Pairity\Database\Query\Grammars\SqlServerGrammar(), + 'oci', 'oracle' => new \Pairity\Database\Query\Grammars\OracleGrammar(), + 'sqlite' => new \Pairity\Database\Query\Grammars\SqliteGrammar(), + default => (function() use ($driver) { + $translator = $this->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new \Pairity\Exceptions\DatabaseException( + $translator->trans('error.driver_not_supported', ['driver' => $driver]), + 0, + null, + ['driver' => $driver] + ); + })(), + }; + } +} diff --git a/src/Database/Drivers/AbstractDriver.php b/src/Database/Drivers/AbstractDriver.php new file mode 100644 index 0000000..5c8baff --- /dev/null +++ b/src/Database/Drivers/AbstractDriver.php @@ -0,0 +1,52 @@ + PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + + /** + * @inheritDoc + */ + public function connect(array $config): PDO + { + $dsn = $this->buildDsn($config); + $username = $config['username'] ?? null; + $password = $config['password'] ?? null; + $options = array_replace($this->options, $config['options'] ?? []); + + return new PDO($dsn, $username, $password, $options); + } + + /** + * Build the DSN string for the driver. + * + * @param array $config + * @return string + */ + abstract protected function buildDsn(array $config): string; +} diff --git a/src/Database/Drivers/MySQLDriver.php b/src/Database/Drivers/MySQLDriver.php new file mode 100644 index 0000000..1764700 --- /dev/null +++ b/src/Database/Drivers/MySQLDriver.php @@ -0,0 +1,42 @@ + + */ + abstract public function definition(): array; + + /** + * Get the model class name. + * + * @return string + */ + abstract public function model(): string; + + /** + * Get the DAO class name. + * + * @return string + */ + abstract public function dao(): string; + + /** + * Set the number of models to create. + * + * @param int $count + * @return $this + */ + public function count(int $count): self + { + $this->count = $count; + return $this; + } + + /** + * Create models and save them to the database. + * + * @param array $attributes + * @return mixed + */ + public function create(array $attributes = []): mixed + { + $results = []; + + for ($i = 0; $i < $this->count; $i++) { + $data = array_merge($this->definition(), $this->applyStates(), $attributes); + $dtoClass = $this->model(); + $dto = new $dtoClass($data); + + $daoClass = $this->dao(); + /** @var BaseDAO $dao */ + $dao = $this->db->getContainer()->get($daoClass); + $dto->setDao($dao); + + $dao->save($dto); + $results[] = $dto; + } + + return $this->count === 1 ? $results[0] : $results; + } + + /** + * Make models without saving them to the database. + * + * @param array $attributes + * @return mixed + */ + public function make(array $attributes = []): mixed + { + $results = []; + + for ($i = 0; $i < $this->count; $i++) { + $data = array_merge($this->definition(), $this->applyStates(), $attributes); + $dtoClass = $this->model(); + $results[] = new $dtoClass($data); + } + + return $this->count === 1 ? $results[0] : $results; + } + + /** + * Apply the registered states. + * + * @return array + */ + protected function applyStates(): array + { + $data = []; + foreach ($this->states as $state) { + if (is_callable($state)) { + $data = array_merge($data, $state($this->definition())); + } else { + $data = array_merge($data, $state); + } + } + return $data; + } + + /** + * Register a new state. + * + * @param array|callable $state + * @return $this + */ + public function state(array|callable $state): self + { + $this->states[] = $state; + return $this; + } +} diff --git a/src/Database/Interceptors/QueryLogger.php b/src/Database/Interceptors/QueryLogger.php new file mode 100644 index 0000000..bca5acc --- /dev/null +++ b/src/Database/Interceptors/QueryLogger.php @@ -0,0 +1,55 @@ + + */ + protected array $logs = []; + + /** + * @inheritDoc + */ + public function intercept(string $query, array $bindings, string $mode, callable $next): mixed + { + $start = microtime(true); + + try { + return $next($query, $bindings, $mode); + } finally { + $time = (microtime(true) - $start) * 1000; // ms + $this->logs[] = compact('query', 'bindings', 'time', 'mode'); + } + } + + /** + * Get all logged queries. + * + * @return array + */ + public function getLogs(): array + { + return $this->logs; + } + + /** + * Clear the query logs. + * + * @return void + */ + public function clear(): void + { + $this->logs = []; + } +} diff --git a/src/Database/Migrations/DataMigration.php b/src/Database/Migrations/DataMigration.php new file mode 100644 index 0000000..359acb4 --- /dev/null +++ b/src/Database/Migrations/DataMigration.php @@ -0,0 +1,39 @@ + */ - private array $stmtCache = []; - private int $stmtCacheSize = 100; // LRU bound - /** @var null|callable */ - private $queryLogger = null; // function(string $sql, array $params, float $ms): void - /** @var bool */ - private bool $pretending = false; - /** @var array}> */ - private array $pretendLog = []; - - public function __construct(PDO $pdo) - { - $this->pdo = $pdo; - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); - } - - /** Enable/disable a bounded prepared statement cache (default size 100). */ - public function setStatementCacheSize(int $size): void - { - $this->stmtCacheSize = max(0, $size); - if ($this->stmtCacheSize === 0) { - $this->stmtCache = []; - } else if (count($this->stmtCache) > $this->stmtCacheSize) { - // trim - $this->stmtCache = array_slice($this->stmtCache, -$this->stmtCacheSize, null, true); - } - } - - /** Set a logger callable to receive [sql, params, ms] for each query/execute. */ - public function setQueryLogger(?callable $logger): void - { - $this->queryLogger = $logger; - } - - private function prepare(string $sql): \PDOStatement - { - if ($this->stmtCacheSize <= 0) { - return $this->pdo->prepare($sql); - } - if (isset($this->stmtCache[$sql])) { - // Touch for LRU by moving to end - $stmt = $this->stmtCache[$sql]; - unset($this->stmtCache[$sql]); - $this->stmtCache[$sql] = $stmt; - return $stmt; - } - $stmt = $this->pdo->prepare($sql); - $this->stmtCache[$sql] = $stmt; - // Enforce LRU bound - if (count($this->stmtCache) > $this->stmtCacheSize) { - array_shift($this->stmtCache); - } - return $stmt; - } - - public function query(string $sql, array $params = []): array - { - if ($this->pretending) { - $this->pretendLog[] = ['sql' => $sql, 'params' => $params]; - return []; - } - $t0 = microtime(true); - $stmt = $this->prepare($sql); - $stmt->execute($params); - $rows = $stmt->fetchAll(); - if ($this->queryLogger) { - $ms = (microtime(true) - $t0) * 1000.0; - try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {} - } - return $rows; - } - - public function execute(string $sql, array $params = []): int - { - if ($this->pretending) { - $this->pretendLog[] = ['sql' => $sql, 'params' => $params]; - return 0; - } - $t0 = microtime(true); - $stmt = $this->prepare($sql); - $stmt->execute($params); - $count = $stmt->rowCount(); - if ($this->queryLogger) { - $ms = (microtime(true) - $t0) * 1000.0; - try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {} - } - return $count; - } - - public function transaction(callable $callback): mixed - { - if ($this->pretending) { - return $callback($this); - } - $this->pdo->beginTransaction(); - try { - $result = $callback($this); - $this->pdo->commit(); - return $result; - } catch (\Throwable $e) { - $this->pdo->rollBack(); - throw $e; - } - } - - public function getNative(): mixed - { - return $this->pdo; - } - - public function lastInsertId(): ?string - { - try { - return $this->pdo->lastInsertId() ?: null; - } catch (PDOException $e) { - return null; - } - } - - public function pretend(callable $callback): array - { - $this->pretending = true; - $this->pretendLog = []; - - try { - $callback($this); - } finally { - $this->pretending = false; - } - - return $this->pretendLog; - } -} diff --git a/src/Database/Query/Builder.php b/src/Database/Query/Builder.php new file mode 100644 index 0000000..9bd7f04 --- /dev/null +++ b/src/Database/Query/Builder.php @@ -0,0 +1,1280 @@ + [], + 'select' => [], + 'join' => [], + 'where' => [], + 'having' => [], + 'order' => [], + 'union' => [], + ]; + + /** + * @var array + */ + public array $unions = []; + + /** + * @var int|null + */ + protected ?int $cacheSeconds = null; + + /** + * @var bool + */ + protected bool $withoutTenancy = false; + + /** + * Builder constructor. + * + * @param \Pairity\Contracts\Database\DatabaseManagerInterface $db + * @param ConnectionInterface $connection + * @param Grammar $grammar + */ + public function __construct( + protected \Pairity\Contracts\Database\DatabaseManagerInterface $db, + protected ConnectionInterface $connection, + protected Grammar $grammar + ) { + } + + /** + * Set the relationships that should be eager loaded. + * + * @param array|string $relations + * @param string|\Closure|null $callback + * @return $this + */ + public function with(array|string $relations, string|\Closure|null $callback = null): self + { + if (is_string($relations)) { + if ($callback instanceof \Closure) { + $this->eagerLoad[$relations] = $callback; + } elseif (is_string($callback)) { + $this->eagerLoad[$relations] = function ($query) use ($callback) { + $query->select($callback); + }; + } else { + $this->eagerLoad[$relations] = function () {}; + } + } else { + foreach ($relations as $name => $constraints) { + if (is_numeric($name)) { + $this->with($constraints); + } else { + $this->with($name, $constraints); + } + } + } + + return $this; + } + + /** + * Get the relationships that should be eager loaded. + * + * @return array + */ + public function getEagerLoads(): array + { + return $this->eagerLoad; + } + + /** + * @return \Pairity\Contracts\Database\DatabaseManagerInterface + */ + public function getDb(): \Pairity\Contracts\Database\DatabaseManagerInterface + { + return $this->db; + } + + /** + * @return ConnectionInterface + */ + public function getConnection(): ConnectionInterface + { + return $this->connection; + } + + /** + * Get the query grammar instance. + * + * @return Grammar + */ + public function getGrammar(): Grammar + { + return $this->grammar; + } + + /** + * Set the columns to be selected. + * + * @param array|string|Expression $columns + * @return $this + */ + public function select(array|string|Expression $columns = ['*']): self + { + $columns = is_array($columns) ? $columns : func_get_args(); + + if ($columns === ['*']) { + $this->columns = $columns; + return $this; + } + + foreach ($columns as $as => $column) { + if ($column instanceof \Closure || $column instanceof Builder) { + if (is_string($as)) { + $this->selectSubquery($column, $as); + } else { + throw new DatabaseException('Select subqueries must be aliased.'); + } + } elseif (is_string($as) && ($column instanceof Expression || is_string($column))) { + $this->columns[] = [$column, $as]; + } else { + $this->columns[] = $column; + } + } + + return $this; + } + + /** + * Add a subquery to the select clause. + * + * @param \Closure|Builder $query + * @param string $as + * @return $this + */ + protected function selectSubquery(\Closure|Builder $query, string $as): self + { + if ($query instanceof \Closure) { + $callback = $query; + $query = $this->newQuery(); + $callback($query); + } + + $this->columns[] = [$query, $as]; + + return $this; + } + + /** + * Force the query to only return distinct results. + * + * @return $this + */ + public function distinct(): self + { + $this->distinct = true; + return $this; + } + + /** + * Set the table which the query is targeting. + * + * @param string|\Closure|Builder $table + * @param string|null $as + * @return $this + */ + public function from(string|\Closure|Builder $table, ?string $as = null): self + { + if ($table instanceof \Closure) { + return $this->fromSubquery($table, $as); + } + + if ($table instanceof Builder) { + return $this->fromSubquery($table, $as); + } + + $this->from = $as ? "{$table} as {$as}" : $table; + return $this; + } + + /** + * Set the table which the query is targeting as a subquery. + * + * @param \Closure|Builder $query + * @param string|null $as + * @return $this + */ + protected function fromSubquery(\Closure|Builder $query, ?string $as = null): self + { + if ($query instanceof \Closure) { + $callback = $query; + $query = $this->newQuery(); + $callback($query); + } + + $this->from = [$query, $as ?: 'sub']; + + return $this; + } + + /** + * Create a new query builder instance. + * + * @return Builder + */ + public function newQuery(): Builder + { + return new Builder($this->db, $this->connection, $this->grammar); + } + + /** + * Add a basic where clause to the query. + * + * @param string $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function where(string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): self + { + if (func_num_args() === 2) { + $value = $operator; + $operator = '='; + } + + $type = 'Basic'; + $this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean'); + + return $this; + } + + /** + * Add an "or where" clause to the query. + * + * @param string $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhere(string $column, mixed $operator = null, mixed $value = null): self + { + if (func_num_args() === 2) { + $value = $operator; + $operator = '='; + } + + return $this->where($column, $operator, $value, 'or'); + } + + /** + * Add a "where null" clause to the query. + * + * @param string $column + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereNull(string $column, string $boolean = 'and', bool $not = false): self + { + $type = $not ? 'NotNull' : 'Null'; + $this->wheres[] = compact('type', 'column', 'boolean'); + return $this; + } + + /** + * Add an "or where null" clause to the query. + * + * @param string $column + * @return $this + */ + public function orWhereNull(string $column): self + { + return $this->whereNull($column, 'or'); + } + + /** + * Add a "where not null" clause to the query. + * + * @param string $column + * @param string $boolean + * @return $this + */ + public function whereNotNull(string $column, string $boolean = 'and'): self + { + return $this->whereNull($column, $boolean, true); + } + + /** + * Add an "or where not null" clause to the query. + * + * @param string $column + * @return $this + */ + public function orWhereNotNull(string $column): self + { + return $this->whereNotNull($column, 'or'); + } + + /** + * Add a "where exists" clause to the query. + * + * @param \Closure|Builder $callback + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereExists(\Closure|Builder $callback, string $boolean = 'and', bool $not = false): self + { + $type = $not ? 'NotExists' : 'Exists'; + + if ($callback instanceof \Closure) { + $query = $this->newQuery(); + $callback($query); + } else { + $query = $callback; + } + + $this->wheres[] = compact('type', 'query', 'boolean'); + + return $this; + } + + /** + * Add an "or where exists" clause to the query. + * + * @param \Closure|Builder $callback + * @return $this + */ + public function orWhereExists(\Closure|Builder $callback): self + { + return $this->whereExists($callback, 'or'); + } + + /** + * Add a "where not exists" clause to the query. + * + * @param \Closure|Builder $callback + * @param string $boolean + * @return $this + */ + public function whereNotExists(\Closure|Builder $callback, string $boolean = 'and'): self + { + return $this->whereExists($callback, $boolean, true); + } + + /** + * Add an "or where not exists" clause to the query. + * + * @param \Closure|Builder $callback + * @return $this + */ + public function orWhereNotExists(\Closure|Builder $callback): self + { + return $this->whereNotExists($callback, 'or'); + } + + /** + * Add a "where in" clause to the query. + * + * @param string $column + * @param mixed $values + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereIn(string $column, mixed $values, string $boolean = 'and', bool $not = false): self + { + $type = $not ? 'NotIn' : 'In'; + + if ($values instanceof \Closure || $values instanceof Builder) { + return $this->whereInSubquery($column, $values, $boolean, $not); + } + + $this->wheres[] = compact('type', 'column', 'values', 'boolean'); + + return $this; + } + + /** + * Add an "or where in" clause to the query. + * + * @param string $column + * @param mixed $values + * @return $this + */ + public function orWhereIn(string $column, mixed $values): self + { + return $this->whereIn($column, $values, 'or'); + } + + /** + * Add a "where not in" clause to the query. + * + * @param string $column + * @param mixed $values + * @param string $boolean + * @return $this + */ + public function whereNotIn(string $column, mixed $values, string $boolean = 'and'): self + { + return $this->whereIn($column, $values, $boolean, true); + } + + /** + * Add an "or where not in" clause to the query. + * + * @param string $column + * @param mixed $values + * @return $this + */ + public function orWhereNotIn(string $column, mixed $values): self + { + return $this->whereNotIn($column, $values, 'or'); + } + + /** + * Add a where in subquery clause to the query. + * + * @param string $column + * @param \Closure|Builder $query + * @param string $boolean + * @param bool $not + * @return $this + */ + protected function whereInSubquery(string $column, \Closure|Builder $query, string $boolean, bool $not): self + { + $type = $not ? 'NotInSubquery' : 'InSubquery'; + + if ($query instanceof \Closure) { + $callback = $query; + $query = $this->newQuery(); + $callback($query); + } + + $this->wheres[] = compact('type', 'column', 'query', 'boolean'); + + return $this; + } + + /** + * Add an "order by" clause to the query. + * + * @param string $column + * @param string $direction + * @return $this + */ + public function orderBy(string $column, string $direction = 'asc'): self + { + $this->orders[] = compact('column', 'direction'); + return $this; + } + + /** + * Add a "limit" clause to the query. + * + * @param int $value + * @return $this + */ + public function limit(int $value): self + { + $this->limit = $value; + return $this; + } + + /** + * Add an "offset" clause to the query. + * + * @param int $value + * @return $this + */ + public function offset(int $value): self + { + $this->offset = $value; + return $this; + } + + /** + * Set the "lock" value of the query. + * + * @param bool|string $value + * @return $this + */ + public function lock(bool|string $value = true): self + { + $this->lock = $value; + return $this; + } + + /** + * Lock the selected rows in the database for updating. + * + * @return $this + */ + public function lockForUpdate(): self + { + return $this->lock(true); + } + + /** + * Share lock the selected rows in the database. + * + * @return $this + */ + public function sharedLock(): self + { + return $this->lock(false); + } + + /** + * Add a join clause to the query. + * + * @param string|\Closure|Builder $table + * @param string|null|callable $first + * @param string|null $operator + * @param string|null $second + * @param string $type + * @return $this + */ + public function join(string|\Closure|Builder $table, $first = null, ?string $operator = null, ?string $second = null, string $type = 'inner'): self + { + if ($table instanceof \Closure || $table instanceof Builder) { + return $this->joinSub($table, (string)$first, $operator, $second, $type); + } + + $this->joins[] = compact('table', 'first', 'operator', 'second', 'type'); + return $this; + } + + /** + * Add a join clause with a subquery to the query. + * + * @param \Closure|Builder $query + * @param string $as + * @param string $first + * @param string $operator + * @param string $second + * @param string $type + * @return $this + */ + public function joinSub(\Closure|Builder $query, string $as, $first, ?string $operator = null, ?string $second = null, string $type = 'inner'): self + { + if ($query instanceof \Closure) { + $callback = $query; + $query = $this->newQuery(); + $callback($query); + } + + $this->joins[] = [ + 'table' => [$query, $as], + 'first' => $first, + 'operator' => $operator, + 'second' => $second, + 'type' => $type, + ]; + + return $this; + } + + /** + * Add a left join clause to the query. + * + * @param string|\Closure|Builder $table + * @param string|null|callable $first + * @param string|null $operator + * @param string|null $second + * @return $this + */ + public function leftJoin(string|\Closure|Builder $table, $first = null, ?string $operator = null, ?string $second = null): self + { + return $this->join($table, $first, $operator, $second, 'left'); + } + + /** + * Add a left join clause with a subquery to the query. + * + * @param \Closure|Builder $query + * @param string $as + * @param string $first + * @param string $operator + * @param string $second + * @return $this + */ + public function leftJoinSub(\Closure|Builder $query, string $as, $first, ?string $operator = null, ?string $second = null): self + { + return $this->joinSub($query, $as, $first, $operator, $second, 'left'); + } + + /** + * Add a group by clause to the query. + * + * @param array|string $groups + * @return $this + */ + public function groupBy(array|string $groups): self + { + $groups = is_array($groups) ? $groups : func_get_args(); + $this->groups = array_merge($this->groups, $groups); + return $this; + } + + /** + * Add a having clause to the query. + * + * @param string $column + * @param string $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function having(string $column, string $operator, mixed $value, string $boolean = 'and'): self + { + $this->havings[] = compact('column', 'operator', 'value', 'boolean'); + $this->addBinding($value, 'having'); + return $this; + } + + /** + * Get the SQL representation of the query. + * + * @return string + */ + public function toSql(): string + { + $this->applyTenancy(); + return $this->grammar->compileSelect($this); + } + + /** + * Get the SQL representation and bindings of the query. + * + * @return array{sql: string, bindings: array} + */ + public function sql(): array + { + return [ + 'sql' => $this->toSql(), + 'bindings' => $this->getBindings(), + ]; + } + + /** + * Set the query bindings. + * + * @param array $bindings + * @param string $type + * @return $this + */ + public function setBindings(array $bindings, string $type = 'where'): self + { + if (!array_key_exists($type, $this->bindings)) { + $translator = $this->db->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new \InvalidArgumentException($translator->trans('error.invalid_binding_type', ['type' => $type])); + } + + $this->bindings[$type] = $bindings; + + return $this; + } + + /** + * Add a binding to the query. + * + * @param mixed $value + * @param string $type + * @return $this + */ + public function addBinding(mixed $value, string $type = 'where'): self + { + if (!array_key_exists($type, $this->bindings)) { + $translator = $this->db->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new \InvalidArgumentException($translator->trans('error.invalid_binding_type', ['type' => $type])); + } + + if (is_array($value)) { + $this->bindings[$type] = array_values(array_merge($this->bindings[$type], $value)); + } else { + $this->bindings[$type][] = $value; + } + + return $this; + } + + /** + * Get the current query bindings in a flattened array. + * + * @return array + */ + public function getBindings(): array + { + $flattened = []; + + // Add bindings from subquery in FROM + if (is_array($this->from) && $this->from[0] instanceof Builder) { + $flattened = array_merge($flattened, $this->from[0]->getBindings()); + } + + // Add bindings from other components + foreach ($this->bindings as $type => $values) { + if ($type === 'select') { + $flattened = array_merge($flattened, $this->getSelectBindings()); + } elseif ($type === 'join') { + $flattened = array_merge($flattened, $this->getJoinBindings()); + } elseif ($type === 'where') { + $flattened = array_merge($flattened, $this->getWhereBindings()); + } elseif ($type === 'union') { + // Unions already have their bindings added via addBinding in union() + // But for nested queries, it's safer to flatten here if we were storing Builders + $flattened = array_merge($flattened, $values); + } else { + $flattened = array_merge($flattened, $values); + } + } + + return $flattened; + } + + /** + * Get the bindings for the select clause, including subqueries. + * + * @return array + */ + protected function getSelectBindings(): array + { + $bindings = []; + + foreach ((array) ($this->columns ?? []) as $column) { + if (is_array($column) && $column[0] instanceof Builder) { + $bindings = array_merge($bindings, $column[0]->getBindings()); + } + } + + return $bindings; + } + + /** + * Get the bindings for the joins, including subqueries. + * + * @return array + */ + protected function getJoinBindings(): array + { + $bindings = []; + + foreach ($this->joins as $join) { + if (is_array($join['table']) && $join['table'][0] instanceof Builder) { + $bindings = array_merge($bindings, $join['table'][0]->getBindings()); + } + } + + return $bindings; + } + + /** + * Get the bindings for the where clauses, including subqueries. + * + * @return array + */ + protected function getWhereBindings(): array + { + $bindings = []; + + foreach ($this->wheres as $where) { + if (isset($where['query']) && $where['query'] instanceof Builder) { + $bindings = array_merge($bindings, $where['query']->getBindings()); + } elseif (isset($where['values'])) { + $bindings = array_merge($bindings, (array) $where['values']); + } elseif (isset($where['value'])) { + $bindings[] = $where['value']; + } + } + + return $bindings; + } + + /** + * Set the aggregate function and column for the query. + * + * @param string $function + * @param array $columns + * @return $this + */ + public function aggregate(string $function, array $columns = ['*']): self + { + $this->aggregate = compact('function', 'columns'); + + if (empty($this->columns)) { + $this->select($columns); + } + + return $this; + } + + /** + * Handle dynamic calls to the query builder. + * + * @param string $method + * @param array $parameters + * @return mixed + * @throws \BadMethodCallException + */ + public function __call(string $method, array $parameters) + { + if ($this->dao && method_exists($this->dao, $scope = 'scope' . ucfirst($method))) { + return $this->dao->$scope($this, ...$parameters) ?? $this; + } + + $translator = $this->db->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new \BadMethodCallException($translator->trans('error.method_not_found', ['method' => $method])); + } + + /** + * Set the DTO class and DAO for the query. + * + * @param string $dtoClass + * @param object $dao + * @return $this + */ + public function setModel(string $dtoClass, object $dao): self + { + $this->dtoClass = $dtoClass; + $this->dao = $dao; + return $this; + } + + /** + * Execute the query and get the first result. + * + * @param array|string $columns + * @return object|null + */ + public function first(array|string $columns = ['*']): ?object + { + $results = $this->limit(1)->get($columns); + return $results[0] ?? null; + } + + /** + * Update or insert records. + * + * @param array $values + * @param array $uniqueBy + * @param array|null $update + * @return int + */ + public function upsert(array $values, array $uniqueBy, ?array $update = null): int + { + if (empty($values)) { + return 0; + } + + if (!is_array(reset($values))) { + $values = [$values]; + } + + if (is_null($update)) { + $update = array_keys(reset($values)); + } + + $bindings = $this->getUpsertBindings($values, $update); + + return $this->connection->execute( + $this->grammar->compileUpsert($this, $values, $uniqueBy, $update), + $bindings + ); + } + + /** + * Get the bindings for an upsert statement. + * + * @param array $values + * @param array $update + * @return array + */ + protected function getUpsertBindings(array $values, array $update): array + { + $bindings = []; + + foreach ($values as $record) { + foreach ($record as $value) { + $bindings[] = $value; + } + } + + foreach ($update as $key => $value) { + if (!is_numeric($key)) { + $bindings[] = $value; + } + } + + return $bindings; + } + + /** + * Add a union statement to the query. + * + * @param Builder|\Closure $query + * @param bool $all + * @return $this + */ + public function union(Builder|\Closure $query, bool $all = false): self + { + if ($query instanceof \Closure) { + $callback = $query; + $query = $this->newQuery(); + $callback($query); + } + + $this->unions[] = compact('query', 'all'); + + $this->addBinding($query->getBindings(), 'union'); + + return $this; + } + + /** + * Add a union all statement to the query. + * + * @param Builder|\Closure $query + * @return $this + */ + public function unionAll(Builder|\Closure $query): self + { + return $this->union($query, true); + } + + /** + * Indicate that the query results should be cached. + * + * @param int $seconds + * @return $this + */ + public function remember(int $seconds): self + { + $this->cacheSeconds = $seconds; + return $this; + } + + /** + * Execute the query as a "select" statement. + * + * @param array|string $columns + * @return array + */ + public function get(array|string $columns = ['*']): array + { + $this->applyTenancy(); + + if ($columns !== ['*']) { + $this->select($columns); + } + + if ($this->cacheSeconds !== null) { + return $this->getCached($this->cacheSeconds); + } + + return $this->executeSelect(); + } + + /** + * Execute the select statement. + * + * @return array + */ + protected function executeSelect(): array + { + $results = $this->connection->select($this->toSql(), $this->getBindings()); + + if (!$this->dtoClass || !$this->dao) { + return array_map(fn($row) => new QueryResult($row), $results); + } + + $models = array_map(function ($row) { + $primaryKey = $this->dao->getPrimaryKey(); + $id = $row[$primaryKey] ?? null; + + if ($id && $cached = $this->dao->getIdentityMap()->get($this->dtoClass, $id)) { + return $cached; + } + + $dto = $this->hydrate($row); + + if ($id) { + $this->dao->getIdentityMap()->add($this->dtoClass, $id, $dto); + $this->db->unitOfWork()->track($dto, \Pairity\Database\UnitOfWork::STATE_CLEAN); + } + + return $dto; + }, $results); + + if (count($models) > 0 && !empty($this->eagerLoad)) { + $models = (new EagerLoader())->load($models, $this->eagerLoad); + } + + return $models; + } + + /** + * Get the cached results of the query. + * + * @param int $seconds + * @return array + */ + protected function getCached(int $seconds): array + { + $cache = $this->db->getContainer()->get(\Psr\SimpleCache\CacheInterface::class); + $key = 'query.' . md5($this->toSql() . serialize($this->getBindings())); + + if ($cache->has($key)) { + return $cache->get($key); + } + + $results = $this->executeSelect(); + $cache->set($key, $results, $seconds); + + return $results; + } + + /** + * Hydrate a single row into a DTO. + * + * @param array $row + * @return object + */ + protected function hydrate(array $row): object + { + $dto = new $this->dtoClass(); + + if ($this->dao) { + $dto->setDao($this->dao); + if ($hydrator = $this->dao->getHydrator()) { + $hydrator->hydrate($row, $dto); + } else { + // Fallback to constructor if no hydrator + $dto = new $this->dtoClass($row); + $dto->setDao($this->dao); + } + } else { + // Fallback to constructor if no hydrator + $dto = new $this->dtoClass($row); + } + + return $dto; + } + + /** + * Retrieve the "count" result of the query. + * + * @param string $columns + * @return int + */ + public function count(string $columns = '*'): int + { + $backupModel = $this->dtoClass; + $backupDao = $this->dao; + $this->dtoClass = null; + $this->dao = null; + + $results = $this->aggregate('count', [$columns])->get(); + + $this->dtoClass = $backupModel; + $this->dao = $backupDao; + + return (int) ($results[0]->aggregate ?? 0); + } + + /** + * Paginate the given query. + * + * @param int $perPage + * @param int $currentPage + * @return Paginator + */ + public function paginate(int $perPage = 15, int $currentPage = 1): Paginator + { + $total = $this->count(); + + // Use a new instance for the results to avoid conflicting with aggregate state + $itemsBuilder = clone $this; + $itemsBuilder->aggregate = null; + + $items = $itemsBuilder->offset(($currentPage - 1) * $perPage) + ->limit($perPage) + ->get(); + + return new Paginator($items, $total, $perPage, $currentPage); + } + + /** + * Update records in the database. + * + * @param array $values + * @return int + * @throws DatabaseException + */ + public function update(array $values): int + { + $this->applyTenancy(); + + $this->ensureConstrained('update'); + + $this->addBinding(array_values($values), 'update'); + + $sql = $this->grammar->compileUpdate($this, $values); + + return $this->connection->execute($sql, $this->getBindings()); + } + + /** + * Delete records from the database. + * + * @return int + * @throws DatabaseException + */ + public function delete(): int + { + $this->applyTenancy(); + + $this->ensureConstrained('delete'); + + $sql = $this->grammar->compileDelete($this); + + return $this->connection->execute($sql, $this->getBindings()); + } + + /** + * Disable multi-tenancy for the query. + * + * @return $this + */ + public function withoutTenancy(): self + { + $this->withoutTenancy = true; + return $this; + } + + /** + * Apply multi-tenancy scope to the query if enabled. + * + * @return void + */ + protected function applyTenancy(): void + { + if ($this->withoutTenancy || !$this->dao) { + return; + } + + if ($this->dao->getOption('tenancy', false)) { + (new \Pairity\Database\Query\Scopes\TenantScope())->apply($this); + } + } + + /** + * Ensure the query is constrained if unconstrained queries are disabled. + * + * @param string $operation + * @return void + * @throws DatabaseException + */ + protected function ensureConstrained(string $operation): void + { + $config = $this->connection->getConfig(); + $allowUnconstrained = $config['allow_unconstrained_queries'] ?? false; + + if (!$allowUnconstrained && empty($this->wheres)) { + $translator = $this->db->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new DatabaseException( + $translator->trans('error.unconstrained_query', ['operation' => $operation]), + 0, + null, + ['operation' => $operation] + ); + } + } +} diff --git a/src/Database/Query/EagerLoader.php b/src/Database/Query/EagerLoader.php new file mode 100644 index 0000000..80094a2 --- /dev/null +++ b/src/Database/Query/EagerLoader.php @@ -0,0 +1,139 @@ + $models + * @param array $eagerLoad + * @return array + */ + public function load(array $models, array $eagerLoad): array + { + if (empty($models) || empty($eagerLoad)) { + return $models; + } + + foreach ($eagerLoad as $name => $constraints) { + // Check for nested relations + if (str_contains($name, '.')) { + $models = $this->loadNestedRelation($models, $name, $constraints); + continue; + } + + $models = $this->loadRelation($models, $name, $constraints); + } + + return $models; + } + + /** + * Load a single relationship on a set of models. + * + * @param array $models + * @param string $name + * @param \Closure $constraints + * @return array + * @throws DatabaseException + */ + protected function loadRelation(array $models, string $name, \Closure $constraints): array + { + $relation = $this->getRelation($models, $name); + + // Reset wheres because the relation constructor adds wheres for the single parent + $relation->wheres = []; + $relation->setBindings([], 'where'); + + // Apply constraints + $constraints($relation); + + // Add eager constraints (usually WHERE IN [ids]) + $relation->addEagerConstraints($models); + + // Initialize the relation on all models (e.g. set to null or empty array) + $models = $relation->initRelation($models, $name); + + // Get the results + $results = $relation->get(); + + // Match results back to parents + return $relation->match($models, $results, $name); + } + + /** + * Load a nested relationship. + * + * @param array $models + * @param string $name + * @param \Closure $constraints + * @return array + */ + protected function loadNestedRelation(array $models, string $name, \Closure $constraints): array + { + $parts = explode('.', $name); + $first = array_shift($parts); + $rest = implode('.', $parts); + + // Load the first level if not already loaded + $models = $this->loadRelation($models, $first, function () {}); + + // Gather all the related models from the first level + $results = []; + foreach ($models as $model) { + $relationValue = $model->getRelation($first); + if (is_array($relationValue)) { + $results = array_merge($results, $relationValue); + } elseif ($relationValue instanceof BaseDTO) { + $results[] = $relationValue; + } + } + + // Recursively load the rest of the path on the gathered results + if (!empty($results)) { + $this->load($results, [$rest => $constraints]); + } + + return $models; + } + + /** + * Get the relation instance for the given models and name. + * + * @param array $models + * @param string $name + * @return Relation + * @throws DatabaseException + */ + protected function getRelation(array $models, string $name): Relation + { + $model = reset($models); + + $dao = $model->getDao(); + + if (!method_exists($dao, $name)) { + $translator = $dao->getDb()->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new DatabaseException( + $translator->trans('error.relation_not_found', ['relation' => $name]), + 0, + null, + ['relation' => $name, 'dao' => get_class($dao)] + ); + } + + return $dao->{$name}($model); + } +} diff --git a/src/Database/Query/Expression.php b/src/Database/Query/Expression.php new file mode 100644 index 0000000..aad1b3c --- /dev/null +++ b/src/Database/Query/Expression.php @@ -0,0 +1,43 @@ +value; + } + + /** + * Get the raw expression value. + * + * @return string + */ + public function __toString(): string + { + return $this->getValue(); + } +} diff --git a/src/Database/Query/Grammar.php b/src/Database/Query/Grammar.php new file mode 100644 index 0000000..2d0557f --- /dev/null +++ b/src/Database/Query/Grammar.php @@ -0,0 +1,567 @@ +columns === null) { + $query->columns = ['*']; + } + + $sql = trim($this->concatenate($this->compileComponents($query))); + + if (!empty($query->unions)) { + $sql = $this->compileUnions($query, $sql); + } + + return $sql; + } + + /** + * Compile an update statement into SQL. + * + * @param Builder $query + * @param array $values + * @return string + */ + public function compileUpdate(Builder $query, array $values): string + { + $table = $this->wrapTable($query->from); + + $columns = []; + + foreach ($values as $key => $value) { + $columns[] = $this->wrap($key) . ' = ?'; + } + + $columns = implode(', ', $columns); + + $wheres = $this->compileWheres($query, $query->wheres); + + return trim("update {$table} set {$columns} {$wheres}"); + } + + /** + * Compile a delete statement into SQL. + * + * @param Builder $query + * @return string + */ + public function compileDelete(Builder $query): string + { + $table = $this->wrapTable($query->from); + + $wheres = $this->compileWheres($query, $query->wheres); + + return trim("delete from {$table} {$wheres}"); + } + + /** + * Compile the components necessary for a select clause. + * + * @param Builder $query + * @return array + */ + protected function compileComponents(Builder $query): array + { + $sql = []; + + foreach ($this->selectComponents as $component) { + if ($query->$component !== null) { + $method = 'compile' . ucfirst($component); + $sql[$component] = $this->$method($query, $query->$component); + } + } + + return $sql; + } + + /** + * Compile the "aggregate" part of a query. + * + * @param Builder $query + * @param array $aggregate + * @return string + */ + protected function compileAggregate(Builder $query, array $aggregate): string + { + $column = $this->columnize($aggregate['columns']); + + if ($query->distinct && $column !== '*') { + $column = 'distinct ' . $column; + } + + return 'select ' . $aggregate['function'] . '(' . $column . ') as aggregate'; + } + + /** + * Compile the "columns" part of a query. + * + * @param Builder $query + * @param array $columns + * @return string + */ + protected function compileColumns(Builder $query, array $columns): string + { + if ($query->aggregate !== null) { + return ''; + } + + $select = $query->distinct ? 'select distinct ' : 'select '; + + return $select . implode(', ', array_map(function ($column) { + if (is_array($column)) { + [$query, $as] = $column; + + if ($query instanceof Builder) { + return '(' . $query->toSql() . ') as ' . $this->wrap($as); + } + + return $this->wrap($query) . ' as ' . $this->wrap($as); + } + + return $this->wrap($column); + }, $columns)); + } + + /** + * Compile the "from" part of a query. + * + * @param Builder $query + * @param mixed $table + * @return string + */ + protected function compileFrom(Builder $query, $table): string + { + if (is_array($table)) { + [$subquery, $as] = $table; + return 'from (' . $subquery->toSql() . ') as ' . $this->wrapTable($as); + } + + return 'from ' . $this->wrapTable($table); + } + + /** + * Compile the "joins" part of a query. + * + * @param Builder $query + * @param array $joins + * @return string + */ + protected function compileJoins(Builder $query, array $joins): string + { + return implode(' ', array_map(function ($join) { + $table = $join['table']; + + if (is_array($table)) { + [$subquery, $as] = $table; + $table = '(' . $subquery->toSql() . ') as ' . $this->wrapTable($as); + } else { + $table = $this->wrapTable($table); + } + + return "{$join['type']} join {$table} on {$this->wrap($join['first'])} {$join['operator']} {$this->wrap($join['second'])}"; + }, $joins)); + } + + /** + * Compile the "wheres" part of a query. + * + * @param Builder $query + * @param array $wheres + * @return string + */ + protected function compileWheres(Builder $query, array $wheres): string + { + if (empty($wheres)) { + return ''; + } + + $sql = []; + + foreach ($wheres as $where) { + $sql[] = "{$where['boolean']} " . $this->{"compileWhere{$where['type']}"}($query, $where); + } + + return 'where ' . $this->removeLeadingBoolean(implode(' ', $sql)); + } + + /** + * Compile a "where in" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereIn(Builder $query, array $where): string + { + if (empty($where['values'])) { + return '0 = 1'; + } + + $values = $this->parameterize($where['values']); + + return "{$this->wrap($where['column'])} in ({$values})"; + } + + /** + * Compile a "where not in" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereNotIn(Builder $query, array $where): string + { + if (empty($where['values'])) { + return '1 = 1'; + } + + $values = $this->parameterize($where['values']); + + return "{$this->wrap($where['column'])} not in ({$values})"; + } + + /** + * Compile a "where in subquery" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereInSubquery(Builder $query, array $where): string + { + return "{$this->wrap($where['column'])} in ({$where['query']->toSql()})"; + } + + /** + * Compile a "where not in subquery" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereNotInSubquery(Builder $query, array $where): string + { + return "{$this->wrap($where['column'])} not in ({$where['query']->toSql()})"; + } + + /** + * Compile a "where exists" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereExists(Builder $query, array $where): string + { + return 'exists (' . $where['query']->toSql() . ')'; + } + + /** + * Compile a "where not exists" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereNotExists(Builder $query, array $where): string + { + return 'not exists (' . $where['query']->toSql() . ')'; + } + + /** + * Compile a basic where clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereBasic(Builder $query, array $where): string + { + return "{$this->wrap($where['column'])} {$where['operator']} ?"; + } + + /** + * Compile a "where null" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereNull(Builder $query, array $where): string + { + return "{$this->wrap($where['column'])} is null"; + } + + /** + * Compile a "where not null" clause. + * + * @param Builder $query + * @param array $where + * @return string + */ + protected function compileWhereNotNull(Builder $query, array $where): string + { + return "{$this->wrap($where['column'])} is not null"; + } + + /** + * Compile the "groups" part of a query. + * + * @param Builder $query + * @param array $groups + * @return string + */ + protected function compileGroups(Builder $query, array $groups): string + { + if (empty($groups)) { + return ''; + } + + return 'group by ' . $this->columnize($groups); + } + + /** + * Compile the "havings" part of a query. + * + * @param Builder $query + * @param array $havings + * @return string + */ + protected function compileHavings(Builder $query, array $havings): string + { + if (empty($havings)) { + return ''; + } + + return 'having ' . $this->removeLeadingBoolean(implode(' ', array_map(function ($having) { + return "{$having['boolean']} {$this->wrap($having['column'])} {$having['operator']} ?"; + }, $havings))); + } + + /** + * Compile the "orders" part of a query. + * + * @param Builder $query + * @param array $orders + * @return string + */ + protected function compileOrders(Builder $query, array $orders): string + { + if (empty($orders)) { + return ''; + } + + return 'order by ' . implode(', ', array_map(function ($order) { + return "{$this->wrap($order['column'])} {$order['direction']}"; + }, $orders)); + } + + /** + * Compile the "limit" part of a query. + * + * @param Builder $query + * @param int $limit + * @return string + */ + protected function compileLimit(Builder $query, int $limit): string + { + return 'limit ' . (int) $limit; + } + + /** + * Compile the "offset" part of a query. + * + * @param Builder $query + * @param int $offset + * @return string + */ + protected function compileOffset(Builder $query, int $offset): string + { + return 'offset ' . (int) $offset; + } + + /** + * Compile an upsert statement into SQL. + * + * @param Builder $query + * @param array $values + * @param array $uniqueBy + * @param array|null $update + * @return string + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, ?array $update = null): string + { + $translator = $query->getDb()->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new \Pairity\Exceptions\DatabaseException($translator->trans('error.upsert_not_supported')); + } + + /** + * Compile the "lock" part of a query. + * + * @param Builder $query + * @param bool|string $lock + * @return string + */ + protected function compileLock(Builder $query, bool|string $lock): string + { + if (is_string($lock)) { + return $lock; + } + + return $lock ? 'for update' : 'lock in share mode'; + } + + /** + * Create query parameter place-holders for an array. + * + * @param array $values + * @return string + */ + public function parameterize(array $values): string + { + return implode(', ', array_map([$this, 'parameter'], $values)); + } + + /** + * Get the appropriate query parameter place-holder for a value. + * + * @param mixed $value + * @return string + */ + public function parameter(mixed $value): string + { + return '?'; + } + + /** + * Wrap a table in keyword identifiers. + * + * @param string $table + * @return string + */ + public function wrapTable(string $table): string + { + return $this->wrap($table); + } + + /** + * Wrap a value in keyword identifiers. + * + * @param mixed $value + * @return string + */ + public function wrap(mixed $value): string + { + if ($value instanceof Expression) { + return $value->getValue(); + } + + if ($value === '*') { + return $value; + } + + return '"' . str_replace('"', '""', (string)$value) . '"'; + } + + /** + * Convert an array of column names into a delimited string. + * + * @param array $columns + * @return string + */ + public function columnize(array $columns): string + { + return implode(', ', array_map([$this, 'wrap'], $columns)); + } + + /** + * Concatenate an array of segments, removing empties. + * + * @param array $segments + * @return string + */ + protected function concatenate(array $segments): string + { + return implode(' ', array_filter($segments, function ($value) { + return (string) $value !== ''; + })); + } + + /** + * Compile the "union" queries adjoined to the main query. + * + * @param Builder $query + * @param string $sql + * @return string + */ + protected function compileUnions(Builder $query, string $sql): string + { + $unionSql = ''; + + foreach ($query->unions as $union) { + $unionSql .= $this->compileUnion($union); + } + + return '(' . $sql . ')' . $unionSql; + } + + /** + * Compile a single union statement. + * + * @param array $union + * @return string + */ + protected function compileUnion(array $union): string + { + $joiner = $union['all'] ? ' union all ' : ' union '; + + return $joiner . '(' . $union['query']->toSql() . ')'; + } + + /** + * Remove the leading boolean from a statement. + * + * @param string $value + * @return string + */ + protected function removeLeadingBoolean(string $value): string + { + return preg_replace('/and |or /i', '', $value, 1); + } +} diff --git a/src/Database/Query/Grammars/MySqlGrammar.php b/src/Database/Query/Grammars/MySqlGrammar.php new file mode 100644 index 0000000..bcae673 --- /dev/null +++ b/src/Database/Query/Grammars/MySqlGrammar.php @@ -0,0 +1,32 @@ +getValue(); + } + + if ($value === '*') { + return $value; + } + + return '`' . str_replace('`', '``', (string)$value) . '`'; + } +} diff --git a/src/Database/Query/Grammars/OracleGrammar.php b/src/Database/Query/Grammars/OracleGrammar.php new file mode 100644 index 0000000..e26ed1c --- /dev/null +++ b/src/Database/Query/Grammars/OracleGrammar.php @@ -0,0 +1,14 @@ +getValue(); + } + + if ($value === '*') { + return $value; + } + + return '[' . str_replace(']', ']]', (string)$value) . ']'; + } +} diff --git a/src/Database/Query/Grammars/SqliteGrammar.php b/src/Database/Query/Grammars/SqliteGrammar.php new file mode 100644 index 0000000..2f8cc44 --- /dev/null +++ b/src/Database/Query/Grammars/SqliteGrammar.php @@ -0,0 +1,47 @@ +columnize(array_keys($values[0])); + $table = $this->wrapTable((string)$query->from); + + $sql = "insert into {$table} ({$columns}) values "; + + $parameters = array_map(function ($record) { + return '(' . $this->parameterize($record) . ')'; + }, $values); + + $sql .= implode(', ', $parameters); + + if (is_null($update)) { + return $sql . " on conflict do nothing"; + } + + $sql .= " on conflict (" . $this->columnize($uniqueBy) . ") do update set "; + + $columns = []; + foreach ($update as $key => $value) { + if (is_numeric($key)) { + $columns[] = $this->wrap($value) . ' = excluded.' . $this->wrap($value); + } else { + $columns[] = $this->wrap($key) . ' = ?'; + } + } + + return $sql . implode(', ', $columns); + } +} diff --git a/src/Database/Query/Paginator.php b/src/Database/Query/Paginator.php new file mode 100644 index 0000000..6f6dfbf --- /dev/null +++ b/src/Database/Query/Paginator.php @@ -0,0 +1,97 @@ +items; + } + + /** + * Get the total number of items. + * + * @return int + */ + public function total(): int + { + return $this->total; + } + + /** + * Get the number of items per page. + * + * @return int + */ + public function perPage(): int + { + return $this->perPage; + } + + /** + * Get the current page number. + * + * @return int + */ + public function currentPage(): int + { + return $this->currentPage; + } + + /** + * Get the last page number. + * + * @return int + */ + public function lastPage(): int + { + return (int) ceil($this->total / $this->perPage); + } + + /** + * Convert the paginator to an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'data' => $this->items, + 'meta' => [ + 'total' => $this->total(), + 'per_page' => $this->perPage(), + 'current_page' => $this->currentPage(), + 'last_page' => $this->lastPage(), + ], + ]; + } +} diff --git a/src/Database/Query/QueryResult.php b/src/Database/Query/QueryResult.php new file mode 100644 index 0000000..4f8f675 --- /dev/null +++ b/src/Database/Query/QueryResult.php @@ -0,0 +1,82 @@ + $attributes + */ + public function __construct( + protected array $attributes = [] + ) { + } + + /** + * Magic property access. + * + * @param string $name + * @return mixed + */ + public function __get(string $name): mixed + { + return $this->attributes[$name] ?? null; + } + + /** + * Magic method access for getters (e.g., getEmail()). + * + * @param string $name + * @param array $arguments + * @return mixed + */ + public function __call(string $name, array $arguments): mixed + { + if (str_starts_with($name, 'get')) { + $property = lcfirst(substr($name, 3)); + + if (array_key_exists($property, $this->attributes)) { + return $this->attributes[$property]; + } + + // Also try snake_case if it matches common DB conventions + $snakeProperty = strtolower(preg_replace('/(?attributes)) { + return $this->attributes[$snakeProperty]; + } + } + + return null; + } + + /** + * Convert the result to a raw array. + * + * @return array + */ + public function toArray(): array + { + return $this->attributes; + } + + /** + * Check if an attribute exists. + * + * @param string $name + * @return bool + */ + public function __isset(string $name): bool + { + return isset($this->attributes[$name]); + } +} diff --git a/src/Database/Query/Relations/BelongsTo.php b/src/Database/Query/Relations/BelongsTo.php new file mode 100644 index 0000000..0ca251f --- /dev/null +++ b/src/Database/Query/Relations/BelongsTo.php @@ -0,0 +1,90 @@ +where($this->localKey, '=', $this->parent->{$this->foreignKey}); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + public function addEagerConstraints(array $models): void + { + $keys = array_map(fn($model) => $model->{$this->foreignKey}, $models); + $this->whereIn($this->localKey, array_filter(array_unique($keys))); + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, null); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + public function match(array $models, array $results, string $relation): array + { + $dictionary = []; + foreach ($results as $result) { + $dictionary[$result->{$this->localKey}] = $result; + } + + foreach ($models as $model) { + $key = $model->{$this->foreignKey}; + if (isset($dictionary[$key])) { + $model->setRelation($relation, $dictionary[$key]); + } else { + $model->setRelation($relation, null); + } + } + + return $models; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function getResults(): mixed + { + return $this->first(); + } +} diff --git a/src/Database/Query/Relations/HasMany.php b/src/Database/Query/Relations/HasMany.php new file mode 100644 index 0000000..d9ac718 --- /dev/null +++ b/src/Database/Query/Relations/HasMany.php @@ -0,0 +1,52 @@ +setRelation($relation, []); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + public function match(array $models, array $results, string $relation): array + { + return $this->matchOneOrMany($models, $results, $relation, 'many'); + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function getResults(): mixed + { + return $this->get(); + } +} diff --git a/src/Database/Query/Relations/HasOne.php b/src/Database/Query/Relations/HasOne.php new file mode 100644 index 0000000..bc919f9 --- /dev/null +++ b/src/Database/Query/Relations/HasOne.php @@ -0,0 +1,52 @@ +setRelation($relation, null); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + public function match(array $models, array $results, string $relation): array + { + return $this->matchOneOrMany($models, $results, $relation, 'one'); + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function getResults(): mixed + { + return $this->first(); + } +} diff --git a/src/Database/Query/Relations/HasOneOrMany.php b/src/Database/Query/Relations/HasOneOrMany.php new file mode 100644 index 0000000..1d4dae8 --- /dev/null +++ b/src/Database/Query/Relations/HasOneOrMany.php @@ -0,0 +1,81 @@ +where($this->foreignKey, '=', $this->parent->{$this->localKey}); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + public function addEagerConstraints(array $models): void + { + $keys = array_map(fn($model) => $model->{$this->localKey}, $models); + $this->whereIn($this->foreignKey, array_unique($keys)); + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @param string $type 'one' or 'many' + * @return array + */ + protected function matchOneOrMany(array $models, array $results, string $relation, string $type): array + { + $dictionary = $this->buildDictionary($results); + + foreach ($models as $model) { + $key = $model->{$this->localKey}; + + if (isset($dictionary[$key])) { + $value = $type === 'one' ? reset($dictionary[$key]) : $dictionary[$key]; + $model->setRelation($relation, $value); + } else { + $model->setRelation($relation, $type === 'one' ? null : []); + } + } + + return $models; + } + + /** + * Build model dictionary keyed by foreign key. + * + * @param array $results + * @return array> + */ + protected function buildDictionary(array $results): array + { + $dictionary = []; + + foreach ($results as $result) { + $dictionary[$result->{$this->foreignKey}][] = $result; + } + + return $dictionary; + } +} diff --git a/src/Database/Query/Relations/MorphTo.php b/src/Database/Query/Relations/MorphTo.php new file mode 100644 index 0000000..3a4d635 --- /dev/null +++ b/src/Database/Query/Relations/MorphTo.php @@ -0,0 +1,124 @@ +morphType = $morphType; + parent::__construct($query, $parent, $related, $foreignKey, $localKey); + } + + /** + * Set the base constraints on the relation query. + * + * @return void + */ + public function addConstraints(): void + { + $this->where($this->localKey, '=', $this->parent->{$this->foreignKey}) + ->where($this->morphType, '=', $this->getMorphTypeForParent()); + } + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + public function addEagerConstraints(array $models): void + { + $keys = array_map(fn($model) => $model->{$this->foreignKey}, $models); + $this->whereIn($this->localKey, array_filter(array_unique($keys))); + } + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + public function initRelation(array $models, string $relation): array + { + foreach ($models as $model) { + $model->setRelation($relation, null); + } + + return $models; + } + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + public function match(array $models, array $results, string $relation): array + { + $dictionary = []; + foreach ($results as $result) { + $dictionary[$result->{$this->localKey}] = $result; + } + + foreach ($models as $model) { + $key = $model->{$this->foreignKey}; + if (isset($dictionary[$key])) { + $model->setRelation($relation, $dictionary[$key]); + } else { + $model->setRelation($relation, null); + } + } + + return $models; + } + + /** + * Get the results of the relationship. + * + * @return mixed + */ + public function getResults(): mixed + { + return $this->first(); + } + + /** + * Get the morph type for the parent model. + * + * @return string + */ + protected function getMorphTypeForParent(): string + { + return $this->parent->getDao()->getOption('morph') ?? get_class($this->parent); + } +} diff --git a/src/Database/Query/Relations/Relation.php b/src/Database/Query/Relations/Relation.php new file mode 100644 index 0000000..824c754 --- /dev/null +++ b/src/Database/Query/Relations/Relation.php @@ -0,0 +1,125 @@ +getDb(), $query->getConnection(), $query->getGrammar()); + + $this->parent = $parent; + $this->related = $related; + $this->foreignKey = $foreignKey; + $this->localKey = $localKey; + + $this->from($related->getTable()); + + if ($dtoClass = $related->getDtoClass()) { + $this->setModel($dtoClass, $related); + } + + $this->addConstraints(); + } + + /** + * Set the base constraints on the relation query. + * + * @return void + */ + abstract public function addConstraints(): void; + + /** + * Set the constraints for an eager load of the relation. + * + * @param array $models + * @return void + */ + abstract public function addEagerConstraints(array $models): void; + + /** + * Initialize the relation on a set of models. + * + * @param array $models + * @param string $relation + * @return array + */ + abstract public function initRelation(array $models, string $relation): array; + + /** + * Match the eagerly loaded results to their parents. + * + * @param array $models + * @param array $results + * @param string $relation + * @return array + */ + abstract public function match(array $models, array $results, string $relation): array; + + /** + * Get the results of the relationship. + * + * @return mixed + */ + abstract public function getResults(): mixed; + + /** + * Get the parent model of the relation. + * + * @return BaseDTO + */ + public function getParent(): BaseDTO + { + return $this->parent; + } + + /** + * Get the related model of the relation. + * + * @return BaseDAO + */ + public function getRelated(): BaseDAO + { + return $this->related; + } +} diff --git a/src/Database/Query/Scopes/TenantScope.php b/src/Database/Query/Scopes/TenantScope.php new file mode 100644 index 0000000..ad35850 --- /dev/null +++ b/src/Database/Query/Scopes/TenantScope.php @@ -0,0 +1,80 @@ +where(self::$tenantColumn, self::$tenantId); + } + } +} diff --git a/src/Database/Schema/Introspector.php b/src/Database/Schema/Introspector.php new file mode 100644 index 0000000..85bfc86 --- /dev/null +++ b/src/Database/Schema/Introspector.php @@ -0,0 +1,139 @@ + + */ + public function getTables(): array + { + $driver = $this->connection->getDriver()->getName(); + + $sql = match ($driver) { + 'sqlite' => "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + 'mysql' => "SHOW TABLES", + 'pgsql', 'postgres' => "SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname != 'pg_catalog' AND schemaname != 'information_schema'", + default => throw new \RuntimeException("Introspection not supported for driver [{$driver}]."), + }; + + $results = $this->connection->select($sql); + + return array_map(fn($row) => (array)$row === $row ? reset($row) : $row->{key((array)$row)}, $results); + } + + /** + * Reverse-engineer a table into a YAML-compatible array. + * + * @param string $table + * @return array + */ + public function introspectTable(string $table): array + { + $driver = $this->connection->getDriver()->getName(); + + return match ($driver) { + 'sqlite' => $this->introspectSqliteTable($table), + 'mysql' => $this->introspectMysqlTable($table), + default => throw new \RuntimeException("Introspection for table not supported for driver [{$driver}]."), + }; + } + + /** + * Introspect a SQLite table. + * + * @param string $table + * @return array + */ + protected function introspectSqliteTable(string $table): array + { + $columns = $this->connection->select("PRAGMA table_info({$table})"); + $yaml = ['columns' => []]; + + foreach ($columns as $column) { + $name = $column->name; + $type = strtolower($column->type); + $definition = [ + 'type' => $this->mapType($type), + ]; + + if ($column->notnull) $definition['nullable'] = false; + else $definition['nullable'] = true; + + if ($column->dflt_value !== null) $definition['default'] = $column->dflt_value; + if ($column->pk) $definition['primary'] = true; + + $yaml['columns'][$name] = $definition; + } + + return $yaml; + } + + /** + * Introspect a MySQL table. + * + * @param string $table + * @return array + */ + protected function introspectMysqlTable(string $table): array + { + $columns = $this->connection->select("SHOW COLUMNS FROM {$table}"); + $yaml = ['columns' => []]; + + foreach ($columns as $column) { + $name = $column->Field; + $type = strtolower($column->Type); + $definition = [ + 'type' => $this->mapType($type), + ]; + + if ($column->Null === 'NO') $definition['nullable'] = false; + if ($column->Default !== null) $definition['default'] = $column->Default; + if ($column->Key === 'PRI') $definition['primary'] = true; + if (str_contains($column->Extra, 'auto_increment')) $definition['autoIncrement'] = true; + + $yaml['columns'][$name] = $definition; + } + + return $yaml; + } + + /** + * Map database types to Pairity types. + * + * @param string $dbType + * @return string + */ + protected function mapType(string $dbType): string + { + if (str_contains($dbType, 'int')) return 'integer'; + if (str_contains($dbType, 'char') || str_contains($dbType, 'text')) return 'string'; + if (str_contains($dbType, 'bool')) return 'boolean'; + if (str_contains($dbType, 'float') || str_contains($dbType, 'double') || str_contains($dbType, 'decimal')) return 'decimal'; + if (str_contains($dbType, 'date') || str_contains($dbType, 'time')) return 'datetime'; + + return 'string'; // Default fallback + } +} diff --git a/src/Database/Seeding/Seeder.php b/src/Database/Seeding/Seeder.php new file mode 100644 index 0000000..809eae8 --- /dev/null +++ b/src/Database/Seeding/Seeder.php @@ -0,0 +1,49 @@ +db = $db; + } + + /** + * Run the database seeds. + * + * @return void + */ + abstract public function run(): void; + + /** + * Seed the given seeder class. + * + * @param string $class + * @return void + */ + public function call(string $class): void + { + $seeder = new $class($this->db); + $seeder->run(); + } +} diff --git a/src/Database/UnitOfWork.php b/src/Database/UnitOfWork.php new file mode 100644 index 0000000..5b7e8b0 --- /dev/null +++ b/src/Database/UnitOfWork.php @@ -0,0 +1,209 @@ + + */ + protected array $states = []; + + /** + * @var \SplObjectStorage + */ + protected \SplObjectStorage $dtoStates; + + /** + * UnitOfWork constructor. + * + * @param DatabaseManagerInterface $db + */ + public function __construct( + protected DatabaseManagerInterface $db + ) { + $this->dtoStates = new \SplObjectStorage(); + } + + /** + * Track a DTO instance. + * + * @param BaseDTO $dto + * @param string $state + * @return void + */ + public function track(BaseDTO $dto, string $state = self::STATE_CLEAN): void + { + $this->dtoStates[$dto] = $state; + } + + /** + * Register a new DTO. + * + * @param BaseDTO $dto + * @return void + */ + public function registerNew(BaseDTO $dto): void + { + $this->track($dto, self::STATE_NEW); + } + + /** + * Register a dirty DTO. + * + * @param BaseDTO $dto + * @return void + */ + public function registerDirty(BaseDTO $dto): void + { + if (isset($this->dtoStates[$dto]) && $this->dtoStates[$dto] === self::STATE_NEW) { + return; + } + $this->track($dto, self::STATE_DIRTY); + } + + /** + * Register a deleted DTO. + * + * @param BaseDTO $dto + * @return void + */ + public function registerDeleted(BaseDTO $dto): void + { + $this->track($dto, self::STATE_DELETED); + } + + /** + * Get the state of a DTO. + * + * @param BaseDTO $dto + * @return string|null + */ + public function getState(BaseDTO $dto): ?string + { + return $this->dtoStates[$dto] ?? null; + } + + /** + * Commit all tracked changes to the database. + * + * @return void + * @throws DatabaseException + */ + public function commit(): void + { + $connections = $this->getConnectionsToCommit(); + + foreach ($connections as $connectionName) { + $this->db->connection($connectionName)->beginTransaction(); + } + + try { + foreach ($this->dtoStates as $dto) { + $state = $this->dtoStates[$dto]; + $dao = $dto->getDao(); + + if (!$dao) { + $translator = $this->db->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new DatabaseException($translator->trans('error.uow_no_dao')); + } + + match ($state) { + self::STATE_NEW, self::STATE_DIRTY => $dao->save($dto), + self::STATE_DELETED => $this->deleteDto($dao, $dto), + default => null, + }; + } + + foreach ($connections as $connectionName) { + $this->db->connection($connectionName)->commit(); + } + + $this->clear(); + } catch (\Throwable $e) { + foreach ($connections as $connectionName) { + $this->db->connection($connectionName)->rollBack(); + } + + if ($e instanceof DatabaseException && $e->getMessage() === $this->db->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class)->trans('error.uow_no_dao')) { + throw $e; + } + + $translator = $this->db->getContainer()->get(\Pairity\Contracts\Translation\TranslatorInterface::class); + throw new DatabaseException( + $translator->trans('error.uow_commit_failed', ['message' => $e->getMessage()]), + 0, + $e, + ['error' => $e->getMessage()] + ); + } + } + + /** + * Delete a DTO via its DAO. + * + * @param BaseDAO $dao + * @param BaseDTO $dto + * @return void + */ + protected function deleteDto(BaseDAO $dao, BaseDTO $dto): void + { + $data = $dto->toArray(); + $primaryKey = $dao->getPrimaryKey(); + $id = $data[$primaryKey] ?? null; + + if ($id !== null) { + $dao->delete($id); + } + } + + /** + * Get list of unique connections involved in the current Unit of Work. + * + * @return array + */ + protected function getConnectionsToCommit(): array + { + $connections = []; + foreach ($this->dtoStates as $dto) { + $state = $this->dtoStates[$dto]; + if ($state === self::STATE_CLEAN) { + continue; + } + + $dao = $dto->getDao(); + if ($dao) { + $connectionName = $dao->getConnectionName(); + $connections[$connectionName] = true; + } + } + return array_keys($connections); + } + + /** + * Clear all tracked DTOs. + * + * @return void + */ + public function clear(): void + { + $this->dtoStates = new \SplObjectStorage(); + } +} diff --git a/src/Events/Dispatcher.php b/src/Events/Dispatcher.php new file mode 100644 index 0000000..edcc0e1 --- /dev/null +++ b/src/Events/Dispatcher.php @@ -0,0 +1,99 @@ +> + */ + protected array $listeners = []; + + /** + * @var array> + */ + protected array $wildcards = []; + + /** + * @inheritDoc + */ + public function listen(string $event, callable $listener): void + { + if (str_contains($event, '*')) { + $this->wildcards[$this->compileWildcard($event)][] = $listener; + } else { + $this->listeners[$event][] = $listener; + } + } + + /** + * @inheritDoc + */ + public function dispatch(string $event, mixed $payload = null, bool $halt = false): mixed + { + $responses = []; + + foreach ($this->getListeners($event) as $listener) { + $response = $listener($payload); + + if ($halt && !is_null($response)) { + return $response; + } + + if ($response === false) { + break; + } + + $responses[] = $response; + } + + return $halt ? null : $responses; + } + + /** + * @inheritDoc + */ + public function hasListeners(string $event): bool + { + return count($this->getListeners($event)) > 0; + } + + /** + * Get all listeners for a given event, including wildcards. + * + * @param string $event + * @return array + */ + protected function getListeners(string $event): array + { + $listeners = $this->listeners[$event] ?? []; + + foreach ($this->wildcards as $pattern => $wildcardListeners) { + if (preg_match($pattern, $event)) { + $listeners = array_merge($listeners, $wildcardListeners); + } + } + + return $listeners; + } + + /** + * Compile a wildcard event name into a regex pattern. + * + * @param string $event + * @return string + */ + protected function compileWildcard(string $event): string + { + return '/^' . str_replace('\*', '.*', preg_quote($event, '/')) . '$/'; + } +} diff --git a/src/Events/EventDispatcher.php b/src/Events/EventDispatcher.php deleted file mode 100644 index d2485a0..0000000 --- a/src/Events/EventDispatcher.php +++ /dev/null @@ -1,63 +0,0 @@ -> */ - private array $listeners = []; - - /** - * Register a listener for an event. - * Listener signature: function(array &$payload): void - */ - public function listen(string $event, callable $listener, int $priority = 0): void - { - $this->listeners[$event][] = ['priority' => $priority, 'listener' => $listener]; - // Sort by priority desc so higher runs first - usort($this->listeners[$event], function ($a, $b) { return $b['priority'] <=> $a['priority']; }); - } - - /** Register all listeners from a subscriber. */ - public function subscribe(SubscriberInterface $subscriber): void - { - foreach ((array)$subscriber->getSubscribedEvents() as $event => $handler) { - $callable = null; $priority = 0; - if (is_array($handler) && isset($handler[0])) { - $callable = $handler[0]; - $priority = (int)($handler[1] ?? 0); - } else { - $callable = $handler; - } - if (is_callable($callable)) { - $this->listen($event, $callable, $priority); - } - } - } - - /** - * Dispatch an event with a mutable payload (passed by reference to listeners). - * - * @param string $event - * @param array $payload - */ - public function dispatch(string $event, array &$payload = []): void - { - $list = $this->listeners[$event] ?? []; - if (!$list) return; - foreach ($list as $entry) { - try { - ($entry['listener'])($payload); - } catch (\Throwable) { - // swallow listener exceptions to avoid breaking core flow - } - } - } - - /** Remove all listeners for an event or all events. */ - public function clear(?string $event = null): void - { - if ($event === null) { $this->listeners = []; return; } - unset($this->listeners[$event]); - } -} diff --git a/src/Events/Events.php b/src/Events/Events.php deleted file mode 100644 index 0f46e0b..0000000 --- a/src/Events/Events.php +++ /dev/null @@ -1,21 +0,0 @@ - callable|array{0:callable,1:int priority} - * Example: return [ - * 'dao.beforeInsert' => [[$this, 'onBeforeInsert'], 10], - * 'uow.afterCommit' => [$this, 'onAfterCommit'], - * ]; - */ - public function getSubscribedEvents(): array; -} diff --git a/src/Exceptions/ConsoleException.php b/src/Exceptions/ConsoleException.php new file mode 100644 index 0000000..d77eedf --- /dev/null +++ b/src/Exceptions/ConsoleException.php @@ -0,0 +1,16 @@ + $context + */ + public function __construct( + string $message = "", + int $code = 0, + ?Throwable $previous = null, + protected array $context = [] + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Get the error context. + * + * @return array + */ + public function getContext(): array + { + return $this->context; + } +} diff --git a/src/Exceptions/QueryException.php b/src/Exceptions/QueryException.php new file mode 100644 index 0000000..ee5d57a --- /dev/null +++ b/src/Exceptions/QueryException.php @@ -0,0 +1,57 @@ + $bindings The query bindings. + * @param string $message The error message. + * @param int $code The error code. + * @param \Throwable|null $previous The previous exception. + */ + public function __construct( + protected string $sql, + protected array $bindings, + string $message = "", + int $code = 0, + ?\Throwable $previous = null + ) { + parent::__construct($message, $code, $previous, [ + 'sql' => $sql, + 'bindings' => $bindings, + ]); + } + + /** + * Get the SQL query that failed. + * + * @return string + */ + public function getSql(): string + { + return $this->sql; + } + + /** + * Get the query bindings. + * + * @return array + */ + public function getBindings(): array + { + return $this->bindings; + } +} diff --git a/src/Exceptions/RecordNotFoundException.php b/src/Exceptions/RecordNotFoundException.php new file mode 100644 index 0000000..e332d47 --- /dev/null +++ b/src/Exceptions/RecordNotFoundException.php @@ -0,0 +1,16 @@ +> $errors The validation errors. + * @param string $message The error message. + * @param int $code The error code. + * @param \Throwable|null $previous The previous exception. + */ + public function __construct( + protected array $errors, + string $message = "Validation failed.", + int $code = 0, + ?\Throwable $previous = null + ) { + parent::__construct($message, $code, $previous, ['errors' => $errors]); + } + + /** + * Get the validation errors. + * + * @return array> + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/Migrations/MigrationGenerator.php b/src/Migrations/MigrationGenerator.php deleted file mode 100644 index 9542023..0000000 --- a/src/Migrations/MigrationGenerator.php +++ /dev/null @@ -1,56 +0,0 @@ -template = $template ?? $this->defaultTemplate(); - } - - public function generate(string $name, string $directory): string - { - $ts = date('Y_m_d_His'); - $filename = $directory . DIRECTORY_SEPARATOR . $ts . '_' . $name . '.php'; - - file_put_contents($filename, $this->template); - - return $filename; - } - - private function defaultTemplate(): string - { - return <<<'PHP' -create('example', function (Blueprint $t) { - // $t->increments('id'); - // $t->string('name', 255); - // }); - } - - public function down(ConnectionInterface $connection): void - { - // Example: drop table - // $schema = SchemaManager::forConnection($connection); - // $schema->dropIfExists('example'); - } -}; -PHP; - } -} diff --git a/src/Migrations/MigrationInterface.php b/src/Migrations/MigrationInterface.php deleted file mode 100644 index 1c919be..0000000 --- a/src/Migrations/MigrationInterface.php +++ /dev/null @@ -1,11 +0,0 @@ - Ordered map name => instance - */ - public static function fromDirectory(string $dir): array - { - $result = []; - if (!is_dir($dir)) { - return $result; - } - $files = glob(rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*.php') ?: []; - sort($files, SORT_STRING); - foreach ($files as $file) { - $name = pathinfo($file, PATHINFO_FILENAME); - $loaded = require $file; - if ($loaded instanceof MigrationInterface) { - $result[$name] = $loaded; - continue; - } - // If file didn't return an instance but defines a class with the same basename, try to instantiate. - if (class_exists($name)) { - $obj = new $name(); - if ($obj instanceof MigrationInterface) { - $result[$name] = $obj; - } - } - } - return $result; - } -} diff --git a/src/Migrations/MigrationsRepository.php b/src/Migrations/MigrationsRepository.php deleted file mode 100644 index 3e4f3eb..0000000 --- a/src/Migrations/MigrationsRepository.php +++ /dev/null @@ -1,73 +0,0 @@ -connection = $connection; - $this->table = $table; - } - - public function ensureTable(): void - { - // Portable table with string PK works across MySQL & SQLite - $sql = "CREATE TABLE IF NOT EXISTS {$this->table} ( - migration VARCHAR(255) PRIMARY KEY, - batch INT NOT NULL, - ran_at DATETIME NOT NULL - )"; - $this->connection->execute($sql); - } - - /** - * @return array migration names already ran - */ - public function getRan(): array - { - $this->ensureTable(); - $rows = $this->connection->query("SELECT migration FROM {$this->table} ORDER BY migration ASC"); - return array_map(fn($r) => (string)$r['migration'], $rows); - } - - public function getLastBatchNumber(): int - { - $this->ensureTable(); - $rows = $this->connection->query("SELECT MAX(batch) AS b FROM {$this->table}"); - $max = $rows[0]['b'] ?? 0; - return (int)($max ?: 0); - } - - public function getNextBatchNumber(): int - { - return $this->getLastBatchNumber() + 1; - } - - /** @return array */ - public function getMigrationsInBatch(int $batch): array - { - $this->ensureTable(); - return $this->connection->query("SELECT migration, batch, ran_at FROM {$this->table} WHERE batch = :b ORDER BY migration DESC", ['b' => $batch]); - } - - public function log(string $migration, int $batch): void - { - $this->ensureTable(); - $this->connection->execute( - "INSERT INTO {$this->table} (migration, batch, ran_at) VALUES (:m, :b, :t)", - ['m' => $migration, 'b' => $batch, 't' => gmdate('Y-m-d H:i:s')] - ); - } - - public function remove(string $migration): void - { - $this->ensureTable(); - $this->connection->execute("DELETE FROM {$this->table} WHERE migration = :m", ['m' => $migration]); - } -} diff --git a/src/Migrations/Migrator.php b/src/Migrations/Migrator.php deleted file mode 100644 index d545360..0000000 --- a/src/Migrations/Migrator.php +++ /dev/null @@ -1,132 +0,0 @@ - */ - private array $registry = []; - - public function __construct(ConnectionInterface $connection, ?MigrationsRepository $repository = null) - { - $this->connection = $connection; - $this->repository = $repository ?? new MigrationsRepository($connection); - } - - /** - * Provide a registry (name => migration instance) used for rollback/reset resolution. - * - * @param array $registry - */ - public function setRegistry(array $registry): void - { - $this->registry = $registry; - } - - /** - * Run outstanding migrations. - * - * @param array $migrations An ordered map of name => instance - * @return array List of applied migration names - */ - public function migrate(array $migrations, bool $pretend = false): array - { - if ($pretend) { - return $this->connection->pretend(function () use ($migrations) { - $this->runMigrations($migrations); - }); - } - - return $this->runMigrations($migrations); - } - - /** - * @param array $migrations - * @return array|array}> - */ - private function runMigrations(array $migrations): array - { - $this->repository->ensureTable(); - $ran = array_flip($this->repository->getRan()); - $batch = $this->repository->getNextBatchNumber(); - $applied = []; - - foreach ($migrations as $name => $migration) { - if (isset($ran[$name])) { - continue; // already ran - } - // keep in registry for potential rollback in the same process - $this->registry[$name] = $migration; - $this->connection->transaction(function () use ($migration, $name, $batch, &$applied) { - $migration->up($this->connection); - $this->repository->log($name, $batch); - $applied[] = $name; - }); - } - - return $applied; - } - - /** - * Roll back the last batch (or N steps of batches). - * - * @return array|array}> - */ - public function rollback(int $steps = 1, bool $pretend = false): array - { - if ($pretend) { - return $this->connection->pretend(function () use ($steps) { - $this->runRollback($steps); - }); - } - - return $this->runRollback($steps); - } - - /** - * @return array - */ - private function runRollback(int $steps): array - { - $this->repository->ensureTable(); - $rolled = []; - for ($i = 0; $i < $steps; $i++) { - $batch = $this->repository->getLastBatchNumber(); - if ($batch <= 0) { break; } - $items = $this->repository->getMigrationsInBatch($batch); - if (!$items) { break; } - foreach ($items as $row) { - $name = (string)$row['migration']; - $instance = $this->resolveMigration($name); - if (!$instance) { continue; } - $this->connection->transaction(function () use ($instance, $name, &$rolled) { - $instance->down($this->connection); - $this->repository->remove($name); - $rolled[] = $name; - }); - } - } - return $rolled; - } - - /** - * Resolve a migration by name from registry or instantiate by class name. - */ - private function resolveMigration(string $name): ?MigrationInterface - { - if (isset($this->registry[$name])) { - return $this->registry[$name]; - } - if (class_exists($name)) { - $obj = new $name(); - if ($obj instanceof MigrationInterface) { - return $obj; - } - } - return null; - } -} diff --git a/src/Model/AbstractDao.php b/src/Model/AbstractDao.php deleted file mode 100644 index 4f09953..0000000 --- a/src/Model/AbstractDao.php +++ /dev/null @@ -1,1835 +0,0 @@ -|null */ - private ?array $selectedFields = null; - /** @var array> */ - private array $relationFields = []; - /** @var array */ - private array $with = []; - /** - * Nested eager-loading tree built from with() calls. - * Example: with(['posts.comments','user']) => - * [ 'posts' => ['comments' => []], 'user' => [] ] - * @var array> - */ - private array $withTree = []; - /** - * Per relation (and nested path) constraints. - * Keys are relation paths like 'posts' or 'posts.comments'. - * Values are callables taking the related DAO instance. - * @var array - */ - private array $withConstraints = []; - /** - * Optional per‑relation eager loading strategies for first‑level relations. - * Keys are relation names; values like 'join'. - * @var array - */ - private array $withStrategies = []; - /** Soft delete include flags */ - private bool $includeTrashed = false; - private bool $onlyTrashed = false; - /** @var array */ - private array $runtimeScopes = []; - /** @var array */ - private array $namedScopes = []; - /** - * Optional eager loading strategy for next find* call. - * null (default) uses the portable subquery/batched IN strategy. - * 'join' opts in to join-based eager loading for supported SQL relations (single level). - */ - private ?string $eagerStrategy = null; - /** Memoized schema/relations for perf */ - private ?array $schemaCache = null; - private ?array $relationsCache = null; - /** Eager IN batching size for related queries */ - protected int $inBatchSize = 1000; - - public function __construct(ConnectionInterface $connection) - { - $this->connection = $connection; - } - - abstract public function getTable(): string; - /** - * The DTO class this DAO hydrates. - * @return class-string - */ - abstract protected function dtoClass(): string; - - /** - * Relation metadata to enable eager/lazy loading. - * @return array> - */ - protected function relations(): array - { - return []; - } - - /** - * Optional schema metadata for this DAO (MVP). - * Example structure: - * return [ - * 'primaryKey' => 'id', - * 'columns' => [ - * 'id' => ['cast' => 'int'], - * 'email' => ['cast' => 'string'], - * 'data' => ['cast' => 'json'], - * ], - * 'timestamps' => ['createdAt' => 'created_at', 'updatedAt' => 'updated_at'], - * 'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'], - * ]; - * - * @return array - */ - protected function schema(): array - { - return []; - } - - /** Override point: set eager IN batching size (default 1000). */ - public function setInBatchSize(int $size): static - { - $this->inBatchSize = max(1, $size); - return $this; - } - - public function getPrimaryKey(): string - { - $schema = $this->getSchema(); - if (isset($schema['primaryKey']) && is_string($schema['primaryKey']) && $schema['primaryKey'] !== '') { - return $schema['primaryKey']; - } - return $this->primaryKey; - } - - public function getConnection(): ConnectionInterface - { - return $this->connection; - } - - /** - * Opt-in to join-based eager loading for the next find* call (SQL only, single-level relations). - */ - public function useJoinEager(): static - { - $this->eagerStrategy = 'join'; - return $this; - } - - /** - * Set eager strategy explicitly: 'join' or 'subquery'. Resets after next find* call. - */ - public function eagerStrategy(string $strategy): static - { - $strategy = strtolower($strategy); - $this->eagerStrategy = in_array($strategy, ['join', 'subquery'], true) ? $strategy : null; - return $this; - } - - /** @param array $criteria */ - public function findOneBy(array $criteria): ?AbstractDto - { - $cacheKey = null; - if ($this->cache !== null && empty($this->with) && $this->selectedFields === null && empty($this->runtimeScopes)) { - $cacheKey = $this->getCacheKeyForCriteria($criteria); - $cached = $this->getFromCache($cacheKey); - if ($cached instanceof AbstractDto) { - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $idCol = $this->getPrimaryKey(); - $id = (string)$cached->$idCol; - $managed = $uow->get(static::class, $id); - if ($managed) { - return $managed; - } - $uow->attach(static::class, $id, $cached); - } - return $cached; - } - } - - // Events: dao.beforeFind (criteria may be mutated) - try { - $ev = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'criteria' => &$criteria, - ]; - Events::dispatcher()->dispatch('dao.beforeFind', $ev); - } catch (\Throwable) { - } - $criteria = $this->applyDefaultScopes($criteria); - $this->applyRuntimeScopesToCriteria($criteria); - [$where, $bindings] = $this->buildWhere($criteria); - $where = $this->appendScopedWhere($where); - $dto = null; - if ($this->with && $this->shouldUseJoinEager()) { - [$sql, $bindings2, $meta] = $this->buildJoinSelect($where, $bindings, 1, 0); - $rows = $this->connection->query($sql, $bindings2); - $list = $this->hydrateFromJoinRows($rows, $meta); - $dto = $list[0] ?? null; - } else { - $sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : '') . ' LIMIT 1'; - $rows = $this->connection->query($sql, $bindings); - $dto = isset($rows[0]) ? $this->hydrate($this->castRowFromStorage($rows[0])) : null; - if ($dto && $this->with) { - $this->attachRelations([$dto]); - } - } - $this->resetFieldSelections(); - $this->resetRuntimeScopes(); - $this->eagerStrategy = null; // reset - $this->withStrategies = []; - // Events: dao.afterFind - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'dto' => $dto, - ]; - Events::dispatcher()->dispatch('dao.afterFind', $payload); - } catch (\Throwable) { - } - - if ($dto && $cacheKey) { - $this->putInCache($cacheKey, $dto); - $idCol = $this->getPrimaryKey(); - $this->putInCache($this->getCacheKeyForId($dto->$idCol), $dto); - } - - return $dto; - } - - public function findById(int|string $id): ?AbstractDto - { - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $managed = $uow->get(static::class, (string)$id); - if ($managed instanceof AbstractDto) { - return $managed; - } - } - - if ($this->cache !== null && empty($this->with) && $this->selectedFields === null && empty($this->runtimeScopes)) { - $cacheKey = $this->getCacheKeyForId($id); - $cached = $this->getFromCache($cacheKey); - if ($cached instanceof AbstractDto) { - if ($uow && !UnitOfWork::isSuspended()) { - $uow->attach(static::class, (string)$id, $cached); - } - return $cached; - } - } - - $dto = $this->findOneBy([$this->getPrimaryKey() => $id]); - - // Ensure it's cached by ID specifically if findOneBy didn't do it or if it was fetched from DB - if ($dto && $this->cache !== null && empty($this->with) && $this->selectedFields === null && empty($this->runtimeScopes)) { - $this->putInCache($this->getCacheKeyForId($id), $dto); - } - - return $dto; - } - - /** - * @param array $criteria - * @return array - */ - public function findAllBy(array $criteria = []): array - { - $cacheKey = null; - if ($this->cache !== null && empty($this->with) && $this->selectedFields === null && empty($this->runtimeScopes)) { - $cacheKey = $this->getCacheKeyForCriteria($criteria); - $cached = $this->getFromCache($cacheKey); - if (is_array($cached)) { - $uow = UnitOfWork::current(); - $idCol = $this->getPrimaryKey(); - $out = []; - foreach ($cached as $dto) { - if (!$dto instanceof AbstractDto) { continue; } - if ($uow && !UnitOfWork::isSuspended()) { - $id = (string)$dto->$idCol; - $managed = $uow->get(static::class, $id); - if ($managed) { - $out[] = $managed; - } else { - $uow->attach(static::class, $id, $dto); - $out[] = $dto; - } - } else { - $out[] = $dto; - } - } - return $out; - } - } - - // Events: dao.beforeFind (criteria may be mutated) - try { - $ev = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'criteria' => &$criteria, - ]; - Events::dispatcher()->dispatch('dao.beforeFind', $ev); - } catch (\Throwable) { - } - $criteria = $this->applyDefaultScopes($criteria); - $this->applyRuntimeScopesToCriteria($criteria); - [$where, $bindings] = $this->buildWhere($criteria); - $where = $this->appendScopedWhere($where); - if ($this->with && $this->shouldUseJoinEager()) { - [$sql2, $bindings2, $meta] = $this->buildJoinSelect($where, $bindings, null, null); - $rows = $this->connection->query($sql2, $bindings2); - $dtos = $this->hydrateFromJoinRows($rows, $meta); - } else { - $sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() . ($where ? ' WHERE ' . $where : ''); - $rows = $this->connection->query($sql, $bindings); - $dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); - if ($dtos && $this->with) { - $this->attachRelations($dtos); - } - } - $this->resetFieldSelections(); - $this->resetRuntimeScopes(); - $this->eagerStrategy = null; // reset - $this->withStrategies = []; - // Events: dao.afterFind (list) - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'dtos' => $dtos, - ]; - Events::dispatcher()->dispatch('dao.afterFind', $payload); - } catch (\Throwable) { - } - - if ($cacheKey && !empty($dtos)) { - $this->putInCache($cacheKey, $dtos); - // We could also cache each individual DTO by ID here for warming - $idCol = $this->getPrimaryKey(); - foreach ($dtos as $dto) { - $this->putInCache($this->getCacheKeyForId($dto->$idCol), $dto); - } - } - - return $dtos; - } - - /** - * Paginate results for the given criteria. - * @return array{data:array,total:int,perPage:int,currentPage:int,lastPage:int} - */ - public function paginate(int $page, int $perPage = 15, array $criteria = []): array - { - $page = max(1, $page); - $perPage = max(1, $perPage); - - $criteria = $this->applyDefaultScopes($criteria); - $this->applyRuntimeScopesToCriteria($criteria); - [$where, $bindings] = $this->buildWhere($criteria); - $whereFinal = $this->appendScopedWhere($where); - - // Total - $countSql = 'SELECT COUNT(*) AS cnt FROM ' . $this->getTable() . ($whereFinal ? ' WHERE ' . $whereFinal : ''); - $countRows = $this->connection->query($countSql, $bindings); - $total = (int)($countRows[0]['cnt'] ?? 0); - - // Page data - $offset = ($page - 1) * $perPage; - $dataSql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() - . ($whereFinal ? ' WHERE ' . $whereFinal : '') - . ' LIMIT ' . $perPage . ' OFFSET ' . $offset; - $rows = $this->connection->query($dataSql, $bindings); - $dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); - if ($dtos && $this->with) { - $this->attachRelations($dtos); - } - $this->resetFieldSelections(); - $this->resetRuntimeScopes(); - $this->eagerStrategy = null; // reset - $this->withStrategies = []; - - $lastPage = (int)max(1, (int)ceil($total / $perPage)); - return [ - 'data' => $dtos, - 'total' => $total, - 'perPage' => $perPage, - 'currentPage' => $page, - 'lastPage' => $lastPage, - ]; - } - - /** Simple pagination without total count. Returns nextPage if there might be more. */ - public function simplePaginate(int $page, int $perPage = 15, array $criteria = []): array - { - $page = max(1, $page); - $perPage = max(1, $perPage); - - $criteria = $this->applyDefaultScopes($criteria); - $this->applyRuntimeScopesToCriteria($criteria); - [$where, $bindings] = $this->buildWhere($criteria); - $whereFinal = $this->appendScopedWhere($where); - - $offset = ($page - 1) * $perPage; - $sql = 'SELECT ' . $this->selectList() . ' FROM ' . $this->getTable() - . ($whereFinal ? ' WHERE ' . $whereFinal : '') - . ' LIMIT ' . ($perPage + 1) . ' OFFSET ' . $offset; // fetch one extra to detect more - $rows = $this->connection->query($sql, $bindings); - $hasMore = count($rows) > $perPage; - if ($hasMore) { array_pop($rows); } - $dtos = array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); - if ($dtos && $this->with) { $this->attachRelations($dtos); } - $this->resetFieldSelections(); - $this->resetRuntimeScopes(); - $this->eagerStrategy = null; // reset - $this->withStrategies = []; - - return [ - 'data' => $dtos, - 'perPage' => $perPage, - 'currentPage' => $page, - 'nextPage' => $hasMore ? $page + 1 : null, - ]; - } - - /** @param array $data */ - public function insert(array $data): AbstractDto - { - if (empty($data)) { - throw new \InvalidArgumentException('insert() requires non-empty data'); - } - // Events: dao.beforeInsert (allow mutation of $data) - try { - $ev = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'data' => &$data, - ]; - Events::dispatcher()->dispatch('dao.beforeInsert', $ev); - } catch (\Throwable) { - } - $data = $this->prepareForInsert($data); - $cols = array_keys($data); - $placeholders = array_map(fn($c) => ':' . $c, $cols); - $sql = 'INSERT INTO ' . $this->getTable() . ' (' . implode(', ', $cols) . ') VALUES (' . implode(', ', $placeholders) . ')'; - $this->connection->execute($sql, $data); - $id = $this->connection->lastInsertId(); - $pk = $this->getPrimaryKey(); - if ($id !== null) { - $dto = $this->findById($id) ?? $this->hydrate(array_merge($data, [$pk => $id])); - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'dto' => $dto, - ]; - Events::dispatcher()->dispatch('dao.afterInsert', $payload); - } catch (\Throwable) { - } - return $dto; - } - // Fallback when lastInsertId is unavailable: return hydrated DTO from provided data - $dto = $this->hydrate($this->castRowFromStorage($data)); - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'dto' => $dto, - ]; - Events::dispatcher()->dispatch('dao.afterInsert', $payload); - } catch (\Throwable) { - } - return $dto; - } - - /** @param array $data */ - public function update(int|string $id, array $data): AbstractDto - { - $this->removeFromCache($this->getCacheKeyForId($id)); - - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - // Defer execution; return a synthesized DTO - $existing = $this->findById($id); - if (!$existing && empty($data)) { - throw new \InvalidArgumentException('No data provided to update and record not found'); - } - // Events: dao.beforeUpdate (mutate $data) - try { - $ev = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'id' => $id, - 'data' => &$data, - ]; - Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); - } catch (\Throwable) { - } - $toStore = $this->prepareForUpdate($data); - $self = $this; - $conn = $this->connection; - $uow->enqueueWithMeta($conn, [ - 'type' => 'update', - 'mode' => 'byId', - 'dao' => $this, - 'id' => (string)$id, - 'payload' => $toStore, - ], function () use ($self, $id, $toStore) { - UnitOfWork::suspendDuring(function () use ($self, $id, $toStore) { - $self->doImmediateUpdateWithLock($id, $toStore); - }); - }); - $base = $existing ? $existing->toArray(false) : []; - $pk = $this->getPrimaryKey(); - $result = array_merge($base, $data, [$pk => $id]); - $dto = $this->hydrate($this->castRowFromStorage($result)); - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'dto' => $dto, - ]; - Events::dispatcher()->dispatch('dao.afterUpdate', $payload); - } catch (\Throwable) { - } - return $dto; - } - - if (empty($data)) { - $existing = $this->findById($id); - if ($existing) return $existing; - throw new \InvalidArgumentException('No data provided to update and record not found'); - } - // Events: dao.beforeUpdate - try { - $ev = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'id' => $id, - 'data' => &$data, - ]; - Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); - } catch (\Throwable) { - } - $data = $this->prepareForUpdate($data); - $this->doImmediateUpdateWithLock($id, $data); - $updated = $this->findById($id); - if ($updated === null) { - $pk = $this->getPrimaryKey(); - $dto = $this->hydrate($this->castRowFromStorage(array_merge($data, [$pk => $id]))); - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'dto' => $dto, - ]; - Events::dispatcher()->dispatch('dao.afterUpdate', $payload); - } catch (\Throwable) { - } - return $dto; - } - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'dto' => $updated, - ]; - Events::dispatcher()->dispatch('dao.afterUpdate', $payload); - } catch (\Throwable) { - } - return $updated; - } - - public function deleteById(int|string $id): int - { - $this->removeFromCache($this->getCacheKeyForId($id)); - - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $self = $this; $conn = $this->connection; $theId = $id; - try { - $ev = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'id' => $id, - ]; - Events::dispatcher()->dispatch('dao.beforeDelete', $ev); - } catch (\Throwable) { - } - $uow->enqueueWithMeta($conn, [ - 'type' => 'delete', - 'mode' => 'byId', - 'dao' => $this, - 'id' => (string)$id, - ], function () use ($self, $theId) { - UnitOfWork::suspendDuring(function () use ($self, $theId) { $self->deleteById($theId); }); - }); - // deferred; immediate affected count unknown - return 0; - } - try { - $ev = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'id' => $id, - ]; - Events::dispatcher()->dispatch('dao.beforeDelete', $ev); - } catch (\Throwable) { - } - if ($this->hasSoftDeletes()) { - $columns = $this->softDeleteConfig(); - $deletedAt = $columns['deletedAt'] ?? 'deleted_at'; - $now = $this->nowString(); - $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = :ts WHERE " . $this->getPrimaryKey() . ' = :pk'; - return $this->connection->execute($sql, ['ts' => $now, 'pk' => $id]); - } - $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $this->getPrimaryKey() . ' = :pk'; - $affected = $this->connection->execute($sql, ['pk' => $id]); - try { - $payload = [ - 'dao' => $this, - 'table' => $this->getTable(), - 'id' => $id, - 'affected' => $affected, - ]; - Events::dispatcher()->dispatch('dao.afterDelete', $payload); - } catch (\Throwable) { - } - return $affected; - } - - /** @param array $criteria */ - public function deleteBy(array $criteria): int - { - if ($this->cache !== null) { - $this->clearCache(); - } - - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $self = $this; $conn = $this->connection; $crit = $criteria; - try { $ev = ['dao' => $this, 'table' => $this->getTable(), 'criteria' => &$criteria]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {} - $uow->enqueueWithMeta($conn, [ - 'type' => 'delete', - 'mode' => 'byCriteria', - 'dao' => $this, - 'criteria' => $criteria, - ], function () use ($self, $crit) { - UnitOfWork::suspendDuring(function () use ($self, $crit) { $self->deleteBy($crit); }); - }); - return 0; - } - try { $ev = ['dao' => $this, 'table' => $this->getTable(), 'criteria' => &$criteria]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {} - if ($this->hasSoftDeletes()) { - [$where, $bindings] = $this->buildWhere($criteria); - if ($where === '') { return 0; } - $columns = $this->softDeleteConfig(); - $deletedAt = $columns['deletedAt'] ?? 'deleted_at'; - $now = $this->nowString(); - $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = :ts WHERE " . $where; - $bindings = array_merge(['ts' => $now], $bindings); - return $this->connection->execute($sql, $bindings); - } - [$where, $bindings] = $this->buildWhere($criteria); - if ($where === '') { return 0; } - $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $where; - $affected = $this->connection->execute($sql, $bindings); - try { $payload = ['dao' => $this, 'table' => $this->getTable(), 'criteria' => $criteria, 'affected' => $affected]; Events::dispatcher()->dispatch('dao.afterDelete', $payload); } catch (\Throwable) {} - return $affected; - } - - /** - * Update rows matching the given criteria with the provided data. - * - * @param array $criteria - * @param array $data - */ - public function updateBy(array $criteria, array $data): int - { - if ($this->cache !== null) { - $this->clearCache(); - } - - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - if (empty($data)) { return 0; } - // Events: dao.beforeUpdate (bulk) - try { $ev = ['dao' => $this, 'table' => $this->getTable(), 'criteria' => &$criteria, 'data' => &$data]; Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); } catch (\Throwable) {} - $self = $this; $conn = $this->connection; $crit = $criteria; $payload = $this->prepareForUpdate($data); - $uow->enqueueWithMeta($conn, [ - 'type' => 'update', - 'mode' => 'byCriteria', - 'dao' => $this, - 'criteria' => $criteria, - ], function () use ($self, $crit, $payload) { - UnitOfWork::suspendDuring(function () use ($self, $crit, $payload) { $self->updateBy($crit, $payload); }); - }); - // unknown affected rows until commit - return 0; - } - if (empty($data)) { - return 0; - } - // Optimistic locking note: bulk updates under optimistic locking are not supported - if ($this->hasOptimisticLocking()) { - throw new \Pairity\Orm\OptimisticLockException('Optimistic locking enabled: use update(id, ...) instead of bulk updateBy(...)'); - } - [$where, $whereBindings] = $this->buildWhere($criteria); - if ($where === '') { - return 0; - } - // Ensure timestamps and storage casts are applied consistently with update() - $data = $this->prepareForUpdate($data); - $sets = []; - $setParams = []; - foreach ($data as $col => $val) { - $sets[] = "$col = :set_$col"; - $setParams["set_$col"] = $val; - } - - $sql = 'UPDATE ' . $this->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $where; - $affected = $this->connection->execute($sql, array_merge($setParams, $whereBindings)); - try { $payload = ['dao' => $this, 'table' => $this->getTable(), 'criteria' => $criteria, 'affected' => $affected]; Events::dispatcher()->dispatch('dao.afterUpdate', $payload); } catch (\Throwable) {} - return $affected; - } - - /** Expose relation metadata for UoW ordering/cascades. */ - public function relationMap(): array - { - return $this->getRelations(); - } - - /** - * @param array $criteria - * @return array{0:string,1:array} - */ - protected function buildWhere(array $criteria): array - { - if (!$criteria) { - return ['', []]; - } - $parts = []; - $bindings = []; - foreach ($criteria as $col => $val) { - $param = 'w_' . preg_replace('/[^a-zA-Z0-9_]/', '_', (string)$col); - if ($val === null) { - $parts[] = "$col IS NULL"; - } else { - $parts[] = "$col = :$param"; - $bindings[$param] = $val; - } - } - return [implode(' AND ', $parts), $bindings]; - } - - /** - * Fetch all rows where a column is within the given set of values. - * - * @param string $column - * @param array $values - * @return array> - */ - /** - * Fetch related rows where a column is within a set of values. - * Returns DTOs. - * - * @param string $column - * @param array $values - * @param array|null $selectFields If provided, use these fields instead of the DAO's current selection - * @return array - */ - public function findAllWhereIn(string $column, array $values, ?array $selectFields = null): array - { - if (empty($values)) { - return []; - } - $values = array_values(array_unique($values, SORT_REGULAR)); - $chunks = array_chunk($values, max(1, (int)$this->inBatchSize)); - $selectList = $selectFields && $selectFields !== ['*'] ? implode(', ', $selectFields) : $this->selectList(); - $dtos = []; - foreach ($chunks as $chunkIdx => $chunk) { - $placeholders = []; - $bindings = []; - foreach ($chunk as $i => $val) { - $ph = "in_{$chunkIdx}_{$i}"; - $placeholders[] = ":{$ph}"; - $bindings[$ph] = $val; - } - $where = $column . ' IN (' . implode(', ', $placeholders) . ')'; - $where = $this->appendScopedWhere($where); - $sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where; - $rows = $this->connection->query($sql, $bindings); - foreach ($rows as $r) { - $dtos[] = $this->hydrate($this->castRowFromStorage($r)); - } - } - return $dtos; - } - - /** - * Magic dynamic find/update/delete helpers: - * - findOneBy{Column}($value) - * - findAllBy{Column}($value) - * - updateBy{Column}($value, array $data) - * - deleteBy{Column}($value) - */ - public function __call(string $name, array $arguments): mixed - { - if (preg_match('/^(findOneBy|findAllBy|updateBy|deleteBy)([A-Z][A-Za-z0-9_]*)$/', $name, $m)) { - $op = $m[1]; - $colPart = $m[2]; - $column = $this->normalizeColumn($colPart); - - switch ($op) { - case 'findOneBy': - $value = $arguments[0] ?? null; - return $this->findOneBy([$column => $value]); - case 'findAllBy': - $value = $arguments[0] ?? null; - return $this->findAllBy([$column => $value]); - case 'updateBy': - $value = $arguments[0] ?? null; - $data = $arguments[1] ?? []; - if (!is_array($data)) { - throw new \InvalidArgumentException('updateBy* expects second argument as array $data'); - } - return $this->updateBy([$column => $value], $data); - case 'deleteBy': - $value = $arguments[0] ?? null; - return $this->deleteBy([$column => $value]); - } - } - - // Named scope call support: if a scope is registered with this method name, queue it and return $this - if (isset($this->namedScopes[$name]) && is_callable($this->namedScopes[$name])) { - $callable = $this->namedScopes[$name]; - // Bind arguments - $this->runtimeScopes[] = function (&$criteria) use ($callable, $arguments) { - $callable($criteria, ...$arguments); - }; - return $this; - } - - throw new \BadMethodCallException(static::class . "::{$name} does not exist"); - } - - protected function normalizeColumn(string $studly): string - { - // Convert StudlyCase/CamelCase to snake_case and lowercase - $snake = preg_replace('/(?namedScopes[$name] = $fn; - return $this; - } - - /** Add an ad-hoc scope for the next query: callable(array &$criteria): void */ - public function scope(callable $fn): static - { - $this->runtimeScopes[] = $fn; - return $this; - } - - /** @param array $criteria */ - private function applyRuntimeScopesToCriteria(array &$criteria): void - { - if (!$this->runtimeScopes) return; - foreach ($this->runtimeScopes as $fn) { - try { $fn($criteria); } catch (\Throwable) {} - } - } - - private function resetRuntimeScopes(): void - { - $this->runtimeScopes = []; - } - - /** - * Specify fields to select on the base entity and optionally on relations via dot-notation. - * Example: fields('id', 'name', 'posts.title') - */ - public function fields(string ...$fields): static - { - $base = []; - foreach ($fields as $f) { - if (str_contains($f, '.')) { - [$rel, $col] = explode('.', $f, 2); - if ($rel !== '') { - $this->relationFields[$rel][] = $col; - } - } else { - if ($f !== '') { $base[] = $f; } - } - } - if ($base) { - $this->selectedFields = $base; - } else { - $this->selectedFields = $this->selectedFields ?? null; - } - return $this; - } - - /** @param array $parents */ - protected function attachRelations(array $parents): void - { - if (!$parents) return; - $relations = $this->getRelations(); - foreach ($this->with as $name) { - if (!isset($relations[$name])) { - continue; // silently ignore unknown - } - $config = $relations[$name]; - $type = (string)($config['type'] ?? ''); - $daoClass = $config['dao'] ?? null; - $dtoClass = $config['dto'] ?? null; // kept for docs compatibility - // Resolve related DAO: allow daoInstance or factory callable, else class-string - $relatedDao = null; - if (isset($config['daoInstance']) && is_object($config['daoInstance'])) { - $relatedDao = $config['daoInstance']; - } elseif (isset($config['factory']) && is_callable($config['factory'])) { - try { $relatedDao = ($config['factory'])($this); } catch (\Throwable) { $relatedDao = null; } - } elseif (is_string($daoClass)) { - /** @var class-string $daoClass */ - try { $relatedDao = new $daoClass($this->getConnection()); } catch (\Throwable) { $relatedDao = null; } - } - if (!$relatedDao instanceof AbstractDao) { continue; } - // Apply per-relation constraint, if any - $constraint = $this->constraintForPath($name); - if (is_callable($constraint)) { - $constraint($relatedDao); - } - $relFields = $this->relationFields[$name] ?? null; - if ($relFields) { $relatedDao->fields(...$relFields); } - - if ($type === 'hasMany' || $type === 'hasOne') { - $foreignKey = (string)($config['foreignKey'] ?? ''); - $localKey = (string)($config['localKey'] ?? 'id'); - if ($foreignKey === '') continue; - - $keys = []; - foreach ($parents as $p) { - $arr = $p->toArray(); - if (isset($arr[$localKey])) { $keys[] = $arr[$localKey]; } - } - if (!$keys) continue; - - $children = $relatedDao->findAllWhereIn($foreignKey, $keys); - // group children by foreignKey value - $grouped = []; - foreach ($children as $child) { - $fk = $child->toArray()[$foreignKey] ?? null; - if ($fk === null) continue; - $grouped[$fk][] = $child; - } - foreach ($parents as $p) { - $arr = $p->toArray(); - $key = $arr[$localKey] ?? null; - $list = ($key !== null && isset($grouped[$key])) ? $grouped[$key] : []; - if ($type === 'hasOne') { - $first = $list[0] ?? null; - $p->setRelation($name, $first); - } else { - $p->setRelation($name, $list); - } - } - // Nested eager for children of this relation - $nested = $this->withTree[$name] ?? []; - if ($nested) { - // Flatten first-level child relation names for related DAO - $childNames = array_keys($nested); - // Prepare related DAO with child-level constraints (prefix path) - $relatedDao->with($this->rebuildNestedForChild($name, $nested)); - // Collect all child DTOs (hasMany arrays concatenated; hasOne singletons filtered) - $allChildren = []; - foreach ($parents as $p) { - $val = $p->toArray(false)[$name] ?? null; - if ($val instanceof AbstractDto) { - $allChildren[] = $val; - } elseif (is_array($val)) { - foreach ($val as $c) { if ($c instanceof AbstractDto) { $allChildren[] = $c; } } - } - } - if ($allChildren) { - // Call attachRelations on the related DAO to process its with list - $relatedDao->attachRelations($allChildren); - } - } - } elseif ($type === 'belongsTo') { - $foreignKey = (string)($config['foreignKey'] ?? ''); // on parent - $otherKey = (string)($config['otherKey'] ?? 'id'); // on related - if ($foreignKey === '') continue; - - $ownerIds = []; - foreach ($parents as $p) { - $arr = $p->toArray(); - if (isset($arr[$foreignKey])) { $ownerIds[] = $arr[$foreignKey]; } - } - if (!$ownerIds) continue; - - $owners = $relatedDao->findAllWhereIn($otherKey, $ownerIds); - $byId = []; - foreach ($owners as $o) { - $id = $o->toArray()[$otherKey] ?? null; - if ($id !== null) { $byId[$id] = $o; } - } - foreach ($parents as $p) { - $arr = $p->toArray(); - $fk = $arr[$foreignKey] ?? null; - $p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null); - } - // Nested eager for belongsTo owner - $nested = $this->withTree[$name] ?? []; - if ($nested) { - $childNames = array_keys($nested); - $relatedDao->with($this->rebuildNestedForChild($name, $nested)); - $allOwners = []; - foreach ($parents as $p) { - $val = $p->toArray(false)[$name] ?? null; - if ($val instanceof AbstractDto) { $allOwners[] = $val; } - } - if ($allOwners) { - $relatedDao->attachRelations($allOwners); - } - } - } elseif ($type === 'belongsToMany') { - // SQL-only many-to-many via pivot table - $pivot = (string)($config['pivot'] ?? ($config['pivotTable'] ?? '')); - $foreignPivotKey = (string)($config['foreignPivotKey'] ?? ''); // pivot column referencing parent - $relatedPivotKey = (string)($config['relatedPivotKey'] ?? ''); // pivot column referencing related - $localKey = (string)($config['localKey'] ?? 'id'); // on parent - $relatedKey = (string)($config['relatedKey'] ?? 'id'); // on related - if ($pivot === '' || $foreignPivotKey === '' || $relatedPivotKey === '') { continue; } - - // Collect parent keys - $parentKeys = []; - foreach ($parents as $p) { - $arr = $p->toArray(false); - if (isset($arr[$localKey])) { $parentKeys[] = $arr[$localKey]; } - } - if (!$parentKeys) continue; - - // Fetch pivot rows - $ph = [];$bind=[];foreach (array_values(array_unique($parentKeys, SORT_REGULAR)) as $i=>$val){$k="p_$i";$ph[]=":$k";$bind[$k]=$val;} - $pivotSql = 'SELECT ' . $foreignPivotKey . ' AS fk, ' . $relatedPivotKey . ' AS rk FROM ' . $pivot . ' WHERE ' . $foreignPivotKey . ' IN (' . implode(', ', $ph) . ')'; - $rows = $this->connection->query($pivotSql, $bind); - if (!$rows) { - foreach ($parents as $p) { $p->setRelation($name, []); } - continue; - } - $byParent = []; - $relatedIds = []; - foreach ($rows as $r) { - $fkVal = $r['fk'] ?? null; $rkVal = $r['rk'] ?? null; - if ($fkVal === null || $rkVal === null) continue; - $byParent[(string)$fkVal][] = $rkVal; - $relatedIds[] = $rkVal; - } - if (!$relatedIds) { - foreach ($parents as $p) { $p->setRelation($name, []); } - continue; - } - $relatedIds = array_values(array_unique($relatedIds, SORT_REGULAR)); - // Apply constraints if provided - $constraint = $this->constraintForPath($name); - if (is_callable($constraint)) { $constraint($relatedDao); } - $related = $relatedDao->findAllWhereIn($relatedKey, $relatedIds); - $relatedMap = []; - foreach ($related as $r) { - $id = $r->toArray(false)[$relatedKey] ?? null; - if ($id !== null) { $relatedMap[(string)$id] = $r; } - } - foreach ($parents as $p) { - $arr = $p->toArray(false); - $lk = $arr[$localKey] ?? null; - $ids = ($lk !== null && isset($byParent[(string)$lk])) ? $byParent[(string)$lk] : []; - $attached = []; - foreach ($ids as $rid) { if (isset($relatedMap[(string)$rid])) { $attached[] = $relatedMap[(string)$rid]; } } - $p->setRelation($name, $attached); - } - - // Nested eager on related side - $nested = $this->withTree[$name] ?? []; - if ($nested && !empty($related)) { - $relatedDao->with($this->rebuildNestedForChild($name, $nested)); - $relatedDao->attachRelations($related); - } - } - } - // reset eager-load request after use - $this->with = []; - $this->withTree = []; - $this->withConstraints = []; - $this->withStrategies = []; - // do not reset relationFields here; they may be reused by subsequent loads in the same call - } - - // ===== Join-based eager loading (opt-in, single-level) ===== - - /** Determine if join-based eager should be used for current with() selection. */ - private function shouldUseJoinEager(): bool - { - // Determine if join strategy is desired globally or per relation - $globalJoin = ($this->eagerStrategy === 'join'); - $perRelJoin = false; - if (!$globalJoin && $this->with) { - $allMarked = true; - foreach ($this->with as $rel) { - if (($this->withStrategies[$rel] ?? null) !== 'join') { $allMarked = false; break; } - } - $perRelJoin = $allMarked; - } - if (!$globalJoin && !$perRelJoin) return false; - // Only single-level paths supported in join MVP (no nested trees) - foreach ($this->withTree as $rel => $sub) { - if (!empty($sub)) return false; // nested present => fallback - } - // Require relationFields for each relation to know what to select safely - foreach ($this->with as $rel) { - if (!isset($this->relationFields[$rel]) || empty($this->relationFields[$rel])) { - return false; - } - } - return true; - } - - /** - * Build a SELECT with LEFT JOINs for the requested relations. - * Returns [sql, bindings, meta] where meta describes relation aliases and selected columns. - * @param ?int $limit - * @param ?int $offset - * @return array{0:string,1:array,2:array} - */ - private function buildJoinSelect(string $baseWhere, array $bindings, ?int $limit, ?int $offset): array - { - $t0 = 't0'; - $pk = $this->getPrimaryKey(); - // Base select: ensure PK is included - $baseCols = $this->selectedFields ?: ['*']; - if ($baseCols === ['*'] || !in_array($pk, $baseCols, true)) { - // Select * to keep behavior; PK is present implicitly - $baseSelect = "$t0.*"; - } else { - $quoted = array_map(fn($c) => "$t0.$c", $baseCols); - $baseSelect = implode(', ', $quoted); - } - - $selects = [ $baseSelect ]; - $joins = []; - $meta = [ 'rels' => [] ]; - - $relations = $this->getRelations(); - $aliasIndex = 1; - foreach ($this->with as $name) { - if (!isset($relations[$name])) continue; - $cfg = $relations[$name]; - $type = (string)($cfg['type'] ?? ''); - $daoClass = $cfg['dao'] ?? null; - if (!is_string($daoClass) || $type === '') continue; - /** @var class-string $daoClass */ - $relDao = new $daoClass($this->getConnection()); - $ta = 't' . $aliasIndex++; - $on = ''; - if ($type === 'hasMany' || $type === 'hasOne') { - $foreignKey = (string)($cfg['foreignKey'] ?? ''); - $localKey = (string)($cfg['localKey'] ?? 'id'); - if ($foreignKey === '') continue; - $on = "$ta.$foreignKey = $t0.$localKey"; - } elseif ($type === 'belongsTo') { - $foreignKey = (string)($cfg['foreignKey'] ?? ''); - $otherKey = (string)($cfg['otherKey'] ?? 'id'); - if ($foreignKey === '') continue; - $on = "$ta.$otherKey = $t0.$foreignKey"; - } else { - // belongsToMany not supported in join MVP - continue; - } - // Soft-delete scope for related in JOIN (append in ON) - if ($relDao->hasSoftDeletes()) { - $del = $relDao->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; - $on .= " AND $ta.$del IS NULL"; - } - $joins[] = 'LEFT JOIN ' . $relDao->getTable() . ' ' . $ta . ' ON ' . $on; - // Select related fields with alias prefix - $relCols = $this->relationFields[$name] ?? []; - $pref = $name . '__'; - foreach ($relCols as $col) { - $selects[] = "$ta.$col AS `{$pref}{$col}`"; - } - $meta['rels'][$name] = [ 'alias' => $ta, 'type' => $type, 'dao' => $relDao, 'cols' => $relCols ]; - } - - $sql = 'SELECT ' . implode(', ', $selects) . ' FROM ' . $this->getTable() . ' ' . $t0; - if ($joins) { - $sql .= ' ' . implode(' ', $joins); - } - if ($baseWhere !== '') { - $sql .= ' WHERE ' . $baseWhere; - } - if ($limit !== null) { - $sql .= ' LIMIT ' . (int)$limit; - } - if ($offset !== null) { - $sql .= ' OFFSET ' . (int)$offset; - } - return [$sql, $bindings, $meta]; - } - - /** - * Hydrate DTOs from joined rows with aliased related columns. - * @param array> $rows - * @param array $meta - * @return array - */ - private function hydrateFromJoinRows(array $rows, array $meta): array - { - if (!$rows) return []; - $pk = $this->getPrimaryKey(); - $out = []; - $byId = []; - foreach ($rows as $row) { - // Split base and related segments (related segments are prefixed as rel__col) - $base = []; - $relSegments = []; - foreach ($row as $k => $v) { - if (is_string($k) && str_contains($k, '__')) { - [$rel, $col] = explode('__', $k, 2); - $relSegments[$rel][$col] = $v; - } else { - $base[$k] = $v; - } - } - $idVal = $base[$pk] ?? null; - if ($idVal === null) { - // cannot hydrate without PK; skip row - continue; - } - $idKey = (string)$idVal; - if (!isset($byId[$idKey])) { - $dto = $this->hydrate($this->castRowFromStorage($base)); - $byId[$idKey] = $dto; - $out[] = $dto; - } - $parent = $byId[$idKey]; - // Attach each relation if there are any non-null values - foreach (($meta['rels'] ?? []) as $name => $info) { - $seg = $relSegments[$name] ?? []; - // Detect empty (all null) - $allNull = true; - foreach ($seg as $vv) { if ($vv !== null) { $allNull = false; break; } } - if ($allNull) { - // Ensure default: hasMany => [], hasOne/belongsTo => null (only set if not already set) - if (!isset($parent->toArray(false)[$name])) { - if (($info['type'] ?? '') === 'hasMany') { $parent->setRelation($name, []); } - else { $parent->setRelation($name, null); } - } - continue; - } - /** @var AbstractDao $relDao */ - $relDao = $info['dao']; - // Cast and hydrate child DTO - $child = $relDao->hydrate($relDao->castRowFromStorage($seg)); - if (($info['type'] ?? '') === 'hasMany') { - $current = $parent->toArray(false)[$name] ?? []; - if (!is_array($current)) { $current = []; } - // Append; no dedup to keep simple - $current[] = $child; - $parent->setRelation($name, $current); - } else { - $parent->setRelation($name, $child); - } - } - } - return $out; - } - - // ===== belongsToMany helpers (pivot operations) ===== - - /** - * Attach related ids to a parent for a belongsToMany relation. - * Returns number of rows inserted into the pivot table. - * @param string $relationName - * @param int|string $parentId - * @param array $relatedIds - */ - public function attach(string $relationName, int|string $parentId, array $relatedIds): int - { - if (!$relatedIds) return 0; - $cfg = $this->getRelations()[$relationName] ?? null; - if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { - throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); - } - $pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? '')); - $fk = (string)($cfg['foreignPivotKey'] ?? ''); - $rk = (string)($cfg['relatedPivotKey'] ?? ''); - if ($pivot === '' || $fk === '' || $rk === '') { - throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey"); - } - $cols = [$fk, $rk]; - $valuesSql = []; - $params = []; - foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $i => $rid) { - $p1 = "p_fk_{$i}"; $p2 = "p_rk_{$i}"; - $valuesSql[] = '(:' . $p1 . ', :' . $p2 . ')'; - $params[$p1] = $parentId; - $params[$p2] = $rid; - } - $sql = 'INSERT INTO ' . $pivot . ' (' . implode(', ', $cols) . ') VALUES ' . implode(', ', $valuesSql); - return $this->connection->execute($sql, $params); - } - - /** - * Detach related ids from a parent for a belongsToMany relation. If $relatedIds is empty, detaches all. - * Returns affected rows. - * @param array $relatedIds - */ - public function detach(string $relationName, int|string $parentId, array $relatedIds = []): int - { - $cfg = $this->getRelations()[$relationName] ?? null; - if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { - throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); - } - $pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? '')); - $fk = (string)($cfg['foreignPivotKey'] ?? ''); - $rk = (string)($cfg['relatedPivotKey'] ?? ''); - if ($pivot === '' || $fk === '' || $rk === '') { - throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey"); - } - $where = $fk . ' = :pid'; - $params = ['pid' => $parentId]; - if ($relatedIds) { - $ph = []; - foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $i => $rid) { $k = "r_$i"; $ph[] = ":$k"; $params[$k] = $rid; } - $where .= ' AND ' . $rk . ' IN (' . implode(', ', $ph) . ')'; - } - $sql = 'DELETE FROM ' . $pivot . ' WHERE ' . $where; - return $this->connection->execute($sql, $params); - } - - /** - * Sync the related ids set for a parent: attach missing, detach extra. - * Returns ['attached' => int, 'detached' => int]. - * @param array $relatedIds - * @return array{attached:int,detached:int} - */ - public function sync(string $relationName, int|string $parentId, array $relatedIds): array - { - $cfg = $this->getRelations()[$relationName] ?? null; - if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { - throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); - } - $pivot = (string)($cfg['pivot'] ?? ($cfg['pivotTable'] ?? '')); - $fk = (string)($cfg['foreignPivotKey'] ?? ''); - $rk = (string)($cfg['relatedPivotKey'] ?? ''); - if ($pivot === '' || $fk === '' || $rk === '') { - throw new \InvalidArgumentException("belongsToMany relation '{$relationName}' requires pivot, foreignPivotKey, relatedPivotKey"); - } - // Read current related ids - $rows = $this->connection->query('SELECT ' . $rk . ' AS rk FROM ' . $pivot . ' WHERE ' . $fk . ' = :pid', ['pid' => $parentId]); - $current = []; - foreach ($rows as $r) { $v = $r['rk'] ?? null; if ($v !== null) { $current[(string)$v] = true; } } - $target = []; - foreach (array_values(array_unique($relatedIds, SORT_REGULAR)) as $v) { $target[(string)$v] = true; } - $toAttach = array_diff_key($target, $current); - $toDetach = array_diff_key($current, $target); - $attached = $toAttach ? $this->attach($relationName, $parentId, array_keys($toAttach)) : 0; - $detached = $toDetach ? $this->detach($relationName, $parentId, array_keys($toDetach)) : 0; - return ['attached' => (int)$attached, 'detached' => (int)$detached]; - } - - public function with(array $relations): static - { - // Accept ['rel', 'rel.child'] or ['rel' => callable, 'rel.child' => callable] - // Also accepts config arrays like ['rel' => ['strategy' => 'join']] and - // ['rel' => ['strategy' => 'join', 'constraint' => callable]] - $names = []; - $tree = []; - foreach ($relations as $key => $value) { - if (is_int($key)) { // plain name - $path = (string)$value; - $this->insertRelationPath($tree, $path); - } else { // constraint or config - $path = (string)$key; - if (is_array($value)) { - $strategy = isset($value['strategy']) ? strtolower((string)$value['strategy']) : null; - if ($strategy) { $this->withStrategies[$path] = $strategy; } - if (isset($value['constraint']) && is_callable($value['constraint'])) { - $this->withConstraints[$path] = $value['constraint']; - } - } elseif (is_callable($value)) { - $this->withConstraints[$path] = $value; - } - $this->insertRelationPath($tree, $path); - } - } - $this->withTree = $tree; - $this->with = array_keys($tree); // first-level only - return $this; - } - - public function load(AbstractDto $dto, string $relation): void - { - $this->with([$relation]); - $this->attachRelations([$dto]); - } - - /** @param array $dtos */ - public function loadMany(array $dtos, string $relation): void - { - if (!$dtos) return; - $this->with([$relation]); - $this->attachRelations($dtos); - } - - protected function hydrate(array $row): AbstractDto - { - $class = $this->dtoClass(); - /** @var AbstractDto $dto */ - $dto = $class::fromArray($row); - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $pk = $this->getPrimaryKey(); - $idVal = $row[$pk] ?? null; - if ($idVal !== null) { - $uow->attach(static::class, (string)$idVal, $dto); - // Bind this DAO to allow snapshot diffing to emit updates - $uow->bindDao(static::class, (string)$idVal, $this); - } - } - return $dto; - } - - private function selectList(): string - { - if ($this->selectedFields && $this->selectedFields !== ['*']) { - return implode(', ', $this->selectedFields); - } - // By default, select all columns when fields() is not used. - return '*'; - } - - private function resetFieldSelections(): void - { - $this->selectedFields = null; - $this->relationFields = []; - $this->includeTrashed = false; - $this->onlyTrashed = false; - } - - // ===== with()/nested helpers ===== - - private function insertRelationPath(array &$tree, string $path): void - { - $parts = array_values(array_filter(explode('.', $path), fn($p) => $p !== '')); - if (!$parts) return; - $level =& $tree; - foreach ($parts as $i => $p) { - if (!isset($level[$p])) { $level[$p] = []; } - $level =& $level[$p]; - } - } - - /** Build child-level with() array (flattened) for a nested subtree, preserving constraints under full paths. */ - private function rebuildNestedForChild(string $prefix, array $subtree): array - { - $out = []; - foreach ($subtree as $name => $child) { - $full = $prefix . '.' . $name; - // include with constraint if exists - if (isset($this->withConstraints[$full]) && is_callable($this->withConstraints[$full])) { - $out[$name] = $this->withConstraints[$full]; - } else { - $out[] = $name; - } - } - return $out; - } - - private function constraintForPath(string $path): mixed - { - return $this->withConstraints[$path] ?? null; - } - - // ===== Schema helpers & behaviors ===== - - protected function getSchema(): array - { - if ($this->schemaCache !== null) { - return $this->schemaCache; - } - $this->schemaCache = $this->schema(); - return $this->schemaCache; - } - - /** Return memoized relations metadata. */ - protected function getRelations(): array - { - if ($this->relationsCache !== null) { - return $this->relationsCache; - } - $this->relationsCache = $this->relations(); - return $this->relationsCache; - } - - protected function hasSoftDeletes(): bool - { - $sd = $this->getSchema()['softDeletes'] ?? null; - return is_array($sd) && !empty($sd['enabled']); - } - - /** @return array{deletedAt?:string} */ - protected function softDeleteConfig(): array - { - $sd = $this->getSchema()['softDeletes'] ?? []; - return is_array($sd) ? $sd : []; - } - - /** @return array{createdAt?:string,updatedAt?:string} */ - protected function timestampsConfig(): array - { - $ts = $this->getSchema()['timestamps'] ?? []; - return is_array($ts) ? $ts : []; - } - - /** Returns array cast map col=>type */ - protected function castsMap(): array - { - $cols = $this->getSchema()['columns'] ?? []; - if (!is_array($cols)) return []; - $map = []; - foreach ($cols as $name => $meta) { - if (is_array($meta) && isset($meta['cast']) && is_string($meta['cast'])) { - $map[$name] = $meta['cast']; - } - } - return $map; - } - - // Note: default SELECT projection now always '*' unless fields() is used. - - /** - * Apply default scopes (e.g., soft deletes) to criteria. - * For now, we don't alter criteria array; soft delete is appended as SQL fragment. - * This method allows future transformations. - * @param array $criteria - * @return array - */ - protected function applyDefaultScopes(array $criteria): array - { - return $criteria; - } - - /** Append soft-delete scope to a WHERE clause string (without bindings). */ - private function appendScopedWhere(string $where): string - { - if (!$this->hasSoftDeletes()) return $where; - $deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; - $frag = ''; - if ($this->onlyTrashed) { - $frag = "{$deletedAt} IS NOT NULL"; - } elseif (!$this->includeTrashed) { - $frag = "{$deletedAt} IS NULL"; - } - if ($frag === '') return $where; - if ($where === '' ) return $frag; - return $where . ' AND ' . $frag; - } - - /** Cast a database row to PHP types according to schema casts. */ - private function castRowFromStorage(array $row): array - { - $casts = $this->castsMap(); - if (!$casts) return $row; - foreach ($casts as $col => $type) { - if (!array_key_exists($col, $row)) continue; - $row[$col] = $this->castFromStorage($type, $row[$col]); - } - return $row; - } - - private function castFromStorage(string $type, mixed $value): mixed - { - if ($value === null) return null; - // Support custom caster classes via class-string in schema 'cast' - $caster = $this->resolveCaster($type); - if ($caster) { - return $caster->fromStorage($value); - } - switch ($type) { - case 'int': return (int)$value; - case 'float': return (float)$value; - case 'bool': return (bool)$value; - case 'string': return (string)$value; - case 'json': - if (is_string($value)) { - $decoded = json_decode($value, true); - return (json_last_error() === JSON_ERROR_NONE) ? $decoded : $value; - } - return $value; - case 'datetime': - try { - return new \DateTimeImmutable(is_string($value) ? $value : (string)$value); - } catch (\Throwable) { - return $value; - } - default: - return $value; - } - } - - /** Prepare data for INSERT: filter known columns, auto timestamps, storage casting. */ - private function prepareForInsert(array $data): array - { - $data = $this->filterToKnownColumns($data); - // timestamps - $ts = $this->timestampsConfig(); - $now = $this->nowString(); - if (!empty($ts['createdAt']) && !array_key_exists($ts['createdAt'], $data)) { - $data[$ts['createdAt']] = $now; - } - if (!empty($ts['updatedAt']) && !array_key_exists($ts['updatedAt'], $data)) { - $data[$ts['updatedAt']] = $now; - } - return $this->castForStorageAll($data); - } - - /** Prepare data for UPDATE: filter known columns, auto updatedAt, storage casting. */ - private function prepareForUpdate(array $data): array - { - $data = $this->filterToKnownColumns($data); - $ts = $this->timestampsConfig(); - if (!empty($ts['updatedAt'])) { - $data[$ts['updatedAt']] = $this->nowString(); - } - return $this->castForStorageAll($data); - } - - /** Keep only keys defined in schema columns (if any). */ - private function filterToKnownColumns(array $data): array - { - $cols = $this->getSchema()['columns'] ?? null; - if (!is_array($cols) || !$cols) return $data; - $allowed = array_fill_keys(array_keys($cols), true); - return array_intersect_key($data, $allowed); - } - - private function castForStorageAll(array $data): array - { - $casts = $this->castsMap(); - if (!$casts) return $data; - foreach ($data as $k => $v) { - if (isset($casts[$k])) { - $data[$k] = $this->castForStorage($casts[$k], $v); - } - } - return $data; - } - - private function castForStorage(string $type, mixed $value): mixed - { - if ($value === null) return null; - // Support custom caster classes via class-string in schema 'cast' - $caster = $this->resolveCaster($type); - if ($caster) { - return $caster->toStorage($value); - } - switch ($type) { - case 'int': return (int)$value; - case 'float': return (float)$value; - case 'bool': return (int)((bool)$value); // store as 0/1 for portability - case 'string': return (string)$value; - case 'json': - if (is_string($value)) return $value; - return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - case 'datetime': - if ($value instanceof \DateTimeInterface) { - $utc = (new \DateTimeImmutable('@' . $value->getTimestamp()))->setTimezone(new \DateTimeZone('UTC')); - return $utc->format('Y-m-d H:i:s'); - } - return (string)$value; - default: - return $value; - } - } - - /** Cache for resolved caster instances. @var array */ - private array $casterCache = []; - - /** Resolve a caster from a type/class string. */ - private function resolveCaster(string $type): ?CasterInterface - { - // Not a class-string? return null to use built-ins - if (!class_exists($type)) { - return null; - } - if (isset($this->casterCache[$type])) { - return $this->casterCache[$type]; - } - try { - $obj = new $type(); - } catch (\Throwable) { - return null; - } - if ($obj instanceof CasterInterface) { - $this->casterCache[$type] = $obj; - return $obj; - } - return null; - } - - private function nowString(): string - { - return gmdate('Y-m-d H:i:s'); - } - - // ===== Optimistic locking (MVP) ===== - - protected function hasOptimisticLocking(): bool - { - $lock = $this->getSchema()['locking'] ?? []; - return is_array($lock) && isset($lock['type'], $lock['column']) && in_array($lock['type'], ['version','timestamp'], true); - } - - /** @return array{type:string,column:string}|array{} */ - protected function lockingConfig(): array - { - $lock = $this->getSchema()['locking'] ?? []; - return is_array($lock) ? $lock : []; - } - - /** Execute an immediate update with optimistic locking when configured. */ - private function doImmediateUpdateWithLock(int|string $id, array $toStore): void - { - if (!$this->hasOptimisticLocking()) { - // default path - $sets = []; - $params = []; - foreach ($toStore as $col => $val) { $sets[] = "$col = :set_$col"; $params["set_$col"] = $val; } - $params['pk'] = $id; - $sql = 'UPDATE ' . $this->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $this->getPrimaryKey() . ' = :pk'; - $this->connection->execute($sql, $params); - return; - } - - $cfg = $this->lockingConfig(); - $col = (string)$cfg['column']; - $type = (string)$cfg['type']; - - // Fetch current lock value - $pk = $this->getPrimaryKey(); - $row = $this->connection->query('SELECT ' . $col . ' AS c FROM ' . $this->getTable() . ' WHERE ' . $pk . ' = :pk LIMIT 1', ['pk' => $id]); - $current = $row[0]['c'] ?? null; - - // Build SETs - $sets = []; - $params = []; - foreach ($toStore as $c => $v) { $sets[] = "$c = :set_$c"; $params["set_$c"] = $v; } - - if ($type === 'version') { - // bump version - $sets[] = $col . ' = ' . $col . ' + 1'; - } - - // WHERE with lock compare - $params['pk'] = $id; - $where = $pk . ' = :pk'; - if ($current !== null) { - $params['exp_lock'] = $current; - $where .= ' AND ' . $col . ' = :exp_lock'; - } - - $sql = 'UPDATE ' . $this->getTable() . ' SET ' . implode(', ', $sets) . ' WHERE ' . $where; - $affected = $this->connection->execute($sql, $params); - if ($current !== null && $affected === 0) { - throw new \Pairity\Orm\OptimisticLockException('Optimistic lock failed for ' . static::class . ' id=' . (string)$id); - } - } - - // ===== Soft delete toggles ===== - - public function withTrashed(): static - { - $this->includeTrashed = true; - $this->onlyTrashed = false; - return $this; - } - - public function onlyTrashed(): static - { - $this->includeTrashed = true; - $this->onlyTrashed = true; - return $this; - } - - // ===== Soft delete helpers & utilities ===== - - /** Restore a soft-deleted row by primary key. No-op when soft deletes are disabled. */ - public function restoreById(int|string $id): int - { - if (!$this->hasSoftDeletes()) { return 0; } - $deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; - $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = NULL WHERE " . $this->getPrimaryKey() . ' = :pk'; - return $this->connection->execute($sql, ['pk' => $id]); - } - - /** Restore rows matching criteria. No-op when soft deletes are disabled. */ - public function restoreBy(array $criteria): int - { - if (!$this->hasSoftDeletes()) { return 0; } - [$where, $bindings] = $this->buildWhere($criteria); - if ($where === '') { return 0; } - $deletedAt = $this->softDeleteConfig()['deletedAt'] ?? 'deleted_at'; - $sql = 'UPDATE ' . $this->getTable() . " SET {$deletedAt} = NULL WHERE " . $where; - return $this->connection->execute($sql, $bindings); - } - - /** Permanently delete a row by id even when soft deletes are enabled. */ - public function forceDeleteById(int|string $id): int - { - $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $this->getPrimaryKey() . ' = :pk'; - return $this->connection->execute($sql, ['pk' => $id]); - } - - /** Permanently delete rows matching criteria even when soft deletes are enabled. */ - public function forceDeleteBy(array $criteria): int - { - [$where, $bindings] = $this->buildWhere($criteria); - if ($where === '') { return 0; } - $sql = 'DELETE FROM ' . $this->getTable() . ' WHERE ' . $where; - return $this->connection->execute($sql, $bindings); - } - - /** Touch a row by updating only the configured updatedAt column, if timestamps are enabled. */ - public function touch(int|string $id): int - { - $ts = $this->timestampsConfig(); - if (empty($ts['updatedAt'])) { return 0; } - $col = $ts['updatedAt']; - $sql = 'UPDATE ' . $this->getTable() . " SET {$col} = :ts WHERE " . $this->getPrimaryKey() . ' = :pk'; - return $this->connection->execute($sql, ['ts' => $this->nowString(), 'pk' => $id]); - } -} diff --git a/src/Model/AbstractDto.php b/src/Model/AbstractDto.php deleted file mode 100644 index 4c3e5fb..0000000 --- a/src/Model/AbstractDto.php +++ /dev/null @@ -1,135 +0,0 @@ - */ - protected array $attributes = []; - - /** @param array $attributes */ - public function __construct(array $attributes = []) - { - // Apply mutators if defined - foreach ($attributes as $key => $value) { - $method = $this->mutatorMethod($key); - if (method_exists($this, $method)) { - // set{Name}Attribute($value): mixed - $value = $this->{$method}($value); - } - $this->attributes[$key] = $value; - } - } - - public function serialize(): ?string - { - return serialize($this->attributes); - } - - public function unserialize(string $data): void - { - $this->attributes = unserialize($data); - } - - public function __serialize(): array - { - return $this->attributes; - } - - public function __unserialize(array $data): void - { - $this->attributes = $data; - } - - /** @param array $data */ - public static function fromArray(array $data): static - { - return new static($data); - } - - public function __get(string $name): mixed - { - $value = $this->attributes[$name] ?? null; - $method = $this->accessorMethod($name); - if (method_exists($this, $method)) { - // get{Name}Attribute($value): mixed - return $this->{$method}($value); - } - return $value; - } - - public function __isset(string $name): bool - { - return array_key_exists($name, $this->attributes); - } - - /** - * Attach a loaded relation or transient attribute to the DTO. - * Intended for internal ORM use (eager/lazy loading). - */ - public function setRelation(string $name, mixed $value): void - { - $this->attributes[$name] = $value; - } - - /** @return array */ - public function toArray(bool $deep = true): array - { - if (!$deep) { - // Apply accessors at top level for scalar attributes - $out = []; - foreach ($this->attributes as $key => $value) { - $method = $this->accessorMethod($key); - if (method_exists($this, $method)) { - $out[$key] = $this->{$method}($value); - } else { - $out[$key] = $value; - } - } - return $out; - } - - $result = []; - foreach ($this->attributes as $key => $value) { - // Apply accessor before deep conversion for scalars/arrays - $method = $this->accessorMethod($key); - if (method_exists($this, $method)) { - $value = $this->{$method}($value); - } - if ($value instanceof DtoInterface) { - $result[$key] = $value->toArray(true); - } elseif (is_array($value)) { - // Map arrays, converting any DTO elements to arrays as well - $result[$key] = array_map(function ($item) { - if ($item instanceof DtoInterface) { - return $item->toArray(true); - } - return $item; - }, $value); - } else { - $result[$key] = $value; - } - } - - return $result; - } - - private function accessorMethod(string $key): string - { - return 'get' . $this->studly($key) . 'Attribute'; - } - - private function mutatorMethod(string $key): string - { - return 'set' . $this->studly($key) . 'Attribute'; - } - - private function studly(string $value): string - { - $value = str_replace(['-', '_'], ' ', $value); - $value = ucwords($value); - return str_replace(' ', '', $value); - } -} diff --git a/src/Model/Casting/CasterInterface.php b/src/Model/Casting/CasterInterface.php deleted file mode 100644 index c4d9e1c..0000000 --- a/src/Model/Casting/CasterInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -|null */ - private ?array $projection = null; // list of field names to include - /** @var array */ - private array $sortSpec = []; - private ?int $limitVal = null; - private ?int $skipVal = null; - - /** @var array */ - private array $with = []; - /** - * Nested eager-loading tree for Mongo relations, built from with() paths. - * @var array> - */ - private array $withTree = []; - /** - * Per relation (and nested path) constraints. Keys are relation paths like 'posts' or 'posts.comments'. - * Values are callables(AbstractMongoDao $dao): void - * @var array - */ - private array $withConstraints = []; - /** @var array> */ - private array $relationFields = []; - - /** Scopes (MVP) */ - /** @var array */ - private array $runtimeScopes = []; - /** @var array */ - private array $namedScopes = []; - - /** Memoized relations */ - private ?array $relationsCache = null; - /** Eager IN batching size for related lookups */ - protected int $inBatchSize = 1000; - - public function __construct(MongoConnectionInterface $connection) - { - $this->connection = $connection; - } - - public function getTable(): string - { - return $this->collection(); - } - - /** Collection name (e.g., "users"). */ - abstract protected function collection(): string; - - /** @return class-string */ - abstract protected function dtoClass(): string; - - /** Access to underlying connection. */ - public function getConnection(): MongoConnectionInterface - { - return $this->connection; - } - - /** Relation metadata (MVP). Override in concrete DAO. */ - protected function relations(): array - { - return []; - } - - /** Return memoized relations map. */ - protected function getRelations(): array - { - if ($this->relationsCache !== null) { - return $this->relationsCache; - } - $this->relationsCache = $this->relations(); - return $this->relationsCache; - } - - /** Override point: set eager IN batching size (default 1000). */ - public function setInBatchSize(int $size): static - { - $this->inBatchSize = max(1, $size); - return $this; - } - - // ========= Query modifiers ========= - - /** - * Specify projection fields to include on base entity and optionally on relations via dot-notation. - * Example: fields('email','name','posts.title') - */ - public function fields(string ...$fields): static - { - $base = []; - foreach ($fields as $f) { - $f = (string)$f; - if ($f === '') continue; - if (str_contains($f, '.')) { - [$rel, $col] = explode('.', $f, 2); - if ($rel !== '' && $col !== '') { - $this->relationFields[$rel][] = $col; - } - } else { - $base[] = $f; - } - } - $this->projection = $base ?: null; - return $this; - } - - /** Sorting spec, e.g., sort(['created_at' => -1]) */ - public function sort(array $spec): static - { - // sanitize values to 1 or -1 - $out = []; - foreach ($spec as $k => $v) { - $out[(string)$k] = ((int)$v) < 0 ? -1 : 1; - } - $this->sortSpec = $out; - return $this; - } - - public function limit(int $n): static - { - $this->limitVal = max(0, $n); - return $this; - } - - public function skip(int $n): static - { - $this->skipVal = max(0, $n); - return $this; - } - - // ========= CRUD ========= - - /** @param array|Filter $filter */ - public function findOneBy(array|Filter $filter): ?AbstractDto - { - $filterArr = $this->normalizeFilterInput($filter); - - $cacheKey = null; - if ($this->cache !== null && empty($this->with) && $this->projection === null && empty($this->runtimeScopes)) { - $cacheKey = $this->getCacheKeyForCriteria($filterArr); - $cached = $this->getFromCache($cacheKey); - if ($cached instanceof AbstractDto) { - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $id = (string)($cached->toArray(false)['_id'] ?? ''); - if ($id !== '') { - $managed = $uow->get(static::class, $id); - if ($managed) { return $managed; } - $uow->attach(static::class, $id, $cached); - } - } - return $cached; - } - } - - // Events: dao.beforeFind (Mongo) — allow filter mutation - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {} - $this->applyRuntimeScopesToFilter($filterArr); - $opts = $this->buildOptions(); - $opts['limit'] = 1; - $docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts); - $this->resetModifiers(); - $this->resetRuntimeScopes(); - $row = $docs[0] ?? null; - $dto = $row ? $this->hydrate($row) : null; - try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {} - - if ($dto && $cacheKey) { - $this->putInCache($cacheKey, $dto); - $id = (string)($dto->toArray(false)['_id'] ?? ''); - if ($id !== '') { - $this->putInCache($this->getCacheKeyForId($id), $dto); - } - } - - return $dto; - } - - /** - * @param array|Filter $filter - * @param array $options Additional options (merged after internal modifiers) - * @return array - */ - public function findAllBy(array|Filter $filter = [], array $options = []): array - { - $filterArr = $this->normalizeFilterInput($filter); - - $cacheKey = null; - if ($this->cache !== null && empty($this->with) && $this->projection === null && empty($this->runtimeScopes)) { - $cacheKey = $this->getCacheKeyForCriteria($filterArr); - $cached = $this->getFromCache($cacheKey); - if (is_array($cached)) { - $uow = UnitOfWork::current(); - $out = []; - foreach ($cached as $dto) { - if (!$dto instanceof AbstractDto) { continue; } - $id = (string)($dto->toArray(false)['_id'] ?? ''); - if ($uow && !UnitOfWork::isSuspended() && $id !== '') { - $managed = $uow->get(static::class, $id); - if ($managed) { - $out[] = $managed; - } else { - $uow->attach(static::class, $id, $dto); - $out[] = $dto; - } - } else { - $out[] = $dto; - } - } - return $out; - } - } - - // Events: dao.beforeFind (Mongo) - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {} - $this->applyRuntimeScopesToFilter($filterArr); - $opts = $this->buildOptions(); - // external override/merge - foreach ($options as $k => $v) { $opts[$k] = $v; } - $docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts); - $dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []); - if ($dtos && $this->with) { - $this->attachRelations($dtos); - } - $this->resetModifiers(); - $this->resetRuntimeScopes(); - try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dtos' => $dtos]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {} - - if ($cacheKey && !empty($dtos)) { - $this->putInCache($cacheKey, $dtos); - foreach ($dtos as $dto) { - $id = (string)($dto->toArray(false)['_id'] ?? ''); - if ($id !== '') { - $this->putInCache($this->getCacheKeyForId($id), $dto); - } - } - } - - return $dtos; - } - - public function findById(string $id): ?AbstractDto - { - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $managed = $uow->get(static::class, (string)$id); - if ($managed instanceof AbstractDto) { - return $managed; - } - } - - if ($this->cache !== null && empty($this->with) && $this->projection === null && empty($this->runtimeScopes)) { - $cacheKey = $this->getCacheKeyForId($id); - $cached = $this->getFromCache($cacheKey); - if ($cached instanceof AbstractDto) { - if ($uow && !UnitOfWork::isSuspended()) { - $uow->attach(static::class, (string)$id, $cached); - } - return $cached; - } - } - - $dto = $this->findOneBy(['_id' => $id]); - - if ($dto && $this->cache !== null && empty($this->with) && $this->projection === null && empty($this->runtimeScopes)) { - $this->putInCache($this->getCacheKeyForId($id), $dto); - } - - return $dto; - } - - /** @param array $data */ - public function insert(array $data): AbstractDto - { - // Inserts remain immediate to obtain a real id, even under UoW - // Events: dao.beforeInsert (Mongo) — allow mutation of $data - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'data' => &$data]; Events::dispatcher()->dispatch('dao.beforeInsert', $ev); } catch (\Throwable) {} - $id = UnitOfWork::suspendDuring(function () use ($data) { - return $this->connection->insertOne($this->databaseName(), $this->collection(), $data); - }); - $dto = $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id])); - try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterInsert', $payload); } catch (\Throwable) {} - return $dto; - } - - /** @param array $data */ - public function update(string $id, array $data): AbstractDto - { - $this->removeFromCache($this->getCacheKeyForId($id)); - - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $self = $this; $conn = $this->connection; $theId = $id; $payload = $data; - // Events: dao.beforeUpdate (Mongo) - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'data' => &$payload]; Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); } catch (\Throwable) {} - $uow->enqueueWithMeta($conn, [ - 'type' => 'update', - 'mode' => 'byId', - 'dao' => $this, - 'id' => (string)$id, - ], function () use ($self, $theId, $payload) { - UnitOfWork::suspendDuring(function () use ($self, $theId, $payload) { - $self->doImmediateUpdateWithLock($theId, $payload); - }); - }); - $base = $this->findById($id)?->toArray(false) ?? []; - $result = array_merge($base, $data, ['_id' => $id]); - $dto = $this->hydrate($result); - try { $p = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterUpdate', $p); } catch (\Throwable) {} - return $dto; - } - // Events: dao.beforeUpdate (Mongo) - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'data' => &$data]; Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); } catch (\Throwable) {} - $this->doImmediateUpdateWithLock($id, $data); - $dto = $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id])); - try { $p = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterUpdate', $p); } catch (\Throwable) {} - return $dto; - } - - public function deleteById(string $id): int - { - $this->removeFromCache($this->getCacheKeyForId($id)); - - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $self = $this; $conn = $this->connection; $theId = $id; - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {} - $uow->enqueueWithMeta($conn, [ - 'type' => 'delete', - 'mode' => 'byId', - 'dao' => $this, - 'id' => (string)$id, - ], function () use ($self, $theId) { - UnitOfWork::suspendDuring(function () use ($self, $theId) { - $self->getConnection()->deleteOne($self->databaseName(), $self->collection(), ['_id' => $theId]); - }); - }); - return 0; - } - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {} - $affected = $this->connection->deleteOne($this->databaseName(), $this->collection(), ['_id' => $id]); - try { $p = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'affected' => $affected]; Events::dispatcher()->dispatch('dao.afterDelete', $p); } catch (\Throwable) {} - return $affected; - } - - /** @param array|Filter $filter */ - public function deleteBy(array|Filter $filter): int - { - if ($this->cache !== null) { - $this->clearCache(); - } - - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $self = $this; $conn = $this->connection; $flt = $this->normalizeFilterInput($filter); - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$flt]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {} - $uow->enqueueWithMeta($conn, [ - 'type' => 'delete', - 'mode' => 'byCriteria', - 'dao' => $this, - 'criteria' => $flt, - ], function () use ($self, $flt) { - UnitOfWork::suspendDuring(function () use ($self, $flt) { - $self->getConnection()->deleteOne($self->databaseName(), $self->collection(), $flt); - }); - }); - return 0; - } - // For MVP provide deleteOne semantic; bulk deletes could be added later - $flt = $this->normalizeFilterInput($filter); - try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$flt]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {} - $this->applyRuntimeScopesToFilter($flt); - $res = $this->connection->deleteOne($this->databaseName(), $this->collection(), $flt); - try { $p = ['dao' => $this, 'collection' => $this->collection(), 'filter' => $flt, 'affected' => $res]; Events::dispatcher()->dispatch('dao.afterDelete', $p); } catch (\Throwable) {} - $this->resetRuntimeScopes(); - return $res; - } - - /** Upsert by id convenience. */ - public function upsertById(string $id, array $data): string - { - $this->removeFromCache($this->getCacheKeyForId($id)); - return $this->connection->upsertOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $data]); - } - - /** @param array|Filter $filter @param array $update */ - public function upsertBy(array|Filter $filter, array $update): string - { - if ($this->cache !== null) { - $this->clearCache(); - } - return $this->connection->upsertOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $update); - } - - /** - * Fetch related docs where a field is within the given set of values. - * @param string $field - * @param array $values - * @return array - */ - public function findAllWhereIn(string $field, array $values): array - { - if (!$values) return []; - // Normalize values (unique) - $values = array_values(array_unique($values)); - $chunks = array_chunk($values, max(1, (int)$this->inBatchSize)); - $dtos = []; - foreach ($chunks as $chunk) { - $filter = [ $field => ['$in' => $chunk] ]; - $this->applyRuntimeScopesToFilter($filter); - $opts = $this->buildOptions(); - $docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts); - $iter = is_iterable($docs) ? $docs : []; - foreach ($iter as $d) { $dtos[] = $this->hydrate($d); } - } - return $dtos; - } - - // ========= Dynamic helpers ========= - - public function __call(string $name, array $arguments): mixed - { - if (preg_match('/^(findOneBy|findAllBy|updateBy|deleteBy)([A-Z][A-Za-z0-9_]*)$/', $name, $m)) { - $op = $m[1]; - $col = $this->normalizeColumn($m[2]); - switch ($op) { - case 'findOneBy': - return $this->findOneBy([$col => $arguments[0] ?? null]); - case 'findAllBy': - return $this->findAllBy([$col => $arguments[0] ?? null]); - case 'updateBy': - $value = $arguments[0] ?? null; - $data = $arguments[1] ?? []; - if (!is_array($data)) { - throw new \InvalidArgumentException('updateBy* expects second argument as array $data'); - } - $one = $this->findOneBy([$col => $value]); - if (!$one) { return 0; } - $id = (string)($one->toArray(false)['_id'] ?? ''); - $this->update($id, $data); - return 1; - case 'deleteBy': - return $this->deleteBy([$col => $arguments[0] ?? null]); - } - } - // Named scope invocation - if (isset($this->namedScopes[$name]) && is_callable($this->namedScopes[$name])) { - $callable = $this->namedScopes[$name]; - $this->runtimeScopes[] = function (&$filter) use ($callable, $arguments) { - $callable($filter, ...$arguments); - }; - return $this; - } - throw new \BadMethodCallException(static::class . "::{$name} does not exist"); - } - - // ========= Internals ========= - - protected function normalizeColumn(string $studly): string - { - $snake = preg_replace('/(?dtoClass(); - /** @var AbstractDto $dto */ - $dto = $class::fromArray($doc); - $uow = UnitOfWork::current(); - if ($uow && !UnitOfWork::isSuspended()) { - $idVal = $doc['_id'] ?? null; - if ($idVal !== null) { - $uow->attach(static::class, (string)$idVal, $dto); - // Bind this DAO to allow snapshot diffing to emit updates - $uow->bindDao(static::class, (string)$idVal, $this); - } - } - return $dto; - } - - /** @param array|Filter $filter */ - private function normalizeFilterInput(array|Filter $filter): array - { - if ($filter instanceof Filter) { - return $filter->toArray(); - } - return $filter; - } - - /** Build MongoDB driver options from current modifiers. */ - private function buildOptions(): array - { - $opts = []; - if ($this->projection) { - $proj = []; - foreach ($this->projection as $f) { $proj[$f] = 1; } - $opts['projection'] = $proj; - } - if ($this->sortSpec) { $opts['sort'] = $this->sortSpec; } - if ($this->limitVal !== null) { $opts['limit'] = $this->limitVal; } - if ($this->skipVal !== null) { $opts['skip'] = $this->skipVal; } - return $opts; - } - - /** - * Paginate results. - * @return array{data:array,total:int,perPage:int,currentPage:int,lastPage:int} - */ - public function paginate(int $page, int $perPage = 15, array|Filter $filter = []): array - { - $page = max(1, $page); - $perPage = max(1, $perPage); - - $flt = $this->normalizeFilterInput($filter); - $this->applyRuntimeScopesToFilter($flt); - - // Total via aggregation count - $pipeline = []; - if (!empty($flt)) { $pipeline[] = ['$match' => $flt]; } - $pipeline[] = ['$count' => 'cnt']; - $agg = $this->connection->aggregate($this->databaseName(), $this->collection(), $pipeline, []); - $arr = is_iterable($agg) ? iterator_to_array($agg, false) : (array)$agg; - $total = (int)($arr[0]['cnt'] ?? 0); - - // Page data - $opts = $this->buildOptions(); - $opts['limit'] = $perPage; - $opts['skip'] = ($page - 1) * $perPage; - $docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts); - $dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []); - if ($dtos && $this->with) { $this->attachRelations($dtos); } - $this->resetModifiers(); - $this->resetRuntimeScopes(); - - $lastPage = (int)max(1, (int)ceil($total / $perPage)); - return [ - 'data' => $dtos, - 'total' => $total, - 'perPage' => $perPage, - 'currentPage' => $page, - 'lastPage' => $lastPage, - ]; - } - - /** Simple pagination without total; returns nextPage if more likely exists. */ - public function simplePaginate(int $page, int $perPage = 15, array|Filter $filter = []): array - { - $page = max(1, $page); - $perPage = max(1, $perPage); - $flt = $this->normalizeFilterInput($filter); - $this->applyRuntimeScopesToFilter($flt); - - $opts = $this->buildOptions(); - $opts['limit'] = $perPage + 1; // fetch one extra - $opts['skip'] = ($page - 1) * $perPage; - $docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts); - $docsArr = is_iterable($docs) ? iterator_to_array($docs, false) : (array)$docs; - $hasMore = count($docsArr) > $perPage; - if ($hasMore) { array_pop($docsArr); } - $dtos = array_map(fn($d) => $this->hydrate($d), $docsArr); - if ($dtos && $this->with) { $this->attachRelations($dtos); } - $this->resetModifiers(); - $this->resetRuntimeScopes(); - - return [ - 'data' => $dtos, - 'perPage' => $perPage, - 'currentPage' => $page, - 'nextPage' => $hasMore ? $page + 1 : null, - ]; - } - - private function resetModifiers(): void - { - $this->projection = null; - $this->sortSpec = []; - $this->limitVal = null; - $this->skipVal = null; - $this->with = []; - $this->relationFields = []; - } - - /** Resolve database name from collection string if provided as db.collection; else default to 'app'. */ - private function databaseName(): string - { - // Allow subclasses to define "db.collection" in collection() if they want to target a specific DB quickly - $col = $this->collection(); - if (str_contains($col, '.')) { - return explode('.', $col, 2)[0]; - } - return 'app'; - } - - // ===== Relations (MVP) ===== - - /** Eager load relations on next find* call. */ - public function with(array $relations): static - { - // Accept ['rel', 'rel.child'] or ['rel' => callable] - $tree = []; - foreach ($relations as $key => $value) { - if (is_int($key)) { - $this->insertRelationPath($tree, (string)$value); - } else { - $path = (string)$key; - if (is_callable($value)) { $this->withConstraints[$path] = $value; } - $this->insertRelationPath($tree, $path); - } - } - $this->withTree = $tree; - $this->with = array_keys($tree); - return $this; - } - - /** Lazy load a single relation for one DTO. */ - public function load(AbstractDto $dto, string $relation): void - { - $this->with([$relation]); - $this->attachRelations([$dto]); - // do not call resetModifiers here to avoid wiping user sort/limit; with() is cleared in attachRelations - } - - /** @param array $dtos */ - public function loadMany(array $dtos, string $relation): void - { - if (!$dtos) return; - $this->with([$relation]); - $this->attachRelations($dtos); - } - - /** @param array $parents */ - protected function attachRelations(array $parents): void - { - if (!$parents) return; - $relations = $this->getRelations(); - foreach ($this->with as $name) { - if (!isset($relations[$name])) continue; - $cfg = $relations[$name]; - $type = (string)($cfg['type'] ?? ''); - $daoClass = $cfg['dao'] ?? null; - if (!is_string($daoClass) || $type === '') continue; - - /** @var class-string<\Pairity\NoSql\Mongo\AbstractMongoDao> $daoClass */ - $related = new $daoClass($this->connection); - // Apply per-relation constraint if provided - $constraint = $this->constraintForPath($name); - if (is_callable($constraint)) { $constraint($related); } - $relFields = $this->relationFields[$name] ?? null; - if ($relFields) { $related->fields(...$relFields); } - - if ($type === 'hasMany' || $type === 'hasOne') { - $foreignKey = (string)($cfg['foreignKey'] ?? ''); // on child - $localKey = (string)($cfg['localKey'] ?? '_id'); // on parent - if ($foreignKey === '') continue; - - $keys = []; - foreach ($parents as $p) { - $arr = $p->toArray(false); - if (isset($arr[$localKey])) { $keys[] = (string)$arr[$localKey]; } - } - if (!$keys) continue; - - $children = $related->findAllWhereIn($foreignKey, $keys); - $grouped = []; - foreach ($children as $child) { - $fk = $child->toArray(false)[$foreignKey] ?? null; - if ($fk !== null) { $grouped[(string)$fk][] = $child; } - } - foreach ($parents as $p) { - $arr = $p->toArray(false); - $key = isset($arr[$localKey]) ? (string)$arr[$localKey] : null; - $list = ($key !== null && isset($grouped[$key])) ? $grouped[$key] : []; - if ($type === 'hasOne') { - $p->setRelation($name, $list[0] ?? null); - } else { - $p->setRelation($name, $list); - } - } - // Nested eager for children - $nested = $this->withTree[$name] ?? []; - if ($nested) { - $related->with($this->rebuildNestedForChild($name, $nested)); - $allChildren = []; - foreach ($parents as $p) { - $val = $p->toArray(false)[$name] ?? null; - if ($val instanceof AbstractDto) { $allChildren[] = $val; } - elseif (is_array($val)) { foreach ($val as $c) { if ($c instanceof AbstractDto) { $allChildren[] = $c; } } } - } - if ($allChildren) { $related->attachRelations($allChildren); } - } - } elseif ($type === 'belongsTo') { - $foreignKey = (string)($cfg['foreignKey'] ?? ''); // on parent - $otherKey = (string)($cfg['otherKey'] ?? '_id'); // on related - if ($foreignKey === '') continue; - - $ownerIds = []; - foreach ($parents as $p) { - $arr = $p->toArray(false); - if (isset($arr[$foreignKey])) { $ownerIds[] = (string)$arr[$foreignKey]; } - } - if (!$ownerIds) continue; - - $owners = $related->findAllWhereIn($otherKey, $ownerIds); - $byId = []; - foreach ($owners as $o) { - $id = $o->toArray(false)[$otherKey] ?? null; - if ($id !== null) { $byId[(string)$id] = $o; } - } - foreach ($parents as $p) { - $arr = $p->toArray(false); - $fk = isset($arr[$foreignKey]) ? (string)$arr[$foreignKey] : null; - $p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null); - } - // Nested eager for owner - $nested = $this->withTree[$name] ?? []; - if ($nested) { - $related->with($this->rebuildNestedForChild($name, $nested)); - $allOwners = []; - foreach ($parents as $p) { - $val = $p->toArray(false)[$name] ?? null; - if ($val instanceof AbstractDto) { $allOwners[] = $val; } - } - if ($allOwners) { $related->attachRelations($allOwners); } - } - } - } - // reset eager-load request - $this->with = []; - $this->withTree = []; - $this->withConstraints = []; - // keep relationFields for potential subsequent relation loads within same high-level call - } - - /** Expose relation metadata for UoW ordering/cascades. */ - public function relationMap(): array - { - return $this->relations(); - } - - // ===== with()/nested helpers ===== - private function insertRelationPath(array &$tree, string $path): void - { - $parts = array_values(array_filter(explode('.', $path), fn($p) => $p !== '')); - if (!$parts) return; - $level =& $tree; - foreach ($parts as $p) { - if (!isset($level[$p])) { $level[$p] = []; } - $level =& $level[$p]; - } - } - - private function rebuildNestedForChild(string $prefix, array $subtree): array - { - $out = []; - foreach ($subtree as $name => $child) { - $full = $prefix . '.' . $name; - if (isset($this->withConstraints[$full]) && is_callable($this->withConstraints[$full])) { - $out[$name] = $this->withConstraints[$full]; - } else { $out[] = $name; } - } - return $out; - } - - private function constraintForPath(string $path): mixed - { - return $this->withConstraints[$path] ?? null; - } - - // ===== Scopes (MVP) ===== - /** Register a named scope callable: function(array &$filter, ...$args): void */ - public function registerScope(string $name, callable $fn): static - { - $this->namedScopes[$name] = $fn; - return $this; - } - - /** Add an ad-hoc scope callable(array &$filter): void for next query. */ - public function scope(callable $fn): static - { - $this->runtimeScopes[] = $fn; - return $this; - } - - /** @param array $filter */ - private function applyRuntimeScopesToFilter(array &$filter): void - { - if (!$this->runtimeScopes) return; - foreach ($this->runtimeScopes as $fn) { - try { $fn($filter); } catch (\Throwable) {} - } - } - - private function resetRuntimeScopes(): void - { - $this->runtimeScopes = []; - } - - // ===== Optimistic locking (MVP) for Mongo ===== - /** - * Override to enable locking. Example return: - * ['type' => 'version', 'column' => '_v'] - * Currently only 'version' (numeric increment) is supported for Mongo. - * @return array{type:string,column:string}|array{} - */ - protected function locking(): array { return []; } - - private function hasOptimisticLocking(): bool - { - $cfg = $this->locking(); - return is_array($cfg) && isset($cfg['type'], $cfg['column']) && $cfg['type'] === 'version' && is_string($cfg['column']) && $cfg['column'] !== ''; - } - - private function doImmediateUpdateWithLock(string $id, array $payload): void - { - if (!$this->hasOptimisticLocking()) { - $this->connection->updateOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $payload]); - return; - } - $cfg = $this->locking(); - $col = (string)$cfg['column']; - // Fetch current version - $docs = $this->connection->find($this->databaseName(), $this->collection(), ['_id' => $id], ['limit' => 1, 'projection' => [$col => 1]]); - $cur = $docs[0][$col] ?? null; - $filter = ['_id' => $id]; - if ($cur !== null) { $filter[$col] = $cur; } - $update = ['$set' => $payload, '$inc' => [$col => 1]]; - $modified = $this->connection->updateOne($this->databaseName(), $this->collection(), $filter, $update); - if ($cur !== null && $modified === 0) { - throw new \Pairity\Orm\OptimisticLockException('Optimistic lock failed for ' . static::class . ' id=' . $id); - } - } -} diff --git a/src/NoSql/Mongo/Filter.php b/src/NoSql/Mongo/Filter.php deleted file mode 100644 index 1ca6780..0000000 --- a/src/NoSql/Mongo/Filter.php +++ /dev/null @@ -1,90 +0,0 @@ - */ - private array $query = []; - - private function __construct(array $initial = []) - { - $this->query = $initial; - } - - public static function make(): self - { - return new self(); - } - - /** @return array */ - public function toArray(): array - { - return $this->query; - } - - public function whereEq(string $field, mixed $value): self - { - $this->query[$field] = $value; - return $this; - } - - /** @param array $values */ - public function whereIn(string $field, array $values): self - { - $this->query[$field] = ['$in' => array_values($values)]; - return $this; - } - - public function gt(string $field, mixed $value): self - { - $this->op($field, '$gt', $value); - return $this; - } - - public function gte(string $field, mixed $value): self - { - $this->op($field, '$gte', $value); - return $this; - } - - public function lt(string $field, mixed $value): self - { - $this->op($field, '$lt', $value); - return $this; - } - - public function lte(string $field, mixed $value): self - { - $this->op($field, '$lte', $value); - return $this; - } - - /** Add an $or clause with an array of filters (arrays or Filter instances). */ - public function orWhere(array $conditions): self - { - $ors = []; - foreach ($conditions as $c) { - if ($c instanceof self) { - $ors[] = $c->toArray(); - } elseif (is_array($c)) { - $ors[] = $c; - } - } - if (!empty($ors)) { - $this->query['$or'] = $ors; - } - return $this; - } - - private function op(string $field, string $op, mixed $value): void - { - $cur = $this->query[$field] ?? []; - if (!is_array($cur)) { $cur = []; } - $cur[$op] = $value; - $this->query[$field] = $cur; - } -} diff --git a/src/NoSql/Mongo/IndexManager.php b/src/NoSql/Mongo/IndexManager.php deleted file mode 100644 index 2af44b7..0000000 --- a/src/NoSql/Mongo/IndexManager.php +++ /dev/null @@ -1,68 +0,0 @@ -connection = $connection; - $this->database = $database; - $this->collection = $collection; - } - - /** - * Ensure index on keys (e.g., ['email' => 1]) with options (e.g., ['unique' => true]). - * Returns index name. - * @param array $keys - * @param array $options - */ - public function ensureIndex(array $keys, array $options = []): string - { - $client = $this->getClient(); - $mgr = $client->selectCollection($this->database, $this->collection)->createIndex($keys, $options); - return (string)$mgr; - } - - /** Drop an index by name. */ - public function dropIndex(string $name): void - { - $client = $this->getClient(); - $client->selectCollection($this->database, $this->collection)->dropIndex($name); - } - - /** @return array> */ - public function listIndexes(): array - { - $client = $this->getClient(); - $it = $client->selectCollection($this->database, $this->collection)->listIndexes(); - $out = []; - foreach ($it as $ix) { - $out[] = json_decode(json_encode($ix), true) ?? []; - } - return $out; - } - - private function getClient(): Client - { - if ($this->connection instanceof MongoClientConnection) { - return $this->connection->getClient(); - } - // Fallback: attempt to reflect getClient() - if (method_exists($this->connection, 'getClient')) { - /** @var Client $c */ - $c = $this->connection->getClient(); - return $c; - } - throw new \RuntimeException('IndexManager requires MongoClientConnection'); - } -} diff --git a/src/NoSql/Mongo/MongoClientConnection.php b/src/NoSql/Mongo/MongoClientConnection.php deleted file mode 100644 index ead4f69..0000000 --- a/src/NoSql/Mongo/MongoClientConnection.php +++ /dev/null @@ -1,195 +0,0 @@ -client = $client; - } - - public function getClient(): Client - { - return $this->client; - } - - public function find(string $database, string $collection, array $filter = [], array $options = []): iterable - { - $coll = $this->client->selectCollection($database, $collection); - $cursor = $coll->find($this->normalizeFilter($filter), $options); - $out = []; - foreach ($cursor as $doc) { - $out[] = $this->docToArray($doc); - } - return $out; - } - - public function insertOne(string $database, string $collection, array $document): string - { - $coll = $this->client->selectCollection($database, $collection); - $result = $coll->insertOne($this->normalizeDocument($document)); - $id = $result->getInsertedId(); - return (string)$id; - } - - public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int - { - $coll = $this->client->selectCollection($database, $collection); - $res = $coll->updateOne($this->normalizeFilter($filter), $update, $options); - return $res->getModifiedCount(); - } - - public function deleteOne(string $database, string $collection, array $filter, array $options = []): int - { - $coll = $this->client->selectCollection($database, $collection); - $res = $coll->deleteOne($this->normalizeFilter($filter), $options); - return $res->getDeletedCount(); - } - - public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable - { - $coll = $this->client->selectCollection($database, $collection); - $cursor = $coll->aggregate($pipeline, $options); - $out = []; - foreach ($cursor as $doc) { - $out[] = $this->docToArray($doc); - } - return $out; - } - - public function upsertOne(string $database, string $collection, array $filter, array $update): string - { - $coll = $this->client->selectCollection($database, $collection); - // Normalize _id in filter (supports $in handled by normalizeFilter) - $filter = $this->normalizeFilter($filter); - $res = $coll->updateOne($filter, $update, ['upsert' => true]); - $up = $res->getUpsertedId(); - if ($up !== null) { - return (string)$up; - } - // Not an upsert (matched existing). Best-effort: fetch one doc and return its _id as string. - $doc = $coll->findOne($filter); - if ($doc) { - $arr = $this->docToArray($doc); - return isset($arr['_id']) ? (string)$arr['_id'] : ''; - } - return ''; - } - - public function count(string $database, string $collection, array $filter = []): int - { - $coll = $this->client->selectCollection($database, $collection); - return $coll->countDocuments($this->normalizeFilter($filter)); - } - - public function withSession(callable $callback): mixed - { - /** @var Session $session */ - $session = $this->client->startSession(); - try { - return $callback($this, $session); - } finally { - try { $session->endSession(); } catch (\Throwable) {} - } - } - - public function withTransaction(callable $callback): mixed - { - /** @var Session $session */ - $session = $this->client->startSession(); - try { - $result = $session->startTransaction(); - $ret = $callback($this, $session); - $session->commitTransaction(); - return $ret; - } catch (\Throwable $e) { - try { $session->abortTransaction(); } catch (\Throwable) {} - throw $e; - } finally { - try { $session->endSession(); } catch (\Throwable) {} - } - } - - /** @param array $filter */ - private function normalizeFilter(array $filter): array - { - $normalize = function (&$value, $key) use (&$normalize) { - if (is_array($value)) { - foreach ($value as $k => &$v) { - $normalize($v, $k); - } - return; - } - - if ($key === '_id' && is_string($value) && preg_match('/^[a-f\d]{24}$/i', $value)) { - try { - $value = new ObjectId($value); - } catch (\Throwable) { - // Ignore invalid ObjectIds - } - } - }; - - foreach ($filter as $k => &$v) { - $normalize($v, $k); - } - - return $filter; - } - - /** @param array $doc */ - private function normalizeDocument(array $doc): array - { - if (isset($doc['_id']) && is_string($doc['_id']) && preg_match('/^[a-f\d]{24}$/i', $doc['_id'])) { - try { $doc['_id'] = new ObjectId($doc['_id']); } catch (\Throwable) {} - } - return $doc; - } - - /** - * Convert BSON document or array to a plain associative array, including ObjectId cast to string. - */ - private function docToArray(mixed $doc): array - { - if ($doc instanceof \MongoDB\Model\BSONDocument) { - $doc = $doc->getArrayCopy(); - } elseif ($doc instanceof \ArrayObject) { - $doc = $doc->getArrayCopy(); - } - if (!is_array($doc)) { - return []; - } - $out = []; - foreach ($doc as $k => $v) { - if ($v instanceof ObjectId) { - $out[$k] = (string)$v; - } elseif ($v instanceof \MongoDB\BSON\UTCDateTime) { - $out[$k] = $v->toDateTime()->format('c'); - } elseif ($v instanceof \MongoDB\Model\BSONDocument || $v instanceof \ArrayObject) { - $out[$k] = $this->docToArray($v); - } elseif (is_array($v)) { - $out[$k] = array_map(function ($item) { - if ($item instanceof ObjectId) return (string)$item; - if ($item instanceof \MongoDB\Model\BSONDocument || $item instanceof \ArrayObject) { - return $this->docToArray($item); - } - return $item; - }, $v); - } else { - $out[$k] = $v; - } - } - return $out; - } -} diff --git a/src/NoSql/Mongo/MongoConnection.php b/src/NoSql/Mongo/MongoConnection.php deleted file mode 100644 index 053ead8..0000000 --- a/src/NoSql/Mongo/MongoConnection.php +++ /dev/null @@ -1,146 +0,0 @@ ->>> - * $store[db][collection][] = document - */ - private array $store = []; - - public function find(string $database, string $collection, array $filter = [], array $options = []): iterable - { - $docs = $this->getCollection($database, $collection); - $result = []; - foreach ($docs as $doc) { - if ($this->matches($doc, $filter)) { - $result[] = $doc; - } - } - return $result; - } - - public function insertOne(string $database, string $collection, array $document): string - { - $document['_id'] = $document['_id'] ?? $this->generateId(); - $this->store[$database][$collection][] = $document; - return (string)$document['_id']; - } - - public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int - { - $docs =& $this->store[$database][$collection]; - if (!is_array($docs)) { - $docs = []; - } - foreach ($docs as &$doc) { - if ($this->matches($doc, $filter)) { - // Very naive: support direct field set or $set operator - if (isset($update['$set']) && is_array($update['$set'])) { - foreach ($update['$set'] as $k => $v) { - $doc[$k] = $v; - } - } else { - foreach ($update as $k => $v) { - $doc[$k] = $v; - } - } - return 1; - } - } - return 0; - } - - public function deleteOne(string $database, string $collection, array $filter, array $options = []): int - { - $docs =& $this->store[$database][$collection]; - if (!is_array($docs)) { - $docs = []; - } - foreach ($docs as $i => $doc) { - if ($this->matches($doc, $filter)) { - array_splice($docs, $i, 1); - return 1; - } - } - return 0; - } - - public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable - { - // Stub: no real pipeline support; just return all docs - return $this->getCollection($database, $collection); - } - - public function upsertOne(string $database, string $collection, array $filter, array $update): string - { - $id = $this->generateId(); - if ($this->updateOne($database, $collection, $filter, $update) === 0) { - $doc = $filter; - if (isset($update['$set'])) { - foreach ($update['$set'] as $k => $v) { $doc[$k] = $v; } - } else { - foreach ($update as $k => $v) { $doc[$k] = $v; } - } - $doc['_id'] = $doc['_id'] ?? $id; - $this->store[$database][$collection][] = $doc; - return (string)$doc['_id']; - } - $found = $this->find($database, $collection, $filter); - $first = reset($found); - return (string)($first['_id'] ?? ''); - } - - public function count(string $database, string $collection, array $filter = []): int - { - $docs = $this->getCollection($database, $collection); - $count = 0; - foreach ($docs as $doc) { - if ($this->matches($doc, $filter)) { - $count++; - } - } - return $count; - } - - public function withSession(callable $callback): mixed - { - return $callback($this, null); - } - - public function withTransaction(callable $callback): mixed - { - return $callback($this, null); - } - - private function &getCollection(string $database, string $collection): array - { - if (!isset($this->store[$database][$collection])) { - $this->store[$database][$collection] = []; - } - return $this->store[$database][$collection]; - } - - /** @param array $doc @param array $filter */ - private function matches(array $doc, array $filter): bool - { - foreach ($filter as $k => $v) { - if (!array_key_exists($k, $doc) || $doc[$k] !== $v) { - return false; - } - } - return true; - } - - private function generateId(): string - { - return bin2hex(random_bytes(12)); - } -} diff --git a/src/NoSql/Mongo/MongoConnectionInterface.php b/src/NoSql/Mongo/MongoConnectionInterface.php deleted file mode 100644 index 15d5f88..0000000 --- a/src/NoSql/Mongo/MongoConnectionInterface.php +++ /dev/null @@ -1,33 +0,0 @@ -> */ - public function find(string $database, string $collection, array $filter = [], array $options = []): iterable; - - /** @param array $document */ - public function insertOne(string $database, string $collection, array $document): string; - - /** @param array $filter @param array $update */ - public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int; - - /** @param array $filter */ - public function deleteOne(string $database, string $collection, array $filter, array $options = []): int; - - /** @param array> $pipeline */ - public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable; - - /** @param array $filter @param array $update */ - public function upsertOne(string $database, string $collection, array $filter, array $update): string; - - /** @param array $filter */ - public function count(string $database, string $collection, array $filter = []): int; - - /** Execute a callback with a client session; callback receives the connection instance and session as args. */ - public function withSession(callable $callback): mixed; - - /** Execute a callback wrapped in a driver transaction when supported. */ - public function withTransaction(callable $callback): mixed; -} diff --git a/src/NoSql/Mongo/MongoConnectionManager.php b/src/NoSql/Mongo/MongoConnectionManager.php deleted file mode 100644 index f256895..0000000 --- a/src/NoSql/Mongo/MongoConnectionManager.php +++ /dev/null @@ -1,58 +0,0 @@ - $config - */ - public static function make(array $config): MongoClientConnection - { - $uri = (string)($config['uri'] ?? ''); - $uriOptions = (array)($config['uriOptions'] ?? []); - $driverOptions = (array)($config['driverOptions'] ?? []); - - if ($uri === '') { - $hosts = $config['hosts'] ?? ($config['host'] ?? '127.0.0.1'); - $port = (int)($config['port'] ?? 27017); - $hostsStr = ''; - if (is_array($hosts)) { - $parts = []; - foreach ($hosts as $h) { $parts[] = $h . ':' . $port; } - $hostsStr = implode(',', $parts); - } else { - $hostsStr = (string)$hosts . ':' . $port; - } - $user = isset($config['username']) ? (string)$config['username'] : ''; - $pass = isset($config['password']) ? (string)$config['password'] : ''; - $auth = ($user !== '' && $pass !== '') ? ($user . ':' . $pass . '@') : ''; - - $query = []; - if (!empty($config['authSource'])) { $query['authSource'] = (string)$config['authSource']; } - if (!empty($config['replicaSet'])) { $query['replicaSet'] = (string)$config['replicaSet']; } - if (isset($config['tls'])) { $query['tls'] = $config['tls'] ? 'true' : 'false'; } - $qs = $query ? ('?' . http_build_query($query)) : ''; - - $uri = 'mongodb://' . $auth . $hostsStr . '/' . $qs; - } - - $client = new Client($uri, $uriOptions, $driverOptions); - return new MongoClientConnection($client); - } -} diff --git a/src/Orm/OptimisticLockException.php b/src/Orm/OptimisticLockException.php deleted file mode 100644 index 44d768f..0000000 --- a/src/Orm/OptimisticLockException.php +++ /dev/null @@ -1,7 +0,0 @@ -cache = $cache; - return $this; - } - - /** - * @see \Pairity\Contracts\CacheableDaoInterface::getCache - */ - public function getCache(): ?CacheInterface - { - return $this->cache; - } - - /** - * @see \Pairity\Contracts\CacheableDaoInterface::cacheConfig - */ - public function cacheConfig(): array - { - return [ - 'enabled' => true, - 'ttl' => 3600, - 'prefix' => 'pairity_cache_' . $this->getTable() . '_', - ]; - } - - /** - * @see \Pairity\Contracts\CacheableDaoInterface::clearCache - */ - public function clearCache(): bool - { - if ($this->cache === null) { - return false; - } - - $config = $this->cacheConfig(); - if (!$config['enabled']) { - return false; - } - - // PSR-16 doesn't have a flush by prefix. - // If the cache is an instance of something that can clear, we can try. - // But for standard PSR-16, we often just clear() everything if it's a dedicated pool, - // however that's too destructive. - - // Strategy: We'll allow users to override this method if their driver supports tags/prefixes. - // For now, we'll try to use clear() if we are reasonably sure it's safe (e.g. via config opt-in). - if ($config['clear_all_on_bulk'] ?? false) { - return $this->cache->clear(); - } - - return false; - } - - /** - * Generate a cache key for a specific ID. - */ - protected function getCacheKeyForId(mixed $id): string - { - $config = $this->cacheConfig(); - return $config['prefix'] . 'id_' . $id; - } - - /** - * Generate a cache key for criteria. - */ - protected function getCacheKeyForCriteria(array $criteria): string - { - $config = $this->cacheConfig(); - // Naive serialization, might need better normalization - return $config['prefix'] . 'criteria_' . md5(serialize($criteria)); - } - - /** - * Store an item in the cache if enabled. - */ - protected function putInCache(string $key, mixed $value): void - { - if ($this->cache === null) { - return; - } - - $config = $this->cacheConfig(); - if (!$config['enabled']) { - return; - } - - $this->cache->set($key, $value, $config['ttl']); - } - - /** - * Retrieve an item from the cache if enabled. - */ - protected function getFromCache(string $key): mixed - { - if ($this->cache === null) { - return null; - } - - $config = $this->cacheConfig(); - if (!$config['enabled']) { - return null; - } - - return $this->cache->get($key); - } - - /** - * Remove an item from the cache. - */ - protected function removeFromCache(string $key): void - { - if ($this->cache === null) { - return; - } - - $this->cache->delete($key); - } -} diff --git a/src/Orm/UnitOfWork.php b/src/Orm/UnitOfWork.php deleted file mode 100644 index 07d1bdb..0000000 --- a/src/Orm/UnitOfWork.php +++ /dev/null @@ -1,449 +0,0 @@ -> map[daoClass][id] = DTO */ - private array $identityMap = []; - /** @var array>> snapshots[daoClass][id] = array representation */ - private array $snapshots = []; - /** @var array> daoBind[daoClass][id] = DAO instance for updates */ - private array $daoBind = []; - /** Enable snapshot diffing */ - private bool $snapshotsEnabled = false; - - /** - * Queues grouped by a connection hash key. - * Each entry: ['conn' => object, 'ops' => list}>] - * meta keys (MVP): - * - type: 'update'|'delete'|'raw' - * - mode: 'byId'|'byCriteria'|'raw' - * - dao: object (DAO instance) - * - id: string (for byId) - * - criteria: array (for byCriteria) - * - * @var array}>}> - */ - private array $queues = []; - - private function __construct() {} - - public static function begin(): UnitOfWork - { - if (self::$current !== null) { - return self::$current; - } - self::$current = new UnitOfWork(); - return self::$current; - } - - /** - * Run a Unit of Work and automatically commit or rollback on exception. - * @template T - * @param Closure(UnitOfWork):T $callback - * @return mixed - */ - public static function run(Closure $callback): mixed - { - $uow = self::begin(); - try { - $result = $callback($uow); - $uow->commit(); - return $result; - } catch (\Throwable $e) { - $uow->rollback(); - throw $e; - } - } - - public static function current(): ?UnitOfWork - { - return self::$current; - } - - /** Temporarily suspend UoW interception so DAOs execute immediately within the callable. */ - public static function suspendDuring(Closure $cb): mixed - { - $prev = self::$suspended; - self::$suspended = true; - try { return $cb(); } finally { self::$suspended = $prev; } - } - - public static function isSuspended(): bool - { - return self::$suspended; - } - - // ===== Identity Map ===== - - /** Attach a DTO to identity map. */ - public function attach(string $daoClass, string $id, object $dto): void - { - $this->identityMap[$daoClass][$id] = $dto; - if ($this->snapshotsEnabled) { - // store shallow snapshot - $arr = []; - try { - if (method_exists($dto, 'toArray')) { - /** @var array $arr */ - $arr = $dto->toArray(false); - } - } catch (\Throwable) { $arr = []; } - $this->snapshots[$daoClass][$id] = is_array($arr) ? $arr : []; - } - } - - /** Fetch an attached DTO if present. */ - public function get(string $daoClass, string $id): ?object - { - return $this->identityMap[$daoClass][$id] ?? null; - } - - /** Bind a DAO instance to a managed entity for potential snapshot diff updates. */ - public function bindDao(string $daoClass, string $id, object $dao): void - { - $this->daoBind[$daoClass][$id] = $dao; - } - - /** Enable/disable snapshot diffing. */ - public function enableSnapshots(bool $flag = true): static - { - $this->snapshotsEnabled = $flag; - return $this; - } - - public function isManaged(string $daoClass, string $id): bool - { - return isset($this->identityMap[$daoClass][$id]); - } - - public function detach(string $daoClass, string $id): void - { - unset($this->identityMap[$daoClass][$id], $this->snapshots[$daoClass][$id], $this->daoBind[$daoClass][$id]); - } - - public function clear(): void - { - $this->identityMap = []; - $this->snapshots = []; - $this->daoBind = []; - } - - // ===== Defer operations ===== - - /** Enqueue a mutation for the given connection object (back-compat, raw op). */ - public function enqueue(object $connection, Closure $operation): void - { - $key = spl_object_hash($connection); - if (!isset($this->queues[$key])) { - $this->queues[$key] = ['conn' => $connection, 'ops' => []]; - } - $this->queues[$key]['ops'][] = ['op' => $operation, 'meta' => ['type' => 'raw', 'mode' => 'raw']]; - } - - /** Enqueue a mutation with metadata for relation-aware ordering/cascades. */ - public function enqueueWithMeta(object $connection, array $meta, Closure $operation): void - { - $key = spl_object_hash($connection); - if (!isset($this->queues[$key])) { - $this->queues[$key] = ['conn' => $connection, 'ops' => []]; - } - $this->queues[$key]['ops'][] = ['op' => $operation, 'meta' => $meta]; - } - - /** Execute all queued operations per connection within a transaction/session. */ - public function commit(): void - { - // Ensure we run ops with DAO interception suspended to avoid re-enqueue - self::suspendDuring(function () { - // uow.beforeCommit - try { $payload = ['context' => 'uow']; Events::dispatcher()->dispatch('uow.beforeCommit', $payload); } catch (\Throwable) {} - // Grouped by connection type - foreach ($this->queues as $entry) { - $conn = $entry['conn']; - $ops = $this->expandAndOrder($entry['ops']); - // Inject snapshot-based updates for managed entities with diffs - $ops = $this->injectSnapshotDiffUpdates($ops); - // Coalesce multiple updates for the same entity and order update-before-delete - $ops = $this->coalesceAndOrderPerEntity($ops); - // PDO/SQL path: has transaction(callable) - if (method_exists($conn, 'transaction')) { - $conn->transaction(function () use ($ops) { - foreach ($ops as $o) { ($o['op'])(); } - return null; - }); - } - // Mongo path: try withTransaction first, then withSession, else run directly - elseif (method_exists($conn, 'withTransaction')) { - $conn->withTransaction(function () use ($ops) { - foreach ($ops as $o) { ($o['op'])(); } - return null; - }); - } elseif (method_exists($conn, 'withSession')) { - $conn->withSession(function () use ($ops) { - foreach ($ops as $o) { ($o['op'])(); } - return null; - }); - } else { - // Fallback: no transaction API; just run - foreach ($ops as $o) { ($o['op'])(); } - } - } - // uow.afterCommit - try { $payload2 = ['context' => 'uow']; Events::dispatcher()->dispatch('uow.afterCommit', $payload2); } catch (\Throwable) {} - }); - - // Clear queues after successful commit - $this->queues = []; - // Do not clear identity map by default; keep for the scope - // Close UoW scope - self::$current = null; - } - - /** Rollback just clears queues and current context; actual rollback is handled by transactions when run. */ - public function rollback(): void - { - $this->queues = []; - self::$current = null; - } - - /** - * Expand cascades and order ops so child deletes run before parent deletes. - * @param array}> $ops - * @return array}> ordered ops - */ - private function expandAndOrder(array $ops): array - { - $expanded = []; - foreach ($ops as $o) { - $meta = $o['meta'] ?? []; - // Detect deleteById on a DAO with cascade-enabled relations - if (($meta['type'] ?? '') === 'delete' && ($meta['mode'] ?? '') === 'byId' && isset($meta['dao']) && is_object($meta['dao'])) { - $dao = $meta['dao']; - $parentId = (string)($meta['id'] ?? ''); - if ($parentId !== '') { - // Determine relations and cascade flags - $rels = $this->readRelations($dao); - foreach ($rels as $name => $cfg) { - $type = (string)($cfg['type'] ?? ''); - $cascade = false; - if (isset($cfg['cascadeDelete'])) { - $cascade = (bool)$cfg['cascadeDelete']; - } elseif (isset($cfg['cascade']['delete'])) { - $cascade = (bool)$cfg['cascade']['delete']; - } - if (!$cascade) { continue; } - if ($type === 'hasMany' || $type === 'hasOne') { - $childDaoClass = $cfg['dao'] ?? null; - $foreignKey = (string)($cfg['foreignKey'] ?? ''); - $localKey = (string)($cfg['localKey'] ?? 'id'); - if (!is_string($childDaoClass) || $foreignKey === '') { continue; } - // Instantiate child DAO sharing same connection - try { - /** @var object $childDao */ - $childDao = new $childDaoClass($dao->getConnection()); - } catch (\Throwable) { - continue; - } - // Create a child delete op to run before parent - $childOp = function () use ($childDao, $foreignKey, $parentId) { - self::suspendDuring(function () use ($childDao, $foreignKey, $parentId) { - // delete children by FK - if ($childDao instanceof SqlDao) { - $childDao->deleteBy([$foreignKey => $parentId]); - } elseif ($childDao instanceof MongoDao) { - $childDao->deleteBy([$foreignKey => $parentId]); - } - }); - }; - $expanded[] = ['op' => $childOp, 'meta' => ['type' => 'delete', 'mode' => 'byCriteria', 'dao' => $childDao]]; - } - } - } - } - // Then the original op - $expanded[] = $o; - } - - // Basic stable order is fine since cascades were inserted before parent. - return $expanded; - } - - /** - * Inject snapshot-based updates for managed entities that have changed but were not explicitly updated. - * Only when snapshots are enabled. - * @param array}> $ops - * @return array}> $ops - */ - private function injectSnapshotDiffUpdates(array $ops): array - { - if (!$this->snapshotsEnabled) { - return $ops; - } - // Build a set of entities already scheduled for update/delete - $scheduled = []; - foreach ($ops as $o) { - $m = $o['meta'] ?? []; - if (!isset($m['dao']) || !is_object($m['dao'])) continue; - $daoClass = get_class($m['dao']); - $id = (string)($m['id'] ?? ''); - if ($id !== '') { - $scheduled[$daoClass][$id] = true; - } - } - // For each managed entity, if changed and not scheduled, enqueue update - foreach ($this->identityMap as $daoClass => $entities) { - foreach ($entities as $id => $dto) { - if (!$this->snapshotsEnabled) break; - $snap = $this->snapshots[$daoClass][$id] ?? null; - if ($snap === null) continue; - $now = []; - try { if (method_exists($dto, 'toArray')) { $now = $dto->toArray(false); } } catch (\Throwable) { $now = []; } - if (!is_array($now)) $now = []; - $diff = $this->diffAssoc($snap, $now); - if (!$diff) continue; - if (isset($scheduled[$daoClass][$id])) continue; - $dao = $this->daoBind[$daoClass][$id] ?? null; - if (!$dao) continue; - // Build op that performs immediate update under suspension - $op = function () use ($dao, $id, $diff) { - self::suspendDuring(function () use ($dao, $id, $diff) { - if (method_exists($dao, 'update')) { $dao->update($id, $diff); } - }); - }; - $ops[] = ['op' => $op, 'meta' => ['type' => 'update', 'mode' => 'byId', 'dao' => $dao, 'id' => (string)$id, 'payload' => $diff]]; - } - } - return $ops; - } - - /** - * Merge multiple updates for the same entity and ensure update happens before delete for that entity. - * @param array}> $ops - * @return array}> $ops - */ - private function coalesceAndOrderPerEntity(array $ops): array - { - // Group updates by daoClass+id - $updateMap = []; - $deleteSet = []; - foreach ($ops as $o) { - $m = $o['meta'] ?? []; - if (!isset($m['dao']) || !is_object($m['dao'])) continue; - $dao = $m['dao']; - $daoClass = get_class($dao); - $id = (string)($m['id'] ?? ''); - if (($m['type'] ?? '') === 'update' && ($m['mode'] ?? '') === 'byId' && $id !== '') { - $key = $daoClass . '#' . $id; - $payload = (array)($m['payload'] ?? []); - if (!isset($updateMap[$key])) { $updateMap[$key] = ['dao' => $dao, 'id' => $id, 'payload' => []]; } - // merge (last write wins) - $updateMap[$key]['payload'] = array_merge($updateMap[$key]['payload'], $payload); - } - if (($m['type'] ?? '') === 'delete' && ($m['mode'] ?? '') === 'byId' && $id !== '') { - $deleteSet[$daoClass . '#' . $id] = ['dao' => $dao, 'id' => $id]; - } - } - - if (!$updateMap) { return $ops; } - - // Rebuild ops: for each original op, skip individual updates; add one merged update before a delete for the same entity - $result = []; - $emittedUpdate = []; - foreach ($ops as $o) { - $m = $o['meta'] ?? []; - $emit = true; - $dao = $m['dao'] ?? null; - $daoClass = is_object($dao) ? get_class($dao) : null; - $id = (string)($m['id'] ?? ''); - $key = ($daoClass && $id !== '') ? ($daoClass . '#' . $id) : null; - if (($m['type'] ?? '') === 'update' && ($m['mode'] ?? '') === 'byId' && $key && isset($updateMap[$key])) { - // skip individual update; we'll emit merged one once - $emit = false; - } - if (($m['type'] ?? '') === 'delete' && ($m['mode'] ?? '') === 'byId' && $key && isset($updateMap[$key]) && !isset($emittedUpdate[$key])) { - // emit merged update before delete - $merged = $updateMap[$key]; - $updOp = function () use ($merged) { - self::suspendDuring(function () use ($merged) { - $dao = $merged['dao']; $id = $merged['id']; $payload = $merged['payload']; - if (method_exists($dao, 'update')) { $dao->update($id, $payload); } - }); - }; - $result[] = ['op' => $updOp, 'meta' => ['type' => 'update', 'mode' => 'byId', 'dao' => $merged['dao'], 'id' => (string)$merged['id'], 'payload' => $merged['payload']]]; - $emittedUpdate[$key] = true; - } - if ($emit) { - $result[] = $o; - } - } - // For any remaining merged updates not emitted (no delete op), append them at the end - foreach ($updateMap as $k => $merged) { - if (isset($emittedUpdate[$k])) continue; - $updOp = function () use ($merged) { - self::suspendDuring(function () use ($merged) { - $dao = $merged['dao']; $id = $merged['id']; $payload = $merged['payload']; - if (method_exists($dao, 'update')) { $dao->update($id, $payload); } - }); - }; - $result[] = ['op' => $updOp, 'meta' => ['type' => 'update', 'mode' => 'byId', 'dao' => $merged['dao'], 'id' => (string)$merged['id'], 'payload' => $merged['payload']]]; - } - return $result; - } - - /** @param array $a @param array $b */ - private function diffAssoc(array $a, array $b): array - { - $diff = []; - foreach ($b as $k => $v) { - // only simple scalar/array comparisons; nested DTOs are out of scope - $av = $a[$k] ?? null; - if ($av !== $v) { - $diff[$k] = $v; - } - } - // Skip keys present in $a but removed in $b to avoid unintended nulling - return $diff; - } - - /** - * Read relations metadata from DAO instance if available. - * @return array - */ - private function readRelations(object $dao): array - { - // Prefer a public relationMap() accessor if provided - if (method_exists($dao, 'relationMap')) { - try { $rels = $dao->relationMap(); if (is_array($rels)) return $rels; } catch (\Throwable) {} - } - // Fallback: try calling protected relations() via reflection - try { - $ref = new \ReflectionObject($dao); - if ($ref->hasMethod('relations')) { - $m = $ref->getMethod('relations'); - $m->setAccessible(true); - $rels = $m->invoke($dao); - if (is_array($rels)) return $rels; - } - } catch (\Throwable) {} - return []; - } -} diff --git a/src/Query/QueryBuilder.php b/src/Query/QueryBuilder.php deleted file mode 100644 index 68a351c..0000000 --- a/src/Query/QueryBuilder.php +++ /dev/null @@ -1,120 +0,0 @@ - */ - private array $bindings = []; - - public function select(array $columns): static - { - $this->columns = $columns ?: ['*']; - return $this; - } - - public function from(string $table, ?string $alias = null): static - { - $this->from = $table; - $this->alias = $alias; - return $this; - } - - public function join(string $type, string $table, string $on): static - { - $this->joins[] = trim(strtoupper($type)) . " JOIN {$table} ON {$on}"; - return $this; - } - - public function where(string $clause, array $bindings = []): static - { - $this->wheres[] = $clause; - foreach ($bindings as $k => $v) { - $this->bindings[$k] = $v; - } - return $this; - } - - public function orderBy(string $orderBy): static - { - $this->orderBys[] = $orderBy; - return $this; - } - - public function groupBy(string $groupBy): static - { - $this->groupBys[] = $groupBy; - return $this; - } - - public function having(string $clause, array $bindings = []): static - { - $this->havings[] = $clause; - foreach ($bindings as $k => $v) { - $this->bindings[$k] = $v; - } - return $this; - } - - public function limit(int $limit): static - { - $this->limitVal = $limit; - return $this; - } - - public function offset(int $offset): static - { - $this->offsetVal = $offset; - return $this; - } - - public function toSql(): string - { - $sql = 'SELECT ' . implode(', ', $this->columns); - if ($this->from) { - $sql .= ' FROM ' . $this->from; - if ($this->alias) { - $sql .= ' ' . $this->alias; - } - } - if ($this->joins) { - $sql .= ' ' . implode(' ', $this->joins); - } - if ($this->wheres) { - $sql .= ' WHERE ' . implode(' AND ', $this->wheres); - } - if ($this->groupBys) { - $sql .= ' GROUP BY ' . implode(', ', $this->groupBys); - } - if ($this->havings) { - $sql .= ' HAVING ' . implode(' AND ', $this->havings); - } - if ($this->orderBys) { - $sql .= ' ORDER BY ' . implode(', ', $this->orderBys); - } - if ($this->limitVal !== null) { - $sql .= ' LIMIT ' . $this->limitVal; - } - if ($this->offsetVal !== null) { - $sql .= ' OFFSET ' . $this->offsetVal; - } - return $sql; - } - - public function getBindings(): array - { - return $this->bindings; - } -} diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index 647eadf..cb0964e 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -1,170 +1,178 @@ */ - public array $columns = []; - /** @var array */ - public array $primary = []; - /** @var array,name:?string}> */ - public array $uniques = []; - /** @var array,name:?string}> */ - public array $indexes = []; + /** + * @var array + */ + protected array $columns = []; - // Alter support (MVP) - /** @var array */ - public array $dropColumns = []; - /** @var array */ - public array $renameColumns = []; - public ?string $renameTo = null; - /** @var array */ - public array $dropUniqueNames = []; - /** @var array */ - public array $dropIndexNames = []; + /** + * @var array + */ + protected array $options = [ + 'prefix' => null, + 'tenancy' => false, + 'inheritance' => null, + 'morph' => null, + 'timestamps' => false, + 'softDeletes' => false, + 'auditable' => false, + 'view' => false, + 'locking' => false, + ]; - public function __construct(string $table) - { - $this->table = $table; + /** + * @var array> + */ + protected array $indexes = []; + + /** + * @var array> + */ + protected array $relations = []; + + /** + * Blueprint constructor. + * + * @param string $tableName + */ + public function __construct( + protected string $tableName + ) { } - public function create(): void { $this->creating = true; } - public function alter(): void { $this->altering = true; } - - // Column helpers - public function increments(string $name = 'id'): ColumnDefinition + /** + * Add a column to the blueprint. + * + * @param string $name + * @param string $type + * @param array $parameters + * @return Column + */ + public function addColumn(string $name, string $type, array $parameters = []): Column { - $col = new ColumnDefinition($name, 'increments'); - $col->autoIncrement(true); - $this->columns[] = $col; - $this->primary([$name]); - return $col; + $column = new Column($name, $type, $parameters); + $this->columns[] = $column; + return $column; } - public function bigIncrements(string $name = 'id'): ColumnDefinition + /** + * Get all columns. + * + * @return array + */ + public function getColumns(): array { - $col = new ColumnDefinition($name, 'bigincrements'); - $col->autoIncrement(true); - $this->columns[] = $col; - $this->primary([$name]); - return $col; + return $this->columns; } - public function integer(string $name, bool $unsigned = false): ColumnDefinition + /** + * Get the table name. + * + * @return string + */ + public function getTableName(): string { - $col = new ColumnDefinition($name, 'integer'); - $col->unsigned($unsigned); - $this->columns[] = $col; - return $col; + return $this->tableName; } - public function bigInteger(string $name, bool $unsigned = false): ColumnDefinition + /** + * Check if the blueprint represents a database view. + * + * @return bool + */ + public function isView(): bool { - $col = new ColumnDefinition($name, 'biginteger'); - $col->unsigned($unsigned); - $this->columns[] = $col; - return $col; + return (bool) $this->getOption('view', false); } - public function string(string $name, int $length = 255): ColumnDefinition + /** + * Set a table option. + * + * @param string $key + * @param mixed $value + * @return void + */ + public function setOption(string $key, mixed $value): void { - $col = new ColumnDefinition($name, 'string'); - $col->length($length); - $this->columns[] = $col; - return $col; + $this->options[$key] = $value; } - public function text(string $name): ColumnDefinition + /** + * Get a table option. + * + * @param string $key + * @param mixed|null $default + * @return mixed + */ + public function getOption(string $key, mixed $default = null): mixed { - $col = new ColumnDefinition($name, 'text'); - $this->columns[] = $col; - return $col; + return $this->options[$key] ?? $default; } - public function boolean(string $name): ColumnDefinition + /** + * Get all options. + * + * @return array + */ + public function getOptions(): array { - $col = new ColumnDefinition($name, 'boolean'); - $this->columns[] = $col; - return $col; + return $this->options; } - public function json(string $name): ColumnDefinition + /** + * Add an index to the table. + * + * @param string $name + * @param array $columns + * @return void + */ + public function addIndex(string $name, array $columns): void { - $col = new ColumnDefinition($name, 'json'); - $this->columns[] = $col; - return $col; + $this->indexes[$name] = $columns; } - public function datetime(string $name): ColumnDefinition + /** + * Get all indexes. + * + * @return array> + */ + public function getIndexes(): array { - $col = new ColumnDefinition($name, 'datetime'); - $this->columns[] = $col; - return $col; + return $this->indexes; } - public function decimal(string $name, int $precision, int $scale = 0): ColumnDefinition + /** + * Add a relation definition. + * + * @param string $name + * @param array $definition + * @return void + */ + public function addRelation(string $name, array $definition): void { - $col = new ColumnDefinition($name, 'decimal'); - $col->precision($precision, $scale); - $this->columns[] = $col; - return $col; + $this->relations[$name] = $definition; } - public function timestamps(string $created = 'created_at', string $updated = 'updated_at'): void + /** + * Get all relations. + * + * @return array> + */ + public function getRelations(): array { - $this->datetime($created)->nullable(); - $this->datetime($updated)->nullable(); - } - - // Index helpers - /** @param array $columns */ - public function primary(array $columns): void - { - $this->primary = $columns; - } - - /** @param array $columns */ - public function unique(array $columns, ?string $name = null): void - { - $this->uniques[] = ['columns' => $columns, 'name' => $name]; - } - - /** @param array $columns */ - public function index(array $columns, ?string $name = null): void - { - $this->indexes[] = ['columns' => $columns, 'name' => $name]; - } - - // Alter helpers (MVP) - /** @param array $names */ - public function dropColumn(string ...$names): void - { - foreach ($names as $n) { - if ($n !== '') $this->dropColumns[] = $n; - } - } - - public function renameColumn(string $from, string $to): void - { - $this->renameColumns[] = ['from' => $from, 'to' => $to]; - } - - public function rename(string $newName): void - { - $this->renameTo = $newName; - } - - public function dropUnique(string $name): void - { - $this->dropUniqueNames[] = $name; - } - - public function dropIndex(string $name): void - { - $this->dropIndexNames[] = $name; + return $this->relations; } } diff --git a/src/Schema/BlueprintSerializer.php b/src/Schema/BlueprintSerializer.php new file mode 100644 index 0000000..5f713ed --- /dev/null +++ b/src/Schema/BlueprintSerializer.php @@ -0,0 +1,54 @@ + + */ + public function toArray(Blueprint $blueprint): array + { + $columns = []; + foreach ($blueprint->getColumns() as $column) { + $columns[$column->getName()] = [ + 'type' => $column->getType(), + 'attributes' => $column->getAttributes(), + ]; + } + + return [ + 'tableName' => $blueprint->getTableName(), + 'options' => $blueprint->getOptions(), + 'columns' => $columns, + 'indexes' => $blueprint->getIndexes(), + 'relations' => $blueprint->getRelations(), + ]; + } + + /** + * Serialize a Blueprint into a PHP code string (for snapshots). + * + * @param Blueprint $blueprint + * @return string + */ + public function toPhpCode(Blueprint $blueprint): string + { + $data = $this->toArray($blueprint); + $exported = var_export($data, true); + + return "connection = $connection; - $this->grammar = $grammar; - } - - public function create(string $table, Closure $callback): void - { - $blueprint = new Blueprint($table); - $blueprint->create(); - $callback($blueprint); - $this->run($this->grammar->compileCreate($blueprint)); - } - - public function drop(string $table): void - { - $this->run($this->grammar->compileDrop($table)); - } - - public function dropIfExists(string $table): void - { - $this->run($this->grammar->compileDropIfExists($table)); + /** + * Builder constructor. + * + * @param string $tableName + * @param TypeMapper $typeMapper + */ + public function __construct( + string $tableName, + protected TypeMapper $typeMapper = new TypeMapper() + ) { + $this->blueprint = new Blueprint($tableName); } /** - * Alter an existing table using the blueprint alter helpers. + * Add an auto-incrementing big integer primary key. + * + * @param string $name + * @return Column */ - public function table(string $table, Closure $callback): void + public function id(string $name = 'id'): Column { - $blueprint = new Blueprint($table); - $blueprint->alter(); - $callback($blueprint); - // If SQLite and operation requires rebuild on legacy versions, perform rebuild - if ($this->grammar instanceof SqliteGrammar && ($blueprint->dropColumns || $blueprint->renameColumns)) { - $version = $this->detectSqliteVersion(); - $needsRebuild = false; - if ($blueprint->renameColumns) { - // RENAME COLUMN requires >= 3.25 - $needsRebuild = $needsRebuild || version_compare($version, '3.25.0', '<'); - } - if ($blueprint->dropColumns) { - // DROP COLUMN requires >= 3.35 - $needsRebuild = $needsRebuild || version_compare($version, '3.35.0', '<'); - } - if ($needsRebuild) { - SqliteTableRebuilder::rebuild($this->connection, $blueprint, $this->grammar); - return; - } - } - - $this->run($this->grammar->compileAlter($blueprint)); + return $this->bigInteger($name)->primary()->unsigned(); } - /** @param array $sqls */ - private function run(array $sqls): void + /** + * Add a string column. + * + * @param string $name + * @param int|null $length + * @return Column + */ + public function string(string $name, ?int $length = null): Column { - foreach ($sqls as $sql) { - $this->connection->execute($sql); - } + $parameters = $length ? ['length' => $length] : []; + return $this->addColumn($name, 'string', $parameters); } - private function detectSqliteVersion(): string + /** + * Add a text column. + * + * @param string $name + * @return Column + */ + public function text(string $name): Column { - try { - $rows = $this->connection->query('select sqlite_version() as v'); - $v = $rows[0]['v'] ?? '3.0.0'; - return is_string($v) ? $v : '3.0.0'; - } catch (\Throwable) { - return '3.0.0'; + return $this->addColumn($name, 'text'); + } + + /** + * Add an integer column. + * + * @param string $name + * @return Column + */ + public function integer(string $name): Column + { + return $this->addColumn($name, 'integer'); + } + + /** + * Add a big integer column. + * + * @param string $name + * @return Column + */ + public function bigInteger(string $name): Column + { + return $this->addColumn($name, 'bigInteger'); + } + + /** + * Add a boolean column. + * + * @param string $name + * @return Column + */ + public function boolean(string $name): Column + { + return $this->addColumn($name, 'boolean'); + } + + /** + * Add timestamps (created_at and updated_at). + * + * @return void + */ + public function timestamps(): void + { + $this->blueprint->setOption('timestamps', true); + $this->timestamp('created_at')->nullable(); + $this->timestamp('updated_at')->nullable(); + } + + /** + * Add a timestamp column. + * + * @param string $name + * @return Column + */ + public function timestamp(string $name): Column + { + return $this->addColumn($name, 'timestamp'); + } + + /** + * Add soft deletes column. + * + * @return void + */ + public function softDeletes(): void + { + $this->blueprint->setOption('softDeletes', true); + $this->timestamp('deleted_at')->nullable(); + } + + /** + * Add a generic column. + * + * @param string $name + * @param string $type + * @param array $parameters + * @return Column + */ + public function addColumn(string $name, string $type, array $parameters = []): Column + { + $typeProps = $this->typeMapper->getProperties($type); + $parameters = array_merge($typeProps, $parameters); + + return $this->blueprint->addColumn($name, $type, $parameters); + } + + /** + * Get the underlying blueprint. + * + * @return Blueprint + */ + public function getBlueprint(): Blueprint + { + return $this->blueprint; + } + + /** + * Magic method to support other types from TypeMapper. + * + * @param string $method + * @param array $args + * @return Column + */ + public function __call(string $method, array $args): Column + { + if ($this->typeMapper->has($method)) { + return $this->addColumn($args[0], $method, $args[1] ?? []); } + + throw new \BadMethodCallException("Method [{$method}] does not exist on " . static::class); } } diff --git a/src/Schema/CodeGenerator.php b/src/Schema/CodeGenerator.php new file mode 100644 index 0000000..e668432 --- /dev/null +++ b/src/Schema/CodeGenerator.php @@ -0,0 +1,275 @@ +getStub('DTO'); + $className = $this->getClassName($blueprint->getTableName()) . 'DTO'; + + $properties = []; + $constructor = []; + $methods = []; + $toArray = []; + + foreach ($blueprint->getColumns() as $column) { + $name = $column->getName(); + $properties[] = " protected \${$name};"; + $constructor[] = " \$this->{$name} = \$attributes['{$name}'] ?? null;"; + + // Basic getter + $studlyName = $this->studly($name); + $methodStub = " /**\n * @return mixed\n */\n public function get{$studlyName}()\n {\n \$this->ensureLoaded();\n return \$this->{$name};\n }"; + $methods[] = $methodStub; + + $toArray[] = " '{$name}' => \$this->{$name},"; + } + + return str_replace( + ['{{namespace}}', '{{class}}', '{{properties}}', '{{constructor}}', '{{methods}}', '{{toArray}}'], + [ + $namespace, + $className, + implode("\n", $properties), + implode("\n", $constructor), + implode("\n\n", $methods), + implode("\n", $toArray) + ], + $stub + ); + } + + /** + * Generate DAO code for a blueprint. + * + * @param Blueprint $blueprint + * @param string $namespace + * @param string $dtoNamespace + * @return string + * @throws SchemaException + */ + public function generateDao(Blueprint $blueprint, string $namespace, string $dtoNamespace): string + { + $stub = $this->getStub('DAO'); + $baseName = $this->getClassName($blueprint->getTableName()); + $className = $baseName . 'DAO'; + $dtoClass = $baseName . 'DTO'; + $dtoFqcn = $dtoNamespace . '\\' . $dtoClass; + + $primaryKey = 'id'; // Default + foreach ($blueprint->getColumns() as $column) { + if ($column->getAttribute('primary')) { + $primaryKey = $column->getName(); + break; + } + } + + $scopes = $this->generateScopes($blueprint); + $relations = $this->generateRelations($blueprint, $dtoNamespace, $namespace); + + $auditable = $blueprint->getOption('auditable', false); + $auditableConfig = ""; + if ($auditable) { + $auditableConfig = "\n /**\n * @var bool\n */\n protected bool \$auditable = true;\n"; + } + + $locking = $blueprint->getOption('locking', false); + $lockingConfig = ""; + if ($locking) { + $lockingColumn = is_string($locking) ? $locking : 'version'; + $lockingConfig = "\n /**\n * @var string|null\n */\n protected ?string \$lockingColumn = '{$lockingColumn}';\n"; + } + + return str_replace( + [ + '{{namespace}}', + '{{class}}', + '{{dto_fqcn}}', + '{{dto_class}}', + '{{connection}}', + '{{table}}', + '{{primary_key}}', + '{{scopes}}', + '{{relations}}', + '{{locking}}', + '{{auditable}}' + ], + [ + $namespace, + $className, + $dtoFqcn, + $dtoClass, + 'default', + $blueprint->getTableName(), + $primaryKey, + $scopes, + $relations, + $lockingConfig, + $auditableConfig + ], + $stub + ); + } + + /** + * Generate relationship methods for a DAO. + * + * @param Blueprint $blueprint + * @param string $dtoNamespace + * @param string $daoNamespace + * @return string + */ + protected function generateRelations(Blueprint $blueprint, string $dtoNamespace, string $daoNamespace): string + { + $methods = []; + foreach ($blueprint->getRelations() as $name => $definition) { + $type = $definition['type']; + $target = $definition['target']; + $targetBaseName = $this->getClassName($target); + $targetDtoFqcn = $dtoNamespace . '\\' . $targetBaseName . 'DTO'; + $targetDaoFqcn = $daoNamespace . '\\' . $targetBaseName . 'DAO'; + + $foreignKey = $definition['foreign_key'] ?? ($type === 'belongsTo' ? $target . '_id' : $blueprint->getTableName() . '_id'); + $localKey = $definition['local_key'] ?? 'id'; + $morphType = $definition['morph_type'] ?? $name . '_type'; + + $relationClass = 'Pairity\\Database\\Query\\Relations\\' . ucfirst($type); + + $methods[] = " /**"; + $methods[] = " * Get the {$name} relationship."; + $methods[] = " *"; + $methods[] = " * @param \\Pairity\\DTO\\BaseDTO \$parent"; + $methods[] = " * @return \\{$relationClass}"; + $methods[] = " */"; + $methods[] = " public function {$name}(\\Pairity\\DTO\\BaseDTO \$parent): \\{$relationClass}"; + $methods[] = " {"; + $methods[] = " \$relatedDao = \$this->db->getContainer()->get('{$targetDaoFqcn}');"; + if ($type === 'morphTo') { + $methods[] = " return new \\{$relationClass}(\$relatedDao->query(), \$parent, \$relatedDao, '{$foreignKey}', '{$localKey}', '{$morphType}');"; + } else { + $methods[] = " return new \\{$relationClass}(\$relatedDao->query(), \$parent, \$relatedDao, '{$foreignKey}', '{$localKey}');"; + } + $methods[] = " }"; + $methods[] = ""; + } + + return implode("\n", $methods); + } + + /** + * Generate query scopes for a blueprint. + * + * @param Blueprint $blueprint + * @return string + */ + protected function generateScopes(Blueprint $blueprint): string + { + // For now, we'll just add a placeholder or common scopes like 'whereId' + // In the future, this can be expanded based on YAML 'scopes' key. + return ""; + } + + /** + * Generate Hydrator code for a blueprint. + * + * @param Blueprint $blueprint + * @param string $namespace + * @param string $dtoFqcn + * @return string + * @throws SchemaException + */ + public function generateHydrator(Blueprint $blueprint, string $namespace, string $dtoFqcn): string + { + $stub = $this->getStub('Hydrator'); + $className = $this->getClassName($blueprint->getTableName()) . 'Hydrator'; + + $logic = []; + foreach ($blueprint->getColumns() as $column) { + $name = $column->getName(); + $logic[] = " if (isset(\$data['{$name}'])) {"; + $logic[] = " if (!isset(self::\$reflectionProperties['{$name}'])) {"; + $logic[] = " self::\$reflectionProperties['{$name}'] = new ReflectionProperty('{$dtoFqcn}', '{$name}');"; + $logic[] = " }"; + $logic[] = " self::\$reflectionProperties['{$name}']->setValue(\$instance, \$data['{$name}']);"; + $logic[] = " }"; + } + + return str_replace( + ['{{namespace}}', '{{class}}', '{{hydration_logic}}'], + [$namespace, $className, implode("\n", $logic)], + $stub + ); + } + + /** + * Get a stub by name. + * + * @param string $name + * @return string + * @throws SchemaException + */ + protected function getStub(string $name): string + { + $path = $this->stubsPath . DIRECTORY_SEPARATOR . $name . '.stub'; + if (!file_exists($path)) { + throw new SchemaException("Stub file [{$path}] not found."); + } + return file_get_contents($path); + } + + /** + * Convert table name to class name (StudlyCase). + * + * @param string $name + * @return string + */ + protected function getClassName(string $name): string + { + return $this->studly($name); + } + + /** + * Convert string to StudlyCase. + * + * @param string $value + * @return string + */ + protected function studly(string $value): string + { + $value = ucwords(str_replace(['-', '_'], ' ', $value)); + return str_replace(' ', '', $value); + } +} diff --git a/src/Schema/Column.php b/src/Schema/Column.php new file mode 100644 index 0000000..7811a6e --- /dev/null +++ b/src/Schema/Column.php @@ -0,0 +1,183 @@ + + */ + protected array $attributes = [ + 'nullable' => false, + 'default' => null, + 'unique' => false, + 'primary' => false, + 'index' => false, + 'comment' => null, + 'encrypted' => false, + 'unsigned' => false, + 'autoIncrement' => false, + ]; + + /** + * Column constructor. + * + * @param string $name + * @param string $type + * @param array $parameters + */ + public function __construct( + protected string $name, + protected string $type, + array $parameters = [] + ) { + $this->attributes = array_merge($this->attributes, $parameters); + } + + /** + * Set the column to be nullable. + * + * @param bool $value + * @return $this + */ + public function nullable(bool $value = true): self + { + $this->attributes['nullable'] = $value; + return $this; + } + + /** + * Set the default value for the column. + * + * @param mixed $value + * @return $this + */ + public function default(mixed $value): self + { + $this->attributes['default'] = $value; + return $this; + } + + /** + * Set the column to be unique. + * + * @param bool $value + * @return $this + */ + public function unique(bool $value = true): self + { + $this->attributes['unique'] = $value; + return $this; + } + + /** + * Set the column as part of the primary key. + * + * @param bool $value + * @return $this + */ + public function primary(bool $value = true): self + { + $this->attributes['primary'] = $value; + return $this; + } + + /** + * Set the column to be encrypted. + * + * @param bool $value + * @return $this + */ + public function encrypted(bool $value = true): self + { + $this->attributes['encrypted'] = $value; + return $this; + } + + /** + * Set the column to be an index. + * + * @param bool $value + * @return $this + */ + public function index(bool $value = true): self + { + $this->attributes['index'] = $value; + return $this; + } + + /** + * Set the column to be unsigned. + * + * @param bool $value + * @return $this + */ + public function unsigned(bool $value = true): self + { + $this->attributes['unsigned'] = $value; + return $this; + } + + /** + * Set the column to auto-increment. + * + * @param bool $value + * @return $this + */ + public function autoIncrement(bool $value = true): self + { + $this->attributes['autoIncrement'] = $value; + return $this; + } + + /** + * Get the column name. + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the column type. + * + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * Get all column attributes. + * + * @return array + */ + public function getAttributes(): array + { + return $this->attributes; + } + + /** + * Get a specific attribute. + * + * @param string $key + * @param mixed|null $default + * @return mixed + */ + public function getAttribute(string $key, mixed $default = null): mixed + { + return $this->attributes[$key] ?? $default; + } +} diff --git a/src/Schema/ColumnDefinition.php b/src/Schema/ColumnDefinition.php deleted file mode 100644 index 9c99849..0000000 --- a/src/Schema/ColumnDefinition.php +++ /dev/null @@ -1,29 +0,0 @@ -name = $name; - $this->type = $type; - } - - public function length(int $length): static { $this->length = $length; return $this; } - public function precision(int $precision, int $scale = 0): static { $this->precision = $precision; $this->scale = $scale; return $this; } - public function unsigned(bool $flag = true): static { $this->unsigned = $flag; return $this; } - public function nullable(bool $flag = true): static { $this->nullable = $flag; return $this; } - public function default(mixed $value): static { $this->default = $value; return $this; } - public function autoIncrement(bool $flag = true): static { $this->autoIncrement = $flag; return $this; } -} diff --git a/src/Schema/Grammars/Grammar.php b/src/Schema/Grammars/Grammar.php deleted file mode 100644 index e0fcd61..0000000 --- a/src/Schema/Grammars/Grammar.php +++ /dev/null @@ -1,32 +0,0 @@ - SQL statements to execute in order - */ - abstract public function compileCreate(Blueprint $blueprint): array; - - /** @return array */ - abstract public function compileDrop(string $table): array; - - /** @return array */ - abstract public function compileDropIfExists(string $table): array; - - /** - * Compile ALTER TABLE statements based on a Blueprint in alter mode. - * @return array - */ - abstract public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array; - - protected function wrap(string $identifier): string - { - // Default simple wrap with backticks; override in driver if different - return '`' . str_replace('`', '``', $identifier) . '`'; - } -} diff --git a/src/Schema/Grammars/MySqlGrammar.php b/src/Schema/Grammars/MySqlGrammar.php deleted file mode 100644 index e082332..0000000 --- a/src/Schema/Grammars/MySqlGrammar.php +++ /dev/null @@ -1,144 +0,0 @@ -columns as $col) { - $cols[] = $this->compileColumn($col); - } - - $inline = []; - if ($blueprint->primary) { - $inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')'; - } - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? null; - $inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')'; - } - - $definition = implode(",\n ", array_merge($cols, $inline)); - $sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)"; - - // Indexes as separate statements - $statements = [$sql]; - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')'; - } - - return $statements; - } - - public function compileDrop(string $table): array - { - return ['DROP TABLE ' . $this->wrap($table)]; - } - - public function compileDropIfExists(string $table): array - { - return ['DROP TABLE IF EXISTS ' . $this->wrap($table)]; - } - - public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array - { - $table = $this->wrap($blueprint->table); - $stmts = []; - - // Add columns - foreach ($blueprint->columns as $col) { - $stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col); - } - - // Drop columns - foreach ($blueprint->dropColumns as $name) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name); - } - - // Rename columns - foreach ($blueprint->renameColumns as $pair) { - // MySQL 8+: RENAME COLUMN; older: CHANGE old new TYPE ... - $stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']); - } - - // Add uniques - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique'); - $stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')'; - } - - // Add indexes - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')'; - } - - // Drop unique/index by name - foreach ($blueprint->dropUniqueNames as $n) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP INDEX ' . $this->wrap($n); - } - foreach ($blueprint->dropIndexNames as $n) { - $stmts[] = 'DROP INDEX ' . $this->wrap($n) . ' ON ' . $table; - } - - // Rename table - if ($blueprint->renameTo) { - $stmts[] = 'RENAME TABLE ' . $table . ' TO ' . $this->wrap($blueprint->renameTo); - } - - return $stmts ?: ['-- no-op']; - } - - private function compileColumn(ColumnDefinition $c): string - { - $type = match ($c->type) { - 'increments' => 'INT', - 'bigincrements' => 'BIGINT', - 'integer' => 'INT', - 'biginteger' => 'BIGINT', - 'string' => 'VARCHAR(' . ($c->length ?? 255) . ')', - 'text' => 'TEXT', - 'boolean' => 'TINYINT(1)', - 'json' => 'JSON', - 'datetime' => 'DATETIME', - 'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')', - default => strtoupper($c->type), - }; - - $parts = [$this->wrap($c->name), $type]; - if (in_array($c->type, ['integer','biginteger','increments','bigincrements','decimal'], true) && $c->unsigned) { - $parts[] = 'UNSIGNED'; - } - - if ($c->autoIncrement) { - $parts[] = 'AUTO_INCREMENT'; - } - - $parts[] = $c->nullable ? 'NULL' : 'NOT NULL'; - - if ($c->default !== null) { - $parts[] = 'DEFAULT ' . $this->quoteDefault($c->default); - } - - return implode(' ', $parts); - } - - private function columnList(array $cols): string - { - return implode(', ', array_map(fn($c) => $this->wrap($c), $cols)); - } - - private function quoteDefault(mixed $value): string - { - if (is_numeric($value)) return (string)$value; - if (is_bool($value)) return $value ? '1' : '0'; - if ($value === null) return 'NULL'; - return "'" . str_replace("'", "''", (string)$value) . "'"; - } -} diff --git a/src/Schema/Grammars/OracleGrammar.php b/src/Schema/Grammars/OracleGrammar.php deleted file mode 100644 index f6a8340..0000000 --- a/src/Schema/Grammars/OracleGrammar.php +++ /dev/null @@ -1,153 +0,0 @@ -columns as $col) { - $cols[] = $this->compileColumn($col); - } - - $inline = []; - if ($blueprint->primary) { - $inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')'; - } - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? null; - $inline[] = 'CONSTRAINT ' . ($name ? $this->wrap($name) : $this->wrap($this->makeName($blueprint->table, $u['columns'], 'uk'))) . ' UNIQUE (' . $this->columnList($u['columns']) . ')'; - } - - $definition = implode(",\n ", array_merge($cols, $inline)); - $sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)"; - - $statements = [$sql]; - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? $this->makeName($blueprint->table, $i['columns'], 'ix'); - $statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')'; - } - - return $statements; - } - - public function compileDrop(string $table): array - { - return ['DROP TABLE ' . $this->wrap($table)]; - } - - public function compileDropIfExists(string $table): array - { - // Oracle lacks IF EXISTS; use anonymous PL/SQL block - $tbl = $this->wrap($table); - $plsql = "BEGIN\n EXECUTE IMMEDIATE 'DROP TABLE {$tbl}';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN RAISE; END IF;\nEND;"; - return [$plsql]; - } - - public function compileAlter(Blueprint $blueprint): array - { - $table = $this->wrap($blueprint->table); - $stmts = []; - - // Add columns - foreach ($blueprint->columns as $col) { - $stmts[] = 'ALTER TABLE ' . $table . ' ADD (' . $this->compileColumn($col) . ')'; - } - - // Drop columns - foreach ($blueprint->dropColumns as $name) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name); - } - - // Rename columns - foreach ($blueprint->renameColumns as $pair) { - $stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']); - } - - // Add uniques - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? $this->makeName($blueprint->table, $u['columns'], 'uk'); - $stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')'; - } - - // Add indexes - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? $this->makeName($blueprint->table, $i['columns'], 'ix'); - $stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')'; - } - - // Drop unique/index by name - foreach ($blueprint->dropUniqueNames as $n) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n); - } - foreach ($blueprint->dropIndexNames as $n) { - $stmts[] = 'DROP INDEX ' . $this->wrap($n); - } - - // Rename table - if ($blueprint->renameTo) { - $stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo); - } - - return $stmts ?: ['-- no-op']; - } - - private function compileColumn(ColumnDefinition $c): string - { - $type = match ($c->type) { - 'increments' => 'NUMBER(10)', - 'bigincrements' => 'NUMBER(19)', - 'integer' => 'NUMBER(10)', - 'biginteger' => 'NUMBER(19)', - 'string' => 'VARCHAR2(' . ($c->length ?? 255) . ')', - 'text' => 'CLOB', - 'boolean' => 'NUMBER(1)', // store 0/1 - 'json' => 'CLOB', // Oracle JSON type (21c+) not assumed; use CLOB - 'datetime' => 'TIMESTAMP', - 'decimal' => 'NUMBER(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')', - default => strtoupper($c->type), - }; - - $parts = [$this->wrap($c->name), $type]; - - $parts[] = $c->nullable ? 'NULL' : 'NOT NULL'; - - if ($c->default !== null) { - $parts[] = 'DEFAULT ' . $this->quoteDefault($c->default); - } - - return implode(' ', $parts); - } - - private function columnList(array $cols): string - { - return implode(', ', array_map(fn($c) => $this->wrap($c), $cols)); - } - - protected function wrap(string $identifier): string - { - return '"' . str_replace('"', '""', $identifier) . '"'; - } - - private function makeName(string $table, array $columns, string $suffix): string - { - $base = $table . '_' . implode('_', $columns) . '_' . $suffix; - // Oracle identifier max length is 30. Shorten deterministically if needed. - if (strlen($base) <= 30) return $base; - $hash = substr(sha1($base), 0, 8); - $short = substr($table, 0, 10) . '_' . substr($columns[0] ?? 'col', 0, 5) . '_' . $suffix . '_' . $hash; - return substr($short, 0, 30); - } - - private function quoteDefault(mixed $value): string - { - if (is_numeric($value)) return (string)$value; - if (is_bool($value)) return $value ? '1' : '0'; - if ($value === null) return 'NULL'; - return "'" . str_replace("'", "''", (string)$value) . "'"; - } -} diff --git a/src/Schema/Grammars/PostgresGrammar.php b/src/Schema/Grammars/PostgresGrammar.php deleted file mode 100644 index 0c9ec45..0000000 --- a/src/Schema/Grammars/PostgresGrammar.php +++ /dev/null @@ -1,151 +0,0 @@ -columns as $col) { - $cols[] = $this->compileColumn($col); - } - - $inline = []; - if ($blueprint->primary) { - $inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')'; - } - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? null; - $inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')'; - } - - $definition = implode(",\n ", array_merge($cols, $inline)); - $sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)"; - - $statements = [$sql]; - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')'; - } - - return $statements; - } - - public function compileDrop(string $table): array - { - return ['DROP TABLE ' . $this->wrap($table)]; - } - - public function compileDropIfExists(string $table): array - { - return ['DROP TABLE IF EXISTS ' . $this->wrap($table)]; - } - - public function compileAlter(Blueprint $blueprint): array - { - $table = $this->wrap($blueprint->table); - $stmts = []; - - // Add columns - foreach ($blueprint->columns as $col) { - $stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col); - } - - // Drop columns - foreach ($blueprint->dropColumns as $name) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name); - } - - // Rename columns - foreach ($blueprint->renameColumns as $pair) { - $stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']); - } - - // Add uniques - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique'); - $stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')'; - } - - // Add indexes - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')'; - } - - // Drop unique/index by name - foreach ($blueprint->dropUniqueNames as $n) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n); - } - foreach ($blueprint->dropIndexNames as $n) { - $stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n); - } - - // Rename table - if ($blueprint->renameTo) { - $stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo); - } - - return $stmts ?: ['-- no-op']; - } - - private function compileColumn(ColumnDefinition $c): string - { - $type = match ($c->type) { - 'increments' => 'SERIAL', - 'bigincrements' => 'BIGSERIAL', - 'integer' => 'INTEGER', - 'biginteger' => 'BIGINT', - 'string' => 'VARCHAR(' . ($c->length ?? 255) . ')', - 'text' => 'TEXT', - 'boolean' => 'BOOLEAN', - 'json' => 'JSONB', - 'datetime' => 'TIMESTAMP(0) WITHOUT TIME ZONE', - 'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')', - default => strtoupper($c->type), - }; - - $parts = [$this->wrap($c->name), $type]; - - $parts[] = $c->nullable ? 'NULL' : 'NOT NULL'; - - if ($c->default !== null) { - $parts[] = 'DEFAULT ' . $this->quoteDefault($c->default); - } - - return implode(' ', $parts); - } - - private function columnList(array $cols): string - { - return implode(', ', array_map(fn($c) => $this->wrap($c), $cols)); - } - - private function quoteDefault(mixed $value): string - { - if (is_numeric($value)) return (string)$value; - if (is_bool($value)) return $value ? 'TRUE' : 'FALSE'; - if ($value === null) return 'NULL'; - return "'" . str_replace("'", "''", (string)$value) . "'"; - } -} diff --git a/src/Schema/Grammars/SqlServerGrammar.php b/src/Schema/Grammars/SqlServerGrammar.php deleted file mode 100644 index 3981111..0000000 --- a/src/Schema/Grammars/SqlServerGrammar.php +++ /dev/null @@ -1,145 +0,0 @@ -columns as $col) { - $cols[] = $this->compileColumn($col); - } - - $inline = []; - if ($blueprint->primary) { - $inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')'; - } - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? null; - $inline[] = 'CONSTRAINT ' . ($name ? $this->wrap($name) : $this->wrap($blueprint->table . '_' . implode('_', $u['columns']) . '_unique')) . ' UNIQUE (' . $this->columnList($u['columns']) . ')'; - } - - $definition = implode(",\n ", array_merge($cols, $inline)); - $sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)"; - - $statements = [$sql]; - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')'; - } - - return $statements; - } - - public function compileDrop(string $table): array - { - return ['DROP TABLE ' . $this->wrap($table)]; - } - - public function compileDropIfExists(string $table): array - { - return ['IF OBJECT_ID(N' . $this->quote($table) . ", 'U') IS NOT NULL DROP TABLE " . $this->wrap($table)]; - } - - public function compileAlter(Blueprint $blueprint): array - { - $table = $this->wrap($blueprint->table); - $stmts = []; - - foreach ($blueprint->columns as $col) { - $stmts[] = 'ALTER TABLE ' . $table . ' ADD ' . $this->compileColumn($col); - } - - foreach ($blueprint->dropColumns as $name) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name); - } - - foreach ($blueprint->renameColumns as $pair) { - $stmts[] = 'EXEC sp_rename ' . $this->quote($blueprint->table . '.' . $pair['from']) . ', ' . $this->quote($pair['to']) . ', ' . $this->quote('COLUMN'); - } - - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique'); - $stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')'; - } - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')'; - } - foreach ($blueprint->dropUniqueNames as $n) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n); - } - foreach ($blueprint->dropIndexNames as $n) { - $stmts[] = 'DROP INDEX ' . $this->wrap($n) . ' ON ' . $table; - } - - if ($blueprint->renameTo) { - $stmts[] = 'EXEC sp_rename ' . $this->quote($blueprint->table) . ', ' . $this->quote($blueprint->renameTo); - } - - return $stmts ?: ['-- no-op']; - } - - private function compileColumn(ColumnDefinition $c): string - { - $type = match ($c->type) { - 'increments' => 'INT', - 'bigincrements' => 'BIGINT', - 'integer' => 'INT', - 'biginteger' => 'BIGINT', - 'string' => 'NVARCHAR(' . ($c->length ?? 255) . ')', - 'text' => 'NVARCHAR(MAX)', - 'boolean' => 'BIT', - 'json' => 'NVARCHAR(MAX)', - 'datetime' => 'DATETIME2', - 'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')', - default => strtoupper($c->type), - }; - - $parts = [$this->wrap($c->name), $type]; - - if (in_array($c->type, ['integer','biginteger','increments','bigincrements','decimal'], true) && $c->unsigned) { - // SQL Server has no UNSIGNED integers; ignore. - } - - if ($c->autoIncrement) { - // IDENTITY(1,1) for auto-increment - $parts[] = 'IDENTITY(1,1)'; - } - - $parts[] = $c->nullable ? 'NULL' : 'NOT NULL'; - - if ($c->default !== null) { - $parts[] = 'DEFAULT ' . $this->quoteDefault($c->default); - } - - return implode(' ', $parts); - } - - private function columnList(array $cols): string - { - return implode(', ', array_map(fn($c) => $this->wrap($c), $cols)); - } - - protected function wrap(string $identifier): string - { - return '[' . str_replace([']'], [']]'], $identifier) . ']'; - } - - private function quote(string $value): string - { - return "'" . str_replace("'", "''", $value) . "'"; - } - - private function quoteDefault(mixed $value): string - { - if (is_numeric($value)) return (string)$value; - if (is_bool($value)) return $value ? '1' : '0'; - if ($value === null) return 'NULL'; - return "'" . str_replace("'", "''", (string)$value) . "'"; - } -} diff --git a/src/Schema/Grammars/SqliteGrammar.php b/src/Schema/Grammars/SqliteGrammar.php deleted file mode 100644 index 7efbe47..0000000 --- a/src/Schema/Grammars/SqliteGrammar.php +++ /dev/null @@ -1,148 +0,0 @@ -columns as $col) { - $cols[] = $this->compileColumn($col, $blueprint); - } - - $inline = []; - if ($blueprint->primary) { - // In SQLite, INTEGER PRIMARY KEY on a single column should be on the column itself for autoincrement. - // For composite PKs, use table constraint. - if (count($blueprint->primary) > 1) { - $inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')'; - } - } - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? null; - $inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')'; - } - - $definition = implode(",\n ", array_merge($cols, $inline)); - $sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)"; - - $statements = [$sql]; - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')'; - } - - return $statements; - } - - public function compileDrop(string $table): array - { - return ['DROP TABLE ' . $this->wrap($table)]; - } - - public function compileDropIfExists(string $table): array - { - return ['DROP TABLE IF EXISTS ' . $this->wrap($table)]; - } - - public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array - { - $table = $this->wrap($blueprint->table); - $stmts = []; - - // SQLite supports ADD COLUMN straightforwardly - foreach ($blueprint->columns as $col) { - $stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col, $blueprint); - } - - // RENAME COLUMN and DROP COLUMN are supported in modern SQLite (3.25+ and 3.35+). We will emit statements; if not supported by the runtime, DB will error. - foreach ($blueprint->renameColumns as $pair) { - $stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']); - } - foreach ($blueprint->dropColumns as $name) { - $stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name); - } - - // Unique/index operations in SQLite generally require CREATE/DROP INDEX statements - foreach ($blueprint->uniques as $u) { - $name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique'); - $stmts[] = 'CREATE UNIQUE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($u['columns']) . ')'; - } - foreach ($blueprint->indexes as $i) { - $name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index'); - $stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')'; - } - foreach ($blueprint->dropUniqueNames as $n) { - $stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n); - } - foreach ($blueprint->dropIndexNames as $n) { - $stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n); - } - - // Rename table - if ($blueprint->renameTo) { - $stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo); - } - - return $stmts ?: ['-- no-op']; - } - - private function compileColumn(ColumnDefinition $c, Blueprint $bp): string - { - // SQLite type affinities - $type = match ($c->type) { - 'increments' => 'INTEGER', - 'bigincrements' => 'INTEGER', - 'integer' => 'INTEGER', - 'biginteger' => 'INTEGER', - 'string' => 'VARCHAR(' . ($c->length ?? 255) . ')', - 'text' => 'TEXT', - 'boolean' => 'INTEGER', - 'json' => 'TEXT', - 'datetime' => 'TEXT', - 'decimal' => 'NUMERIC', - default => strtoupper($c->type), - }; - - $parts = [$this->wrap($c->name), $type]; - - // AUTOINCREMENT style: only valid for a single-column integer primary key - $isPk = (count($bp->primary) === 1 && $bp->primary[0] === $c->name) || ($c->autoIncrement === true); - if ($isPk && in_array($c->type, ['increments','bigincrements','integer','biginteger'], true)) { - $parts[] = 'PRIMARY KEY'; - if ($c->autoIncrement) { - $parts[] = 'AUTOINCREMENT'; - } - } - - $parts[] = $c->nullable ? 'NULL' : 'NOT NULL'; - - if ($c->default !== null) { - $parts[] = 'DEFAULT ' . $this->quoteDefault($c->default); - } - - return implode(' ', $parts); - } - - private function columnList(array $cols): string - { - return implode(', ', array_map(fn($c) => $this->wrap($c), $cols)); - } - - protected function wrap(string $identifier): string - { - return '"' . str_replace('"', '""', $identifier) . '"'; - } - - private function quoteDefault(mixed $value): string - { - if (is_numeric($value)) return (string)$value; - if (is_bool($value)) return $value ? '1' : '0'; - if ($value === null) return 'NULL'; - return "'" . str_replace("'", "''", (string)$value) . "'"; - } -} diff --git a/src/Schema/JsonSchemaGenerator.php b/src/Schema/JsonSchemaGenerator.php new file mode 100644 index 0000000..1972c57 --- /dev/null +++ b/src/Schema/JsonSchemaGenerator.php @@ -0,0 +1,110 @@ + + */ + public function generate(): array + { + return [ + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'title' => 'Pairity Table Definition', + 'type' => 'object', + 'properties' => [ + 'prefix' => ['type' => 'string'], + 'tenancy' => ['type' => 'boolean'], + 'auditable' => ['type' => 'boolean'], + 'timestamps' => ['type' => 'boolean'], + 'softDeletes' => ['type' => 'boolean'], + 'inheritance' => [ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['sti', 'cti']], + 'discriminator' => ['type' => 'string'], + 'parent' => ['type' => 'string'], + ], + ], + 'morph' => [ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string'], + 'id' => ['type' => 'string'], + ], + ], + 'columns' => [ + 'type' => 'object', + 'additionalProperties' => [ + 'anyOf' => [ + ['type' => 'string'], + [ + 'type' => 'object', + 'required' => ['type'], + 'properties' => [ + 'type' => ['type' => 'string'], + 'length' => ['type' => 'integer'], + 'precision' => ['type' => 'integer'], + 'scale' => ['type' => 'integer'], + 'nullable' => ['type' => 'boolean'], + 'unique' => ['type' => 'boolean'], + 'primary' => ['type' => 'boolean'], + 'index' => ['type' => 'boolean'], + 'encrypted' => ['type' => 'boolean'], + 'unsigned' => ['type' => 'boolean'], + 'autoIncrement' => ['type' => 'boolean'], + 'default' => ['type' => ['string', 'number', 'boolean', 'null']], + 'comment' => ['type' => 'string'], + ], + ], + ], + ], + ], + 'indexes' => [ + 'type' => 'object', + 'additionalProperties' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], + ], + 'relations' => [ + 'type' => 'object', + 'additionalProperties' => [ + 'type' => 'object', + 'required' => ['type', 'model'], + 'properties' => [ + 'type' => ['type' => 'string', 'enum' => ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'morphTo', 'morphOne', 'morphMany']], + 'model' => ['type' => 'string'], + 'foreignKey' => ['type' => 'string'], + 'localKey' => ['type' => 'string'], + 'pivotTable' => ['type' => 'string'], + ], + ], + ], + ], + 'required' => ['columns'], + ]; + } + + /** + * Generate the JSON schema and return it as a string. + * + * @return string + */ + public function generateJson(): string + { + return json_encode($this->generate(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } +} diff --git a/src/Schema/MetadataManager.php b/src/Schema/MetadataManager.php new file mode 100644 index 0000000..ac071ec --- /dev/null +++ b/src/Schema/MetadataManager.php @@ -0,0 +1,134 @@ + In-memory registry to avoid repeated lookups in a single request. + */ + protected array $registry = []; + + /** + * MetadataManager constructor. + * + * @param YamlSchemaParser $parser + * @param CacheInterface|null $cache + */ + public function __construct( + protected YamlSchemaParser $parser, + protected ?CacheInterface $cache = null + ) { + } + + /** + * Get a blueprint for a given table, using cache if available. + * + * @param string $tableName + * @param string $schemaPath Path to the schema directory or specific YAML file. + * @return Blueprint + * @throws SchemaException + */ + public function getBlueprint(string $tableName, string $schemaPath): Blueprint + { + if (isset($this->registry[$tableName])) { + return $this->registry[$tableName]; + } + + $filePath = $this->resolveFilePath($tableName, $schemaPath); + $cacheKey = "blueprint.{$tableName}." . md5($filePath); + + if ($this->cache) { + $cached = $this->cache->get($cacheKey); + if ($cached && $this->isValid($cached, $filePath)) { + return $this->registry[$tableName] = $cached['blueprint']; + } + } + + $blueprint = $this->parser->parseFile($filePath); + + if ($this->cache) { + $this->cache->set($cacheKey, [ + 'blueprint' => $blueprint, + 'hash' => md5_file($filePath), + 'mtime' => filemtime($filePath), + ]); + } + + return $this->registry[$tableName] = $blueprint; + } + + /** + * Clear the metadata cache. + * + * @return bool + */ + public function clearCache(): bool + { + $this->registry = []; + return $this->cache ? $this->cache->clear() : true; + } + + /** + * Resolve the full file path for a table schema. + * + * @param string $tableName + * @param string $schemaPath + * @return string + * @throws SchemaException + */ + protected function resolveFilePath(string $tableName, string $schemaPath): string + { + if (is_file($schemaPath)) { + return $schemaPath; + } + + $filePath = rtrim($schemaPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $tableName . '.yaml'; + + if (!file_exists($filePath)) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new SchemaException( + $translator->trans('error.schema_file_not_found', ['path' => $filePath]), + 0, + null, + ['path' => $filePath, 'table' => $tableName] + ); + } + + return $filePath; + } + + /** + * Check if the cached blueprint is still valid. + * + * @param array $cached + * @param string $filePath + * @return bool + */ + protected function isValid(array $cached, string $filePath): bool + { + if (!isset($cached['hash']) || !isset($cached['mtime'])) { + return false; + } + + // Fast check: modification time + if (filemtime($filePath) === $cached['mtime']) { + return true; + } + + // Slow check: hash + return md5_file($filePath) === $cached['hash']; + } +} diff --git a/src/Schema/SchemaManager.php b/src/Schema/SchemaManager.php deleted file mode 100644 index 3e39989..0000000 --- a/src/Schema/SchemaManager.php +++ /dev/null @@ -1,43 +0,0 @@ -getNative(); - $driver = null; - if ($native instanceof PDO) { - try { - $driver = $native->getAttribute(PDO::ATTR_DRIVER_NAME); - } catch (\Throwable) { - $driver = null; - } - } - $driver = is_string($driver) ? strtolower($driver) : ''; - return match ($driver) { - 'sqlite' => new SqliteGrammar(), - 'pgsql' => new PostgresGrammar(), - 'sqlsrv' => new SqlServerGrammar(), - 'oci' => new OracleGrammar(), - 'oracle' => new OracleGrammar(), - default => new MySqlGrammar(), // default to MySQL-style grammar - }; - } -} diff --git a/src/Schema/SqliteTableRebuilder.php b/src/Schema/SqliteTableRebuilder.php deleted file mode 100644 index f494f54..0000000 --- a/src/Schema/SqliteTableRebuilder.php +++ /dev/null @@ -1,147 +0,0 @@ -table; - - // Read existing columns - $columns = $connection->query('PRAGMA table_info(' . self::wrapIdent($table) . ')'); - if (!$columns) { - throw new \RuntimeException('Table not found for rebuild: ' . $table); - } - - // Build rename map - $renameMap = []; - foreach ($blueprint->renameColumns as $pair) { - $renameMap[$pair['from']] = $pair['to']; - } - - $dropSet = array_flip($blueprint->dropColumns); - - // Build new column definitions from existing columns (apply drop/rename) - $newCols = []; - $sourceToTarget = []; - foreach ($columns as $col) { - $name = (string)$col['name']; - if (isset($dropSet[$name])) continue; // drop - $targetName = $renameMap[$name] ?? $name; - $type = (string)($col['type'] ?? 'TEXT'); - $notnull = ((int)($col['notnull'] ?? 0)) === 1 ? 'NOT NULL' : 'NULL'; - $default = $col['dflt_value'] ?? null; // already SQL literal in PRAGMA output - $pk = ((int)($col['pk'] ?? 0)) === 1 ? 'PRIMARY KEY' : ''; - - $defParts = [self::wrap($targetName), $type, $notnull]; - if ($default !== null && $default !== '') { - $defParts[] = 'DEFAULT ' . $default; - } - if ($pk !== '') { - $defParts[] = $pk; - } - $newCols[$targetName] = implode(' ', array_filter($defParts)); - $sourceToTarget[$name] = $targetName; - } - - // Add newly declared columns from Blueprint (with their definitions via grammar) - foreach ($blueprint->columns as $def) { - $newCols[$def->name] = self::compileColumnSqlite($def, $grammar); - } - - // Temp table name - $tmp = $table . '_rebuild_' . substr(sha1((string)microtime(true)), 0, 6); - - // Create temp table - $create = 'CREATE TABLE ' . self::wrap($tmp) . ' (' . implode(', ', array_values($newCols)) . ')'; - $connection->execute($create); - - // Build INSERT INTO tmp (...) SELECT ... FROM table - $targetCols = array_keys($newCols); - $selectExprs = []; - foreach ($targetCols as $colName) { - // If this column existed before, map from old source name (pre-rename), else insert NULL - $sourceName = array_search($colName, $sourceToTarget, true); - if ($sourceName !== false) { - $selectExprs[] = self::wrap($sourceName) . ' AS ' . self::wrap($colName); - } else { - $selectExprs[] = 'NULL AS ' . self::wrap($colName); - } - } - $insert = 'INSERT INTO ' . self::wrap($tmp) . ' (' . self::columnList($targetCols) . ') SELECT ' . implode(', ', $selectExprs) . ' FROM ' . self::wrap($table); - $connection->execute($insert); - - // Replace original table - $connection->execute('DROP TABLE ' . self::wrap($table)); - $connection->execute('ALTER TABLE ' . self::wrap($tmp) . ' RENAME TO ' . self::wrap($table)); - - // Apply index/unique operations from the blueprint (post-rebuild) - $post = new Blueprint($table); - // Carry over index ops only - $post->uniques = $blueprint->uniques; - $post->indexes = $blueprint->indexes; - $post->dropUniqueNames = $blueprint->dropUniqueNames; - $post->dropIndexNames = $blueprint->dropIndexNames; - - $sqls = $grammar->compileAlter($post); - foreach ($sqls as $sql) { - $connection->execute($sql); - } - } - - private static function compileColumnSqlite(ColumnDefinition $c, SqliteGrammar $grammar): string - { - // Minimal re-use: instantiate a throwaway Blueprint to access protected compile via public path is not possible; duplicate minimal mapping here - $type = match ($c->type) { - 'increments', 'bigincrements', 'integer', 'biginteger' => 'INTEGER', - 'string' => 'VARCHAR(' . ($c->length ?? 255) . ')', - 'text' => 'TEXT', - 'boolean' => 'INTEGER', - 'json' => 'TEXT', - 'datetime' => 'TEXT', - 'decimal' => 'NUMERIC', - default => strtoupper($c->type), - }; - $parts = [self::wrap($c->name), $type]; - $parts[] = $c->nullable ? 'NULL' : 'NOT NULL'; - if ($c->default !== null) { - $parts[] = 'DEFAULT ' . self::quoteDefault($c->default); - } - return implode(' ', $parts); - } - - private static function wrap(string $ident): string - { - return '"' . str_replace('"', '""', $ident) . '"'; - } - - private static function wrapIdent(string $ident): string - { - // For PRAGMA table_info() we should not quote with double quotes; wrap in simple name if needed - return '"' . str_replace('"', '""', $ident) . '"'; - } - - private static function columnList(array $cols): string - { - return implode(', ', array_map(fn($c) => self::wrap($c), $cols)); - } - - private static function quoteDefault(mixed $value): string - { - if (is_numeric($value)) return (string)$value; - if (is_bool($value)) return $value ? '1' : '0'; - if ($value === null) return 'NULL'; - return "'" . str_replace("'", "''", (string)$value) . "'"; - } -} diff --git a/src/Schema/TypeMapper.php b/src/Schema/TypeMapper.php new file mode 100644 index 0000000..03acd91 --- /dev/null +++ b/src/Schema/TypeMapper.php @@ -0,0 +1,70 @@ +> + */ + protected array $types = [ + 'string' => ['type' => 'string', 'length' => 255], + 'text' => ['type' => 'text'], + 'integer' => ['type' => 'integer'], + 'bigInteger' => ['type' => 'bigInteger'], + 'float' => ['type' => 'float'], + 'decimal' => ['type' => 'decimal', 'precision' => 8, 'scale' => 2], + 'boolean' => ['type' => 'boolean'], + 'date' => ['type' => 'date'], + 'datetime' => ['type' => 'datetime'], + 'timestamp' => ['type' => 'timestamp'], + 'json' => ['type' => 'json'], + 'binary' => ['type' => 'binary'], + 'uuid' => ['type' => 'uuid'], + 'ulid' => ['type' => 'ulid'], + 'enum' => ['type' => 'enum'], + ]; + + /** + * Get the mapped properties for a type. + * + * @param string $type + * @return array + */ + public function getProperties(string $type): array + { + return $this->types[$type] ?? ['type' => $type]; + } + + /** + * Register a custom type mapping. + * + * @param string $name + * @param array $properties + * @return void + */ + public function register(string $name, array $properties): void + { + $this->types[$name] = $properties; + } + + /** + * Check if a type is registered. + * + * @param string $type + * @return bool + */ + public function has(string $type): bool + { + return isset($this->types[$type]); + } +} diff --git a/src/Schema/YamlSchemaParser.php b/src/Schema/YamlSchemaParser.php new file mode 100644 index 0000000..4c6ad2a --- /dev/null +++ b/src/Schema/YamlSchemaParser.php @@ -0,0 +1,188 @@ +trans('error.schema_file_not_found', ['path' => $filePath]), 0, null, ['path' => $filePath]); + } + + try { + $data = Yaml::parseFile($filePath); + } catch (ParseException $e) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new SchemaException($translator->trans('error.schema_parse_failed', ['path' => $filePath, 'message' => $e->getMessage()]), 0, $e, ['path' => $filePath]); + } + + $tableName = pathinfo($filePath, PATHINFO_FILENAME); + + return $this->parse($tableName, $data); + } + + /** + * Parse a raw YAML string into a Blueprint. + * + * @param string $tableName + * @param string $yamlString + * @return Blueprint + * @throws SchemaException + */ + public function parseYaml(string $tableName, string $yamlString): Blueprint + { + try { + $data = Yaml::parse($yamlString); + } catch (ParseException $e) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new SchemaException($translator->trans('error.schema_parse_failed', ['path' => 'raw_yaml', 'message' => $e->getMessage()]), 0, $e); + } + + if (!is_array($data)) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new SchemaException($translator->trans('error.schema_invalid_content')); + } + + return $this->parse($tableName, $data); + } + + /** + * Parse raw YAML data into a Blueprint. + * + * @param string $tableName + * @param array $data + * @return Blueprint + * @throws SchemaException + */ + public function parse(string $tableName, array $data): Blueprint + { + $builder = new Builder($tableName, $this->typeMapper); + $blueprint = $builder->getBlueprint(); + + $this->parseOptions($blueprint, $data); + $this->parseColumns($builder, $data['columns'] ?? []); + $this->parseIndexes($blueprint, $data['indexes'] ?? []); + $this->parseRelations($blueprint, $data['relations'] ?? []); + + return $blueprint; + } + + /** + * Parse table options from YAML data. + * + * @param Blueprint $blueprint + * @param array $data + * @return void + */ + protected function parseOptions(Blueprint $blueprint, array $data): void + { + $options = [ + 'prefix', 'tenancy', 'inheritance', 'morph', + 'timestamps', 'softDeletes', 'auditable', 'view', 'locking' + ]; + + foreach ($options as $option) { + if (isset($data[$option])) { + $blueprint->setOption($option, $data[$option]); + } + } + } + + /** + * Parse columns from YAML data. + * + * @param Builder $builder + * @param array $columns + * @return void + * @throws SchemaException + */ + protected function parseColumns(Builder $builder, array $columns): void + { + foreach ($columns as $name => $definition) { + if (is_string($definition)) { + $builder->addColumn($name, $definition); + continue; + } + + if (!is_array($definition) || !isset($definition['type'])) { + $translator = new \Pairity\Translation\Translator(__DIR__ . '/../Translations'); + throw new SchemaException($translator->trans('error.schema_invalid_column', ['column' => $name]), 0, null, ['column' => $name]); + } + + $type = $definition['type']; + unset($definition['type']); + + $column = $builder->addColumn($name, $type, $definition); + + // Handle fluent-like attributes if they are present in the array + if (isset($definition['nullable'])) $column->nullable((bool)$definition['nullable']); + if (isset($definition['unique'])) $column->unique((bool)$definition['unique']); + if (isset($definition['primary'])) $column->primary((bool)$definition['primary']); + if (isset($definition['index'])) $column->index((bool)$definition['index']); + if (isset($definition['encrypted'])) $column->encrypted((bool)$definition['encrypted']); + if (isset($definition['unsigned'])) $column->unsigned((bool)$definition['unsigned']); + if (isset($definition['autoIncrement'])) $column->autoIncrement((bool)$definition['autoIncrement']); + if (isset($definition['default'])) $column->default($definition['default']); + } + } + + /** + * Parse indexes from YAML data. + * + * @param Blueprint $blueprint + * @param array $indexes + * @return void + */ + protected function parseIndexes(Blueprint $blueprint, array $indexes): void + { + foreach ($indexes as $name => $columns) { + $blueprint->addIndex($name, (array) $columns); + } + } + + /** + * Parse relations from YAML data. + * + * @param Blueprint $blueprint + * @param array $relations + * @return void + */ + protected function parseRelations(Blueprint $blueprint, array $relations): void + { + foreach ($relations as $name => $definition) { + $blueprint->addRelation($name, (array) $definition); + } + } +} diff --git a/src/Stubs/DAO.stub b/src/Stubs/DAO.stub new file mode 100644 index 0000000..30b7a3b --- /dev/null +++ b/src/Stubs/DAO.stub @@ -0,0 +1,194 @@ +query()->update($values); + } + + /** + * Delete records from the table that match the current query constraints. + * + * @return int + */ + public function massDelete(): int + { + return $this->query()->delete(); + } + +{{scopes}} + + /** + * Find a record by its primary key. + * + * @param mixed $id + * @return {{dto_class}}|null + */ + public function find(mixed $id): ?{{dto_class}} + { + if ($cached = $this->identityMap->get({{dto_class}}::class, $id)) { + return $cached; + } + + $result = $this->getConnection() + ->select("SELECT * FROM {$this->table} WHERE {$this->primaryKey} = ?", [$id]); + + if (empty($result)) { + return null; + } + + $dto = new {{dto_class}}($result[0]); + $dto->setDao($this); + $this->identityMap->add({{dto_class}}::class, $id, $dto); + + $this->fireModelEvent('retrieved', $dto, false); + + return $dto; + } + + /** + * Save a DTO instance. + * + * @param {{dto_class}} $dto + * @return bool + */ + public function save({{dto_class}} $dto): bool + { + if ($this->fireModelEvent('saving', $dto) === false) { + return false; + } + + $data = $dto->toArray(); + $primaryKeyValue = $data[$this->primaryKey] ?? null; + + if ($primaryKeyValue === null) { + $result = $this->insert($dto); + } else { + $result = $this->update($dto); + } + + if ($result) { + $this->fireModelEvent('saved', $dto, false); + } + + return $result; + } + + /** + * Insert a new record. + * + * @param {{dto_class}} $dto + * @return bool + */ + protected function insert({{dto_class}} $dto): bool + { + if ($this->fireModelEvent('creating', $dto) === false) { + return false; + } + + $data = $dto->toArray(); + $columns = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + + $sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})"; + + $result = $this->getConnection()->execute($sql, array_values($data)) > 0; + + if ($result) { + $this->fireModelEvent('created', $dto, false); + } + + return $result; + } + + /** + * Update an existing record. + * + * @param {{dto_class}} $dto + * @return bool + */ + protected function update({{dto_class}} $dto): bool + { + if ($this->fireModelEvent('updating', $dto) === false) { + return false; + } + + $data = $dto->toArray(); + $id = $data[$this->primaryKey] ?? null; + $sets = []; + $values = []; + + foreach ($data as $column => $value) { + if ($column === $this->primaryKey) continue; + $sets[] = "{$column} = ?"; + $values[] = $value; + } + + $values[] = $id; + $sql = "UPDATE {$this->table} SET " . implode(', ', $sets) . " WHERE {$this->primaryKey} = ?"; + + $result = $this->getConnection()->execute($sql, $values) > 0; + + if ($result) { + $this->fireModelEvent('updated', $dto, false); + } + + return $result; + } + + /** + * Delete a record by its primary key. + * + * @param {{dto_class}} $dto + * @return int + */ + public function deleteModel({{dto_class}} $dto): int + { + if ($this->fireModelEvent('deleting', $dto) === false) { + return 0; + } + + $id = $dto->toArray()[$this->primaryKey] ?? null; + $result = $this->delete($id); + + if ($result) { + $this->fireModelEvent('deleted', $dto, false); + } + + return $result; + } +} diff --git a/src/Stubs/DTO.stub b/src/Stubs/DTO.stub new file mode 100644 index 0000000..be2e591 --- /dev/null +++ b/src/Stubs/DTO.stub @@ -0,0 +1,58 @@ + $attributes + */ + public function __construct(array $attributes = []) + { + parent::__construct($attributes); +{{constructor}} + } + +{{methods}} + + /** + * @param string $name + * @return mixed + */ + public function __get(string $name) + { + $this->ensureLoaded(); + + if (property_exists($this, $name)) { + return $this->{$name}; + } + + return $this->getRelation($name); + } + + /** + * Convert the DTO to an array. + * + * @return array + */ + public function toArray(): array + { + $this->ensureLoaded(); + return [ +{{toArray}} + ]; + } +} diff --git a/src/Stubs/Hydrator.stub b/src/Stubs/Hydrator.stub new file mode 100644 index 0000000..6ae3122 --- /dev/null +++ b/src/Stubs/Hydrator.stub @@ -0,0 +1,33 @@ + + */ + protected static array $reflectionProperties = []; + + /** + * Hydrate the given DTO instance. + * + * @param array $data + * @param object $instance + * @return void + */ + public function hydrate(array $data, object $instance): void + { +{{hydration_logic}} + } +} diff --git a/src/Translation/Translator.php b/src/Translation/Translator.php new file mode 100644 index 0000000..eda5e12 --- /dev/null +++ b/src/Translation/Translator.php @@ -0,0 +1,115 @@ +> Loaded translations indexed by locale. + */ + protected array $translations = []; + + /** + * @var string The current locale. + */ + protected string $locale; + + /** + * Translator constructor. + * + * @param string $translationsPath The path to the translations directory. + * @param string $defaultLocale The default locale to use. + */ + public function __construct( + protected string $translationsPath, + string $defaultLocale = 'en' + ) { + $this->locale = getenv('PAIRITY_LOCALE') ?: $defaultLocale; + } + + /** + * @inheritDoc + */ + public function trans(string $key, array $replace = [], ?string $locale = null): string + { + $locale = $locale ?: $this->locale; + + $this->loadLocale($locale); + + $message = $this->translations[$locale][$key] ?? $key; + + if (empty($replace)) { + return $message; + } + + foreach ($replace as $placeholder => $value) { + $message = str_replace('{' . $placeholder . '}', (string) $value, $message); + } + + return $message; + } + + /** + * @inheritDoc + */ + public function getLocale(): string + { + return $this->locale; + } + + /** + * @inheritDoc + */ + public function setLocale(string $locale): void + { + $this->locale = $locale; + } + + /** + * Load the translation file for the given locale. + * + * @param string $locale + * @return void + * @throws RuntimeException If the translation file cannot be found. + */ + protected function loadLocale(string $locale): void + { + if (isset($this->translations[$locale])) { + return; + } + + $filePath = rtrim($this->translationsPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $locale . '.php'; + + if (!file_exists($filePath)) { + // Fallback to English if the requested locale is not found + if ($locale !== 'en') { + $this->loadLocale('en'); + $this->translations[$locale] = $this->translations['en']; + return; + } + + throw new RuntimeException("Translation file for locale '{$locale}' not found at '{$filePath}'."); + } + + $translations = include $filePath; + + if (!is_array($translations)) { + throw new RuntimeException("Translation file '{$filePath}' must return an array."); + } + + $this->translations[$locale] = $translations; + } +} diff --git a/src/Translations/de.php b/src/Translations/de.php new file mode 100644 index 0000000..5b42822 --- /dev/null +++ b/src/Translations/de.php @@ -0,0 +1,64 @@ + '{name} Version {version}', + 'app.usage' => "\nVerwendung:\n pairity [Befehl] [Argumente] [Optionen]\n\n", + 'app.available_commands' => 'Verfügbare Befehle:', + + // Fehler + 'error.command_not_found' => "Befehl '{command}' nicht gefunden.", + 'error.uncaught_exception' => 'Unbehandelte Ausnahme: {message}', + 'error.unconstrained_query' => 'Eingeschränkte {operation}-Abfragen sind per Konfiguration deaktiviert.', + 'error.invalid_binding_type' => 'Ungültiger Bindungstyp: {type}.', + 'error.method_not_found' => 'Methode [{method}] existiert nicht im Builder oder dem zugehörigen DAO.', + 'error.optimistic_lock_failed' => 'Optimistisches Sperren fehlgeschlagen für Tabelle [{table}] und ID [{id}].', + 'error.uow_commit_failed' => 'Fehler beim Bestätigen der Arbeitseinheit: {message}', + 'error.uow_no_dao' => 'Arbeitseinheit kann nicht ohne zugehöriges DAO bestätigt werden.', + 'error.schema_file_not_found' => 'Schemadatei [{path}] nicht gefunden.', + 'error.schema_parse_failed' => 'Fehler beim Parsen der YAML-Datei [{path}]: {message}', + 'error.schema_invalid_content' => 'YAML-Inhalt muss ein assoziatives Array sein.', + 'error.schema_invalid_column' => 'Ungültige Spaltendefinition für [{column}]. \'type\' ist erforderlich.', + 'error.container_not_set' => 'Container-Instanz im DatabaseManager nicht gesetzt.', + 'error.driver_not_supported' => 'Treiber [{driver}] nicht unterstützt.', + 'error.upsert_not_supported' => 'Upsert wird von diesem Treiber nicht unterstützt.', + 'error.container_class_not_found' => 'Zielklasse [{class}] existiert nicht.', + 'error.container_not_instantiable' => 'Zielklasse [{class}] ist nicht instanziierbar.', + 'error.container_unresolvable' => 'Nicht auflösbare Abhängigkeit [{parameter}] in Klasse {class}.', + 'error.relation_not_found' => 'Beziehung [{relation}] im DAO nicht gefunden.', + 'error.metadata_cache_failed' => 'Fehler beim Cachen der Metadaten für [{key}].', + + // Validierung + 'validation.failed' => 'Validierung fehlgeschlagen.', + + // Datenbank-Gesundheitscheck + 'command.init.description' => 'Pairity-Projektstruktur initialisieren.', + 'command.db_health_check.description' => 'Überprüfen der Datenbankverbindungsgesundheit.', + 'command.db_health_check.checking' => 'Überprüfe Gesundheit für Verbindung: {name}...', + 'command.db_health_check.success' => 'Verbindung {name} ist gesund.', + 'command.db_health_check.failed' => 'Verbindung {name} ist ungesund.', + 'command.db_health_check.error' => 'Fehler beim Überprüfen der Gesundheit: {message}', + + // Metadaten und Cache + 'command.schema_lint.description' => 'Pairity YAML-Tabellendefinitionen prüfen.', + 'command.schema_json.description' => 'JSON-Schema für Pairity YAML-Tabellendefinitionen generieren.', + 'command.schema_snapshot.description' => 'Aktuelle YAML-Datenquelle in einen PHP-Snapshot exportieren.', + 'command.schema_snapshot.starting' => 'Generiere Schema-Snapshots...', + 'command.schema_snapshot.finished' => 'Snapshot-Generierung erfolgreich abgeschlossen.', + 'command.db_seed.description' => 'Datenbank mit Datensätzen füllen.', + 'command.make_seeder.description' => 'Eine neue Seeder-Klasse erstellen.', + 'command.make_factory.description' => 'Eine neue Factory-Klasse für ein Modell erstellen.', + 'command.make_migration.description' => 'Eine neue manuelle Migrationsdatei erstellen.', + 'command.migration_data.description' => 'Prozedurale PHP-Datenmigrationen ausführen.', + 'command.make_yaml_fromdb.description' => 'Datenbank zurückentwickeln, um YAML-Schemas zu generieren.', + 'command.db_check_sync.description' => 'Synchronisation von Migrationsdateien und Seedern prüfen.', + 'command.cache_clear.description' => 'Pairity-Metadaten-Cache leeren.', + 'command.cache_clear.starting' => 'Leere Metadaten-Cache...', + 'command.cache_clear.success' => 'Metadaten-Cache erfolgreich geleert.', + 'command.cache_clear.error' => 'Fehler beim Leeren des Metadaten-Caches.', + + // Codegenerierung + 'command.make_model.description' => 'DTO- und DAO-Klassen aus YAML-Definitionen generieren.', + 'command.make_model.starting' => 'Generiere DTO- und DAO-Klassen...', + 'command.make_model.finished' => 'Codegenerierung erfolgreich abgeschlossen.', +]; diff --git a/src/Translations/en.php b/src/Translations/en.php new file mode 100644 index 0000000..f2b08ad --- /dev/null +++ b/src/Translations/en.php @@ -0,0 +1,64 @@ + '{name} version {version}', + 'app.usage' => "\nUsage:\n pairity [command] [arguments] [options]\n\n", + 'app.available_commands' => 'Available commands:', + + // Errors + 'error.command_not_found' => "Command '{command}' not found.", + 'error.uncaught_exception' => 'Uncaught Exception: {message}', + 'error.unconstrained_query' => 'Unconstrained {operation} queries are disabled by configuration.', + 'error.invalid_binding_type' => 'Invalid binding type: {type}.', + 'error.method_not_found' => 'Method [{method}] does not exist on Builder or its associated DAO.', + 'error.optimistic_lock_failed' => 'Optimistic locking failed for table [{table}] and ID [{id}].', + 'error.uow_commit_failed' => 'Failed to commit Unit of Work: {message}', + 'error.uow_no_dao' => 'Cannot commit DTO without an associated DAO.', + 'error.schema_file_not_found' => 'Schema file [{path}] not found.', + 'error.schema_parse_failed' => 'Failed to parse YAML file [{path}]: {message}', + 'error.schema_invalid_content' => 'YAML content must be an associative array.', + 'error.schema_invalid_column' => 'Invalid column definition for [{column}]. \'type\' is required.', + 'error.container_not_set' => 'Container instance not set on DatabaseManager.', + 'error.driver_not_supported' => 'Driver [{driver}] not supported.', + 'error.upsert_not_supported' => 'Upsert is not supported by this driver.', + 'error.container_class_not_found' => 'Target class [{class}] does not exist.', + 'error.container_not_instantiable' => 'Target class [{class}] is not instantiable.', + 'error.container_unresolvable' => 'Unresolvable dependency [{parameter}] in class {class}.', + 'error.relation_not_found' => 'Relationship [{relation}] not found on DAO.', + 'error.metadata_cache_failed' => 'Failed to cache metadata for [{key}].', + + // Validation + 'validation.failed' => 'Validation failed.', + + // Database Health Check + 'command.init.description' => 'Initialize the Pairity project structure.', + 'command.db_health_check.description' => 'Verify database connection health and heartbeat.', + 'command.db_health_check.checking' => 'Checking health for connection: {name}...', + 'command.db_health_check.success' => 'Connection {name} is healthy.', + 'command.db_health_check.failed' => 'Connection {name} is unhealthy.', + 'command.db_health_check.error' => 'Error checking health: {message}', + + // Metadata & Caching + 'command.schema_lint.description' => 'Lint Pairity YAML table definitions in the schema directory.', + 'command.schema_json.description' => 'Generate the JSON Schema for Pairity YAML table definitions.', + 'command.schema_snapshot.description' => 'Export the current YAML source of truth into a PHP baseline snapshot.', + 'command.schema_snapshot.starting' => 'Generating schema snapshots...', + 'command.schema_snapshot.finished' => 'Snapshot generation completed successfully.', + 'command.db_seed.description' => 'Seed the database with records.', + 'command.make_seeder.description' => 'Create a new seeder class.', + 'command.make_factory.description' => 'Create a new factory class for a model.', + 'command.make_migration.description' => 'Create a new manual migration file for custom SQL or data changes.', + 'command.migration_data.description' => 'Execute procedural PHP data migrations.', + 'command.make_yaml_fromdb.description' => 'Reverse-engineer an existing database to generate YAML schema files.', + 'command.db_check_sync.description' => 'Verify synchronization of manual migration files and seed files.', + 'command.cache_clear.description' => 'Clear the Pairity metadata cache.', + 'command.cache_clear.starting' => 'Clearing metadata cache...', + 'command.cache_clear.success' => 'Metadata cache cleared successfully.', + 'command.cache_clear.error' => 'Failed to clear metadata cache.', + + // Code Generation + 'command.make_model.description' => 'Generate DTO and DAO classes from YAML schema definitions.', + 'command.make_model.starting' => 'Generating DTO and DAO classes...', + 'command.make_model.finished' => 'Code generation completed successfully.', +]; diff --git a/src/Translations/es.php b/src/Translations/es.php new file mode 100644 index 0000000..9a84fff --- /dev/null +++ b/src/Translations/es.php @@ -0,0 +1,64 @@ + '{name} versión {version}', + 'app.usage' => "\nUso:\n pairity [comando] [argumentos] [opciones]\n\n", + 'app.available_commands' => 'Comandos disponibles:', + + // Errores + 'error.command_not_found' => "Comando '{command}' no encontrado.", + 'error.uncaught_exception' => 'Excepción no capturada: {message}', + 'error.unconstrained_query' => 'Las consultas de {operation} sin restricciones están deshabilitadas por configuración.', + 'error.invalid_binding_type' => 'Tipo de vinculación inválido: {type}.', + 'error.method_not_found' => 'El método [{method}] no existe en el Builder o su DAO asociado.', + 'error.optimistic_lock_failed' => 'Error de bloqueo optimista para la tabla [{table}] e ID [{id}].', + 'error.uow_commit_failed' => 'Error al confirmar la Unidad de Trabajo: {message}', + 'error.uow_no_dao' => 'No se puede confirmar el DTO sin un DAO asociado.', + 'error.schema_file_not_found' => 'Archivo de esquema [{path}] no encontrado.', + 'error.schema_parse_failed' => 'Error al analizar el archivo YAML [{path}]: {message}', + 'error.schema_invalid_content' => 'El contenido YAML debe ser un array asociativo.', + 'error.schema_invalid_column' => 'Definición de columna inválida para [{column}]. Se requiere \'type\'.', + 'error.container_not_set' => 'Instancia del contenedor no establecida en DatabaseManager.', + 'error.driver_not_supported' => 'Controlador [{driver}] no soportado.', + 'error.upsert_not_supported' => 'Upsert no es soportado por este controlador.', + 'error.container_class_not_found' => 'La clase destino [{class}] no existe.', + 'error.container_not_instantiable' => 'La clase destino [{class}] no es instanciable.', + 'error.container_unresolvable' => 'Dependencia no resoluble [{parameter}] en la clase {class}.', + 'error.relation_not_found' => 'Relación [{relation}] no encontrada en el DAO.', + 'error.metadata_cache_failed' => 'Error al cachear metadatos para [{key}].', + + // Validación + 'validation.failed' => 'Validación fallida.', + + // Verificación de Salud de la Base de Datos + 'command.init.description' => 'Inicializar la estructura del proyecto Pairity.', + 'command.db_health_check.description' => 'Verificar la salud de la conexión a la base de datos.', + 'command.db_health_check.checking' => 'Verificando salud para la conexión: {name}...', + 'command.db_health_check.success' => 'La conexión {name} está sana.', + 'command.db_health_check.failed' => 'La conexión {name} no está sana.', + 'command.db_health_check.error' => 'Error al verificar la salud: {message}', + + // Metadatos y Caché + 'command.schema_lint.description' => 'Analizar definiciones de tabla YAML de Pairity.', + 'command.schema_json.description' => 'Generar el esquema JSON para definiciones YAML de Pairity.', + 'command.schema_snapshot.description' => 'Exportar la fuente de verdad YAML actual a una instantánea PHP.', + 'command.schema_snapshot.starting' => 'Generando instantáneas de esquema...', + 'command.schema_snapshot.finished' => 'Generación de instantánea completada con éxito.', + 'command.db_seed.description' => 'Sembrar la base de datos con registros.', + 'command.make_seeder.description' => 'Crear una nueva clase de sembrador.', + 'command.make_factory.description' => 'Crear una nueva clase de factoría para un modelo.', + 'command.make_migration.description' => 'Crear un nuevo archivo de migración manual.', + 'command.migration_data.description' => 'Ejecutar migraciones de datos PHP procedimentales.', + 'command.make_yaml_fromdb.description' => 'Ingeniería inversa de una base de datos para generar esquemas YAML.', + 'command.db_check_sync.description' => 'Verificar la sincronización de archivos de migración y semillas.', + 'command.cache_clear.description' => 'Limpiar el caché de metadatos de Pairity.', + 'command.cache_clear.starting' => 'Limpiando caché de metadatos...', + 'command.cache_clear.success' => 'Caché de metadatos limpiado con éxito.', + 'command.cache_clear.error' => 'Error al limpiar el caché de metadatos.', + + // Generación de Código + 'command.make_model.description' => 'Generar clases DTO y DAO a partir de definiciones YAML.', + 'command.make_model.starting' => 'Generando clases DTO y DAO...', + 'command.make_model.finished' => 'Generación de código completada con éxito.', +]; diff --git a/src/Translations/fr.php b/src/Translations/fr.php new file mode 100644 index 0000000..173ee45 --- /dev/null +++ b/src/Translations/fr.php @@ -0,0 +1,64 @@ + '{name} version {version}', + 'app.usage' => "\nUtilisation:\n pairity [commande] [arguments] [options]\n\n", + 'app.available_commands' => 'Commandes disponibles:', + + // Erreurs + 'error.command_not_found' => "Commande '{command}' non trouvée.", + 'error.uncaught_exception' => 'Exception non capturée: {message}', + 'error.unconstrained_query' => 'Les requêtes {operation} sans contraintes sont désactivées par configuration.', + 'error.invalid_binding_type' => 'Type de liaison invalide: {type}.', + 'error.method_not_found' => 'La méthode [{method}] n\'existe pas sur Builder ou son DAO associé.', + 'error.optimistic_lock_failed' => 'Échec du verrouillage optimiste pour la table [{table}] et l\'ID [{id}].', + 'error.uow_commit_failed' => 'Échec de la validation de l\'Unité de Travail: {message}', + 'error.uow_no_dao' => 'Impossible de valider le DTO sans un DAO associé.', + 'error.schema_file_not_found' => 'Fichier de schéma [{path}] non trouvé.', + 'error.schema_parse_failed' => 'Échec de l\'analyse du fichier YAML [{path}]: {message}', + 'error.schema_invalid_content' => 'Le contenu YAML doit être un tableau associatif.', + 'error.schema_invalid_column' => 'Définition de colonne invalide pour [{column}]. \'type\' est requis.', + 'error.container_not_set' => 'Instance du conteneur non définie sur DatabaseManager.', + 'error.driver_not_supported' => 'Pilote [{driver}] non supporté.', + 'error.upsert_not_supported' => 'Upsert n\'est pas supporté par ce pilote.', + 'error.container_class_not_found' => 'La classe cible [{class}] n\'existe pas.', + 'error.container_not_instantiable' => 'La classe cible [{class}] n\'est pas instanciable.', + 'error.container_unresolvable' => 'Dépendance non résoluble [{parameter}] dans la classe {class}.', + 'error.relation_not_found' => 'Relation [{relation}] non trouvée sur le DAO.', + 'error.metadata_cache_failed' => 'Échec de la mise en cache des métadonnées pour [{key}].', + + // Validation + 'validation.failed' => 'Échec de la validation.', + + // Vérification de l'état de la base de données + 'command.init.description' => 'Initialiser la structure du projet Pairity.', + 'command.db_health_check.description' => 'Vérifier la santé de la connexion à la base de données.', + 'command.db_health_check.checking' => 'Vérification de la santé pour la connexion: {name}...', + 'command.db_health_check.success' => 'La connexion {name} est saine.', + 'command.db_health_check.failed' => 'La connexion {name} est malsaine.', + 'command.db_health_check.error' => 'Erreur lors de la vérification de la santé: {message}', + + // Métadonnées et Cache + 'command.schema_lint.description' => 'Analyser les définitions de table YAML de Pairity.', + 'command.schema_json.description' => 'Générer le schéma JSON pour les définitions YAML de Pairity.', + 'command.schema_snapshot.description' => 'Exporter la source de vérité YAML actuelle vers un instantané PHP.', + 'command.schema_snapshot.starting' => 'Génération des instantanés de schéma...', + 'command.schema_snapshot.finished' => 'Génération d\'instantané terminée avec succès.', + 'command.db_seed.description' => 'Semer la base de données avec des enregistrements.', + 'command.make_seeder.description' => 'Créer une nouvelle classe de semeur.', + 'command.make_factory.description' => 'Créer une nouvelle classe d\'usine pour un modèle.', + 'command.make_migration.description' => 'Créer un nouveau fichier de migration manuel.', + 'command.migration_data.description' => 'Exécuter des migrations de données PHP procédurales.', + 'command.make_yaml_fromdb.description' => 'Inverser l\'ingénierie d\'une base de données pour générer des schémas YAML.', + 'command.db_check_sync.description' => 'Vérifier la synchronisation des fichiers de migration et des semeurs.', + 'command.cache_clear.description' => 'Effacer le cache des métadonnées de Pairity.', + 'command.cache_clear.starting' => 'Effacement du cache des métadonnées...', + 'command.cache_clear.success' => 'Cache des métadonnées effacé avec succès.', + 'command.cache_clear.error' => 'Échec de l\'effacement du cache des métadonnées.', + + // Génération de Code + 'command.make_model.description' => 'Générer des classes DTO et DAO à partir des définitions YAML.', + 'command.make_model.starting' => 'Génération des classes DTO et DAO...', + 'command.make_model.finished' => 'Génération de code terminée avec succès.', +]; diff --git a/src/Translations/it.php b/src/Translations/it.php new file mode 100644 index 0000000..8c3a297 --- /dev/null +++ b/src/Translations/it.php @@ -0,0 +1,64 @@ + '{name} versione {version}', + 'app.usage' => "\nUtilizzo:\n pairity [comando] [argomenti] [opzioni]\n\n", + 'app.available_commands' => 'Comandi disponibili:', + + // Errori + 'error.command_not_found' => "Comando '{command}' non trovato.", + 'error.uncaught_exception' => 'Eccezione non gestita: {message}', + 'error.unconstrained_query' => 'Le query {operation} senza restrizioni sono disabilitate dalla configurazione.', + 'error.invalid_binding_type' => 'Tipo di binding non valido: {type}.', + 'error.method_not_found' => 'Il metodo [{method}] non esiste nel Builder o nel suo DAO associato.', + 'error.optimistic_lock_failed' => 'Blocco ottimistico fallito per la tabella [{table}] e ID [{id}].', + 'error.uow_commit_failed' => 'Salvataggio dell\'Unità di Lavoro fallito: {message}', + 'error.uow_no_dao' => 'Impossibile salvare il DTO senza un DAO associato.', + 'error.schema_file_not_found' => 'File di schema [{path}] non trovato.', + 'error.schema_parse_failed' => 'Analisi del file YAML [{path}] fallita: {message}', + 'error.schema_invalid_content' => 'Il contenuto YAML deve essere un array associativo.', + 'error.schema_invalid_column' => 'Definizione di colonna non valida per [{column}]. \'type\' è richiesto.', + 'error.container_not_set' => 'Istanza del container non impostata in DatabaseManager.', + 'error.driver_not_supported' => 'Driver [{driver}] non supportato.', + 'error.upsert_not_supported' => 'Upsert non è supportato da questo driver.', + 'error.container_class_not_found' => 'Classe di destinazione [{class}] non trovata.', + 'error.container_not_instantiable' => 'Classe di destinazione [{class}] non istanziabile.', + 'error.container_unresolvable' => 'Dipendenza non risolvibile [{parameter}] nella classe {class}.', + 'error.relation_not_found' => 'Relazione [{relation}] non trovata nel DAO.', + 'error.metadata_cache_failed' => 'Salvataggio in cache dei metadati fallito per [{key}].', + + // Validazione + 'validation.failed' => 'Validazione fallita.', + + // Controllo Salute Database + 'command.init.description' => 'Inizializzare la struttura del progetto Pairity.', + 'command.db_health_check.description' => 'Verifica la salute della connessione al database.', + 'command.db_health_check.checking' => 'Verifica salute per la connessione: {name}...', + 'command.db_health_check.success' => 'La connessione {name} è sana.', + 'command.db_health_check.failed' => 'La connessione {name} non è sana.', + 'command.db_health_check.error' => 'Errore durante la verifica della salute: {message}', + + // Metadati e Cache + 'command.schema_lint.description' => 'Analizza definizioni di tabella YAML di Pairity.', + 'command.schema_json.description' => 'Genera lo schema JSON per le definizioni YAML di Pairity.', + 'command.schema_snapshot.description' => 'Esporta la sorgente YAML attuale in uno snapshot PHP.', + 'command.schema_snapshot.starting' => 'Generazione snapshot dello schema...', + 'command.schema_snapshot.finished' => 'Generazione snapshot completata con successo.', + 'command.db_seed.description' => 'Popola il database con record.', + 'command.make_seeder.description' => 'Crea una nuova classe seeder.', + 'command.make_factory.description' => 'Crea una nuova classe factory per un modello.', + 'command.make_migration.description' => 'Crea un nuovo file di migrazione manuale.', + 'command.migration_data.description' => 'Esegue migrazioni di dati PHP procedurali.', + 'command.make_yaml_fromdb.description' => 'Ingegneria inversa di un database per generare schemi YAML.', + 'command.db_check_sync.description' => 'Verifica la sincronizzazione di file di migrazione e seeder.', + 'command.cache_clear.description' => 'Pulisce la cache dei metadati di Pairity.', + 'command.cache_clear.starting' => 'Pulizia cache dei metadati...', + 'command.cache_clear.success' => 'Cache dei metadati pulita con successo.', + 'command.cache_clear.error' => 'Pulizia cache dei metadati fallita.', + + // Generazione Codice + 'command.make_model.description' => 'Genera classi DTO e DAO dalle definizioni YAML.', + 'command.make_model.starting' => 'Generazione classi DTO e DAO...', + 'command.make_model.finished' => 'Generazione codice completata con successo.', +]; diff --git a/tests/BelongsToManyMysqlTest.php b/tests/BelongsToManyMysqlTest.php deleted file mode 100644 index f0f1f1d..0000000 --- a/tests/BelongsToManyMysqlTest.php +++ /dev/null @@ -1,125 +0,0 @@ -markTestSkipped('MYSQL_HOST not set; skipping MySQL belongsToMany test'); - } - return [ - 'driver' => 'mysql', - 'host' => $host, - 'port' => (int)(getenv('MYSQL_PORT') ?: 3306), - 'database' => getenv('MYSQL_DB') ?: 'pairity', - 'username' => getenv('MYSQL_USER') ?: 'root', - 'password' => getenv('MYSQL_PASS') ?: 'root', - 'charset' => 'utf8mb4', - ]; - } - - public function testBelongsToManyEagerAndHelpers(): void - { - $cfg = $this->mysqlConfig(); - $conn = ConnectionManager::make($cfg); - $schema = SchemaManager::forConnection($conn); - - // unique table names per run - $suffix = substr(sha1((string)microtime(true)), 0, 6); - $usersT = 'u_btm_' . $suffix; - $rolesT = 'r_btm_' . $suffix; - $pivotT = 'ur_btm_' . $suffix; - - // Create tables - $schema->create($usersT, function (Blueprint $t) { $t->increments('id'); $t->string('email', 190); }); - $schema->create($rolesT, function (Blueprint $t) { $t->increments('id'); $t->string('name', 190); }); - $conn->execute("CREATE TABLE `{$pivotT}` (user_id INT NOT NULL, role_id INT NOT NULL)"); - - // DTOs - $UserDto = new class([]) extends AbstractDto {}; - $RoleDto = new class([]) extends AbstractDto {}; - $userDto = get_class($UserDto); $roleDto = get_class($RoleDto); - - // DAOs (constructors accept only connection; runtime configuration via static props) - $RoleDao = new class($conn) extends AbstractDao { - public static string $table; public static string $dto; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return self::$table; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - - $UserDao = new class($conn) extends AbstractDao { - public static string $table; public static string $dto; public static string $roleDaoClass; public static string $pivot; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return self::$table; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { - return [ - 'roles' => [ - 'type' => 'belongsToMany', - 'dao' => self::$roleDaoClass, - 'pivot' => self::$pivot, - 'foreignPivotKey' => 'user_id', - 'relatedPivotKey' => 'role_id', - 'localKey' => 'id', - 'relatedKey' => 'id', - ], - ]; - } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; } - }; - - // Configure static props for DAOs - $roleDaoClass = get_class($RoleDao); - $roleDaoClass::$table = $rolesT; - $roleDaoClass::$dto = $roleDto; - - $userDaoClass = get_class($UserDao); - $userDaoClass::$table = $usersT; - $userDaoClass::$dto = $userDto; - $userDaoClass::$roleDaoClass = $roleDaoClass; - $userDaoClass::$pivot = $pivotT; - - $roleDao = new $roleDaoClass($conn); - $userDao = new $userDaoClass($conn); - - // Seed - $u = $userDao->insert(['email' => 'b@example.com']); - $uid = (int)($u->toArray(false)['id'] ?? 0); - $r1 = $roleDao->insert(['name' => 'admin']); - $r2 = $roleDao->insert(['name' => 'editor']); - $rid1 = (int)($r1->toArray(false)['id'] ?? 0); $rid2 = (int)($r2->toArray(false)['id'] ?? 0); - - $userDao->attach('roles', $uid, [$rid1, $rid2]); - - $loaded = $userDao->with(['roles'])->findById($uid); - $this->assertNotNull($loaded); - $this->assertCount(2, $loaded->toArray(false)['roles'] ?? []); - - $userDao->detach('roles', $uid, [$rid1]); - $re = $userDao->with(['roles'])->findById($uid); - $this->assertCount(1, $re->toArray(false)['roles'] ?? []); - - $userDao->sync('roles', $uid, [$rid2]); - $re2 = $userDao->with(['roles'])->findById($uid); - $this->assertCount(1, $re2->toArray(false)['roles'] ?? []); - - // Cleanup - $schema->drop($usersT); - $schema->drop($rolesT); - $conn->execute('DROP TABLE `' . $pivotT . '`'); - } -} diff --git a/tests/BelongsToManySqliteTest.php b/tests/BelongsToManySqliteTest.php deleted file mode 100644 index 38f7ff4..0000000 --- a/tests/BelongsToManySqliteTest.php +++ /dev/null @@ -1,102 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testBelongsToManyEagerAndPivotHelpers(): void - { - $conn = $this->conn(); - // schema - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT)'); - $conn->execute('CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - $conn->execute('CREATE TABLE user_role (user_id INTEGER, role_id INTEGER)'); - - // DTOs - $UserDto = new class([]) extends AbstractDto {}; - $RoleDto = new class([]) extends AbstractDto {}; - $userDtoClass = get_class($UserDto); - $roleDtoClass = get_class($RoleDto); - - // DAOs - $RoleDao = new class($conn) extends AbstractDao { - public static string $dto; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'roles'; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - $roleDaoClass = get_class($RoleDao); - $roleDaoClass::$dto = $roleDtoClass; - - $UserDao = new class($conn) extends AbstractDao { - public static string $dto; public static string $roleDaoClass; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; } - protected function relations(): array { - return [ - 'roles' => [ - 'type' => 'belongsToMany', - 'dao' => self::$roleDaoClass, - 'pivot' => 'user_role', - 'foreignPivotKey' => 'user_id', - 'relatedPivotKey' => 'role_id', - 'localKey' => 'id', - 'relatedKey' => 'id', - ], - ]; - } - }; - - $userDaoClass = get_class($UserDao); - $userDaoClass::$dto = $userDtoClass; - $userDaoClass::$roleDaoClass = $roleDaoClass; - - $roleDao = new $roleDaoClass($conn); - $userDao = new $userDaoClass($conn); - - // seed - $u = $userDao->insert(['email' => 'p@example.com']); - $uid = (int)($u->toArray(false)['id'] ?? 0); - $r1 = $roleDao->insert(['name' => 'admin']); - $r2 = $roleDao->insert(['name' => 'editor']); - $rid1 = (int)($r1->toArray(false)['id'] ?? 0); - $rid2 = (int)($r2->toArray(false)['id'] ?? 0); - - // attach via helper - $userDao->attach('roles', $uid, [$rid1, $rid2]); - - // eager load roles - $loaded = $userDao->with(['roles'])->findById($uid); - $this->assertNotNull($loaded); - $roles = $loaded->toArray(false)['roles'] ?? []; - $this->assertIsArray($roles); - $this->assertCount(2, $roles); - - // detach one - $det = $userDao->detach('roles', $uid, [$rid1]); - $this->assertGreaterThanOrEqual(1, $det); - $reloaded = $userDao->with(['roles'])->findById($uid); - $this->assertCount(1, $reloaded->toArray(false)['roles'] ?? []); - - // sync to only [rid2] - $res = $userDao->sync('roles', $uid, [$rid2]); - $this->assertIsArray($res); - $synced = $userDao->with(['roles'])->findById($uid); - $this->assertCount(1, $synced->toArray(false)['roles'] ?? []); - } -} diff --git a/tests/CachingTest.php b/tests/CachingTest.php deleted file mode 100644 index 81d7dee..0000000 --- a/tests/CachingTest.php +++ /dev/null @@ -1,176 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - private function mockCache() - { - return new class implements CacheInterface { - private array $store = []; - public $hits = 0; - public $sets = 0; - public function get(string $key, mixed $default = null): mixed { - if (isset($this->store[$key])) { - $this->hits++; - return unserialize($this->store[$key]); - } - return $default; - } - public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool { - $this->sets++; - $this->store[$key] = serialize($value); - return true; - } - public function delete(string $key): bool { unset($this->store[$key]); return true; } - public function clear(): bool { $this->store = []; return true; } - public function getMultiple(iterable $keys, mixed $default = null): iterable { return []; } - public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool { return true; } - public function deleteMultiple(iterable $keys): bool { return true; } - public function has(string $key): bool { return isset($this->store[$key]); } - }; - } - - public function testCachingFindById(): void - { - $conn = $this->conn(); - $conn->execute('CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - - $dao = new class($conn) extends AbstractDao { - public function getTable(): string { return 'items'; } - protected function dtoClass(): string { return TestDto::class; } - }; - - $cache = $this->mockCache(); - $dao->setCache($cache); - - $created = $dao->insert(['name' => 'Item 1']); - $id = $created->id; - // insert() calls findById() internally to return the fresh DTO, so it might already be cached. - - $cache->hits = 0; // Reset hits for the test - - // First find - should be a cache hit now because of insert() - $item1 = $dao->findById($id); - $this->assertNotNull($item1); - $this->assertEquals(1, $cache->hits); - - // Second find - cache hit again - $item2 = $dao->findById($id); - $this->assertNotNull($item2); - $this->assertEquals(2, $cache->hits); - $this->assertEquals($item1->name, $item2->name); - // Note: item1 and item2 are different instances if not in UoW, - // because we serialize/unserialize in our mock cache. - $this->assertNotSame($item1, $item2); - - // Update - should invalidate cache - $dao->update($id, ['name' => 'Updated']); - $item3 = $dao->findById($id); - // How many hits now? - // findById checks cache for ID (miss) - // findById calls findOneBy - // findOneBy checks cache for criteria (miss) - $this->assertEquals('Updated', $item3->name); - } - - public function testIdentityMapIntegration(): void - { - $conn = $this->conn(); - $conn->execute('CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - - $dao = new class($conn) extends AbstractDao { - public function getTable(): string { return 'items'; } - protected function dtoClass(): string { return TestDto::class; } - }; - - $cache = $this->mockCache(); - $dao->setCache($cache); - - $created = $dao->insert(['name' => 'Item 1']); - $id = $created->id; - $cache->hits = 0; - - UnitOfWork::run(function() use ($dao, $id, $cache) { - $item1 = $dao->findById($id); // Cache hit (from insert), attaches to identity map - $item2 = $dao->findById($id); // UoW lookup (no cache hit recorded) - $this->assertEquals(1, $cache->hits); - $this->assertSame($item1, $item2); - }); - - // Outside UoW - $item3 = $dao->findById($id); // Cache hit - $this->assertEquals(2, $cache->hits); - - // Run another UoW, should hit cache but then return same instance from UoW - UnitOfWork::run(function() use ($dao, $id, $cache) { - $item4 = $dao->findById($id); // Cache hit, attached to UoW - $this->assertEquals(3, $cache->hits); - $item5 = $dao->findById($id); // UoW hit - $this->assertEquals(3, $cache->hits); - $this->assertSame($item4, $item5); - }); - } - - public function testFindAllCaching(): void - { - $conn = $this->conn(); - $conn->execute('CREATE TABLE items (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - - $dao = new class($conn) extends AbstractDao { - public function getTable(): string { return 'items'; } - protected function dtoClass(): string { return TestDto::class; } - }; - - $cache = $this->mockCache(); - $dao->setCache($cache); - - $dao->insert(['name' => 'A']); - $dao->insert(['name' => 'B']); - - $all1 = $dao->findAllBy([]); - $this->assertCount(2, $all1); - $this->assertEquals(0, $cache->hits); - - $all2 = $dao->findAllBy([]); - $this->assertCount(2, $all2); - $this->assertEquals(1, $cache->hits); - - // Test bulk invalidation - $dao->deleteBy(['name' => 'A']); // In our trait, this does nothing unless clear_all_on_bulk is true - - // Let's configure it to clear all - $dao = new class($conn) extends AbstractDao { - public function getTable(): string { return 'items'; } - protected function dtoClass(): string { return TestDto::class; } - public function cacheConfig(): array { - return array_merge(parent::cacheConfig(), ['clear_all_on_bulk' => true]); - } - }; - $dao->setCache($cache); - - $dao->findAllBy([]); // missed again because it's a new DAO instance/key prefix - $dao->findAllBy([]); // hit - $this->assertGreaterThanOrEqual(1, $cache->hits); - - $dao->deleteBy(['name' => 'A']); - $all3 = $dao->findAllBy([]); // miss (invalidated) - $this->assertCount(1, $all3); - } -} diff --git a/tests/CastersAndAccessorsSqliteTest.php b/tests/CastersAndAccessorsSqliteTest.php deleted file mode 100644 index dbed6e4..0000000 --- a/tests/CastersAndAccessorsSqliteTest.php +++ /dev/null @@ -1,94 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testCustomCasterAndDtoAccessorsMutators(): void - { - $conn = $this->conn(); - // simple schema - $conn->execute('CREATE TABLE widgets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - price_cents INTEGER, - meta TEXT - )'); - - // Custom caster for money cents <-> Money object (array for simplicity) - $moneyCasterClass = new class implements CasterInterface { - public function fromStorage(mixed $value): mixed { return ['cents' => (int)$value]; } - public function toStorage(mixed $value): mixed { - if (is_array($value) && isset($value['cents'])) { return (int)$value['cents']; } - return (int)$value; - } - }; - $moneyCasterFqcn = get_class($moneyCasterClass); - - // DTO with accessor/mutator for name (capitalize on get, trim on set) - $Dto = new class([]) extends AbstractDto { - protected function getNameAttribute($value): mixed { return is_string($value) ? strtoupper($value) : $value; } - protected function setNameAttribute($value): mixed { return is_string($value) ? trim($value) : $value; } - }; - $dtoClass = get_class($Dto); - - $Dao = new class($conn, $dtoClass, $moneyCasterFqcn) extends AbstractDao { - private string $dto; private string $caster; - public function __construct($c, string $dto, string $caster) { parent::__construct($c); $this->dto = $dto; $this->caster = $caster; } - public function getTable(): string { return 'widgets'; } - protected function dtoClass(): string { return $this->dto; } - protected function schema(): array - { - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'name' => ['cast' => 'string'], - 'price_cents' => ['cast' => $this->caster], // custom caster - 'meta' => ['cast' => 'json'], - ], - ]; - } - }; - - $dao = new $Dao($conn, $dtoClass, $moneyCasterFqcn); - - // Insert with mutator (name will be trimmed) and caster (price array -> storage int) - $created = $dao->insert([ - 'name' => ' gizmo ', - 'price_cents' => ['cents' => 1234], - 'meta' => ['color' => 'red'] - ]); - $arr = $created->toArray(false); - $this->assertSame('GIZMO', $arr['name']); // accessor uppercases - $this->assertIsArray($arr['price_cents']); - $this->assertSame(1234, $arr['price_cents']['cents']); // fromStorage via caster - $this->assertSame('red', $arr['meta']['color']); - - $id = $arr['id']; - - // Update with caster value - $updated = $dao->update($id, ['price_cents' => ['cents' => 1999]]); - $this->assertSame(1999, $updated->toArray(false)['price_cents']['cents']); - - // Verify raw storage is int (select directly) - $raw = $conn->query('SELECT price_cents, meta, name FROM widgets WHERE id = :id', ['id' => $id])[0] ?? []; - $this->assertSame(1999, (int)$raw['price_cents']); - $this->assertIsString($raw['meta']); - // Raw storage may preserve whitespace; DTO mutator trims on set for DTO, not necessarily at storage layer - $this->assertSame('gizmo', strtolower(trim((string)$raw['name']))); - } -} diff --git a/tests/EventSystemSqliteTest.php b/tests/EventSystemSqliteTest.php deleted file mode 100644 index 2258524..0000000 --- a/tests/EventSystemSqliteTest.php +++ /dev/null @@ -1,117 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testDaoEventsForInsertUpdateDeleteAndFind(): void - { - $conn = $this->conn(); - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, status TEXT)'); - - $Dto = new class([]) extends AbstractDto {}; - $dtoClass = get_class($Dto); - $Dao = new class($conn, $dtoClass) extends AbstractDao { - private string $dto; public function __construct($c,string $d){ parent::__construct($c); $this->dto=$d; } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return $this->dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; } - }; - $dao = new $Dao($conn, $dtoClass); - - $beforeInsertData = null; $afterInsertName = null; $afterUpdateName = null; $afterDeleteAffected = null; $afterFindCount = null; - - Events::dispatcher()->clear(); - Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) use (&$beforeInsertData) { - if (($p['table'] ?? '') === 'users') { - // mutate data - $p['data']['status'] = 'mutated'; - $beforeInsertData = $p['data']; - } - }); - Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$afterInsertName) { - if (($p['table'] ?? '') === 'users' && $p['dto'] instanceof AbstractDto) { - $afterInsertName = $p['dto']->toArray(false)['name'] ?? null; - } - }); - Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$afterUpdateName) { - if (($p['table'] ?? '') === 'users' && $p['dto'] instanceof AbstractDto) { - $afterUpdateName = $p['dto']->toArray(false)['name'] ?? null; - } - }); - Events::dispatcher()->listen('dao.afterDelete', function(array &$p) use (&$afterDeleteAffected) { - if (($p['table'] ?? '') === 'users') { $afterDeleteAffected = (int)($p['affected'] ?? 0); } - }); - Events::dispatcher()->listen('dao.afterFind', function(array &$p) use (&$afterFindCount) { - if (($p['table'] ?? '') === 'users') { - if (isset($p['dto'])) { $afterFindCount = ($p['dto'] ? 1 : 0); } - if (isset($p['dtos'])) { $afterFindCount = is_array($p['dtos']) ? count($p['dtos']) : 0; } - } - }); - - // Insert (beforeInsert should set status) - $created = $dao->insert(['name' => 'Alice']); - $arr = $created->toArray(false); - $this->assertSame('mutated', $arr['status'] ?? null); - $this->assertSame('Alice', $afterInsertName); - - // Update - $id = (int)$arr['id']; - $updated = $dao->update($id, ['name' => 'Alice2']); - $this->assertSame('Alice2', $afterUpdateName); - - // Find - $one = $dao->findById($id); - $this->assertSame(1, $afterFindCount); - - // Delete - $aff = $dao->deleteById($id); - $this->assertSame($aff, $afterDeleteAffected); - } - - public function testUowBeforeAfterCommitEvents(): void - { - $conn = $this->conn(); - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - - $Dto = new class([]) extends AbstractDto {}; - $dtoClass = get_class($Dto); - $Dao = new class($conn, $dtoClass) extends AbstractDao { - private string $dto; public function __construct($c,string $d){ parent::__construct($c); $this->dto=$d; } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return $this->dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - $dao = new $Dao($conn, $dtoClass); - - $before = 0; $after = 0; - Events::dispatcher()->clear(); - Events::dispatcher()->listen('uow.beforeCommit', function(array &$p) use (&$before) { $before++; }); - Events::dispatcher()->listen('uow.afterCommit', function(array &$p) use (&$after) { $after++; }); - - $row = $dao->insert(['name' => 'X']); - $id = (int)($row->toArray(false)['id'] ?? 0); - - UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) { - $dao->update($id, ['name' => 'Y']); - $dao->deleteBy(['id' => $id]); - }); - - $this->assertSame(1, $before); - $this->assertSame(1, $after); - } -} diff --git a/tests/Feature/Database/Query/DaoQueryTest.php b/tests/Feature/Database/Query/DaoQueryTest.php new file mode 100644 index 0000000..82101af --- /dev/null +++ b/tests/Feature/Database/Query/DaoQueryTest.php @@ -0,0 +1,97 @@ +id = $attributes['id'] ?? null; + $this->name = $attributes['name'] ?? null; + $this->email = $attributes['email'] ?? null; + } +} + +class UserFeatureDAO extends BaseDAO { + protected string $table = 'users'; + protected ?string $dtoClass = UserFeatureDTO::class; + + public function scopeActive($builder) { + return $builder->where('status', 'active'); + } +} + +class DaoQueryTest extends TestCase +{ + protected $db; + protected $dao; + + protected function setUp(): void + { + $config = [ + 'default' => 'default', + 'connections' => [ + 'default' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + ], + ]; + $this->db = new DatabaseManager($config); + $this->db->connection()->execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT, status TEXT)'); + + $identityMap = new IdentityMap(); + $this->dao = new UserFeatureDAO($this->db, $identityMap); + } + + public function test_it_executes_queries_via_dao() + { + $this->db->connection()->execute('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', ['Alice', 'alice@example.com', 'active']); + $this->db->connection()->execute('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', ['Bob', 'bob@example.com', 'inactive']); + + $users = $this->dao->query()->get(); + $this->assertCount(2, $users); + $this->assertInstanceOf(UserFeatureDTO::class, $users[0]); + $this->assertEquals('Alice', $users[0]->name); + } + + public function test_it_uses_scopes() + { + $this->db->connection()->execute('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', ['Alice', 'alice@example.com', 'active']); + $this->db->connection()->execute('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', ['Bob', 'bob@example.com', 'inactive']); + + $activeUsers = $this->dao->query()->active()->get(); + $this->assertCount(1, $activeUsers); + $this->assertEquals('Alice', $activeUsers[0]->name); + } + + public function test_it_paginates_results() + { + for ($i = 1; $i <= 20; $i++) { + $this->db->connection()->execute('INSERT INTO users (name, email, status) VALUES (?, ?, ?)', ["User $i", "user$i@example.com", 'active']); + } + + $paginator = $this->dao->query()->paginate(5, 2); + + $this->assertInstanceOf(Paginator::class, $paginator); + $this->assertEquals(20, $paginator->total()); + $this->assertCount(5, $paginator->items()); + $this->assertEquals(2, $paginator->currentPage()); + $this->assertEquals(4, $paginator->lastPage()); + $this->assertEquals('User 6', $paginator->items()[0]->name); + } +} diff --git a/tests/JoinEagerMysqlTest.php b/tests/JoinEagerMysqlTest.php deleted file mode 100644 index d2563eb..0000000 --- a/tests/JoinEagerMysqlTest.php +++ /dev/null @@ -1,149 +0,0 @@ -markTestSkipped('MYSQL_HOST not set; skipping MySQL join eager test'); - } - return [ - 'driver' => 'mysql', - 'host' => $host, - 'port' => (int)(getenv('MYSQL_PORT') ?: 3306), - 'database' => getenv('MYSQL_DB') ?: 'pairity', - 'username' => getenv('MYSQL_USER') ?: 'root', - 'password' => getenv('MYSQL_PASS') ?: 'root', - 'charset' => 'utf8mb4', - ]; - } - - public function testJoinEagerHasManyAndBelongsTo(): void - { - $cfg = $this->mysqlConfig(); - $conn = ConnectionManager::make($cfg); - $schema = SchemaManager::forConnection($conn); - - // Unique table names per run - $suf = substr(sha1((string)microtime(true)), 0, 6); - $usersT = 'je_users_' . $suf; - $postsT = 'je_posts_' . $suf; - - // Create tables - $schema->create($usersT, function (Blueprint $t) { $t->increments('id'); $t->string('name', 190); }); - $schema->create($postsT, function (Blueprint $t) { $t->increments('id'); $t->integer('user_id'); $t->string('title', 190); $t->datetime('deleted_at')->nullable(); }); - - // DTOs - $UserDto = new class([]) extends AbstractDto {}; - $PostDto = new class([]) extends AbstractDto {}; - $uClass = get_class($UserDto); $pClass = get_class($PostDto); - - // DAOs (constructors accept only connection; configured via static props) - $PostDao = new class($conn) extends AbstractDao { - public static string $table; public static string $dto; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return self::$table; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return [ - 'primaryKey' => 'id', - 'columns' => [ 'id'=>['cast'=>'int'], 'user_id'=>['cast'=>'int'], 'title'=>['cast'=>'string'], 'deleted_at'=>['cast'=>'datetime'] ], - 'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'], - ]; } - }; - - $UserDao = new class($conn) extends AbstractDao { - public static string $table; public static string $dto; public static string $postDaoClass; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return self::$table; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { return [ 'posts' => [ 'type'=>'hasMany', 'dao'=>self::$postDaoClass, 'foreignKey'=>'user_id', 'localKey'=>'id' ] ]; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - - // Configure static props - $postDaoClass = get_class($PostDao); - $postDaoClass::$table = $postsT; - $postDaoClass::$dto = $pClass; - $userDaoClass = get_class($UserDao); - $userDaoClass::$table = $usersT; - $userDaoClass::$dto = $uClass; - $userDaoClass::$postDaoClass = $postDaoClass; - - $postDao = new $postDaoClass($conn); - $userDao = new $userDaoClass($conn); - - // Seed - $u1 = $userDao->insert(['name' => 'Alice']); - $u2 = $userDao->insert(['name' => 'Bob']); - $uid1 = (int)$u1->toArray(false)['id']; - $uid2 = (int)$u2->toArray(false)['id']; - $postDao->insert(['user_id' => $uid1, 'title' => 'P1']); - $postDao->insert(['user_id' => $uid1, 'title' => 'P2']); - // soft-deleted child for Bob - $postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]); - - // Baseline batched eager (include posts.user_id for grouping) - $baseline = $userDao->fields('id','name','posts.user_id','posts.title')->with(['posts'])->findAllBy([]); - $this->assertCount(2, $baseline); - $postsAlice = $baseline[0]->toArray(false)['posts'] ?? []; - $this->assertIsArray($postsAlice); - $this->assertCount(2, $postsAlice); - - // Join-based eager (global) - $joined = $userDao->fields('id','name','posts.user_id','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]); - $this->assertCount(2, $joined); - foreach ($joined as $u) { - $posts = $u->toArray(false)['posts'] ?? []; - foreach ($posts as $p) { - $this->assertNotSame('Hidden', $p->toArray(false)['title'] ?? null); - } - } - - // belongsTo join: Posts -> User (use static-prop pattern for both sides) - $UserDao2 = new class($conn) extends AbstractDao { - public static string $table; public static string $dto; - public function __construct($c){ parent::__construct($c); } - public function getTable(): string { return self::$table; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - $userDao2Class = get_class($UserDao2); - $userDao2Class::$table = $usersT; - $userDao2Class::$dto = $uClass; - - $PostDao2 = new class($conn) extends AbstractDao { - public static string $table; public static string $dto; public static string $userDaoClass; - public function __construct($c){ parent::__construct($c); } - public function getTable(): string { return self::$table; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { return [ 'user' => [ 'type'=>'belongsTo', 'dao'=>self::$userDaoClass, 'foreignKey'=>'user_id', 'otherKey'=>'id' ] ]; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; } - }; - $postDao2Class = get_class($PostDao2); - $postDao2Class::$table = $postsT; - $postDao2Class::$dto = $pClass; - $postDao2Class::$userDaoClass = $userDao2Class; - - $postDaoJ = new $postDao2Class($conn); - $rows = $postDaoJ->fields('id','title','user.name')->useJoinEager()->with(['user'])->findAllBy([]); - $this->assertNotEmpty($rows); - $arr = $rows[0]->toArray(false); - $this->assertArrayHasKey('user', $arr); - - // Cleanup - $schema->drop($usersT); - $schema->drop($postsT); - } -} diff --git a/tests/JoinEagerSqliteTest.php b/tests/JoinEagerSqliteTest.php deleted file mode 100644 index d026233..0000000 --- a/tests/JoinEagerSqliteTest.php +++ /dev/null @@ -1,144 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testHasManyJoinEagerWithProjectionAndSoftDeleteScope(): void - { - $conn = $this->conn(); - // schema - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - $conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, deleted_at TEXT NULL)'); - - // DTOs - $UserDto = new class([]) extends AbstractDto {}; - $PostDto = new class([]) extends AbstractDto {}; - $uClass = get_class($UserDto); $pClass = get_class($PostDto); - - // DAOs - $PostDao = new class($conn) extends AbstractDao { - public static string $dto; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return [ - 'primaryKey' => 'id', - 'columns' => [ 'id'=>['cast'=>'int'], 'user_id'=>['cast'=>'int'], 'title'=>['cast'=>'string'], 'deleted_at'=>['cast'=>'datetime'] ], - 'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'], - ]; } - }; - $postDaoClass = get_class($PostDao); - $postDaoClass::$dto = $pClass; - - $UserDao = new class($conn) extends AbstractDao { - public static string $dto; - public static string $postDaoClass; - public function __construct($c){ parent::__construct($c); } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { return [ - 'posts' => [ 'type' => 'hasMany', 'dao' => self::$postDaoClass, 'foreignKey' => 'user_id', 'localKey' => 'id' ], - ]; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - - $userDaoClass = get_class($UserDao); - $userDaoClass::$dto = $uClass; - $userDaoClass::$postDaoClass = $postDaoClass; - - $postDao = new $postDaoClass($conn); - $userDao = new $userDaoClass($conn); - - // seed - $u1 = $userDao->insert(['name' => 'Alice']); - $u2 = $userDao->insert(['name' => 'Bob']); - $uid1 = (int)$u1->toArray(false)['id']; - $uid2 = (int)$u2->toArray(false)['id']; - $postDao->insert(['user_id' => $uid1, 'title' => 'P1']); - $postDao->insert(['user_id' => $uid1, 'title' => 'P2']); - $postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]); // soft-deleted - - // Batched (subquery) for baseline - // Include relation foreign key in projection so eager loader can group children - $baseline = $userDao->fields('id','name','posts.user_id','posts.title')->with(['posts'])->findAllBy([]); - $this->assertCount(2, $baseline); - $alice = $baseline[0]->toArray(false); - $this->assertIsArray($alice['posts'] ?? null); - $this->assertCount(2, $alice['posts']); - - // Join-based eager (opt-in) is under active development; skip join assertions for now. - // $joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]); - // $this->assertCount(2, $joined); - // $aliceJ = $joined[0]->toArray(false); - // $this->assertIsArray($aliceJ['posts'] ?? null); - // $this->assertCount(2, $aliceJ['posts']); - // foreach ($joined as $u) { - // $posts = $u->toArray(false)['posts'] ?? []; - // foreach ($posts as $p) { - // $this->assertNotSame('Hidden', $p->toArray(false)['title'] ?? null); - // } - // } - } - - public function testBelongsToJoinEagerSingleLevel(): void - { - $conn = $this->conn(); - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - $conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)'); - - $UserDto = new class([]) extends AbstractDto {}; - $PostDto = new class([]) extends AbstractDto {}; - $uClass = get_class($UserDto); $pClass = get_class($PostDto); - - $UserDao = new class($conn) extends AbstractDao { - public static string $dto; - public function __construct($c){ parent::__construct($c); } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - $userDaoClass = get_class($UserDao); - $userDaoClass::$dto = $uClass; - - $PostDao = new class($conn) extends AbstractDao { - public static string $dto; - public static string $userDaoClass; - public function __construct($c){ parent::__construct($c); } - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { return [ - 'user' => [ 'type' => 'belongsTo', 'dao' => self::$userDaoClass, 'foreignKey' => 'user_id', 'otherKey' => 'id' ], - ]; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; } - }; - $postDaoClass = get_class($PostDao); - $postDaoClass::$dto = $pClass; - $postDaoClass::$userDaoClass = $userDaoClass; - - $userDao = new $userDaoClass($conn); - $postDao = new $postDaoClass($conn); - - $u = $userDao->insert(['name' => 'Alice']); - $uid = (int)$u->toArray(false)['id']; - $p = $postDao->insert(['user_id' => $uid, 'title' => 'Hello']); - - $rows = $postDao->fields('id','title','user.name')->useJoinEager()->with(['user'])->findAllBy([]); - $this->assertNotEmpty($rows); - $arr = $rows[0]->toArray(false); - $this->assertSame('Hello', $arr['title']); - $this->assertSame('Alice', $arr['user']->toArray(false)['name'] ?? null); - } -} diff --git a/tests/MigrationGeneratorTest.php b/tests/MigrationGeneratorTest.php deleted file mode 100644 index 3563777..0000000 --- a/tests/MigrationGeneratorTest.php +++ /dev/null @@ -1,42 +0,0 @@ -generate('CreateTestTable', $dir); - - $this->assertFileExists($file); - $this->assertStringContainsString('CreateTestTable', $file); - - $content = file_get_contents($file); - $this->assertStringContainsString('implements MigrationInterface', $content); - - unlink($file); - rmdir($dir); - } - - public function testUsesCustomTemplate() - { - $dir = sys_get_temp_dir() . '/pairity_migrations_' . uniqid(); - mkdir($dir); - - $template = "generate('Custom', $dir); - - $this->assertEquals($template, file_get_contents($file)); - - unlink($file); - rmdir($dir); - } -} diff --git a/tests/MongoAdapterTest.php b/tests/MongoAdapterTest.php deleted file mode 100644 index 36af247..0000000 --- a/tests/MongoAdapterTest.php +++ /dev/null @@ -1,87 +0,0 @@ -hasMongoExt()) { - $this->markTestSkipped('ext-mongodb not loaded'); - } - - // Attempt connection; skip if server is unavailable - try { - $conn = MongoConnectionManager::make([ - 'host' => getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - $db = 'pairity_test'; - $col = 'widgets'; - - // Clean up any leftovers - try { - foreach ($conn->find($db, $col, []) as $doc) { - $conn->deleteOne($db, $col, ['_id' => $doc['_id']]); - } - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo operations unavailable: ' . $e->getMessage()); - } - - // Insert - $id = $conn->insertOne($db, $col, [ - 'name' => 'Widget', - 'qty' => 5, - 'tags' => ['a','b'], - ]); - $this->assertNotEmpty($id, 'Inserted _id should be returned'); - - // Find by id - $found = $conn->find($db, $col, ['_id' => $id]); - $this->assertNotEmpty($found, 'Should find inserted doc'); - $this->assertSame('Widget', $found[0]['name'] ?? null); - - // Update - $modified = $conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['qty' => 7]]); - $this->assertGreaterThanOrEqual(0, $modified); - $after = $conn->find($db, $col, ['_id' => $id]); - $this->assertSame(7, $after[0]['qty'] ?? null); - - // Aggregate pipeline - $agg = $conn->aggregate($db, $col, [ - ['$match' => ['qty' => 7]], - ['$project' => ['name' => 1, 'qty' => 1]], - ]); - $this->assertNotEmpty($agg); - - // Delete - $deleted = $conn->deleteOne($db, $col, ['_id' => $id]); - $this->assertGreaterThanOrEqual(1, $deleted); - $remaining = $conn->find($db, $col, ['_id' => $id]); - $this->assertCount(0, $remaining); - } -} diff --git a/tests/MongoDaoTest.php b/tests/MongoDaoTest.php deleted file mode 100644 index f999e1e..0000000 --- a/tests/MongoDaoTest.php +++ /dev/null @@ -1,92 +0,0 @@ -hasMongoExt()) { - $this->markTestSkipped('ext-mongodb not loaded'); - } - - // Connect (skip if server unavailable) - try { - $conn = MongoConnectionManager::make([ - 'host' => getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - // Define DTO/DAO inline for test - $dtoClass = new class([]) extends AbstractDto {}; - $dtoFqcn = get_class($dtoClass); - - $dao = new class($conn, $dtoFqcn) extends AbstractMongoDao { - private string $dto; - public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.widgets'; } - protected function dtoClass(): string { return $this->dto; } - }; - - // Clean collection - foreach ($dao->findAllBy([]) as $doc) { - $id = (string)($doc->toArray(false)['_id'] ?? ''); - if ($id !== '') { $dao->deleteById($id); } - } - - // Insert - $created = $dao->insert(['name' => 'Widget', 'qty' => 5, 'tags' => ['a','b']]); - $arr = $created->toArray(false); - $this->assertArrayHasKey('_id', $arr); - $id = (string)$arr['_id']; - $this->assertNotEmpty($id); - - // Find by id - $found = $dao->findById($id); - $this->assertNotNull($found); - $this->assertSame('Widget', $found->toArray(false)['name'] ?? null); - - // Update - $updated = $dao->update($id, ['qty' => 7]); - $this->assertSame(7, $updated->toArray(false)['qty'] ?? null); - - // Projection, sorting, limit/skip - $list = $dao->fields('name')->sort(['name' => 1])->limit(10)->skip(0)->findAllBy([]); - $this->assertNotEmpty($list); - $this->assertArrayHasKey('name', $list[0]->toArray(false)); - - // Dynamic helper findOneByName - $one = $dao->findOneByName('Widget'); - $this->assertNotNull($one); - - // Delete - $deleted = $dao->deleteById($id); - $this->assertGreaterThanOrEqual(1, $deleted); - $this->assertNull($dao->findById($id)); - } -} diff --git a/tests/MongoEventSystemTest.php b/tests/MongoEventSystemTest.php deleted file mode 100644 index 548c9af..0000000 --- a/tests/MongoEventSystemTest.php +++ /dev/null @@ -1,81 +0,0 @@ -hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); } - - // Connect (skip if server unavailable) - try { - $conn = MongoConnectionManager::make([ - 'host' => \getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(\getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - $Dto = new class([]) extends AbstractDto {}; - $dtoClass = \get_class($Dto); - - $Dao = new class($conn, $dtoClass) extends AbstractMongoDao { - private string $dto; public function __construct($c,string $d){ parent::__construct($c); $this->dto=$d; } - protected function collection(): string { return 'pairity_test.events_users'; } - protected function dtoClass(): string { return $this->dto; } - }; - - $dao = new $Dao($conn, $dtoClass); - - // Clean - foreach ($dao->findAllBy([]) as $doc) { $id = (string)($doc->toArray(false)['_id'] ?? ''); if ($id) { $dao->deleteById($id); } } - - $beforeInsert = null; $afterInsert = false; $afterUpdate = false; $afterDelete = 0; $afterFind = 0; - Events::dispatcher()->clear(); - Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) use (&$beforeInsert){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $p['data']['tag'] = 'x'; $beforeInsert = $p['data']; }}); - Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$afterInsert){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterInsert = true; }}); - Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$afterUpdate){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterUpdate = true; }}); - Events::dispatcher()->listen('dao.afterDelete', function(array &$p) use (&$afterDelete){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterDelete += (int)($p['affected'] ?? 0); }}); - Events::dispatcher()->listen('dao.afterFind', function(array &$p) use (&$afterFind){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterFind += isset($p['dto']) ? (int)!!$p['dto'] : (is_array($p['dtos'] ?? null) ? count($p['dtos']) : 0); }}); - - // Insert - $created = $dao->insert(['email' => 'e@x.com']); - $this->assertTrue($afterInsert); - $this->assertSame('x', $created->toArray(false)['tag'] ?? null); - - // Update - $id = (string)($created->toArray(false)['_id'] ?? ''); - $dao->update($id, ['email' => 'e2@x.com']); - $this->assertTrue($afterUpdate); - - // Find - $one = $dao->findById($id); - $this->assertNotNull($one); - $this->assertGreaterThanOrEqual(1, $afterFind); - - // Delete - $aff = $dao->deleteById($id); - $this->assertSame($aff, $afterDelete); - } -} diff --git a/tests/MongoOptimisticLockTest.php b/tests/MongoOptimisticLockTest.php deleted file mode 100644 index 5b137e2..0000000 --- a/tests/MongoOptimisticLockTest.php +++ /dev/null @@ -1,65 +0,0 @@ -hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); } - - // Connect (skip if server unavailable) - try { - $conn = MongoConnectionManager::make([ - 'host' => \getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(\getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - $dto = new class([]) extends AbstractDto {}; - $dtoClass = \get_class($dto); - - $Dao = new class($conn, $dtoClass) extends AbstractMongoDao { - private string $dto; public function __construct($c, string $d){ parent::__construct($c); $this->dto=$d; } - protected function collection(): string { return 'pairity_test.lock_users'; } - protected function dtoClass(): string { return $this->dto; } - protected function locking(): array { return ['type' => 'version', 'column' => 'version']; } - }; - - $dao = new $Dao($conn, $dtoClass); - - // Clean - foreach ($dao->findAllBy([]) as $doc) { $id = (string)($doc->toArray(false)['_id'] ?? ''); if ($id) { $dao->deleteById($id); } } - - // Insert with initial version 0 - $created = $dao->insert(['email' => 'lock@example.com', 'version' => 0]); - $id = (string)($created->toArray(false)['_id'] ?? ''); - $this->assertNotEmpty($id); - - // Update should bump version to 1 - $dao->update($id, ['email' => 'lock2@example.com']); - $after = $dao->findById($id); - $this->assertNotNull($after); - $this->assertSame(1, (int)($after->toArray(false)['version'] ?? -1)); - } -} diff --git a/tests/MongoPaginationTest.php b/tests/MongoPaginationTest.php deleted file mode 100644 index b45b3bb..0000000 --- a/tests/MongoPaginationTest.php +++ /dev/null @@ -1,97 +0,0 @@ -hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); } - // Connect (skip if server unavailable) - try { - $conn = MongoConnectionManager::make([ - 'host' => \getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(\getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - // Inline DTO and DAOs - $userDto = new class([]) extends AbstractDto {}; - $userDtoClass = \get_class($userDto); - $postDto = new class([]) extends AbstractDto {}; - $postDtoClass = \get_class($postDto); - - $PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao { - private string $dto; public function __construct($c, string $dto){ parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.pg_posts'; } - protected function dtoClass(): string { return $this->dto; } - }; - - $UserDao = new class($conn, $userDtoClass, get_class($PostDao)) extends AbstractMongoDao { - private string $dto; private string $postDaoClass; public function __construct($c,string $dto,string $p){ parent::__construct($c); $this->dto=$dto; $this->postDaoClass=$p; } - protected function collection(): string { return 'pairity_test.pg_users'; } - protected function dtoClass(): string { return $this->dto; } - protected function relations(): array { return [ - 'posts' => [ 'type' => 'hasMany', 'dao' => $this->postDaoClass, 'foreignKey' => 'user_id', 'localKey' => '_id' ], - ]; } - }; - - $postDao = new $PostDao($conn, $postDtoClass); - $userDao = new $UserDao($conn, $userDtoClass, get_class($postDao)); - - // Clean - foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } } - foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } } - - // Seed 26 users; attach posts to some - for ($i=1; $i<=26; $i++) { - $status = $i % 2 === 0 ? 'active' : 'inactive'; - $u = $userDao->insert(['email' => "m{$i}@ex.com", 'status' => $status]); - $uid = (string)($u->toArray(false)['_id'] ?? ''); - if ($i % 4 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'T'.$i]); } - } - - // Paginate - $page = $userDao->paginate(2, 10, []); - $this->assertSame(26, $page['total']); - $this->assertCount(10, $page['data']); - $this->assertSame(3, $page['lastPage']); - - // Simple paginate last page nextPage null - $sp = $userDao->simplePaginate(3, 10, []); - $this->assertNull($sp['nextPage']); - - // fields + sort + with on paginate - $with = $userDao->fields('email','posts.title')->sort(['email' => 1])->with(['posts'])->paginate(1, 5); - $this->assertNotEmpty($with['data']); - $first = $with['data'][0]->toArray(false); - $this->assertArrayHasKey('email', $first); - $this->assertArrayHasKey('posts', $first); - - // Scopes - $userDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; }); - $active = $userDao->active()->paginate(1, 100, []); - // Half of 26 rounded down - $this->assertSame(13, $active['total']); - } -} diff --git a/tests/MongoRelationsTest.php b/tests/MongoRelationsTest.php deleted file mode 100644 index 2c3c451..0000000 --- a/tests/MongoRelationsTest.php +++ /dev/null @@ -1,99 +0,0 @@ -hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); } - - // Connect (skip if server unavailable) - try { - $conn = MongoConnectionManager::make([ - 'host' => \getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(\getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - // Inline DTO classes - $userDto = new class([]) extends AbstractDto {}; - $userDtoClass = \get_class($userDto); - $postDto = new class([]) extends AbstractDto {}; - $postDtoClass = \get_class($postDto); - - // Inline DAOs with relations - $UserDao = new class($conn, $userDtoClass) extends AbstractMongoDao { - private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.users_rel'; } - protected function dtoClass(): string { return $this->dto; } - protected function relations(): array { return [ - 'posts' => [ 'type' => 'hasMany', 'dao' => get_class($this->makePostDao()), 'foreignKey' => 'user_id', 'localKey' => '_id' ], - ]; } - private function makePostDao(): object { return new class($this->getConnection(), 'stdClass') extends AbstractMongoDao { - private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.posts_rel'; } - protected function dtoClass(): string { return $this->dto; } - }; } - }; - - $PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao { - private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.posts_rel'; } - protected function dtoClass(): string { return $this->dto; } - protected function relations(): array { return [ - 'user' => [ 'type' => 'belongsTo', 'dao' => get_class($this->makeUserDao()), 'foreignKey' => 'user_id', 'otherKey' => '_id' ], - ]; } - private function makeUserDao(): object { return new class($this->getConnection(), 'stdClass') extends AbstractMongoDao { - private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.users_rel'; } - protected function dtoClass(): string { return $this->dto; } - }; } - }; - - // Instantiate concrete DAOs for use - $userDao = new $UserDao($conn, $userDtoClass); - $postDao = new $PostDao($conn, $postDtoClass); - - // Clean - foreach ($postDao->findAllBy([]) as $p) { $postDao->deleteById((string)($p->toArray(false)['_id'] ?? '')); } - foreach ($userDao->findAllBy([]) as $u) { $userDao->deleteById((string)($u->toArray(false)['_id'] ?? '')); } - - // Seed one user and two posts - $u = $userDao->insert(['email' => 'r@example.com', 'name' => 'Rel']); - $uid = (string)$u->toArray(false)['_id']; - $postDao->insert(['title' => 'A', 'user_id' => $uid]); - $postDao->insert(['title' => 'B', 'user_id' => $uid]); - - // Eager load posts on users - $users = $userDao->with(['posts'])->findAllBy([]); - $this->assertNotEmpty($users); - $this->assertIsArray($users[0]->toArray(false)['posts'] ?? null); - - // Lazy load belongsTo on a post - $one = $postDao->findOneBy(['title' => 'A']); - $this->assertNotNull($one); - $postDao->load($one, 'user'); - $this->assertNotNull($one->toArray(false)['user'] ?? null); - } -} diff --git a/tests/MysqlSmokeTest.php b/tests/MysqlSmokeTest.php deleted file mode 100644 index fb29e26..0000000 --- a/tests/MysqlSmokeTest.php +++ /dev/null @@ -1,61 +0,0 @@ -markTestSkipped('MYSQL_HOST not set; skipping MySQL smoke test'); - } - return [ - 'driver' => 'mysql', - 'host' => $host, - 'port' => (int)(getenv('MYSQL_PORT') ?: 3306), - 'database' => getenv('MYSQL_DB') ?: 'pairity', - 'username' => getenv('MYSQL_USER') ?: 'root', - 'password' => getenv('MYSQL_PASS') ?: 'root', - 'charset' => 'utf8mb4', - ]; - } - - public function testCreateAndDropTable(): void - { - $cfg = $this->mysqlConfig(); - $conn = ConnectionManager::make($cfg); - $schema = SchemaManager::forConnection($conn); - - $table = 'pairity_smoke_' . substr(sha1((string)microtime(true)), 0, 6); - - $schema->create($table, function (Blueprint $t) { - $t->increments('id'); - $t->string('name', 50); - }); - - $rows = $conn->query('SHOW TABLES LIKE :t', ['t' => $table]); - $this->assertNotEmpty($rows, 'Table should be created'); - - // Alter add column - $schema->table($table, function (Blueprint $t) { - $t->integer('qty'); - }); - - $cols = $conn->query('SHOW COLUMNS FROM `' . $table . '`'); - $names = array_map(fn($r) => $r['Field'] ?? $r['field'] ?? $r['COLUMN_NAME'] ?? '', $cols); - $this->assertContains('qty', $names); - - // Drop - $schema->drop($table); - $rows = $conn->query('SHOW TABLES LIKE :t', ['t' => $table]); - $this->assertEmpty($rows, 'Table should be dropped'); - } -} diff --git a/tests/OptimisticLockSqliteTest.php b/tests/OptimisticLockSqliteTest.php deleted file mode 100644 index a2b869d..0000000 --- a/tests/OptimisticLockSqliteTest.php +++ /dev/null @@ -1,63 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testVersionLockingIncrementsAndBlocksBulkUpdate(): void - { - $conn = $this->conn(); - // schema with version column - $conn->execute('CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT, - version INTEGER NOT NULL DEFAULT 0 - )'); - - $UserDto = new class([]) extends AbstractDto {}; - $dtoClass = get_class($UserDto); - - $UserDao = new class($conn, $dtoClass) extends AbstractDao { - private string $dto; public function __construct($c,string $dto){ parent::__construct($c); $this->dto=$dto; } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return $this->dto; } - protected function schema(): array { - return [ - 'primaryKey' => 'id', - 'columns' => [ 'id'=>['cast'=>'int'], 'name'=>['cast'=>'string'], 'version'=>['cast'=>'int'] ], - 'locking' => ['type' => 'version', 'column' => 'version'], - ]; - } - }; - - $dao = new $UserDao($conn, $dtoClass); - - // Insert - $created = $dao->insert(['name' => 'A']); - $arr = $created->toArray(false); - $id = (int)$arr['id']; - - // First update: should succeed and bump version to 1 - $dao->update($id, ['name' => 'A1']); - $row = $conn->query('SELECT name, version FROM users WHERE id = :id', ['id' => $id])[0] ?? []; - $this->assertSame('A1', (string)($row['name'] ?? '')); - $this->assertSame(1, (int)($row['version'] ?? 0)); - - // Bulk update should throw while locking enabled - $this->expectException(OptimisticLockException::class); - $dao->updateBy(['id' => $id], ['name' => 'A2']); - } -} diff --git a/tests/PaginationSqliteTest.php b/tests/PaginationSqliteTest.php deleted file mode 100644 index 52d560f..0000000 --- a/tests/PaginationSqliteTest.php +++ /dev/null @@ -1,111 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testPaginateAndSimplePaginateWithScopesAndRelations(): void - { - $conn = $this->conn(); - // schema - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, status TEXT)'); - $conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)'); - - // DTOs - $UserDto = new class([]) extends AbstractDto {}; - $PostDto = new class([]) extends AbstractDto {}; - $uClass = get_class($UserDto); $pClass = get_class($PostDto); - - // DAOs (constructors accept only connection; DTO/related FQCNs via static props) - $PostDao = new class($conn) extends AbstractDao { - public static string $dto; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; } - }; - - $postDaoClass = get_class($PostDao); - $postDaoClass::$dto = $pClass; - - $UserDao = new class($conn) extends AbstractDao { - public static string $dto; public static string $postDaoClass; - public function __construct($c){ parent::__construct($c); } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => self::$postDaoClass, - 'foreignKey' => 'user_id', - 'localKey' => 'id', - ], - ]; - } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; } - }; - - $userDaoClass = get_class($UserDao); - $userDaoClass::$dto = $uClass; - $userDaoClass::$postDaoClass = $postDaoClass; - - $postDao = new $postDaoClass($conn); - $userDao = new $userDaoClass($conn); - - // seed 35 users (20 active, 15 inactive) - for ($i=1; $i<=35; $i++) { - $status = $i <= 20 ? 'active' : 'inactive'; - $u = $userDao->insert(['email' => "u{$i}@example.com", 'status' => $status]); - $uid = (int)($u->toArray(false)['id'] ?? 0); - if ($i % 5 === 0) { - $postDao->insert(['user_id' => $uid, 'title' => 'P'.$i]); - } - } - - // paginate page 2 of size 10 - $page = $userDao->paginate(2, 10, []); - $this->assertSame(35, $page['total']); - $this->assertSame(10, count($page['data'])); - $this->assertSame(4, $page['lastPage']); - $this->assertSame(2, $page['currentPage']); - - // simplePaginate last page should have nextPage null - $simple = $userDao->simplePaginate(4, 10, []); - $this->assertNull($simple['nextPage']); - $this->assertSame(10, $simple['perPage']); - - // fields() projection + with() eager on paginated results - $with = $userDao->fields('id', 'email', 'posts.title')->with(['posts'])->paginate(1, 10); - $this->assertNotEmpty($with['data']); - $first = $with['data'][0]->toArray(false); - $this->assertArrayHasKey('id', $first); - $this->assertArrayHasKey('email', $first); - $this->assertArrayHasKey('posts', $first); - - // scopes: named scope to filter active users only - $userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; }); - $activePage = $userDao->active()->paginate(1, 50); - $this->assertSame(20, $activePage['total']); - - // ad-hoc scope combining additional condition (no-op example) - $combined = $userDao->scope(function (&$criteria) { if (!isset($criteria['status'])) { $criteria['status'] = 'inactive'; } }) - ->paginate(1, 100); - $this->assertSame(15, $combined['total']); - } -} diff --git a/tests/PostgresSmokeTest.php b/tests/PostgresSmokeTest.php deleted file mode 100644 index 1f762cb..0000000 --- a/tests/PostgresSmokeTest.php +++ /dev/null @@ -1,61 +0,0 @@ -markTestSkipped('POSTGRES_HOST not set; skipping Postgres smoke test'); - } - return [ - 'driver' => 'pgsql', - 'host' => $host, - 'port' => (int)(getenv('POSTGRES_PORT') ?: 5432), - 'database' => getenv('POSTGRES_DB') ?: 'pairity', - 'username' => getenv('POSTGRES_USER') ?: 'postgres', - 'password' => getenv('POSTGRES_PASS') ?: 'postgres', - ]; - } - - public function testCreateAlterDropCycle(): void - { - $cfg = $this->pgConfig(); - $conn = ConnectionManager::make($cfg); - $schema = SchemaManager::forConnection($conn); - - $suffix = substr(sha1((string)microtime(true)), 0, 6); - $table = 'pg_smoke_' . $suffix; - - // Create - $schema->create($table, function (Blueprint $t) { - $t->increments('id'); - $t->string('name', 100); - }); - - $rows = $conn->query('SELECT tablename FROM pg_tables WHERE tablename = :t', ['t' => $table]); - $this->assertNotEmpty($rows, 'Table should be created'); - - // Alter add column - $schema->table($table, function (Blueprint $t) { - $t->integer('qty'); - }); - $cols = $conn->query('SELECT column_name FROM information_schema.columns WHERE table_name = :t', ['t' => $table]); - $names = array_map(fn($r) => $r['column_name'] ?? '', $cols); - $this->assertContains('qty', $names); - - // Drop - $schema->drop($table); - $rows = $conn->query('SELECT tablename FROM pg_tables WHERE tablename = :t', ['t' => $table]); - $this->assertEmpty($rows, 'Table should be dropped'); - } -} diff --git a/tests/PretendTest.php b/tests/PretendTest.php deleted file mode 100644 index c34d2f6..0000000 --- a/tests/PretendTest.php +++ /dev/null @@ -1,45 +0,0 @@ -createMock(PDO::class); - // Expect no calls to prepare or execute since we are pretending - $pdo->expects($this->never())->method('prepare'); - - $conn = new PdoConnection($pdo); - - $log = $conn->pretend(function($c) { - $c->execute('INSERT INTO users (name) VALUES (?)', ['Alice']); - $c->query('SELECT * FROM users'); - }); - - $this->assertCount(2, $log); - $this->assertEquals('INSERT INTO users (name) VALUES (?)', $log[0]['sql']); - $this->assertEquals(['Alice'], $log[0]['params']); - $this->assertEquals('SELECT * FROM users', $log[1]['sql']); - } - - public function testPretendHandlesTransactions() - { - $pdo = $this->createMock(PDO::class); - $pdo->expects($this->never())->method('beginTransaction'); - - $conn = new PdoConnection($pdo); - - $conn->pretend(function($c) { - $c->transaction(function($c2) { - $c2->execute('DELETE FROM users'); - }); - }); - - $this->assertTrue(true); // Reaching here means no PDO transaction was started - } -} diff --git a/tests/RelationsNestedConstraintsSqliteTest.php b/tests/RelationsNestedConstraintsSqliteTest.php deleted file mode 100644 index 137144b..0000000 --- a/tests/RelationsNestedConstraintsSqliteTest.php +++ /dev/null @@ -1,126 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testNestedEagerAndPerRelationFieldsConstraint(): void - { - $conn = $this->conn(); - // schema - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)'); - $conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)'); - $conn->execute('CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER, body TEXT)'); - - // DTOs - $UserDto = new class([]) extends AbstractDto {}; - $PostDto = new class([]) extends AbstractDto {}; - $CommentDto = new class([]) extends AbstractDto {}; - $uClass = get_class($UserDto); $pClass = get_class($PostDto); $cClass = get_class($CommentDto); - - // DAOs (constructors accept only connection; FQCNs via static props) - $CommentDao = new class($conn) extends AbstractDao { - public static string $dto; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'comments'; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'post_id'=>['cast'=>'int'],'body'=>['cast'=>'string']]]; } - }; - - $commentDaoClass = get_class($CommentDao); - $commentDaoClass::$dto = $cClass; - - $PostDao = new class($conn) extends AbstractDao { - public static string $dto; public static string $commentDaoClass; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { - return [ - 'comments' => [ - 'type' => 'hasMany', - 'dao' => self::$commentDaoClass, - 'foreignKey' => 'post_id', - 'localKey' => 'id', - ], - ]; - } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; } - }; - - $postDaoClass = get_class($PostDao); - $postDaoClass::$dto = $pClass; - $postDaoClass::$commentDaoClass = $commentDaoClass; - - $UserDao = new class($conn) extends AbstractDao { - public static string $dto; public static string $postDaoClass; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return self::$dto; } - protected function relations(): array { - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => self::$postDaoClass, - 'foreignKey' => 'user_id', - 'localKey' => 'id', - ], - ]; - } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } - }; - - $userDaoClass = get_class($UserDao); - $userDaoClass::$dto = $uClass; - $userDaoClass::$postDaoClass = $postDaoClass; - - $commentDao = new $commentDaoClass($conn); - $postDao = new $postDaoClass($conn); - $userDao = new $userDaoClass($conn); - - // seed - $u = $userDao->insert(['name' => 'Alice']); - $uid = (int)($u->toArray(false)['id'] ?? 0); - $p1 = $postDao->insert(['user_id' => $uid, 'title' => 'P1']); - $p2 = $postDao->insert(['user_id' => $uid, 'title' => 'P2']); - $pid1 = (int)($p1->toArray(false)['id'] ?? 0); $pid2 = (int)($p2->toArray(false)['id'] ?? 0); - $commentDao->insert(['post_id' => $pid1, 'body' => 'c1']); - $commentDao->insert(['post_id' => $pid1, 'body' => 'c2']); - $commentDao->insert(['post_id' => $pid2, 'body' => 'c3']); - - // nested eager with per-relation fields constraint (SQL supports fields projection) - $users = $userDao - ->fields( - 'id', 'name', - 'posts.id', 'posts.user_id', 'posts.title', - 'posts.comments.id', 'posts.comments.post_id', 'posts.comments.body' - ) - ->with(['posts', 'posts.comments']) - ->findAllBy(['id' => $uid]); - - $this->assertNotEmpty($users); - $user = $users[0]; - $posts = $user->toArray(false)['posts'] ?? []; - $this->assertIsArray($posts); - $this->assertCount(2, $posts); - // ensure projection respected on posts (at minimum title is present) - $this->assertArrayHasKey('title', $posts[0]->toArray(false)); - // Note: FK like user_id may be included to support grouping during eager load. - // nested comments should exist - $cm = $posts[0]->toArray(false)['comments'] ?? []; - $this->assertIsArray($cm); - $this->assertNotEmpty($cm); - } -} diff --git a/tests/SchemaBuilderSqliteTest.php b/tests/SchemaBuilderSqliteTest.php deleted file mode 100644 index db422a5..0000000 --- a/tests/SchemaBuilderSqliteTest.php +++ /dev/null @@ -1,74 +0,0 @@ - 'sqlite', - 'path' => ':memory:', - ]); - - $schema = SchemaManager::forConnection($conn); - - // Create table (avoid named indexes for SQLite portability) - $schema->create('widgets', function (Blueprint $t) { - $t->increments('id'); - $t->string('name', 100)->nullable(); - $t->integer('qty'); - $t->unique(['name']); - $t->index(['qty']); - }); - - // Verify table exists - $tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets'"); - $this->assertNotEmpty($tables, 'widgets table should exist'); - - // Alter: add column - $schema->table('widgets', function (Blueprint $t) { - $t->string('desc', 255)->nullable(); - }); - - $cols = $conn->query("PRAGMA table_info('widgets')"); - $colNames = array_map(fn($r) => $r['name'], $cols); - $this->assertContains('desc', $colNames); - - // Alter: rename column qty -> quantity - $schema->table('widgets', function (Blueprint $t) { - $t->renameColumn('qty', 'quantity'); - }); - $cols = $conn->query("PRAGMA table_info('widgets')"); - $colNames = array_map(fn($r) => $r['name'], $cols); - $this->assertContains('quantity', $colNames); - $this->assertNotContains('qty', $colNames); - - // Alter: drop column desc - $schema->table('widgets', function (Blueprint $t) { - $t->dropColumn('desc'); - }); - $cols = $conn->query("PRAGMA table_info('widgets')"); - $colNames = array_map(fn($r) => $r['name'], $cols); - $this->assertNotContains('desc', $colNames); - - // Rename table - $schema->table('widgets', function (Blueprint $t) { - $t->rename('widgets_new'); - }); - $tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets_new'"); - $this->assertNotEmpty($tables, 'widgets_new table should exist after rename'); - - // Drop - $schema->drop('widgets_new'); - $tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets_new'"); - $this->assertEmpty($tables, 'widgets_new table should be dropped'); - } -} diff --git a/tests/SoftDeletesTimestampsSqliteTest.php b/tests/SoftDeletesTimestampsSqliteTest.php deleted file mode 100644 index 3ec3c24..0000000 --- a/tests/SoftDeletesTimestampsSqliteTest.php +++ /dev/null @@ -1,105 +0,0 @@ - 'sqlite', - 'path' => ':memory:', - ]); - } - - public function testTimestampsAndSoftDeletesFlow(): void - { - $conn = $this->makeConnection(); - // Create table - $conn->execute('CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, - name TEXT NULL, - status TEXT NULL, - created_at TEXT NULL, - updated_at TEXT NULL, - deleted_at TEXT NULL - )'); - - // Define DTO/DAO - $dto = new class([]) extends AbstractDto {}; - $dao = new class($conn) extends AbstractDao { - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return get_class(new class([]) extends AbstractDto {}); } - protected function schema(): array - { - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'email' => ['cast' => 'string'], - 'name' => ['cast' => 'string'], - 'status' => ['cast' => 'string'], - 'created_at' => ['cast' => 'datetime'], - 'updated_at' => ['cast' => 'datetime'], - 'deleted_at' => ['cast' => 'datetime'], - ], - 'timestamps' => ['createdAt' => 'created_at', 'updatedAt' => 'updated_at'], - 'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'], - ]; - } - }; - - // Insert (created_at & updated_at auto) - $created = $dao->insert(['email' => 't@example.com', 'name' => 'T', 'status' => 'active']); - $arr = $created->toArray(); - $this->assertArrayHasKey('id', $arr); - $this->assertNotEmpty($arr['id']); - $this->assertNotNull($arr['created_at'] ?? null); - $this->assertNotNull($arr['updated_at'] ?? null); - - // Update via update() should change updated_at - $id = $arr['id']; - $prevUpdated = $arr['updated_at']; - // sleep(1) not reliable; just ensure it is a value and after call it exists - $dao->update($id, ['name' => 'T2']); - $after = $dao->findById($id)?->toArray(); - $this->assertNotNull($after); - $this->assertNotNull($after['updated_at'] ?? null); - - // Update via updateBy() also sets updated_at - $dao->updateBy(['id' => $id], ['status' => 'inactive']); - $after2 = $dao->findById($id)?->toArray(); - $this->assertEquals('inactive', $after2['status']); - $this->assertNotNull($after2['updated_at'] ?? null); - - // Default scope excludes soft-deleted - $dao->deleteById($id); - $list = $dao->findAllBy(); - $this->assertCount(0, $list, 'Soft-deleted should be excluded by default'); - - // withTrashed includes, onlyTrashed returns only deleted - $with = $dao->withTrashed()->findAllBy(); - $this->assertCount(1, $with); - $only = $dao->onlyTrashed()->findAllBy(); - $this->assertCount(1, $only); - $this->assertNotNull($only[0]->toArray()['deleted_at'] ?? null); - - // Restore - $dao->restoreById($id); - $afterRestore = $dao->findById($id); - $this->assertNotNull($afterRestore); - $this->assertNull($afterRestore->toArray()['deleted_at'] ?? null); - - // Force delete - $dao->forceDeleteById($id); - $this->assertNull($dao->findById($id)); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..f627dd5 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,29 @@ +cacheDir = __DIR__ . '/../../../storage/test_cache'; + if (is_dir($this->cacheDir)) { + $this->removeDir($this->cacheDir); + } + $this->cache = new FileCache($this->cacheDir); + } + + protected function tearDown(): void + { + if (is_dir($this->cacheDir)) { + $this->removeDir($this->cacheDir); + } + } + + protected function removeDir(string $dir): void + { + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->removeDir("$dir/$file") : unlink("$dir/$file"); + } + rmdir($dir); + } + + public function test_it_can_set_and_get_values() + { + $this->assertTrue($this->cache->set('foo', 'bar')); + $this->assertEquals('bar', $this->cache->get('foo')); + } + + public function test_it_returns_default_for_missing_keys() + { + $this->assertEquals('default', $this->cache->get('missing', 'default')); + } + + public function test_it_can_delete_values() + { + $this->cache->set('foo', 'bar'); + $this->assertTrue($this->cache->delete('foo')); + $this->assertNull($this->cache->get('foo')); + } + + public function test_it_can_clear_cache() + { + $this->cache->set('foo', 'bar'); + $this->cache->set('baz', 'qux'); + $this->assertTrue($this->cache->clear()); + $this->assertNull($this->cache->get('foo')); + $this->assertNull($this->cache->get('baz')); + } + + public function test_it_handles_expiration() + { + $this->cache->set('expired', 'value', -1); + $this->assertNull($this->cache->get('expired')); + } + + public function test_it_can_set_and_get_multiple() + { + $values = ['a' => 1, 'b' => 2]; + $this->assertTrue($this->cache->setMultiple($values)); + $this->assertEquals($values, $this->cache->getMultiple(['a', 'b'])); + } +} diff --git a/tests/Unit/Container/ContainerTest.php b/tests/Unit/Container/ContainerTest.php new file mode 100644 index 0000000..bf5c39e --- /dev/null +++ b/tests/Unit/Container/ContainerTest.php @@ -0,0 +1,62 @@ +container = new Container(); + } + + /** + * Test that the container can bind and resolve a simple closure. + */ + public function test_it_can_bind_and_resolve_closure(): void + { + $this->container->bind('foo', function () { + return 'bar'; + }); + + $this->assertEquals('bar', $this->container->make('foo')); + } + + /** + * Test that the container can bind and resolve a singleton. + */ + public function test_it_can_bind_and_resolve_singleton(): void + { + $this->container->singleton('std', function () { + return new \stdClass(); + }); + + $instance1 = $this->container->make('std'); + $instance2 = $this->container->make('std'); + + $this->assertSame($instance1, $instance2); + } + + /** + * Test that the container can resolve a class name directly. + */ + public function test_it_can_resolve_class_directly(): void + { + $instance = $this->container->make(\stdClass::class); + $this->assertInstanceOf(\stdClass::class, $instance); + } +} diff --git a/tests/Unit/DAO/LifecycleEventTest.php b/tests/Unit/DAO/LifecycleEventTest.php new file mode 100644 index 0000000..f87f325 --- /dev/null +++ b/tests/Unit/DAO/LifecycleEventTest.php @@ -0,0 +1,69 @@ +createMock(DatabaseManagerInterface::class); + $dispatcher = $this->createMock(DispatcherInterface::class); + $identityMap = $this->createMock(IdentityMap::class); + + $db->method('getDispatcher')->willReturn($dispatcher); + + $dao = new class($db, $identityMap) extends BaseDAO { + protected string $table = 'test'; + public function trigger(string $event, $payload) { + return $this->fireModelEvent($event, $payload); + } + }; + + $dto = new class extends BaseDTO {}; + + $dispatcher->expects($this->once()) + ->method('dispatch') + ->with($this->stringContains('pairity.model.saving:'), $dto, true) + ->willReturn(true); + + $dao->trigger('saving', $dto); + } + + public function test_dao_can_halt_via_event() + { + $db = $this->createMock(DatabaseManagerInterface::class); + $dispatcher = $this->createMock(DispatcherInterface::class); + $identityMap = $this->createMock(IdentityMap::class); + + $db->method('getDispatcher')->willReturn($dispatcher); + + // Mock DAO using generated stub logic + $dao = new class($db, $identityMap) extends BaseDAO { + protected string $table = 'test'; + protected string $primaryKey = 'id'; + + public function save(BaseDTO $dto): bool + { + if ($this->fireModelEvent('saving', $dto) === false) { + return false; + } + return true; + } + }; + + $dto = new class extends BaseDTO {}; + + $dispatcher->method('dispatch')->willReturn(false); + + $this->assertFalse($dao->save($dto)); + } +} diff --git a/tests/Unit/DAO/OptimisticLockingTest.php b/tests/Unit/DAO/OptimisticLockingTest.php new file mode 100644 index 0000000..6005c14 --- /dev/null +++ b/tests/Unit/DAO/OptimisticLockingTest.php @@ -0,0 +1,69 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $identityMap = $this->createMock(IdentityMap::class); + + $db->method('connection')->willReturn($connection); + $db->method('getQueryGrammar')->willReturn(new SqliteGrammar()); + + $dao = new class($db, $identityMap) extends BaseDAO { + protected string $table = 'users'; + protected ?string $lockingColumn = 'version'; + }; + + $dto = $this->createMock(BaseDTO::class); + $dto->method('toArray')->willReturn(['id' => 1, 'name' => 'Updated', 'version' => 1]); + + $connection->expects($this->once()) + ->method('execute') + ->with( + $this->stringContains('AND "version" = ?'), + ['Updated', 2, 1, 1] // name='Updated', version=2, id=1, old_version=1 + ) + ->willReturn(1); + + $dao->save($dto); + } + + public function test_it_throws_exception_on_lock_failure() + { + $db = $this->createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $identityMap = $this->createMock(IdentityMap::class); + + $db->method('connection')->willReturn($connection); + $db->method('getQueryGrammar')->willReturn(new SqliteGrammar()); + + $dao = new class($db, $identityMap) extends BaseDAO { + protected string $table = 'users'; + protected ?string $lockingColumn = 'version'; + }; + + $dto = $this->createMock(BaseDTO::class); + $dto->method('toArray')->willReturn(['id' => 1, 'name' => 'Updated', 'version' => 1]); + + $connection->method('execute')->willReturn(0); + + $this->expectException(\Pairity\Exceptions\DatabaseException::class); + $this->expectExceptionMessage('Optimistic locking failed'); + + $dao->save($dto); + } +} diff --git a/tests/Unit/DTO/IdentityMapTest.php b/tests/Unit/DTO/IdentityMapTest.php new file mode 100644 index 0000000..9d268f1 --- /dev/null +++ b/tests/Unit/DTO/IdentityMapTest.php @@ -0,0 +1,35 @@ +add('User', 1, $instance); + + $this->assertSame($instance, $map->get('User', 1)); + $this->assertNull($map->get('User', 2)); + $this->assertNull($map->get('Post', 1)); + } + + public function test_it_can_be_cleared() + { + $map = new IdentityMap(); + $instance = new stdClass(); + + $map->add('User', 1, $instance); + $map->clear(); + + $this->assertNull($map->get('User', 1)); + } +} diff --git a/tests/Unit/DTO/ProxyTest.php b/tests/Unit/DTO/ProxyTest.php new file mode 100644 index 0000000..0f72e4d --- /dev/null +++ b/tests/Unit/DTO/ProxyTest.php @@ -0,0 +1,53 @@ +createMock(DatabaseManagerInterface::class); + $identityMap = new IdentityMap(); + $dao = new UsersDAO($db, $identityMap); + + $factory = new ProxyFactory(); + $proxy = $factory->create(UsersDTO::class, $dao, 123); + + $this->assertInstanceOf(UsersDTO::class, $proxy); + $this->assertInstanceOf(ProxyInterface::class, $proxy); + $this->assertFalse($proxy->__isInitialized()); + $this->assertEquals(123, $proxy->getId()); + } + + public function test_it_triggers_load_on_access() + { + $conn = $this->createMock(ConnectionInterface::class); + $conn->method('select')->willReturn([['id' => 123, 'email' => 'loaded@example.com']]); + + $db = $this->createMock(DatabaseManagerInterface::class); + $db->method('connection')->willReturn($conn); + + $identityMap = new IdentityMap(); + $dao = new UsersDAO($db, $identityMap); + + $factory = new ProxyFactory(); + $proxy = $factory->create(UsersDTO::class, $dao, 123); + + // Accessing email should trigger load + $email = $proxy->getEmail(); + + $this->assertTrue($proxy->__isInitialized()); + $this->assertEquals('loaded@example.com', $email); + } +} diff --git a/tests/Unit/Database/Auditing/AuditingTest.php b/tests/Unit/Database/Auditing/AuditingTest.php new file mode 100644 index 0000000..ca76970 --- /dev/null +++ b/tests/Unit/Database/Auditing/AuditingTest.php @@ -0,0 +1,52 @@ +createMock(Auditor::class); + $listener = new AuditListener($auditor); + $dispatcher = new Dispatcher(); + + $listener->subscribe($dispatcher); + + $db = $this->createMock(DatabaseManagerInterface::class); + $identityMap = $this->createMock(IdentityMap::class); + + $dao = new class($db, $identityMap) extends BaseDAO { + protected string $table = 'users'; + protected bool $auditable = true; + public function getOption(string $key, mixed $default = null): mixed { + if ($key === 'auditable') return true; + return parent::getOption($key, $default); + } + }; + + $dto = new class extends BaseDTO {}; + $dto->setDao($dao); + + $auditor->expects($this->once()) + ->method('getChanges') + ->willReturn(['old' => [], 'new' => ['name' => 'John']]); + + $auditor->expects($this->once()) + ->method('record') + ->with($dto, 'created', [], ['name' => 'John']); + + $dispatcher->dispatch('pairity.model.created: ' . get_class($dto), $dto); + } +} diff --git a/tests/Unit/Database/ConnectionTest.php b/tests/Unit/Database/ConnectionTest.php new file mode 100644 index 0000000..556a823 --- /dev/null +++ b/tests/Unit/Database/ConnectionTest.php @@ -0,0 +1,134 @@ + ':memory:']); + + $connection->execute('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + $affected = $connection->execute('INSERT INTO users (name) VALUES (?)', ['Alice']); + + $this->assertEquals(1, $affected); + + $results = $connection->select('SELECT * FROM users'); + $this->assertCount(1, $results); + $this->assertEquals('Alice', $results[0]['name']); + } + + public function test_it_throws_query_exception_on_invalid_sql() + { + $driver = new SQLiteDriver(); + $connection = new Connection('test', $driver, ['database' => ':memory:']); + + $this->expectException(QueryException::class); + $connection->execute('INVALID SQL'); + } + + public function test_read_write_splitting() + { + $driver = new SQLiteDriver(); + // Use two different in-memory databases to simulate splitting + $config = [ + 'read' => ['database' => ':memory:'], + 'write' => ['database' => ':memory:'], + ]; + $connection = new Connection('test', $driver, $config); + + // Write to master + $connection->execute('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + $connection->execute('INSERT INTO test DEFAULT VALUES'); + + // Read from slave (should be empty because it's a different :memory: DB) + // But wait, the sticky logic will kick in after execute! + // Let's test WITHOUT sticky first or by forcing a new connection. + + $connection2 = new Connection('test2', $driver, $config); + // We can't easily test separate :memory: DBs if they are both in the same process + // and we want them to be DIFFERENT. Actually each :memory: IS different. + + $results = $connection2->select("SELECT name FROM sqlite_master WHERE type='table' AND name='test'"); + $this->assertCount(0, $results, 'Read connection should not see tables created in write connection (different :memory: DBs)'); + } + + public function test_transactions() + { + $driver = new SQLiteDriver(); + $connection = new Connection('test', $driver, ['database' => ':memory:']); + + $connection->execute('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + + $connection->beginTransaction(); + $connection->execute('INSERT INTO test DEFAULT VALUES'); + $this->assertEquals(1, $connection->transactionLevel()); + $connection->commit(); + + $this->assertEquals(0, $connection->transactionLevel()); + $this->assertCount(1, $connection->select('SELECT * FROM test')); + } + + public function test_transaction_rollback() + { + $driver = new SQLiteDriver(); + $connection = new Connection('test', $driver, ['database' => ':memory:']); + + $connection->execute('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + + $connection->beginTransaction(); + $connection->execute('INSERT INTO test DEFAULT VALUES'); + $connection->rollBack(); + + $this->assertCount(0, $connection->select('SELECT * FROM test')); + } + + public function test_nested_transactions_via_savepoints() + { + $driver = new SQLiteDriver(); + $connection = new Connection('test', $driver, ['database' => ':memory:']); + + $connection->execute('CREATE TABLE test (id INTEGER PRIMARY KEY)'); + + $connection->beginTransaction(); // Level 1 + $connection->execute('INSERT INTO test DEFAULT VALUES'); // 1 record + + $connection->beginTransaction(); // Level 2 (Savepoint) + $connection->execute('INSERT INTO test DEFAULT VALUES'); // 2 records total + $this->assertCount(2, $connection->select('SELECT * FROM test')); + $connection->rollBack(); // Rollback to Savepoint + + $this->assertEquals(1, $connection->transactionLevel()); + $this->assertCount(1, $connection->select('SELECT * FROM test')); + + $connection->commit(); + $this->assertCount(1, $connection->select('SELECT * FROM test')); + } + + public function test_interceptors() + { + $driver = new SQLiteDriver(); + $connection = new Connection('test', $driver, ['database' => ':memory:']); + + $interceptor = new class implements \Pairity\Contracts\Database\InterceptorInterface { + public int $called = 0; + public function intercept(string $query, array $bindings, string $mode, callable $next): mixed { + $this->called++; + return $next($query, $bindings, $mode); + } + }; + + $connection->addInterceptor($interceptor); + $connection->select('SELECT 1'); + + $this->assertEquals(1, $interceptor->called); + } +} diff --git a/tests/Unit/Database/DatabaseManagerTest.php b/tests/Unit/Database/DatabaseManagerTest.php new file mode 100644 index 0000000..df2e400 --- /dev/null +++ b/tests/Unit/Database/DatabaseManagerTest.php @@ -0,0 +1,112 @@ + 'sqlite', + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + ], + ]; + + $manager = new DatabaseManager($config); + $connection = $manager->connection(); + + $this->assertInstanceOf(Connection::class, $connection); + $this->assertEquals('sqlite', $connection->getName()); + $this->assertInstanceOf(SQLiteDriver::class, $connection->getDriver()); + } + + public function test_it_resolves_named_connection() + { + $config = [ + 'default' => 'sqlite', + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + 'other' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + ], + ]; + + $manager = new DatabaseManager($config); + $connection = $manager->connection('other'); + + $this->assertEquals('other', $connection->getName()); + } + + public function test_it_caches_connections() + { + $config = [ + 'default' => 'sqlite', + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + ], + ]; + + $manager = new DatabaseManager($config); + $connection1 = $manager->connection(); + $connection2 = $manager->connection(); + + $this->assertSame($connection1, $connection2); + } + + public function test_it_can_reconnect() + { + $config = [ + 'default' => 'sqlite', + 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], + ], + ]; + + $manager = new DatabaseManager($config); + $connection1 = $manager->connection(); + $connection2 = $manager->reconnect(); + + $this->assertNotSame($connection1, $connection2); + } + + public function test_it_throws_exception_for_unsupported_driver() + { + $config = [ + 'default' => 'invalid', + 'connections' => [ + 'invalid' => [ + 'driver' => 'unsupported', + ], + ], + ]; + + $manager = new DatabaseManager($config); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Database driver [unsupported] not supported.'); + + $manager->connection(); + } +} diff --git a/tests/Unit/Database/Drivers/DriverTest.php b/tests/Unit/Database/Drivers/DriverTest.php new file mode 100644 index 0000000..f9b0293 --- /dev/null +++ b/tests/Unit/Database/Drivers/DriverTest.php @@ -0,0 +1,75 @@ + 'localhost', + 'port' => 5432, + 'database' => 'testdb', + 'schema' => 'myschema', + 'sslmode' => 'require', + ]; + + // We need to use reflection because buildDsn is protected, + // or just test the getName() and rely on integration tests if any. + // Actually, let's use a small helper or just test getName for now and trust logic. + // Better: let's test DSN via a public wrapper or reflection. + + $method = new \ReflectionMethod(PostgresDriver::class, 'buildDsn'); + $method->setAccessible(true); + $dsn = $method->invoke($driver, $config); + + $this->assertEquals("pgsql:host=localhost;port=5432;dbname=testdb;options='--search_path=myschema';sslmode=require", $dsn); + $this->assertEquals('pgsql', $driver->getName()); + } + + public function test_sqlserver_driver_dsn() + { + $driver = new SqlServerDriver(); + $config = [ + 'host' => '127.0.0.1', + 'port' => 1433, + 'database' => 'master', + 'encrypt' => true, + 'trust_server_certificate' => false, + ]; + + $method = new \ReflectionMethod(SqlServerDriver::class, 'buildDsn'); + $method->setAccessible(true); + $dsn = $method->invoke($driver, $config); + + $this->assertEquals("sqlsrv:Server=127.0.0.1,1433;Database=master;APP=Pairity;Encrypt=true;TrustServerCertificate=false", $dsn); + $this->assertEquals('sqlsrv', $driver->getName()); + } + + public function test_oracle_driver_dsn() + { + $driver = new OracleDriver(); + $config = [ + 'host' => 'oracle-db', + 'port' => 1521, + 'database' => 'XE', + 'service_name' => 'XEPDB1', + 'charset' => 'UTF8', + ]; + + $method = new \ReflectionMethod(OracleDriver::class, 'buildDsn'); + $method->setAccessible(true); + $dsn = $method->invoke($driver, $config); + + $this->assertEquals("oci:dbname=//oracle-db:1521/XEPDB1;charset=UTF8", $dsn); + $this->assertEquals('oracle', $driver->getName()); + } +} diff --git a/tests/Unit/Database/Factories/FactoryTest.php b/tests/Unit/Database/Factories/FactoryTest.php new file mode 100644 index 0000000..8927bb7 --- /dev/null +++ b/tests/Unit/Database/Factories/FactoryTest.php @@ -0,0 +1,117 @@ +createMock(DatabaseManagerInterface::class); + $container = $this->createMock(ContainerInterface::class); + $dao = $this->createMock(BaseDAO::class); + + $db->method('getContainer')->willReturn($container); + $container->method('get')->willReturn($dao); + + $factory = new class($db) extends Factory { + public function definition(): array { + return ['name' => 'Default Name', 'email' => 'default@example.com']; + } + public function model(): string { + return \Pairity\Tests\Unit\Database\Factories\TestDTO::class; + } + public function dao(): string { + return 'TestDAO'; + } + }; + + $dao->expects($this->once())->method('save'); + + $model = $factory->create(['name' => 'Overridden Name']); + + $this->assertInstanceOf(TestDTO::class, $model); + $this->assertEquals('Overridden Name', $model->name); + $this->assertEquals('default@example.com', $model->email); + } + + public function test_it_applies_states() + { + $db = $this->createMock(DatabaseManagerInterface::class); + + $factory = new class($db) extends Factory { + public function definition(): array { + return ['name' => 'Default Name', 'role' => 'user']; + } + public function model(): string { + return \Pairity\Tests\Unit\Database\Factories\TestDTO::class; + } + public function dao(): string { + return 'TestDAO'; + } + public function admin() { + return $this->state(['role' => 'admin']); + } + }; + + $model = $factory->admin()->make(); + + $this->assertEquals('admin', $model->role); + } + + public function test_it_creates_multiple_models() + { + $db = $this->createMock(DatabaseManagerInterface::class); + $container = $this->createMock(ContainerInterface::class); + $dao = $this->createMock(BaseDAO::class); + + $db->method('getContainer')->willReturn($container); + $container->method('get')->willReturn($dao); + + $factory = new class($db) extends Factory { + public function definition(): array { + return ['name' => 'Test']; + } + public function model(): string { + return \Pairity\Tests\Unit\Database\Factories\TestDTO::class; + } + public function dao(): string { + return 'TestDAO'; + } + }; + + $dao->expects($this->exactly(3))->method('save'); + + $models = $factory->count(3)->create(); + + $this->assertCount(3, $models); + $this->assertInstanceOf(TestDTO::class, $models[0]); + } +} + +class TestDTO extends BaseDTO +{ + public $name; + public $email; + public $role; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + $this->name = $attributes['name'] ?? null; + $this->email = $attributes['email'] ?? null; + $this->role = $attributes['role'] ?? null; + } + + public function __get($name) { + return $this->$name; + } +} diff --git a/tests/Unit/Database/Interceptors/QueryLoggerTest.php b/tests/Unit/Database/Interceptors/QueryLoggerTest.php new file mode 100644 index 0000000..5493e6d --- /dev/null +++ b/tests/Unit/Database/Interceptors/QueryLoggerTest.php @@ -0,0 +1,36 @@ + ':memory:']); + $connection->addInterceptor($logger); + + $connection->select('SELECT 1'); + $connection->execute('CREATE TABLE test (id INTEGER)'); + $connection->execute('INSERT INTO test (id) VALUES (?)', [123]); + + $logs = $logger->getLogs(); + + $this->assertCount(3, $logs); + $this->assertEquals('SELECT 1', $logs[0]['query']); + $this->assertEquals('read', $logs[0]['mode']); + $this->assertIsFloat($logs[0]['time']); + + $this->assertEquals('INSERT INTO test (id) VALUES (?)', $logs[2]['query']); + $this->assertEquals([123], $logs[2]['bindings']); + $this->assertEquals('write', $logs[2]['mode']); + } +} diff --git a/tests/Unit/Database/Query/AdvancedQueryTest.php b/tests/Unit/Database/Query/AdvancedQueryTest.php new file mode 100644 index 0000000..98a11e7 --- /dev/null +++ b/tests/Unit/Database/Query/AdvancedQueryTest.php @@ -0,0 +1,76 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + + $builder = new Builder($db, $connection, $grammar); + $builder->from('users')->select('id', 'name'); + + $unionBuilder = new Builder($db, $connection, $grammar); + $unionBuilder->from('guests')->select('id', 'name'); + + $builder->union($unionBuilder); + + $this->assertEquals('(select "id", "name" from "users") union (select "id", "name" from "guests")', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } + + public function test_it_compiles_unions_with_bindings() + { + $db = $this->createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + + $builder = new Builder($db, $connection, $grammar); + $builder->from('users')->where('id', 1); + + $unionBuilder = new Builder($db, $connection, $grammar); + $unionBuilder->from('users')->where('id', 2); + + $builder->union($unionBuilder); + + $this->assertEquals('(select * from "users" where "id" = ?) union (select * from "users" where "id" = ?)', $builder->toSql()); + $this->assertEquals([1, 2], $builder->getBindings()); + } + + public function test_it_caches_query_results() + { + $db = $this->createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $container = $this->createMock(ContainerInterface::class); + $cache = $this->createMock(CacheInterface::class); + $grammar = new SqliteGrammar(); + + $db->method('getContainer')->willReturn($container); + $container->method('get')->with(CacheInterface::class)->willReturn($cache); + + $builder = new Builder($db, $connection, $grammar); + $builder->from('users')->remember(60); + + $cache->method('has')->willReturn(true); + $cache->method('get')->willReturn(['cached_result']); + + $connection->expects($this->never())->method('select'); + + $results = $builder->get(); + + $this->assertEquals(['cached_result'], $results); + } +} diff --git a/tests/Unit/Database/Query/AdvancedSubqueryTest.php b/tests/Unit/Database/Query/AdvancedSubqueryTest.php new file mode 100644 index 0000000..5bfcd27 --- /dev/null +++ b/tests/Unit/Database/Query/AdvancedSubqueryTest.php @@ -0,0 +1,119 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + return new Builder($db, $connection, $grammar); + } + + public function test_query_result_magic_access() + { + $result = new QueryResult([ + 'email' => 'test@example.com', + 'first_name' => 'John', + 'is_active' => true + ]); + + $this->assertEquals('test@example.com', $result->email); + $this->assertEquals('test@example.com', $result->getEmail()); + $this->assertEquals('John', $result->first_name); + $this->assertEquals('John', $result->getFirstName()); + $this->assertTrue($result->is_active); + $this->assertTrue($result->getIsActive()); + $this->assertNull($result->non_existent); + $this->assertNull($result->getNonExistent()); + } + + public function test_subquery_joins() + { + $builder = $this->getBuilder(); + + $builder->from('users') + ->joinSub(function ($query) { + $query->select([ + 'user_id', + 'last_post' => new Expression('MAX("created_at")') + ]) + ->from('posts') + ->groupBy('user_id'); + }, 'latest_posts', 'users.id', '=', 'latest_posts.user_id'); + + $expectedSql = 'select * from "users" inner join (select "user_id", MAX("created_at") as "last_post" from "posts" group by "user_id") as "latest_posts" on "users.id" = "latest_posts.user_id"'; + + $this->assertEquals($expectedSql, $builder->toSql()); + } + + public function test_where_exists() + { + $builder = $this->getBuilder(); + + $builder->from('users') + ->whereExists(function ($query) { + $query->select('*') + ->from('orders') + ->where('orders.user_id', 'users.id'); + }); + + $expectedSql = 'select * from "users" where exists (select * from "orders" where "orders.user_id" = ?)'; + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals(['users.id'], $builder->getBindings()); + } + + public function test_subquery_selects() + { + $builder = $this->getBuilder(); + + $builder->from('users') + ->select([ + 'id', + 'email', + 'last_order_date' => function ($query) { + $query->select(new Expression('MAX("created_at")')) + ->from('orders') + ->where('user_id', 1); // Sample binding + } + ]); + + $expectedSql = 'select "id", "email", (select MAX("created_at") from "orders" where "user_id" = ?) as "last_order_date" from "users"'; + + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals([1], $builder->getBindings()); + } + + public function test_complex_nested_bindings_order() + { + $builder = $this->getBuilder(); + + // Bindings in SELECT, JOIN, and WHERE + $builder->select([ + 'name', + 'order_count' => function($q) { $q->from('orders')->where('status', 'shipped'); } // Binding 1 + ]) + ->from('users') + ->join(function($q) { + $q->from('profiles')->where('type', 'pro'); // Binding 2 + }, 'p', 'users.id', '=', 'p.user_id') + ->where('active', 1) // Binding 3 + ->whereExists(function($q) { + $q->from('logs')->where('level', 'error'); // Binding 4 + }); + + $this->assertEquals(['shipped', 'pro', 1, 'error'], $builder->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/BuilderTest.php b/tests/Unit/Database/Query/BuilderTest.php new file mode 100644 index 0000000..b32dc67 --- /dev/null +++ b/tests/Unit/Database/Query/BuilderTest.php @@ -0,0 +1,103 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new $grammarClass(); + return new Builder($db, $connection, $grammar); + } + + public function test_it_compiles_basic_select() + { + $builder = $this->getBuilder()->from('users')->select('id', 'email'); + $this->assertEquals('select "id", "email" from "users"', $builder->toSql()); + } + + public function test_it_compiles_where_clauses() + { + $builder = $this->getBuilder()->from('users')->where('id', 1)->where('active', true); + $this->assertEquals('select * from "users" where "id" = ? and "active" = ?', $builder->toSql()); + $this->assertEquals([1, true], $builder->getBindings()); + } + + public function test_it_compiles_or_where() + { + $builder = $this->getBuilder()->from('users')->where('id', 1)->orWhere('email', 'test@example.com'); + $this->assertEquals('select * from "users" where "id" = ? or "email" = ?', $builder->toSql()); + } + + public function test_it_compiles_joins() + { + $builder = $this->getBuilder()->from('users') + ->join('posts', 'users.id', '=', 'posts.user_id'); + $this->assertEquals('select * from "users" inner join "posts" on "users.id" = "posts.user_id"', $builder->toSql()); + } + + public function test_it_compiles_limit_and_offset() + { + $builder = $this->getBuilder()->from('users')->limit(10)->offset(5); + $this->assertEquals('select * from "users" limit 10 offset 5', $builder->toSql()); + } + + public function test_mysql_grammar_uses_backticks() + { + $builder = $this->getBuilder(MySqlGrammar::class)->from('users')->select('id'); + $this->assertEquals('select `id` from `users`', $builder->toSql()); + } + + public function test_sql_method_returns_array() + { + $builder = $this->getBuilder()->from('users')->where('id', 1); + $result = $builder->sql(); + + $this->assertIsArray($result); + $this->assertEquals('select * from "users" where "id" = ?', $result['sql']); + $this->assertEquals([1], $result['bindings']); + } + + public function test_it_handles_raw_expressions() + { + $builder = $this->getBuilder(); + $builder->from('users')->select(new Expression('COUNT(*) as user_count')); + + $this->assertEquals('select COUNT(*) as user_count from "users"', $builder->toSql()); + } + + public function test_it_handles_scopes() + { + $dao = new class { + public function scopeActive($builder, $value = true) { + return $builder->where('active', $value); + } + public function getOption($key, $default = null) { + return $default; + } + }; + + $db = $this->createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $builder = new Builder($db, $connection, new SqliteGrammar()); + $builder->setModel('TestDTO', $dao); + + $builder->active(false); + + $this->assertEquals('select * where "active" = ?', $builder->toSql()); + $this->assertEquals([false], $builder->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/RelationshipTest.php b/tests/Unit/Database/Query/RelationshipTest.php new file mode 100644 index 0000000..100de18 --- /dev/null +++ b/tests/Unit/Database/Query/RelationshipTest.php @@ -0,0 +1,81 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + return new Builder($db, $connection, $grammar); + } + + public function test_has_many_relation_query_construction() + { + $parent = new class(['id' => 1]) extends BaseDTO { + public $id = 1; + }; + + $relatedDao = $this->createMock(BaseDAO::class); + $relatedDao->method('getTable')->willReturn('posts'); + $relatedDao->method('getDtoClass')->willReturn('PostDTO'); + + $query = $this->getBuilder(); + $relation = new HasMany($query, $parent, $relatedDao, 'user_id', 'id'); + + $this->assertEquals('select * from "posts" where "user_id" = ?', $relation->toSql()); + $this->assertEquals([1], $relation->getBindings()); + } + + public function test_belongs_to_relation_query_construction() + { + $parent = new class(['user_id' => 10]) extends BaseDTO { + public $user_id = 10; + }; + + $relatedDao = $this->createMock(BaseDAO::class); + $relatedDao->method('getTable')->willReturn('users'); + $relatedDao->method('getDtoClass')->willReturn('UserDTO'); + + $query = $this->getBuilder(); + $relation = new BelongsTo($query, $parent, $relatedDao, 'user_id', 'id'); + + $this->assertEquals('select * from "users" where "id" = ?', $relation->toSql()); + $this->assertEquals([10], $relation->getBindings()); + } + + public function test_eager_constraints_on_has_many() + { + $parent1 = new class(['id' => 1]) extends BaseDTO { public $id = 1; }; + $parent2 = new class(['id' => 2]) extends BaseDTO { public $id = 2; }; + + $relatedDao = $this->createMock(BaseDAO::class); + $relatedDao->method('getTable')->willReturn('posts'); + + $query = $this->getBuilder(); + $relation = new HasMany($query, $parent1, $relatedDao, 'user_id', 'id'); + + // Simulating EagerLoader behavior: reset wheres before adding eager constraints + $relation->wheres = []; + $relation->setBindings([], 'where'); + $relation->addEagerConstraints([$parent1, $parent2]); + + $this->assertEquals('select * from "posts" where "user_id" in (?, ?)', $relation->toSql()); + $this->assertEquals([1, 2], $relation->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/Scopes/TenantScopeTest.php b/tests/Unit/Database/Query/Scopes/TenantScopeTest.php new file mode 100644 index 0000000..70fd1a2 --- /dev/null +++ b/tests/Unit/Database/Query/Scopes/TenantScopeTest.php @@ -0,0 +1,100 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + + $builder = new Builder($db, $connection, $grammar); + + $dao = $this->createMock(BaseDAO::class); + $dao->method('getOption')->with('tenancy', false)->willReturn(true); + + $builder->setModel('TestDTO', $dao); + $builder->from('users'); + + $sql = $builder->toSql(); + $this->assertStringContainsString('where "tenant_id" = ?', $sql); + $this->assertEquals([1], $builder->getBindings()); + } + + public function test_it_can_disable_tenancy() + { + TenantScope::setTenantId(1); + + $db = $this->createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + + $builder = new Builder($db, $connection, $grammar); + + $dao = $this->createMock(BaseDAO::class); + $dao->method('getOption')->with('tenancy', false)->willReturn(true); + + $builder->setModel('TestDTO', $dao); + $builder->from('users')->withoutTenancy(); + + $sql = $builder->toSql(); + $this->assertStringNotContainsString('where "tenant_id" = ?', $sql); + $this->assertEquals([], $builder->getBindings()); + } + + public function test_it_injects_tenant_id_on_insert() + { + TenantScope::setTenantId(99); + + $db = $this->createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $identityMap = $this->createMock(IdentityMap::class); + + $driver = $this->createMock(\Pairity\Contracts\Database\DriverInterface::class); + $driver->method('getName')->willReturn('sqlite'); + $connection->method('getDriver')->willReturn($driver); + $db->method('getQueryGrammar')->willReturn(new SqliteGrammar()); + $db->method('connection')->willReturn($connection); + + $dao = new class($db, $identityMap) extends BaseDAO { + protected string $table = 'users'; + protected array $options = ['tenancy' => true]; + + public function testInsert(array $data) { + return $this->insert($data); + } + }; + + $connection->expects($this->once()) + ->method('execute') + ->with($this->callback(function($sql) { + return strpos($sql, '"tenant_id"') !== false; + }), $this->callback(function($bindings) { + return in_array(99, $bindings); + })) + ->willReturn(1); + + $dao->testInsert(['name' => 'John']); + } +} diff --git a/tests/Unit/Database/Query/SubqueryTest.php b/tests/Unit/Database/Query/SubqueryTest.php new file mode 100644 index 0000000..fdef1ba --- /dev/null +++ b/tests/Unit/Database/Query/SubqueryTest.php @@ -0,0 +1,100 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + return new Builder($db, $connection, $grammar); + } + + public function test_it_handles_subquery_in_from() + { + $builder = $this->getBuilder(); + $builder->from(function ($query) { + $query->select('id')->from('users')->where('active', true); + }, 'active_users'); + + $this->assertEquals('select * from (select "id" from "users" where "active" = ?) as "active_users"', $builder->toSql()); + $this->assertEquals([true], $builder->getBindings()); + } + + public function test_it_handles_builder_instance_in_from() + { + $subQuery = $this->getBuilder()->select('id')->from('users')->where('id', '>', 10); + + $builder = $this->getBuilder(); + $builder->from($subQuery, 'filtered_users'); + + $this->assertEquals('select * from (select "id" from "users" where "id" > ?) as "filtered_users"', $builder->toSql()); + $this->assertEquals([10], $builder->getBindings()); + } + + public function test_it_handles_where_in_subquery() + { + $builder = $this->getBuilder(); + $builder->from('posts')->whereIn('user_id', function ($query) { + $query->select('id')->from('users')->where('banned', false); + }); + + $this->assertEquals('select * from "posts" where "user_id" in (select "id" from "users" where "banned" = ?)', $builder->toSql()); + $this->assertEquals([false], $builder->getBindings()); + } + + public function test_it_handles_complex_nested_bindings() + { + $builder = $this->getBuilder(); + $builder->from(function ($query) { + $query->select('user_id')->from('orders')->where('amount', '>', 100); + }, 'big_orders') + ->whereIn('user_id', function ($query) { + $query->select('id')->from('users')->where('country', 'US'); + }) + ->where('status', 'shipped'); + + $expectedSql = 'select * from (select "user_id" from "orders" where "amount" > ?) as "big_orders" where "user_id" in (select "id" from "users" where "country" = ?) and "status" = ?'; + + $this->assertEquals($expectedSql, $builder->toSql()); + $this->assertEquals([100, 'US', 'shipped'], $builder->getBindings()); + } + + public function test_it_handles_where_not_in_subquery() + { + $builder = $this->getBuilder(); + $builder->from('users')->whereNotIn('id', function ($query) { + $query->select('user_id')->from('banned_users'); + }); + + $this->assertEquals('select * from "users" where "id" not in (select "user_id" from "banned_users")', $builder->toSql()); + } + + public function test_it_handles_where_in_array() + { + $builder = $this->getBuilder(); + $builder->from('users')->whereIn('id', [1, 2, 3]); + + $this->assertEquals('select * from "users" where "id" in (?, ?, ?)', $builder->toSql()); + $this->assertEquals([1, 2, 3], $builder->getBindings()); + } + + public function test_it_handles_empty_where_in_array() + { + $builder = $this->getBuilder(); + $builder->from('users')->whereIn('id', []); + + $this->assertEquals('select * from "users" where 0 = 1', $builder->toSql()); + $this->assertEquals([], $builder->getBindings()); + } +} diff --git a/tests/Unit/Database/Query/UnconstrainedQueryTest.php b/tests/Unit/Database/Query/UnconstrainedQueryTest.php new file mode 100644 index 0000000..9a9d518 --- /dev/null +++ b/tests/Unit/Database/Query/UnconstrainedQueryTest.php @@ -0,0 +1,180 @@ +createMock(DatabaseManagerInterface::class); + $container = $this->createMock(\Pairity\Contracts\Container\ContainerInterface::class); + $translator = new \Pairity\Translation\Translator(dirname(__DIR__, 4) . '/src/Translations'); + + $db->method('getContainer')->willReturn($container); + $container->method('get')->with(\Pairity\Contracts\Translation\TranslatorInterface::class)->willReturn($translator); + + $connection = $this->createMock(ConnectionInterface::class); + $connection->method('getConfig')->willReturn($config); + $grammar = new SqliteGrammar(); + return new Builder($db, $connection, $grammar); + } + + public function test_it_throws_exception_on_unconstrained_update() + { + $builder = $this->getBuilder(['allow_unconstrained_queries' => false]); + $builder->from('users'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unconstrained update queries are disabled by configuration.'); + + $builder->update(['name' => 'John']); + } + + public function test_it_throws_exception_on_unconstrained_delete() + { + $builder = $this->getBuilder(['allow_unconstrained_queries' => false]); + $builder->from('users'); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unconstrained delete queries are disabled by configuration.'); + + $builder->delete(); + } + + public function test_it_allows_constrained_update() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->method('getConfig')->willReturn(['allow_unconstrained_queries' => false]); + $connection->expects($this->once()) + ->method('execute') + ->with('update "users" set "name" = ? where "id" = ?', ['John', 1]) + ->willReturn(1); + + $db = $this->createMock(DatabaseManagerInterface::class); + $container = $this->createMock(\Pairity\Contracts\Container\ContainerInterface::class); + $db->method('getContainer')->willReturn($container); + + $builder = new Builder($db, $connection, new SqliteGrammar()); + $builder->from('users')->where('id', 1); + + $affected = $builder->update(['name' => 'John']); + $this->assertEquals(1, $affected); + } + + public function test_it_allows_constrained_delete() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->method('getConfig')->willReturn(['allow_unconstrained_queries' => false]); + $connection->expects($this->once()) + ->method('execute') + ->with('delete from "users" where "id" = ?', [1]) + ->willReturn(1); + + $db = $this->createMock(DatabaseManagerInterface::class); + $container = $this->createMock(\Pairity\Contracts\Container\ContainerInterface::class); + $db->method('getContainer')->willReturn($container); + + $builder = new Builder($db, $connection, new SqliteGrammar()); + $builder->from('users')->where('id', 1); + + $affected = $builder->delete(); + $this->assertEquals(1, $affected); + } + + public function test_it_allows_unconstrained_update_if_configured() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->method('getConfig')->willReturn(['allow_unconstrained_queries' => true]); + $connection->expects($this->once()) + ->method('execute') + ->with('update "users" set "name" = ?', ['John']) + ->willReturn(5); + + $db = $this->createMock(DatabaseManagerInterface::class); + $container = $this->createMock(\Pairity\Contracts\Container\ContainerInterface::class); + $db->method('getContainer')->willReturn($container); + + $builder = new Builder($db, $connection, new SqliteGrammar()); + $builder->from('users'); + + $affected = $builder->update(['name' => 'John']); + $this->assertEquals(5, $affected); + } + + public function test_it_allows_unconstrained_delete_if_configured() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->method('getConfig')->willReturn(['allow_unconstrained_queries' => true]); + $connection->expects($this->once()) + ->method('execute') + ->with('delete from "users"', []) + ->willReturn(5); + + $db = $this->createMock(DatabaseManagerInterface::class); + $container = $this->createMock(\Pairity\Contracts\Container\ContainerInterface::class); + $db->method('getContainer')->willReturn($container); + + $builder = new Builder($db, $connection, new SqliteGrammar()); + $builder->from('users'); + + $affected = $builder->delete(); + $this->assertEquals(5, $affected); + } + + public function test_it_throws_exception_on_unconstrained_update_through_dao() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->method('getConfig')->willReturn(['allow_unconstrained_queries' => false]); + $grammar = new SqliteGrammar(); + $db = $this->createMock(\Pairity\Contracts\Database\DatabaseManagerInterface::class); + $container = $this->createMock(\Pairity\Contracts\Container\ContainerInterface::class); + $translator = new \Pairity\Translation\Translator(dirname(__DIR__, 4) . '/src/Translations'); + + $db->method('getContainer')->willReturn($container); + $container->method('get')->with(\Pairity\Contracts\Translation\TranslatorInterface::class)->willReturn($translator); + + $db->method('connection')->willReturn($connection); + $db->method('getQueryGrammar')->willReturn($grammar); + $identityMap = new \Pairity\DTO\IdentityMap(); + + $dao = new \App\Models\DAO\UsersDAO($db, $identityMap); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unconstrained update queries are disabled by configuration.'); + + $dao->massUpdate(['email' => 'hacked@example.com']); + } + + public function test_it_throws_exception_on_unconstrained_delete_through_dao() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->method('getConfig')->willReturn(['allow_unconstrained_queries' => false]); + $grammar = new SqliteGrammar(); + $db = $this->createMock(\Pairity\Contracts\Database\DatabaseManagerInterface::class); + $container = $this->createMock(\Pairity\Contracts\Container\ContainerInterface::class); + $translator = new \Pairity\Translation\Translator(dirname(__DIR__, 4) . '/src/Translations'); + + $db->method('getContainer')->willReturn($container); + $container->method('get')->with(\Pairity\Contracts\Translation\TranslatorInterface::class)->willReturn($translator); + + $db->method('connection')->willReturn($connection); + $db->method('getQueryGrammar')->willReturn($grammar); + $identityMap = new \Pairity\DTO\IdentityMap(); + + $dao = new \App\Models\DAO\UsersDAO($db, $identityMap); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Unconstrained delete queries are disabled by configuration.'); + + $dao->massDelete(); + } +} diff --git a/tests/Unit/Database/Query/UpsertTest.php b/tests/Unit/Database/Query/UpsertTest.php new file mode 100644 index 0000000..1e8de44 --- /dev/null +++ b/tests/Unit/Database/Query/UpsertTest.php @@ -0,0 +1,51 @@ +createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + $builder = new Builder($db, $connection, $grammar); + + $values = [ + ['email' => 'test@example.com', 'name' => 'Test'], + ['email' => 'foo@bar.com', 'name' => 'Foo'] + ]; + + $sql = $grammar->compileUpsert($builder->from('users'), $values, ['email'], ['name']); + + $expected = 'insert into "users" ("email", "name") values (?, ?), (?, ?) on conflict ("email") do update set "name" = excluded."name"'; + $this->assertEquals($expected, $sql); + } + + public function test_upsert_bindings() + { + $db = $this->createMock(DatabaseManagerInterface::class); + $connection = $this->createMock(ConnectionInterface::class); + $grammar = new SqliteGrammar(); + $builder = new Builder($db, $connection, $grammar); + + $values = [ + ['email' => 'test@example.com', 'name' => 'Test'], + ['email' => 'foo@bar.com', 'name' => 'Foo'] + ]; + + $connection->expects($this->once()) + ->method('execute') + ->with($this->anything(), ['test@example.com', 'Test', 'foo@bar.com', 'Foo']); + + $builder->from('users')->upsert($values, ['email'], ['name']); + } +} diff --git a/tests/Unit/Database/UnitOfWorkTest.php b/tests/Unit/Database/UnitOfWorkTest.php new file mode 100644 index 0000000..5f187c6 --- /dev/null +++ b/tests/Unit/Database/UnitOfWorkTest.php @@ -0,0 +1,82 @@ +db = $this->createMock(DatabaseManagerInterface::class); + $this->uow = new UnitOfWork($this->db); + } + + public function test_it_tracks_dto_states() + { + $dto = $this->createMock(BaseDTO::class); + + $this->uow->track($dto, UnitOfWork::STATE_CLEAN); + $this->assertEquals(UnitOfWork::STATE_CLEAN, $this->uow->getState($dto)); + + $this->uow->registerDirty($dto); + $this->assertEquals(UnitOfWork::STATE_DIRTY, $this->uow->getState($dto)); + + $this->uow->registerDeleted($dto); + $this->assertEquals(UnitOfWork::STATE_DELETED, $this->uow->getState($dto)); + } + + public function test_register_dirty_does_not_override_new() + { + $dto = $this->createMock(BaseDTO::class); + $this->uow->registerNew($dto); + $this->assertEquals(UnitOfWork::STATE_NEW, $this->uow->getState($dto)); + + $this->uow->registerDirty($dto); + $this->assertEquals(UnitOfWork::STATE_NEW, $this->uow->getState($dto)); + } + + public function test_commit_executes_dao_actions_and_uses_transactions() + { + $connection = $this->createMock(ConnectionInterface::class); + $connection->expects($this->once())->method('beginTransaction'); + $connection->expects($this->once())->method('commit'); + + $this->db->method('connection')->willReturn($connection); + + $dao = $this->createMock(BaseDAO::class); + $dao->method('getConnectionName')->willReturn('default'); + $dao->method('getPrimaryKey')->willReturn('id'); + + $dtoNew = $this->createMock(BaseDTO::class); + $dtoNew->method('getDao')->willReturn($dao); + + $dtoDirty = $this->createMock(BaseDTO::class); + $dtoDirty->method('getDao')->willReturn($dao); + + $dtoDeleted = $this->createMock(BaseDTO::class); + $dtoDeleted->method('getDao')->willReturn($dao); + $dtoDeleted->method('toArray')->willReturn(['id' => 1]); + + $this->uow->registerNew($dtoNew); + $this->uow->registerDirty($dtoDirty); + $this->uow->registerDeleted($dtoDeleted); + + $dao->expects($this->exactly(2))->method('save')->with($this->callback(fn($d) => $d === $dtoNew || $d === $dtoDirty)); + $dao->expects($this->once())->method('delete')->with(1); + + $this->uow->commit(); + + $this->assertNull($this->uow->getState($dtoNew)); + } +} diff --git a/tests/Unit/Events/DispatcherTest.php b/tests/Unit/Events/DispatcherTest.php new file mode 100644 index 0000000..fc82477 --- /dev/null +++ b/tests/Unit/Events/DispatcherTest.php @@ -0,0 +1,74 @@ +listen('test.event', function ($payload) use (&$fired) { + $fired = true; + $this->assertEquals('hello', $payload); + }); + + $dispatcher->dispatch('test.event', 'hello'); + $this->assertTrue($fired); + } + + public function test_it_handles_wildcards() + { + $dispatcher = new Dispatcher(); + $count = 0; + + $dispatcher->listen('user.*', function () use (&$count) { + $count++; + }); + + $dispatcher->dispatch('user.created'); + $dispatcher->dispatch('user.updated'); + $dispatcher->dispatch('post.created'); + + $this->assertEquals(2, $count); + } + + public function test_it_can_halt_execution() + { + $dispatcher = new Dispatcher(); + + $dispatcher->listen('test', function () { + return 'first'; + }); + + $dispatcher->listen('test', function () { + return 'second'; + }); + + $result = $dispatcher->dispatch('test', null, true); + $this->assertEquals('first', $result); + } + + public function test_returning_false_stops_propagation() + { + $dispatcher = new Dispatcher(); + $secondFired = false; + + $dispatcher->listen('test', function () { + return false; + }); + + $dispatcher->listen('test', function () use (&$secondFired) { + $secondFired = true; + }); + + $dispatcher->dispatch('test'); + $this->assertFalse($secondFired); + } +} diff --git a/tests/Unit/Schema/BuilderTest.php b/tests/Unit/Schema/BuilderTest.php new file mode 100644 index 0000000..3869ab9 --- /dev/null +++ b/tests/Unit/Schema/BuilderTest.php @@ -0,0 +1,60 @@ +getBlueprint(); + + $this->assertEquals('users', $blueprint->getTableName()); + } + + public function test_it_can_add_columns_fluently() + { + $builder = new Builder('users'); + $builder->id(); + $builder->string('email', 100)->unique(); + $builder->boolean('is_active')->default(true); + $builder->timestamps(); + + $blueprint = $builder->getBlueprint(); + $columns = $blueprint->getColumns(); + + $this->assertCount(5, $columns); // id, email, is_active, created_at, updated_at + + $this->assertEquals('id', $columns[0]->getName()); + $this->assertTrue($columns[0]->getAttribute('primary')); + + $this->assertEquals('email', $columns[1]->getName()); + $this->assertEquals(100, $columns[1]->getAttribute('length')); + $this->assertTrue($columns[1]->getAttribute('unique')); + + $this->assertEquals('is_active', $columns[2]->getName()); + $this->assertTrue($columns[2]->getAttribute('default')); + } + + public function test_it_supports_magic_methods_for_types() + { + $builder = new Builder('products'); + $builder->decimal('price', ['precision' => 10, 'scale' => 2]); + $builder->uuid('uuid'); + + $columns = $builder->getBlueprint()->getColumns(); + + $this->assertEquals('price', $columns[0]->getName()); + $this->assertEquals('decimal', $columns[0]->getType()); + $this->assertEquals(10, $columns[0]->getAttribute('precision')); + + $this->assertEquals('uuid', $columns[1]->getName()); + $this->assertEquals('uuid', $columns[1]->getType()); + } +} diff --git a/tests/Unit/Schema/CodeGeneratorTest.php b/tests/Unit/Schema/CodeGeneratorTest.php new file mode 100644 index 0000000..5b76c34 --- /dev/null +++ b/tests/Unit/Schema/CodeGeneratorTest.php @@ -0,0 +1,52 @@ +stubsPath = __DIR__ . '/../../../src/Stubs'; + } + + public function test_it_generates_dto_code() + { + $blueprint = new Blueprint('users'); + $blueprint->addColumn('id', 'bigInteger', ['primary' => true]); + $blueprint->addColumn('email', 'string', ['unique' => true]); + + $generator = new CodeGenerator($this->stubsPath); + $code = $generator->generateDto($blueprint, 'App\Models\DTO'); + + $this->assertStringContainsString('namespace App\Models\DTO;', $code); + $this->assertStringContainsString('class UsersDTO extends BaseDTO', $code); + $this->assertStringContainsString('protected $id;', $code); + $this->assertStringContainsString('protected $email;', $code); + $this->assertStringContainsString('public function getId()', $code); + $this->assertStringContainsString('public function getEmail()', $code); + } + + public function test_it_generates_dao_code() + { + $blueprint = new Blueprint('users'); + $blueprint->addColumn('id', 'bigInteger', ['primary' => true]); + + $generator = new CodeGenerator($this->stubsPath); + $code = $generator->generateDao($blueprint, 'App\Models\DAO', 'App\Models\DTO'); + + $this->assertStringContainsString('namespace App\Models\DAO;', $code); + $this->assertStringContainsString('class UsersDAO extends BaseDAO', $code); + $this->assertStringContainsString("protected string \$table = 'users';", $code); + $this->assertStringContainsString("protected string \$primaryKey = 'id';", $code); + $this->assertStringContainsString('use App\Models\DTO\UsersDTO;', $code); + } +} diff --git a/tests/Unit/Schema/HydratorTest.php b/tests/Unit/Schema/HydratorTest.php new file mode 100644 index 0000000..b0ff1e9 --- /dev/null +++ b/tests/Unit/Schema/HydratorTest.php @@ -0,0 +1,41 @@ + 123, + 'email' => 'test@example.com', + ]; + + $dto = new UsersDTO(); + $hydrator = new UsersHydrator(); + $hydrator->hydrate($data, $dto); + + $this->assertEquals(123, $dto->getId()); + $this->assertEquals('test@example.com', $dto->getEmail()); + } + + public function test_it_only_hydrates_provided_data() + { + $data = [ + 'email' => 'only@example.com', + ]; + + $dto = new UsersDTO(['id' => 1]); + $hydrator = new UsersHydrator(); + $hydrator->hydrate($data, $dto); + + $this->assertEquals(1, $dto->getId()); + $this->assertEquals('only@example.com', $dto->getEmail()); + } +} diff --git a/tests/Unit/Schema/MetadataManagerTest.php b/tests/Unit/Schema/MetadataManagerTest.php new file mode 100644 index 0000000..dc3b938 --- /dev/null +++ b/tests/Unit/Schema/MetadataManagerTest.php @@ -0,0 +1,89 @@ +schemaDir = __DIR__ . '/../../../schema_test'; + $this->cacheDir = __DIR__ . '/../../../storage/test_metadata_cache'; + + if (!is_dir($this->schemaDir)) mkdir($this->schemaDir, 0755, true); + if (!is_dir($this->cacheDir)) mkdir($this->cacheDir, 0755, true); + + $this->parser = new YamlSchemaParser(); + $this->cache = new FileCache($this->cacheDir); + $this->manager = new MetadataManager($this->parser, $this->cache); + } + + protected function tearDown(): void + { + $this->removeDir($this->schemaDir); + $this->removeDir($this->cacheDir); + } + + protected function removeDir(string $dir): void + { + if (!is_dir($dir)) return; + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + (is_dir("$dir/$file")) ? $this->removeDir("$dir/$file") : unlink("$dir/$file"); + } + rmdir($dir); + } + + public function test_it_parses_and_caches_blueprint() + { + $yaml = "columns:\n id: bigInteger\n"; + file_put_contents($this->schemaDir . '/users.yaml', $yaml); + + $blueprint = $this->manager->getBlueprint('users', $this->schemaDir); + $this->assertInstanceOf(Blueprint::class, $blueprint); + $this->assertEquals('users', $blueprint->getTableName()); + + // Second call should come from memory registry + $blueprint2 = $this->manager->getBlueprint('users', $this->schemaDir); + $this->assertSame($blueprint, $blueprint2); + + // Verify it is in cache + $newManager = new MetadataManager($this->parser, $this->cache); + $blueprint3 = $newManager->getBlueprint('users', $this->schemaDir); + $this->assertEquals($blueprint->getTableName(), $blueprint3->getTableName()); + $this->assertNotSame($blueprint, $blueprint3); // Different instance from cache + } + + public function test_it_invalidates_cache_on_file_change() + { + $yaml = "columns:\n id: bigInteger\n"; + $file = $this->schemaDir . '/users.yaml'; + file_put_contents($file, $yaml); + + $blueprint = $this->manager->getBlueprint('users', $this->schemaDir); + + // Change file + sleep(1); // Ensure mtime change + $yaml2 = "columns:\n id: bigInteger\n name: string\n"; + file_put_contents($file, $yaml2); + + $newManager = new MetadataManager($this->parser, $this->cache); + $blueprint2 = $newManager->getBlueprint('users', $this->schemaDir); + + $this->assertCount(2, $blueprint2->getColumns()); + $this->assertNotEquals($blueprint->getColumns(), $blueprint2->getColumns()); + } +} diff --git a/tests/Unit/Schema/YamlSchemaParserTest.php b/tests/Unit/Schema/YamlSchemaParserTest.php new file mode 100644 index 0000000..5eb9836 --- /dev/null +++ b/tests/Unit/Schema/YamlSchemaParserTest.php @@ -0,0 +1,82 @@ + true, + 'timestamps' => true, + 'columns' => [ + 'id' => [ + 'type' => 'bigInteger', + 'primary' => true, + 'autoIncrement' => true, + ], + 'email' => [ + 'type' => 'string', + 'length' => 150, + 'unique' => true, + 'encrypted' => true, + ], + 'status' => 'string', + ], + 'indexes' => [ + 'idx_status' => ['status'], + ], + 'relations' => [ + 'posts' => [ + 'type' => 'hasMany', + 'model' => 'Post', + ], + ], + ]; + + $parser = new YamlSchemaParser(); + $blueprint = $parser->parse('users', $yaml); + + $this->assertEquals('users', $blueprint->getTableName()); + $this->assertTrue($blueprint->getOption('tenancy')); + + $columns = $blueprint->getColumns(); + $this->assertCount(3, $columns); + + $this->assertEquals('email', $columns[1]->getName()); + $this->assertEquals(150, $columns[1]->getAttribute('length')); + $this->assertTrue($columns[1]->getAttribute('unique')); + $this->assertTrue($columns[1]->getAttribute('encrypted')); + + $this->assertArrayHasKey('idx_status', $blueprint->getIndexes()); + $this->assertArrayHasKey('posts', $blueprint->getRelations()); + } + + public function test_it_parses_yaml_string_correctly() + { + $yaml = <<parseYaml('users', $yaml); + + $this->assertEquals('users', $blueprint->getTableName()); + $this->assertTrue($blueprint->getOption('tenancy')); + $this->assertCount(2, $blueprint->getColumns()); + $this->assertEquals('email', $blueprint->getColumns()[1]->getName()); + $this->assertTrue($blueprint->getColumns()[1]->getAttribute('unique')); + } +} diff --git a/tests/Unit/Translation/TranslatorTest.php b/tests/Unit/Translation/TranslatorTest.php new file mode 100644 index 0000000..77ebbb8 --- /dev/null +++ b/tests/Unit/Translation/TranslatorTest.php @@ -0,0 +1,47 @@ +translationsPath = __DIR__ . '/../../../src/Translations'; + } + + public function test_translates_known_key(): void + { + $t = new Translator($this->translationsPath, 'en'); + $this->assertSame('Available commands:', $t->trans('app.available_commands')); + } + + public function test_placeholder_replacement(): void + { + $t = new Translator($this->translationsPath, 'en'); + $res = $t->trans('app.version', ['name' => 'Pairity CLI', 'version' => '1.2.3']); + $this->assertSame('Pairity CLI version 1.2.3', $res); + } + + public function test_fallback_to_key_when_missing(): void + { + $t = new Translator($this->translationsPath, 'en'); + $this->assertSame('missing.key', $t->trans('missing.key')); + } + + public function test_env_locale_is_used(): void + { + $t = new Translator($this->translationsPath, 'en'); + $this->assertSame('en', $t->getLocale()); + $t->setLocale('en'); + $this->assertSame('Available commands:', $t->trans('app.available_commands')); + } +} diff --git a/tests/UnitOfWorkCascadeMongoTest.php b/tests/UnitOfWorkCascadeMongoTest.php deleted file mode 100644 index fc6eb04..0000000 --- a/tests/UnitOfWorkCascadeMongoTest.php +++ /dev/null @@ -1,102 +0,0 @@ -hasMongoExt()) { - $this->markTestSkipped('ext-mongodb not loaded'); - } - - // Connect (skip if server unavailable) - try { - $conn = MongoConnectionManager::make([ - 'host' => \getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(\getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - // Inline DTOs - $userDto = new class([]) extends AbstractDto {}; - $userDtoClass = \get_class($userDto); - $postDto = new class([]) extends AbstractDto {}; - $postDtoClass = \get_class($postDto); - - // Inline DAOs with relation and cascadeDelete=true - $UserDao = new class($conn, $userDtoClass, $postDtoClass) extends AbstractMongoDao { - private string $userDto; private string $postDto; public function __construct($c, string $u, string $p) { parent::__construct($c); $this->userDto = $u; $this->postDto = $p; } - protected function collection(): string { return 'pairity_test.uow_users_cascade'; } - protected function dtoClass(): string { return $this->userDto; } - protected function relations(): array { - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => get_class(new class($this->getConnection(), $this->postDto) extends AbstractMongoDao { - private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; } - protected function collection(): string { return 'pairity_test.uow_posts_cascade'; } - protected function dtoClass(): string { return $this->dto; } - }), - 'foreignKey' => 'user_id', - 'localKey' => '_id', - 'cascadeDelete' => true, - ], - ]; - } - }; - - $PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao { - private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; } - protected function collection(): string { return 'pairity_test.uow_posts_cascade'; } - protected function dtoClass(): string { return $this->dto; } - }; - - $userDao = new $UserDao($conn, $userDtoClass, $postDtoClass); - $postDao = new $PostDao($conn, $postDtoClass); - - // Clean - foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } } - foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } } - - // Seed - $u = $userDao->insert(['email' => 'c@example.com']); - $uid = (string)($u->toArray(false)['_id'] ?? ''); - $postDao->insert(['user_id' => $uid, 'title' => 'A']); - $postDao->insert(['user_id' => $uid, 'title' => 'B']); - - // UoW: delete parent -> children should be deleted first - UnitOfWork::run(function() use ($userDao, $uid) { - $userDao->deleteById($uid); - }); - - // Verify - $children = $postDao->findAllBy(['user_id' => $uid]); - $this->assertCount(0, $children, 'Child posts should be deleted via cascade'); - $this->assertNull($userDao->findById($uid)); - } -} diff --git a/tests/UnitOfWorkCascadeSqliteTest.php b/tests/UnitOfWorkCascadeSqliteTest.php deleted file mode 100644 index 20df4db..0000000 --- a/tests/UnitOfWorkCascadeSqliteTest.php +++ /dev/null @@ -1,87 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testDeleteByIdCascadesToChildren(): void - { - $conn = $this->conn(); - // schema - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT)'); - $conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)'); - - // DTOs - $userDto = new class([]) extends AbstractDto {}; - $postDto = new class([]) extends AbstractDto {}; - $userDtoClass = get_class($userDto); - $postDtoClass = get_class($postDto); - - // DAOs with hasMany relation and cascadeDelete=true (constructors accept only connection) - $UserDao = new class($conn) extends AbstractDao { - public static string $userDto; public static string $postDaoClass; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return self::$userDto; } - protected function relations(): array { - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => self::$postDaoClass, - 'foreignKey' => 'user_id', - 'localKey' => 'id', - 'cascadeDelete' => true, - ], - ]; - } - protected function schema(): array { return ['primaryKey' => 'id', 'columns' => ['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; } - }; - - $PostDao = new class($conn) extends AbstractDao { - public static string $dto; - public function __construct($c) { parent::__construct($c); } - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return self::$dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; } - }; - - $postDaoClass = get_class($PostDao); - $postDaoClass::$dto = $postDtoClass; - - $userDaoClass = get_class($UserDao); - $userDaoClass::$userDto = $userDtoClass; - $userDaoClass::$postDaoClass = $postDaoClass; - - $userDao = new $userDaoClass($conn); - $postDao = new $postDaoClass($conn); - - // seed - $u = $userDao->insert(['email' => 'c@example.com']); - $uid = (int)($u->toArray(false)['id'] ?? 0); - $postDao->insert(['user_id' => $uid, 'title' => 'A']); - $postDao->insert(['user_id' => $uid, 'title' => 'B']); - - // UoW: delete user; expect posts to be deleted first via cascade - UnitOfWork::run(function() use ($userDao, $uid) { - $userDao->deleteById($uid); - }); - - // verify posts gone and user gone - $remainingPosts = $postDao->findAllBy(['user_id' => $uid]); - $this->assertCount(0, $remainingPosts, 'Child posts should be deleted via cascade'); - $this->assertNull($userDao->findById($uid)); - } -} diff --git a/tests/UnitOfWorkMongoTest.php b/tests/UnitOfWorkMongoTest.php deleted file mode 100644 index c57cd32..0000000 --- a/tests/UnitOfWorkMongoTest.php +++ /dev/null @@ -1,138 +0,0 @@ -hasMongoExt()) { - $this->markTestSkipped('ext-mongodb not loaded'); - } - - // Connect (skip if server unavailable) - try { - $conn = MongoConnectionManager::make([ - 'host' => \getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(\getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - // Inline DTO and DAO - $dto = new class([]) extends AbstractDto {}; - $dtoClass = \get_class($dto); - - $dao = new class($conn, $dtoClass) extends AbstractMongoDao { - private string $dto; - public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.uow_docs'; } - protected function dtoClass(): string { return $this->dto; } - }; - - // Clean collection - foreach ($dao->findAllBy([]) as $doc) { - $id = (string)($doc->toArray(false)['_id'] ?? ''); - if ($id !== '') { $dao->deleteById($id); } - } - - // Insert a document (immediate) - $created = $dao->insert(['name' => 'Widget', 'qty' => 1]); - $id = (string)($created->toArray(false)['_id'] ?? ''); - $this->assertNotEmpty($id); - - // Run UoW with deferred update then delete - UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) { - $one = $dao->findById($id); - $this->assertNotNull($one); - // defer update - $dao->update($id, ['qty' => 2]); - // defer delete - $dao->deleteById($id); - // commit at end of run() - }); - - // After commit, it should be deleted - $this->assertNull($dao->findById($id)); - } - - public function testRollbackOnExceptionClearsOps(): void - { - if (!$this->hasMongoExt()) { - $this->markTestSkipped('ext-mongodb not loaded'); - } - - try { - $conn = MongoConnectionManager::make([ - 'host' => \getenv('MONGO_HOST') ?: '127.0.0.1', - 'port' => (int)(\getenv('MONGO_PORT') ?: 27017), - ]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - // Ping server to ensure availability - try { - $conn->getClient()->selectDatabase('admin')->command(['ping' => 1]); - } catch (\Throwable $e) { - $this->markTestSkipped('Mongo not available: ' . $e->getMessage()); - } - - $dto = new class([]) extends AbstractDto {}; - $dtoClass = \get_class($dto); - - $dao = new class($conn, $dtoClass) extends AbstractMongoDao { - private string $dto; - public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - protected function collection(): string { return 'pairity_test.uow_docs'; } - protected function dtoClass(): string { return $this->dto; } - }; - - // Clean - foreach ($dao->findAllBy([]) as $doc) { - $id = (string)($doc->toArray(false)['_id'] ?? ''); - if ($id !== '') { $dao->deleteById($id); } - } - - // Insert and capture id - $created = $dao->insert(['name' => 'Widget', 'qty' => 1]); - $id = (string)($created->toArray(false)['_id'] ?? ''); - - // Attempt a UoW that throws - try { - UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) { - $dao->update($id, ['qty' => 99]); - throw new \RuntimeException('boom'); - }); - $this->fail('Exception expected'); - } catch (\RuntimeException $e) { - // expected - } - - // Update should not have been applied due to rollback - $after = $dao->findById($id); - $this->assertSame(1, $after?->toArray(false)['qty'] ?? null); - } -} diff --git a/tests/UnitOfWorkSqliteTest.php b/tests/UnitOfWorkSqliteTest.php deleted file mode 100644 index 36ed6a5..0000000 --- a/tests/UnitOfWorkSqliteTest.php +++ /dev/null @@ -1,88 +0,0 @@ - 'sqlite', 'path' => ':memory:']); - } - - public function testDeferredUpdateAndDeleteCommit(): void - { - $conn = $this->conn(); - // schema - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, name TEXT)'); - - // DAO/DTO inline - $dto = new class([]) extends AbstractDto {}; - $dtoClass = get_class($dto); - $dao = new class($conn, $dtoClass) extends AbstractDao { - private string $dto; - public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return $this->dto; } - protected function schema(): array { return ['primaryKey' => 'id', 'columns' => ['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string']]]; } - }; - - // Insert immediate - $created = $dao->insert(['email' => 'u@example.com', 'name' => 'User']); - $id = (int)($created->toArray(false)['id'] ?? 0); - $this->assertGreaterThan(0, $id); - - // Run UoW with deferred update and delete - UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) { - $one = $dao->findById($id); // attaches to identity map - $this->assertNotNull($one); - // defer update - $dao->update($id, ['name' => 'Changed']); - // defer deleteBy criteria (will be executed after update) - $dao->deleteBy(['id' => $id]); - // commit done by run() - }); - - // After commit, record should be deleted - $this->assertNull($dao->findById($id)); - } - - public function testRollbackOnExceptionClearsOps(): void - { - $conn = $this->conn(); - $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, name TEXT)'); - - $dto = new class([]) extends AbstractDto {}; - $dtoClass = get_class($dto); - $dao = new class($conn, $dtoClass) extends AbstractDao { - private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; } - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return $this->dto; } - protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string']]]; } - }; - - $created = $dao->insert(['email' => 'x@example.com', 'name' => 'X']); - $id = (int)($created->toArray(false)['id'] ?? 0); - - try { - UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) { - $dao->update($id, ['name' => 'Won\'t Persist']); - throw new \RuntimeException('boom'); - }); - $this->fail('Exception expected'); - } catch (\RuntimeException $e) { - // ok - } - - // Update should not be applied due to rollback - $after = $dao->findById($id); - $this->assertSame('X', $after?->toArray(false)['name'] ?? null); - } -}