diff --git a/.gitignore b/.gitignore index 2d7a642..9f1aa78 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,4 @@ crashlytics.properties crashlytics-build.properties fabric.properties -.junie.json \ No newline at end of file +.junie/ \ No newline at end of file diff --git a/MILESTONES.md b/MILESTONES.md new file mode 100644 index 0000000..255ff6b --- /dev/null +++ b/MILESTONES.md @@ -0,0 +1,62 @@ +# 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] + +--- + +## 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 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 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 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 5: Pagination & Query Scopes +- [x] `paginate` and `simplePaginate` for SQL and Mongo +- [x] Ad-hoc and Named Query Scopes + +## Milestone 6: Event System +- [x] Dispatcher and Subscriber interfaces +- [x] DAO lifecycle events +- [x] Unit of Work commit events + +## Milestone 7: Performance Knobs +- [x] PDO prepared-statement cache +- [x] Query timing hooks +- [x] Eager loader IN-batching +- [x] Metadata memoization + +## Milestone 8: Road Ahead +- [x] Broader Schema Builder ALTER coverage +- [x] More dialect nuances for SQL Server/Oracle +- [x] Enhanced CLI commands +- [x] Caching layer +- [ ] Production-ready MongoDB adapter refinements +- [ ] Documentation and expanded examples diff --git a/README.md b/README.md index 7436ea4..9b5b674 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,16 @@ # Pairity -A partitioned‑model PHP ORM (DTO/DAO) with Query Builder, relations, raw SQL helpers, and a portable migrations + schema builder. Namespace: `Pairity\`. Package: `getphred/pairity`. +A partitioned‑model PHP ORM (DTO/DAO) with Query Builder, relations, raw SQL helpers, and a portable migrations + schema builder. ![CI](https://github.com/getphred/pairity/actions/workflows/ci.yml/badge.svg) ![Packagist](https://img.shields.io/packagist/v/getphred/pairity.svg) -## Contributing - -This is an early foundation. Contributions, discussions, and design proposals are welcome. Please open an issue to coordinate larger features. - -## License - -MIT - ## Installation -- Requirements: PHP >= 8.2, PDO extension for your database(s) -- Install via Composer: +- **Requirements**: PHP >= 8.2, PDO extension for your database(s) +- **Install via Composer**: -``` +```bash composer require getphred/pairity ``` @@ -28,37 +20,24 @@ After install, you can use the CLI at `vendor/bin/pairity`. This project uses PHPUnit 10. The default test suite excludes MongoDB integration tests by default for portability. -- Install dev dependencies: - -``` +- **Install dev dependencies**: +```bash composer install ``` -- Run the default suite (SQLite + unit tests; Mongo tests excluded by default): - -``` +- **Run the default suite** (SQLite + unit tests; Mongo tests excluded by default): +```bash vendor/bin/phpunit ``` -- Run MongoDB integration tests (requires `ext-mongodb >= 2.1` and a reachable server): - - Provide connection via environment variables and include the group: - -``` -MONGO_HOST=127.0.0.1 MONGO_PORT=27017 \ -vendor/bin/phpunit --group mongo-integration +- **Run MongoDB integration tests** (requires `ext-mongodb >= 2.1` and a reachable server): +```bash +MONGO_HOST=127.0.0.1 MONGO_PORT=27017 vendor/bin/phpunit --group mongo-integration ``` -Notes: -- When Mongo is unavailable or the extension is missing, Mongo tests will self‑skip. -- PostgreSQL smoke test requires environment variables (skips if missing): - - `POSTGRES_HOST` (required to enable), optional `POSTGRES_PORT`, `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASS`. - - PostgreSQL identifiers are generated with double quotes by the schema grammar. -- MySQL tests require: - - `MYSQL_HOST` (required to enable), optional `MYSQL_PORT`, `MYSQL_DB`, `MYSQL_USER`, `MYSQL_PASS`. +## Quick Start -## Quick start - -Minimal example with SQLite (file db.sqlite) and a simple `users` DAO/DTO. +Minimal example with SQLite and a simple `users` DAO/DTO. ```php use Pairity\Database\ConnectionManager; @@ -71,972 +50,30 @@ $conn = ConnectionManager::make([ 'path' => __DIR__ . '/db.sqlite', ]); -// 2) Ensure table exists (demo) -$conn->execute('CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT NOT NULL, - name TEXT NULL, - status TEXT NULL -)'); - -// 3) Define DTO + DAO +// 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; } } -// 4) CRUD +// 3) CRUD $dao = new UserDao($conn); -$created = $dao->insert(['email' => 'a@b.com', 'name' => 'Alice', 'status' => 'active']); -$one = $dao->findById($created->toArray()['id']); -$many = $dao->findAllBy(['status' => 'active']); -$dao->update($created->toArray()['id'], ['name' => 'Alice Updated']); -$dao->deleteById($created->toArray()['id']); +$created = $dao->insert(['email' => 'a@b.com', 'name' => 'Alice']); +$user = $dao->findById($created->id); ``` -For MySQL, configure: +For more detailed usage and technical specifications, see [SPECS.md](SPECS.md). -```php -$conn = ConnectionManager::make([ - 'driver' => 'mysql', - 'host' => '127.0.0.1', - 'port' => 3306, - 'database' => 'app', - 'username' => 'root', - 'password' => 'secret', - 'charset' => 'utf8mb4', -]); -``` +## Documentation +- [Specifications](SPECS.md) +- [Milestones & Roadmap](MILESTONES.md) +- [Examples](examples/) -## Concepts +## Contributing -- DTO (Data Transfer Object): a lightweight data bag. Extend `Pairity\Model\AbstractDto`. Convert to arrays via `toArray(bool $deep = true)`. -- DAO (Data Access Object): table‑focused persistence and relations. Extend `Pairity\Model\AbstractDao` and implement: - - `getTable(): string` - - `dtoClass(): string` (class-string of your DTO) - - Optional: `schema()` for casts, timestamps, soft deletes - - Optional: `relations()` for `hasOne`/`hasMany`/`belongsTo` -- Relations are DAO‑centric: call `with([...])` to eager load; `load()`/`loadMany()` for lazy. -- Field projection via `fields('id', 'name', 'posts.title')` with dot‑notation for related selects. -- Raw SQL: use `ConnectionInterface::query`, `execute`, `transaction`, `lastInsertId`. -- Query Builder: a simple builder (`Pairity\Query\QueryBuilder`) exists for ad‑hoc SQL composition. +This is an early foundation. Contributions, discussions, and design proposals are welcome. Please open an issue to coordinate larger features. -## Dynamic DAO methods +## License -AbstractDao supports dynamic helpers, mapped to column names (Studly/camel to snake_case): - -- `findOneBy($value): ?DTO` -- `findAllBy($value): DTO[]` -- `updateBy($value, array $data): int` (returns affected rows) -- `deleteBy($value): int` - -Examples: - -```php -$user = $dao->findOneByEmail('a@b.com'); -$actives = $dao->findAllByStatus('active'); -$dao->updateByEmail('a@b.com', ['name' => 'New Name']); -$dao->deleteByEmail('gone@b.com'); -``` - -## Selecting fields - -- Default projection is `SELECT *`. -- Use `fields(...$fields)` to limit columns. You can include relation fields using dot‑notation: - -```php -$users = (new UserDao($conn)) - ->fields('id', 'name', 'posts.title') - ->with(['posts']) - ->findAllBy(['status' => 'active']); -``` - -Notes: -- `fields()` affects only the next `find*` call and then resets. -- Relation field selections are passed to the related DAO when eager loading. - -## Supported databases - -- MySQL/MariaDB -- SQLite -- PostgreSQL -- SQL Server -- Oracle - -NoSQL: -- MongoDB (production): `Pairity\NoSql\Mongo\MongoClientConnection` via `mongodb/mongodb` + `ext-mongodb`. -- MongoDB (stub): `Pairity\NoSql\Mongo\MongoConnection` (in‑memory) remains for experimentation without external deps. - -### MongoDB (production adapter) - -Pairity includes a production‑ready MongoDB adapter that wraps the official `mongodb/mongodb` library. - -Requirements: -- PHP `ext-mongodb` (installed in PHP), and Composer dependency `mongodb/mongodb` (already required by this package). - -Connect using the `MongoConnectionManager`: - -```php -use Pairity\NoSql\Mongo\MongoConnectionManager; - -// Option A: Full URI -$conn = MongoConnectionManager::make([ - 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin', -]); - -// Option B: Discrete params -$conn = MongoConnectionManager::make([ - 'host' => '127.0.0.1', - 'port' => 27017, - // 'username' => 'user', - // 'password' => 'pass', - // 'authSource' => 'admin', - // 'replicaSet' => 'rs0', - // 'tls' => false, -]); - -// Basic CRUD -$db = 'app'; -$col = 'users'; - -$id = $conn->insertOne($db, $col, ['email' => 'mongo@example.com', 'name' => 'Alice']); -$one = $conn->find($db, $col, ['_id' => $id]); -$conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['name' => 'Alice Updated']]); -$conn->deleteOne($db, $col, ['_id' => $id]); -``` - -Notes: -- `_id` strings that look like 24‑hex ObjectIds are automatically converted to `ObjectId` on input; returned documents convert `ObjectId` back to strings. -- Aggregation pipelines are supported via `$conn->aggregate($db, $collection, $pipeline, $options)`. -- See `examples/nosql/mongo_crud.php` for a runnable demo. - -## Raw SQL - -Use the `ConnectionInterface` behind your DAO for direct SQL. - -```php -use Pairity\Contracts\ConnectionInterface; - -// Get connection from DAO -$conn = $dao->getConnection(); - -// SELECT -$rows = $conn->query('SELECT id, email FROM users WHERE status = :s', ['s' => 'active']); - -// INSERT/UPDATE/DELETE -$affected = $conn->execute('UPDATE users SET status = :s WHERE id = :id', ['s' => 'inactive', 'id' => 10]); - -// Transaction -$conn->transaction(function (ConnectionInterface $db) { - $db->execute('INSERT INTO logs(message) VALUES(:m)', ['m' => 'started']); - // ... -}); -``` - -## Relations (DAO‑centric MVP) - -Declare relations in your DAO by overriding `relations()` and use `with()` for eager loading or `load()`/`loadMany()` for lazy loading. - -Example: `User hasMany Posts`, `Post belongsTo User` - -```php -use Pairity\Model\AbstractDto; -use Pairity\Model\AbstractDao; - -class UserDto extends AbstractDto {} -class PostDto extends AbstractDto {} - -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, - 'dto' => PostDto::class, - 'foreignKey' => 'user_id', // on posts - 'localKey' => 'id', // on users - ], - ]; - } -} - -class PostDao extends AbstractDao { - public function getTable(): string { return 'posts'; } - protected function dtoClass(): string { return PostDto::class; } - protected function relations(): array { - return [ - 'user' => [ - 'type' => 'belongsTo', - 'dao' => UserDao::class, - 'dto' => UserDto::class, - 'foreignKey' => 'user_id', // on posts - 'otherKey' => 'id', // on users - ], - ]; - } -} - -$users = (new UserDao($conn)) - ->fields('id', 'name', 'posts.title') - ->with(['posts']) - ->findAllBy(['status' => 'active']); - -// Lazy load a relation later -$postDao = new PostDao($conn); -$post = $postDao->findOneBy(['id' => 10]); -$postDao->load($post, 'user'); -``` - -Notes: -- Eager loader batches queries using `IN (...)` lookups under the hood. -- Loaded relations are attached onto the DTO under the relation name (e.g., `$user->posts`). -- `hasOne` is supported like `hasMany` but attaches a single DTO instead of a list. - -### Join‑based eager loading (opt‑in, SQL) - -For single‑level relations on SQL DAOs, you can opt‑in to a join‑based eager loading strategy that fetches parent and related rows in one query using `LEFT JOIN`s. - -Usage: - -```php -// Require explicit projection for related fields when joining -$users = (new UserDao($conn)) - ->fields('id', 'name', 'posts.title') - ->useJoinEager() // opt‑in for the next call - ->with(['posts']) // single‑level only in MVP - ->findAllBy([]); -``` - -Behavior and limitations (MVP): -- Single‑level only: `with(['posts'])` is supported; nested paths like `posts.comments` fall back to the default batched strategy. -- You must specify the fields to load from related tables via `fields('relation.column')` so the ORM can alias columns safely. -- Supported relation types: `hasOne`, `hasMany`, `belongsTo`. -- `belongsToMany` continues to use the portable two‑query pivot strategy. -- Soft deletes on related tables are respected by adding `... AND related.deleted_at IS NULL` to the join condition when configured in the related DAO `schema()`. -- Per‑relation constraints that rely on ordering/limits aren’t applied in join mode in this MVP; prefer the default batched strategy for those cases. - -Tip: If join mode can’t be used (e.g., nested paths or missing relation field projections), Pairity silently falls back to the portable batched eager loader. - -Per‑relation hint (optional): - -You can hint join strategy per relation path. This is useful when you want to selectively join specific relations in a single‑level eager load. The join will be used only when safe (single‑level paths and explicit relation projections are present), otherwise Pairity falls back to the portable strategy. - -```php -$users = (new UserDao($conn)) - ->fields('id','name','posts.title') - ->with([ - // Hint join for posts; you can also pass a callable under 'constraint' alongside 'strategy' - 'posts' => ['strategy' => 'join'] - ]) - ->findAllBy([]); -``` - -Notes: -- If you also call `useJoinEager()` or `eagerStrategy('join')`, that global setting takes precedence. -- Join eager is still limited to single‑level relations in this MVP. Nested paths (e.g., `posts.comments`) will use the portable strategy. - -### belongsToMany (SQL) and pivot helpers - -Pairity supports many‑to‑many relations for SQL DAOs via a pivot table. Declare `belongsToMany` in your DAO’s `relations()` and use the built‑in pivot helpers `attach`, `detach`, and `sync`. - -Relation metadata keys: -- `type` = `belongsToMany` -- `dao` = related DAO class -- `pivot` (or `pivotTable`) = pivot table name -- `foreignPivotKey` = pivot column referencing the parent table -- `relatedPivotKey` = pivot column referencing the related table -- `localKey` = parent primary key column (default `id`) -- `relatedKey` = related primary key column (default `id`) - -Example (users ↔ roles): - -```php -class UserDao extends AbstractDao { - protected function relations(): array { - return [ - 'roles' => [ - 'type' => 'belongsToMany', - 'dao' => RoleDao::class, - 'pivot' => 'user_role', - 'foreignPivotKey' => 'user_id', - 'relatedPivotKey' => 'role_id', - 'localKey' => 'id', - 'relatedKey' => 'id', - ], - ]; - } -} - -$user = $userDao->insert(['email' => 'a@b.com']); -$uid = $user->toArray(false)['id']; -$userDao->attach('roles', $uid, [$roleId1, $roleId2]); // insert into pivot -$userDao->detach('roles', $uid, [$roleId1]); // delete specific -$userDao->sync('roles', $uid, [$roleId2]); // make roles exactly this set - -$with = $userDao->with(['roles'])->findById($uid); // eager load related roles -``` - -See `examples/mysql_relations_pivot.php` for a runnable snippet. - -### Nested eager loading - -You can request nested eager loading using dot notation. Example: load a user’s posts and each post’s comments: - -```php -$users = $userDao->with(['posts.comments'])->findAllBy([...]); -``` - -Nested eager loading works for SQL and Mongo DAOs. Pairity performs separate batched fetches per relation level to remain portable across drivers. - -### Per‑relation constraints - -Pass a callable per relation path to customize how the related DAO queries data for that relation. The callable receives the related DAO instance so you can specify fields, ordering, and limits. - -- SQL example (per‑relation `fields()` projection and ordering): - -```php -$users = $userDao->with([ - 'posts' => function (UserPostDao $dao) { - $dao->fields('id', 'title'); - // $dao->orderBy('created_at DESC'); // if your DAO exposes ordering - }, - 'posts.comments' // nested -])->findAllBy(['status' => 'active']); -``` - -- Mongo example (projection, sort, limit): - -```php -$docs = $userMongoDao->with([ - 'posts' => function (PostMongoDao $dao) { - $dao->fields('title')->sort(['title' => 1])->limit(10); - }, - 'posts.comments' -])->findAllBy([]); -``` - -Constraints are applied only to the specific relation path they are defined on. - -## Model metadata & schema mapping (MVP) - -Define schema metadata on your DAO by overriding `schema()`. The schema enables: -- Column casts (storage <-> PHP): `int`, `float`, `bool`, `string`, `datetime`, `json` -- Timestamps automation (`createdAt`, `updatedAt` filled automatically) -- Soft deletes (update `deletedAt` instead of hard delete, with query scopes) - -Example: - -```php -use Pairity\Model\AbstractDao; - -class UserDao extends AbstractDao -{ - public function getTable(): string { return 'users'; } - protected function dtoClass(): string { return UserDto::class; } - - // Optional: declare primary key, casts, timestamps, soft deletes - protected function schema(): array - { - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'email' => ['cast' => 'string'], - 'name' => ['cast' => 'string'], - 'status' => ['cast' => 'string'], - // if present in your table - 'data' => ['cast' => 'json'], - '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', - ], - ]; - } -} - -// Usage (defaults to SELECT * unless you call fields()) -$users = (new UserDao($conn)) - ->findAllBy(['status' => 'active']); - -// Soft delete vs hard delete -(new UserDao($conn))->deleteById(10); // if softDeletes enabled => sets deleted_at timestamp - -// Query scopes for soft deletes -$all = (new UserDao($conn))->withTrashed()->findAllBy(); // include soft-deleted rows -$trashedOnly = (new UserDao($conn))->onlyTrashed()->findAllBy(); // only soft-deleted - -// Casting on hydration and storage -$user = (new UserDao($conn))->findById(1); // date columns become DateTimeImmutable; json becomes array -$created = (new UserDao($conn))->insert([ - 'email' => 'a@b.com', - 'name' => 'Alice', - 'status' => 'active', - 'data' => ['tags' => ['a','b']], // stored as JSON automatically -]); -``` - -### Timestamps & Soft Deletes - -- Configure in your DAO `schema()` using keys: - - `timestamps` → `['createdAt' => 'created_at', 'updatedAt' => 'updated_at']` - - `softDeletes` → `['enabled' => true, 'deletedAt' => 'deleted_at']` -- Behavior: - - On `insert()`, both `created_at` and `updated_at` are auto-filled (UTC `Y-m-d H:i:s`). - - On `update()` and `updateBy()`, `updated_at` is auto-updated. - - On `deleteById()` / `deleteBy()`, if soft deletes are enabled, rows are marked by setting `deleted_at` instead of being physically removed. - - Default queries exclude soft-deleted rows. Use scopes `withTrashed()` and `onlyTrashed()` to modify visibility. - - Helpers: - - `restoreById($id)` / `restoreBy($criteria)` — set `deleted_at` to NULL. - - `forceDeleteById($id)` / `forceDeleteBy($criteria)` — permanently delete. - - `touch($id)` — update only the `updated_at` column. - -Example: - -```php -$dao = new UserDao($conn); -$user = $dao->insert(['email' => 'x@y.com']); // created_at/updated_at filled -$dao->update($user->id, ['name' => 'Updated']); // updated_at bumped -$dao->deleteById($user->id); // soft delete -$also = $dao->withTrashed()->findById($user->id); // visible with trashed -$dao->restoreById($user->id); // restore -$dao->forceDeleteById($user->id); // permanent -``` - -## Migrations & Schema Builder - -Pairity ships a lightweight migrations runner and a portable schema builder focused on MySQL and SQLite for v1. You can declare migrations as PHP classes implementing `Pairity\Migrations\MigrationInterface` and build tables with a fluent `Schema` builder. - -Supported: -- Table operations: `create`, `drop`, `dropIfExists`, `table(...)` (ALTER) -- Columns: `increments`, `bigIncrements`, `integer`, `bigInteger`, `string(varchar)`, `text`, `boolean`, `json`, `datetime`, `decimal(precision, scale)`, `timestamps()` -- Indexes: `primary([...])`, `unique([...], ?name)`, `index([...], ?name)` -- ALTER (MVP): `add column` (all drivers), `drop column` (MySQL, Postgres, SQL Server; SQLite 3.35+), `rename column` (MySQL 8+/Postgres/SQL Server; SQLite 3.25+), `add/drop index/unique`, `rename table` -- Drivers: MySQL/MariaDB (default), SQLite (auto-detected), PostgreSQL (pgsql), SQL Server (sqlsrv), Oracle (oci) - -Example migration (see `examples/migrations/CreateUsersTable.php`): - -```php -use Pairity\Migrations\MigrationInterface; -use Pairity\Contracts\ConnectionInterface; -use Pairity\Schema\SchemaManager; -use Pairity\Schema\Blueprint; - -return new class implements MigrationInterface { - public function up(ConnectionInterface $connection): void - { - $schema = SchemaManager::forConnection($connection); - $schema->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(); - }); - } - - public function down(ConnectionInterface $connection): void - { - $schema = SchemaManager::forConnection($connection); - $schema->dropIfExists('users'); - } -}; -``` - -Running migrations (SQLite example): - -```php - 'sqlite', - 'path' => __DIR__ . '/../db.sqlite', -]); - -$createUsers = require __DIR__ . '/migrations/CreateUsersTable.php'; - -$migrator = new Migrator($conn); -$migrator->setRegistry(['CreateUsersTable' => $createUsers]); - -$applied = $migrator->migrate(['CreateUsersTable' => $createUsers]); -echo 'Applied: ' . json_encode($applied) . PHP_EOL; -``` - -Notes: -- The migrations runner tracks applied migrations in a `migrations` table with batches. -- For rollback, keep a registry of name => instance in the same process or use class names that can be autoloaded. -- SQLite has limitations around `ALTER TABLE` operations; this MVP emits native `ALTER TABLE` for supported versions (ADD COLUMN always; RENAME/DROP COLUMN require modern SQLite). For legacy versions, operations may fail; rebuild strategies can be added later. - - Pairity includes a best-effort table rebuild fallback for legacy SQLite: when `DROP COLUMN`/`RENAME COLUMN` is unsupported, it recreates the table and copies data. Complex constraints/triggers may not be preserved. - -### CLI - -Pairity ships a tiny CLI for migrations. After `composer install`, the binary is available as `vendor/bin/pairity` or as a project bin if installed as a dependency. - -Usage: - -``` -pairity migrate [--path=DIR] [--config=FILE] -pairity rollback [--steps=N] [--config=FILE] -pairity status [--path=DIR] [--config=FILE] -pairity reset [--config=FILE] -pairity make:migration Name [--path=DIR] -``` - -Options and environment: - -- If `--config=FILE` is provided, it must be a PHP file returning the ConnectionManager config array. -- Otherwise, the CLI reads environment variables: - - `DB_DRIVER` (mysql|mariadb|pgsql|postgres|postgresql|sqlite|sqlsrv) - - `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`, `DB_CHARSET` (MySQL) - - `DB_PATH` (SQLite path) -- If nothing is provided, defaults to a SQLite file at `db.sqlite` in project root. - -Migration discovery: -- The CLI looks for migrations in `./database/migrations`, then `project/database/migrations`, then `examples/migrations`. -- Each PHP file should `return` a `MigrationInterface` instance (see examples). Files are applied in filename order. - -Example ALTER migration (users add bio): - -```php -return new class implements MigrationInterface { - public function up(ConnectionInterface $connection): void - { - $schema = SchemaManager::forConnection($connection); - $schema->table('users', function (Blueprint $t) { - $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'); - }); - } -}; -``` - -Notes: -- Schema is optional; if omitted, DAOs behave as before (no casting, no timestamps/soft deletes). -- Timestamps use UTC and the format `Y-m-d H:i:s` for portability. -- Default `SELECT` is `*`. To limit columns, use `fields()`; it always takes precedence. - -## DTO toArray (deep vs shallow) - -DTOs implement `toArray(bool $deep = true)`. - -- When `$deep` is true (default): the DTO is converted to an array and any related DTOs (including arrays of DTOs) are recursively converted. -- When `$deep` is false: only the top-level attributes are converted; related DTOs remain as objects. - -Example: - -```php -$users = (new UserDao($conn)) - ->with(['posts']) - ->findAllBy(['status' => 'active']); - -$deep = array_map(fn($u) => $u->toArray(), $users); // deep (default) -$shallow = array_map(fn($u) => $u->toArray(false), $users); // shallow -``` - -## Attribute accessors/mutators & custom casters (Milestone C) - -Pairity supports lightweight DTO accessors/mutators and pluggable per‑column casters declared in your DAO `schema()`. - -### DTO attribute accessors/mutators - -- Accessor: define `protected function get{Name}Attribute($value): mixed` to transform a field when reading via property access or `toArray()`. -- Mutator: define `protected function set{Name}Attribute($value): mixed` to normalize a field when the DTO is hydrated from an array (constructor/fromArray). - -Example: - -```php -class UserDto extends \Pairity\Model\AbstractDto { - // Uppercase name when reading - protected function getNameAttribute($value): mixed { - return is_string($value) ? strtoupper($value) : $value; - } - // Trim name on hydration - protected function setNameAttribute($value): mixed { - return is_string($value) ? trim($value) : $value; - } -} -``` - -Accessors are applied for top‑level keys in `toArray(true|false)`. Relations (nested DTOs) apply their own accessors during their own `toArray()`. - -### Custom casters - -In addition to built‑in casts (`int`, `float`, `bool`, `string`, `datetime`, `json`), you can declare a custom caster class per column. A caster implements: - -```php -use Pairity\Model\Casting\CasterInterface; - -final class MoneyCaster implements CasterInterface { - public function fromStorage(mixed $value): mixed { - // DB integer cents -> PHP array/object - return ['cents' => (int)$value]; - } - public function toStorage(mixed $value): mixed { - // PHP array/object -> DB integer cents - return is_array($value) && isset($value['cents']) ? (int)$value['cents'] : (int)$value; - } -} -``` - -Declare it in the DAO `schema()` under the column’s `cast` using its class name: - -```php -protected function schema(): array -{ - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'name' => ['cast' => 'string'], - 'price_cents' => ['cast' => MoneyCaster::class], // custom caster - 'meta' => ['cast' => 'json'], - ], - ]; -} -``` - -Behavior: -- On SELECT, Pairity hydrates the DTO and applies `fromStorage()` per column (or built‑ins). -- On INSERT/UPDATE, Pairity applies `toStorage()` per column (or built‑ins) and maintains timestamp/soft‑delete behavior. -- Custom caster class strings are resolved once and cached per DAO instance. - -See the test `tests/CastersAndAccessorsSqliteTest.php` for a complete, runnable example. - -## Unit of Work (opt‑in) - -Pairity includes an optional Unit of Work (UoW) that can be enabled per block to batch updates/deletes and use an identity map: - -```php -use Pairity\Orm\UnitOfWork; - -UnitOfWork::run(function(UnitOfWork $uow) use ($userDao, $postDao) { - $user = $userDao->findById(42); // identity map - $userDao->update(42, ['name' => 'New']); // deferred update - $postDao->deleteBy(['user_id' => 42]); // deferred delete -}); // transactional commit -``` - -Behavior (MVP): -- Outside a UoW, DAO calls execute immediately (today’s behavior). -- Inside a UoW, updates/deletes are deferred; inserts remain immediate (to return real IDs). Commit runs within a transaction/session per connection. -- Relation‑aware cascades: if a relation is marked with `cascadeDelete`, child deletes run before the parent delete at commit time. - -### Optimistic locking (MVP) - -You can enable optimistic locking to avoid lost updates. Two strategies are supported: - -- SQL via DAO `schema()` - -```php -protected function schema(): array -{ - return [ - 'primaryKey' => 'id', - 'columns' => [ - 'id' => ['cast' => 'int'], - 'name' => ['cast' => 'string'], - 'version' => ['cast' => 'int'], - ], - 'locking' => [ - 'type' => 'version', // or 'timestamp' - 'column' => 'version', // compare‑and‑set column - ], - ]; -} -``` - -When locking is enabled, `update($id, $data)` performs a compare‑and‑set on the configured column and increments it for `type = version`. If the row has changed since the read, an `OptimisticLockException` is thrown. - -Note: bulk `updateBy(...)` is blocked under optimistic locking to avoid unsafe mass updates. - -- MongoDB via DAO `locking()` - -```php -protected function locking(): array -{ - return [ 'type' => 'version', 'column' => 'version' ]; -} -``` - -Mongo `update($id, $data)` reads the current `version` and issues a conditional `updateOne` with `{$inc: {version: 1}}`. If the compare fails, an `OptimisticLockException` is thrown. - -Tests: see `tests/OptimisticLockSqliteTest.php` (SQLite). Mongo tests are guarded and run in CI when `ext-mongodb` and a server are available. - -## Pagination - -Both SQL and Mongo DAOs provide pagination helpers that return DTOs alongside metadata. They honor the usual query modifiers: - -- SQL: `fields()`, `with([...])` (eager load) -- Mongo: `fields()` (projection), `sort()`, `with([...])` - -Methods and return shapes: - -```php -// SQL -/** @return array{data: array, total: int, perPage: int, currentPage: int, lastPage: int} */ -$page = $userDao->paginate(page: 2, perPage: 10, criteria: ['status' => 'active']); - -/** @return array{data: array, perPage: int, currentPage: int, nextPage: int|null} */ -$simple = $userDao->simplePaginate(page: 1, perPage: 10, criteria: []); - -// Mongo -$page = $userMongoDao->paginate(2, 10, /* filter */ []); -$simple = $userMongoDao->simplePaginate(1, 10, /* filter */ []); -``` - -Example (SQL + SQLite): - -```php -$page1 = (new UserDao($conn))->paginate(1, 10); // total + lastPage included -$sp = (new UserDao($conn))->simplePaginate(1, 10); // no total; nextPage detection - -// With projection and eager loading -$with = (new UserDao($conn)) - ->fields('id','email','posts.title') - ->with(['posts']) - ->paginate(1, 5); -``` - -Example (Mongo): - -```php -$with = (new UserMongoDao($mongo)) - ->fields('email','posts.title') - ->sort(['email' => 1]) - ->with(['posts']) - ->paginate(1, 10, []); -``` - -See examples: `examples/sqlite_pagination.php` and `examples/nosql/mongo_pagination.php`. - -## Query Scopes (MVP) - -Define small, reusable filters using scopes. Scopes are reset after each `find*`/`paginate*` call. - -- Ad‑hoc scope: `scope(callable $fn)` where `$fn` mutates the criteria/filter array for the next query. -- Named scopes: `registerScope('name', fn (&$criteria, ...$args) => ...)` and then call `$dao->name(...$args)` before `find*`/`paginate*`. - -SQL example: - -```php -$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; }); - -$active = $userDao->active()->paginate(1, 50); - -// Combine with ad‑hoc scope -$inactive = $userDao->scope(function (&$criteria) { $criteria['status'] = 'inactive'; }) - ->findAllBy(); -``` - -Mongo example (filter scopes): - -```php -$userMongoDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; }); -$page = $userMongoDao->active()->paginate(1, 25, []); -``` - -## Unit of Work (opt-in) - -Pairity offers an optional Unit of Work (UoW) that you can enable per block to batch and order mutations atomically, while keeping the familiar DAO/DTO API. - -What it gives you: -- Identity Map: the same in-memory DTO instance per `[DAO class + id]` during the UoW scope. -- Deferred mutations: inside a UoW, `update()`, `updateBy()`, `deleteById()`, and `deleteBy()` are queued and executed on commit in a transaction/session. -- Atomicity: SQL paths use a transaction per connection; Mongo uses a session/transaction when supported. - -What stays the same: -- Outside a UoW scope, DAOs behave exactly as before (immediate execution). -- Inside a UoW, `insert()` executes immediately to return the real ID. - -Basic usage: - -```php -use Pairity\Orm\UnitOfWork; - -UnitOfWork::run(function(UnitOfWork $uow) use ($userDao, $postDao) { - $user = $userDao->findById(42); // managed instance via identity map - $userDao->update(42, ['name' => 'New']); // deferred - $postDao->insert(['user_id' => 42, 'title' => 'Hello']); // immediate (real id) - $postDao->deleteBy(['title' => 'Old']); // deferred -}); // commits or rolls back on exception -``` - -Manual scoping: - -```php -$uow = UnitOfWork::begin(); -// ... perform DAO calls ... -$uow->commit(); // or $uow->rollback(); -``` - -Caveats and notes: -- Inserts are immediate by design to obtain primary keys; updates/deletes are deferred. -- If you need to force an immediate operation within a UoW (for advanced cases), DAOs use an internal `UnitOfWork::suspendDuring()` helper to avoid re-enqueueing nested calls. -- The UoW MVP does not yet apply cascade rules; ordering is per-connection in enqueue order. - -### Relation-aware delete ordering and cascades (MVP) - -- When you enable a UoW and enqueue a parent delete via `deleteById()`, Pairity will automatically delete child rows/documents first for relations marked with a cascade flag, then execute the parent delete. This ensures referential integrity without manual orchestration. - -- Supported relation types for cascades: `hasMany`, `hasOne`. - -- Enable cascades by adding a flag to the relation metadata in your DAO: - -```php -protected function relations(): array -{ - return [ - 'posts' => [ - 'type' => 'hasMany', - 'dao' => PostDao::class, - 'foreignKey' => 'user_id', - 'localKey' => 'id', - 'cascadeDelete' => true, // or: 'cascade' => ['delete' => true] - ], - ]; -} -``` - -Behavior details: -- Inside `UnitOfWork::run(...)`, enqueuing `UserDao->deleteById($id)` will synthesize and run `PostDao->deleteBy(['user_id' => $id])` before deleting the user. -- Works for both SQL DAOs and Mongo DAOs. -- Current MVP focuses on delete cascades; cascades for updates and more advanced ordering rules can be added later. - -## Event system (Milestone F) - -Pairity provides a lightweight event system so you can hook into DAO operations and UoW commits for audit logging, validation, normalization, caching hooks, etc. - -### Dispatcher and subscribers - -- Global access: `Pairity\Events\Events::dispatcher()` returns the singleton dispatcher. -- Register listeners: `listen(string $event, callable $listener, int $priority = 0)`. -- Register subscribers: implement `Pairity\Events\SubscriberInterface` with `getSubscribedEvents(): array` returning `event => callable|[callable, priority]`. - -Example listener registration: - -```php -use Pairity\Events\Events; - -// Normalize a field before insert -Events::dispatcher()->listen('dao.beforeInsert', function (array &$payload) { - // Payload always contains 'dao' and table/collection + input data by reference - if (($payload['table'] ?? '') === 'users') { - $payload['data']['name'] = trim((string)($payload['data']['name'] ?? '')); - } -}); - -// Audit after update -Events::dispatcher()->listen('dao.afterUpdate', function (array &$payload) { - // $payload['dto'] is the updated DTO (SQL or Mongo) - // write to your log sink here -}); -``` - -### Event names and payloads - -DAO events (SQL and Mongo): -- `dao.beforeFind` → `{ dao, table|collection, criteria|filter& }` (criteria/filter is passed by reference) -- `dao.afterFind` → `{ dao, table|collection, dto|null }` or `{ dao, table|collection, dtos: DTO[] }` -- `dao.beforeInsert`→ `{ dao, table|collection, data& }` (data by reference) -- `dao.afterInsert` → `{ dao, table|collection, dto }` -- `dao.beforeUpdate`→ `{ dao, table|collection, id?, criteria?, data& }` (data by reference; `criteria` for bulk SQL updates) -- `dao.afterUpdate` → `{ dao, table|collection, dto }` (or `{ affected }` for bulk SQL `updateBy`) -- `dao.beforeDelete`→ `{ dao, table|collection, id? , criteria|filter?& }` -- `dao.afterDelete` → `{ dao, table|collection, id?|criteria|filter?, affected:int }` - -Unit of Work events: -- `uow.beforeCommit` → `{ context: 'uow' }` -- `uow.afterCommit` → `{ context: 'uow' }` - -Notes: -- Listeners run in priority order (higher first). Exceptions thrown inside listeners are swallowed to avoid breaking core flows. -- When no listeners are registered, the overhead is negligible (fast-path checks). - -### Example subscriber - -```php -use Pairity\Events\SubscriberInterface; -use Pairity\Events\Events; - -final class AuditSubscriber implements SubscriberInterface -{ - public function getSubscribedEvents(): array - { - return [ - 'dao.afterInsert' => [[$this, 'onAfterInsert'], 10], - 'uow.afterCommit' => [$this, 'onAfterCommit'], - ]; - } - - public function onAfterInsert(array &$payload): void - { - // e.g., push to queue/log sink - } - - public function onAfterCommit(array &$payload): void - { - // flush buffered audits - } -} - -// Somewhere during bootstrap -Events::dispatcher()->subscribe(new AuditSubscriber()); -``` - -## Performance knobs (Milestone G) - -Pairity includes a few opt‑in performance features. Defaults remain conservative and portable. - -- PDO prepared‑statement cache (bounded LRU): - - Internals: `Pairity\Database\PdoConnection` caches prepared statements by SQL string. - - API: `$conn->setStatementCacheSize(100);` (0 disables). Default: 100. - -- Query timing hook: - - API: `$conn->setQueryLogger(function(string $sql, array $params, float $ms) { /* log */ });` - - Called for both `query()` and `execute()`; zero overhead when unset. - -- Eager loader IN‑batching (SQL + Mongo): - - DAOs chunk large `IN (...)` / `$in` lookups to avoid huge parameter lists. - - API: `$dao->setInBatchSize(1000);` (default 1000) — affects internal relation fetches and `findAllWhereIn()`. - -- Metadata memoization: - - DAOs memoize `schema()` and `relations()` per instance to reduce repeated array building. - - No user action required; available automatically. - -Example UoW + locking + snapshots demo: see `examples/uow_locking_snapshot.php`. - -## Roadmap - -- Relations enhancements: - - Nested eager loading (e.g., `posts.comments`) - - `belongsToMany` (pivot tables) - - Optional join‑based eager loading strategy -- Unit of Work & Identity Map -- Schema builder: broader ALTER coverage and more dialect nuances; better SQLite rebuild for complex constraints -- CLI: additional commands and quality‑of‑life improvements -- Testing matrix and examples for more drivers -- Caching layer and query logging hooks -- Production NoSQL adapters (MongoDB driver integration) \ No newline at end of file +MIT \ No newline at end of file diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..ee8cae6 --- /dev/null +++ b/SPECS.md @@ -0,0 +1,125 @@ +# Pairity Specifications + +## 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. + +### Namespace +`Pairity\` + +### Package +`getphred/pairity` + +## 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. + +### 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`. + +## Features + +### 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. + +### 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` + +### 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. + +### 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`. + +### 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. + +### 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. + +### Event System +Lightweight hook system for DAO operations and UoW commits. +- Events: `dao.before*`, `dao.after*`, `uow.beforeCommit`, `uow.afterCommit`. +- Dispatcher and Subscriber interfaces. + +### Pagination +- `paginate(page, perPage, criteria)`: Returns data with total, lastPage, etc. +- `simplePaginate(page, perPage, criteria)`: Returns data without total (uses nextPage detection). + +### Query Scopes +- Ad-hoc: `scope(callable $fn)`. +- Named: Registered via `registerScope()`. + +## Databases + +### Supported SQL +- MySQL / MariaDB +- SQLite (including table rebuild fallback for legacy versions) +- PostgreSQL +- SQL Server +- Oracle + +### NoSQL (MongoDB) +- Production adapter: Wraps `mongodb/mongodb` library. +- Stub adapter: In-memory for experimentation. +- Supports aggregation pipelines, pagination, and optimistic locking. + +## 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). + +## 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). diff --git a/bin/pairity b/bin/pairity index 561ce65..a25eaed 100644 --- a/bin/pairity +++ b/bin/pairity @@ -5,14 +5,14 @@ declare(strict_types=1); require __DIR__ . '/../vendor/autoload.php'; -use Pairity\Database\ConnectionManager; -use Pairity\Migrations\Migrator; -use Pairity\Migrations\MigrationLoader; - -// Simple CLI utility for migrations - -function stderr(string $msg): void { fwrite(STDERR, $msg . PHP_EOL); } -function stdout(string $msg): void { fwrite(STDOUT, $msg . PHP_EOL); } +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; function parseArgs(array $argv): array { $args = ['_cmd' => $argv[1] ?? 'help']; @@ -35,104 +35,17 @@ function parseArgs(array $argv): array { return $args; } -function loadConfig(array $args): array { - // Priority: --config=path.php (must return array), else env vars, else SQLite db.sqlite in project root - 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 '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; - case 'sqlite': - $cfg += [ - 'path' => getenv('DB_PATH') ?: (__DIR__ . '/../db.sqlite'), - ]; - break; - default: - // fall back later - break; - } - return $cfg; - } - - // Default: SQLite file in project root - return [ - 'driver' => 'sqlite', - 'path' => __DIR__ . '/../db.sqlite', - ]; -} - -function migrationsDir(array $args): string { - if (isset($args['path'])) return (string)$args['path']; - $candidates = [getcwd() . '/database/migrations', __DIR__ . '/../database/migrations', __DIR__ . '/../examples/migrations']; - foreach ($candidates as $dir) { - if (is_dir($dir)) return $dir; - } - return __DIR__ . '/../examples/migrations'; -} - -function ensureDir(string $dir): void { - if (!is_dir($dir)) { - if (!mkdir($dir, 0777, true) && !is_dir($dir)) { - throw new RuntimeException('Failed to create directory: ' . $dir); - } - } -} - 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 { - switch ($cmd) { - case 'migrate': - $config = loadConfig($args); - $conn = ConnectionManager::make($config); - $dir = migrationsDir($args); - $migrations = MigrationLoader::fromDirectory($dir); - if (!$migrations) { - stdout('No migrations found in ' . $dir); - exit(0); - } - $migrator = new Migrator($conn); - $migrator->setRegistry($migrations); - $applied = $migrator->migrate($migrations); - stdout('Applied: ' . json_encode($applied)); - break; - - case 'rollback': - $config = loadConfig($args); - $conn = ConnectionManager::make($config); - $dir = migrationsDir($args); - $migrations = MigrationLoader::fromDirectory($dir); - $migrator = new Migrator($conn); - $migrator->setRegistry($migrations); - $steps = isset($args['steps']) ? max(1, (int)$args['steps']) : 1; - $rolled = $migrator->rollback($steps); - stdout('Rolled back: ' . json_encode($rolled)); - break; - - case 'status': - $config = loadConfig($args); - $conn = ConnectionManager::make($config); - $dir = migrationsDir($args); - $migrations = array_keys(MigrationLoader::fromDirectory($dir)); - $repo = new \Pairity\Migrations\MigrationsRepository($conn); - $ran = $repo->getRan(); - $pending = array_values(array_diff($migrations, $ran)); - stdout('Ran: ' . json_encode($ran)); - stdout('Pending: ' . json_encode($pending)); - break; - - case 'reset': - $config = loadConfig($args); - $conn = ConnectionManager::make($config); - $dir = migrationsDir($args); - $migrations = MigrationLoader::fromDirectory($dir); - $migrator = new Migrator($conn); - $migrator->setRegistry($migrations); - $totalRolled = []; - while (true) { - $rolled = $migrator->rollback(1); - if (!$rolled) break; - $totalRolled = array_merge($totalRolled, $rolled); - } - stdout('Reset complete. Rolled back: ' . json_encode($totalRolled)); - break; - - case 'make:migration': - $name = $args[0] ?? null; - if (!$name) { - stderr('Missing migration Name. Usage: pairity make:migration CreateUsersTable [--path=DIR]'); - exit(1); - } - $dir = migrationsDir($args); - ensureDir($dir); - $ts = date('Y_m_d_His'); - $file = $dir . DIRECTORY_SEPARATOR . $ts . '_' . $name . '.php'; - $template = <<<'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; - file_put_contents($file, $template); - stdout('Created: ' . $file); - break; - - case 'mongo:index:ensure': - // Args: DB COLLECTION KEYS_JSON [--unique] - $db = $args[0] ?? null; - $col = $args[1] ?? null; - $keysJson = $args[2] ?? null; - if (!$db || !$col || !$keysJson) { - stderr('Usage: pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]'); - exit(1); - } - $config = loadConfig($args); - $conn = \Pairity\NoSql\Mongo\MongoConnectionManager::make($config); - $idx = new \Pairity\NoSql\Mongo\IndexManager($conn, $db, $col); - $keys = json_decode($keysJson, true); - if (!is_array($keys)) { stderr('Invalid KEYS_JSON (must be object like {"email":1})'); exit(1); } - $opts = []; - if (!empty($args['unique'])) { $opts['unique'] = true; } - $name = $idx->ensureIndex($keys, $opts); - stdout('Ensured index: ' . $name); - break; - - case 'mongo:index:drop': - // Args: DB COLLECTION NAME - $db = $args[0] ?? null; - $col = $args[1] ?? null; - $name = $args[2] ?? null; - if (!$db || !$col || !$name) { stderr('Usage: pairity mongo:index:drop DB COLLECTION NAME'); exit(1); } - $config = loadConfig($args); - $conn = \Pairity\NoSql\Mongo\MongoConnectionManager::make($config); - $idx = new \Pairity\NoSql\Mongo\IndexManager($conn, $db, $col); - $idx->dropIndex($name); - stdout('Dropped index: ' . $name); - break; - - case 'mongo:index:list': - // Args: DB COLLECTION - $db = $args[0] ?? null; - $col = $args[1] ?? null; - if (!$db || !$col) { stderr('Usage: pairity mongo:index:list DB COLLECTION'); exit(1); } - $config = loadConfig($args); - $conn = \Pairity\NoSql\Mongo\MongoConnectionManager::make($config); - $idx = new \Pairity\NoSql\Mongo\IndexManager($conn, $db, $col); - $list = $idx->listIndexes(); - stdout(json_encode($list)); - break; - - default: - cmd_help(); - break; - } -} catch (Throwable $e) { - stderr('Error: ' . $e->getMessage()); + $class = $commands[$cmd]; + /** @var \Pairity\Console\CommandInterface $instance */ + $instance = new $class(); + $instance->execute($args); +} catch (\Throwable $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . PHP_EOL); exit(1); } diff --git a/composer.json b/composer.json index 116b88b..80503f4 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,8 @@ "require": { "php": "^8.2", "ext-mongodb": "*", - "mongodb/mongodb": "^2.0" + "mongodb/mongodb": "^2.0", + "psr/simple-cache": "^3.0" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 211e630..c6a6f3a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "24e6da7d8a9daef39392b4ae7486292e", + "content-hash": "64942e8c928a3d237f245d668b7c255b", "packages": [ { "name": "mongodb/mongodb", @@ -133,6 +133,57 @@ }, "time": "2024-09-11T13:17:53+00:00" }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, { "name": "symfony/polyfill-php85", "version": "v1.33.0", @@ -1891,7 +1942,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.1", + "php": "^8.2", "ext-mongodb": "*" }, "platform-dev": {}, diff --git a/src/Console/AbstractCommand.php b/src/Console/AbstractCommand.php new file mode 100644 index 0000000..4379b33 --- /dev/null +++ b/src/Console/AbstractCommand.php @@ -0,0 +1,102 @@ +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/CommandInterface.php b/src/Console/CommandInterface.php new file mode 100644 index 0000000..d1d7d2d --- /dev/null +++ b/src/Console/CommandInterface.php @@ -0,0 +1,13 @@ + $args + */ + public function execute(array $args): void; +} diff --git a/src/Console/MakeMigrationCommand.php b/src/Console/MakeMigrationCommand.php new file mode 100644 index 0000000..5c8b584 --- /dev/null +++ b/src/Console/MakeMigrationCommand.php @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..826ea68 --- /dev/null +++ b/src/Console/MigrateCommand.php @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..495647e --- /dev/null +++ b/src/Console/MongoCommands.php @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..c2a3dc8 --- /dev/null +++ b/src/Console/ResetCommand.php @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..f00f7ed --- /dev/null +++ b/src/Console/RollbackCommand.php @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..be6b48e --- /dev/null +++ b/src/Console/StatusCommand.php @@ -0,0 +1,23 @@ +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/Contracts/CacheableDaoInterface.php b/src/Contracts/CacheableDaoInterface.php new file mode 100644 index 0000000..7714d7b --- /dev/null +++ b/src/Contracts/CacheableDaoInterface.php @@ -0,0 +1,30 @@ +}> + */ + public function pretend(callable $callback): array; } diff --git a/src/Database/PdoConnection.php b/src/Database/PdoConnection.php index 4b3b4a0..ba86d7d 100644 --- a/src/Database/PdoConnection.php +++ b/src/Database/PdoConnection.php @@ -14,6 +14,10 @@ class PdoConnection implements ConnectionInterface 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) { @@ -63,6 +67,10 @@ class PdoConnection implements ConnectionInterface 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); @@ -76,6 +84,10 @@ class PdoConnection implements ConnectionInterface 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); @@ -89,6 +101,9 @@ class PdoConnection implements ConnectionInterface public function transaction(callable $callback): mixed { + if ($this->pretending) { + return $callback($this); + } $this->pdo->beginTransaction(); try { $result = $callback($this); @@ -113,4 +128,18 @@ class PdoConnection implements ConnectionInterface 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/Migrations/MigrationGenerator.php b/src/Migrations/MigrationGenerator.php new file mode 100644 index 0000000..9542023 --- /dev/null +++ b/src/Migrations/MigrationGenerator.php @@ -0,0 +1,56 @@ +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/Migrator.php b/src/Migrations/Migrator.php index 40868f4..d545360 100644 --- a/src/Migrations/Migrator.php +++ b/src/Migrations/Migrator.php @@ -33,7 +33,22 @@ class Migrator * @param array $migrations An ordered map of name => instance * @return array List of applied migration names */ - public function migrate(array $migrations): array + 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()); @@ -59,9 +74,23 @@ class Migrator /** * Roll back the last batch (or N steps of batches). * - * @return array List of rolled back migration names + * @return array|array}> */ - public function rollback(int $steps = 1): 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 = []; diff --git a/src/Model/AbstractDao.php b/src/Model/AbstractDao.php index b594618..4f09953 100644 --- a/src/Model/AbstractDao.php +++ b/src/Model/AbstractDao.php @@ -4,12 +4,16 @@ namespace Pairity\Model; use Pairity\Contracts\ConnectionInterface; use Pairity\Contracts\DaoInterface; +use Pairity\Contracts\CacheableDaoInterface; +use Pairity\Orm\Traits\CanCache; use Pairity\Orm\UnitOfWork; use Pairity\Model\Casting\CasterInterface; use Pairity\Events\Events; -abstract class AbstractDao implements DaoInterface +abstract class AbstractDao implements DaoInterface, CacheableDaoInterface { + use CanCache; + protected ConnectionInterface $connection; protected string $primaryKey = 'id'; /** @var array|null */ @@ -142,6 +146,25 @@ abstract class AbstractDao implements DaoInterface /** @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 = [ @@ -184,6 +207,13 @@ abstract class AbstractDao implements DaoInterface 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; } @@ -196,7 +226,26 @@ abstract class AbstractDao implements DaoInterface return $managed; } } - return $this->findOneBy([$this->getPrimaryKey() => $id]); + + 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; } /** @@ -205,6 +254,33 @@ abstract class AbstractDao implements DaoInterface */ 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 = [ @@ -245,6 +321,16 @@ abstract class AbstractDao implements DaoInterface 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; } @@ -378,6 +464,8 @@ abstract class AbstractDao implements DaoInterface /** @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 @@ -473,6 +561,8 @@ abstract class AbstractDao implements DaoInterface 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; @@ -530,6 +620,10 @@ abstract class AbstractDao implements DaoInterface /** @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; @@ -571,6 +665,10 @@ abstract class AbstractDao implements DaoInterface */ 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; } diff --git a/src/Model/AbstractDto.php b/src/Model/AbstractDto.php index b1f7122..4c3e5fb 100644 --- a/src/Model/AbstractDto.php +++ b/src/Model/AbstractDto.php @@ -4,7 +4,7 @@ namespace Pairity\Model; use Pairity\Contracts\DtoInterface; -abstract class AbstractDto implements DtoInterface +abstract class AbstractDto implements DtoInterface, \Serializable { /** @var array */ protected array $attributes = []; @@ -23,6 +23,26 @@ abstract class AbstractDto implements DtoInterface } } + 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 { diff --git a/src/Orm/Traits/CanCache.php b/src/Orm/Traits/CanCache.php new file mode 100644 index 0000000..1d631bc --- /dev/null +++ b/src/Orm/Traits/CanCache.php @@ -0,0 +1,132 @@ +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/tests/CachingTest.php b/tests/CachingTest.php new file mode 100644 index 0000000..81d7dee --- /dev/null +++ b/tests/CachingTest.php @@ -0,0 +1,176 @@ + '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/MigrationGeneratorTest.php b/tests/MigrationGeneratorTest.php new file mode 100644 index 0000000..3563777 --- /dev/null +++ b/tests/MigrationGeneratorTest.php @@ -0,0 +1,42 @@ +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/PretendTest.php b/tests/PretendTest.php new file mode 100644 index 0000000..c34d2f6 --- /dev/null +++ b/tests/PretendTest.php @@ -0,0 +1,45 @@ +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 + } +}