Get Production ready
Some checks are pending
CI / test (8.2) (push) Waiting to run
CI / test (8.3) (push) Waiting to run

This commit is contained in:
Funky Waddle 2026-01-06 13:01:31 -06:00
parent 68f3c05868
commit 6252dd6107
3 changed files with 130 additions and 22 deletions

View file

@ -2,23 +2,26 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
#### Unreleased #### [0.1.0] - 2026-01-06
- Core ORM (DAO/DTO) with dynamic finders, `fields()` projection, relations (hasOne/hasMany/belongsTo), nested eager loading, perrelation constraints, and SQL `belongsToMany` with pivot helpers (`attach`, `detach`, `sync`). ##### Added
- MongoDB production adapter (`ext-mongodb` + `mongodb/mongodb`) and Mongo DAO layer with relations (MVP), projections/sort/limit, pagination, and a small filter builder. - **Caching Layer**: Integrated PSR-16 (Simple Cache) support into `AbstractDao` and `AbstractMongoDao`.
- Pagination helpers for SQL and Mongo: `paginate` and `simplePaginate`. - Automated cache invalidation on write operations.
- Model metadata & schema mapping: column casts (incl. custom casters), timestamps, soft deletes. - Identity Map synchronization for cached objects.
- Migrations & Schema Builder (portable): create/drop/alter; CLI (`vendor/bin/pairity`) with migrate/rollback/status/reset/make:migration. Drivers: MySQL/MariaDB, SQLite, PostgreSQL, SQL Server, Oracle. - Customizable TTL and prefix per DAO.
- Joinbased eager loading (optin, SQL, singlelevel) with safe fallbacks. - **CLI Enhancements**:
- Unit of Work (optin): identity map; deferred updates/deletes; relationaware delete cascades; optimistic locking; snapshot diffing (flagged); identity map controls; coalescing. - Extracted CLI logic into dedicated Command classes in `Pairity\Console`.
- Event system: DAO and UoW events; listeners/subscribers. - Added `--pretend` flag to `migrate`, `rollback`, and `reset` commands for dry-run support.
- CI: GitHub Actions matrix (PHP 8.18.3) with MySQL + Mongo services; guarded tests. - 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`.
#### 0.1.0 — 2025-12-11 ##### 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.
- Dependencies: upgrade `mongodb/mongodb` to `^2.0` (tested at 2.1.x). Requires `ext-mongodb >= 2.1`. #### [0.0.1] - 2025-12-11
- Tests: migrate to PHPUnit 10; update `phpunit.xml.dist` and test cases accordingly. - Initial development version with core ORM, Relations, Migrations, and basic MongoDB support.
- MongoDB tests: marked as `@group mongo-integration` and skipped by default via `phpunit.xml.dist` group exclusion. Each test pings the server and skips when not available.
- SQL tests: stabilized MySQL `belongsToMany` eager and join-eager tests by aligning anonymous DAO constructors with runtime patterns and adding needed projections.
- PostgreSQL: fix identifier quoting by using double quotes in `PostgresGrammar` (no more backticks in generated DDL). `PostgresSmokeTest` passes when a Postgres instance is available.
- SQLite: portable DDL in schema tests; minor assertion cleanups for accessors/casters.

View file

@ -53,10 +53,10 @@
- [x] Eager loader IN-batching - [x] Eager loader IN-batching
- [x] Metadata memoization - [x] Metadata memoization
## Milestone 8: Road Ahead ## Milestone 8: Road Ahead [x]
- [x] Broader Schema Builder ALTER coverage - [x] Broader Schema Builder ALTER coverage
- [x] More dialect nuances for SQL Server/Oracle - [x] More dialect nuances for SQL Server/Oracle
- [x] Enhanced CLI commands - [x] Enhanced CLI commands
- [x] Caching layer - [x] Caching layer
- [ ] Production-ready MongoDB adapter refinements - [x] Production-ready MongoDB adapter refinements
- [ ] Documentation and expanded examples - [x] Documentation and expanded examples

View file

@ -2,6 +2,8 @@
namespace Pairity\NoSql\Mongo; namespace Pairity\NoSql\Mongo;
use Pairity\Contracts\CacheableDaoInterface;
use Pairity\Orm\Traits\CanCache;
use Pairity\Model\AbstractDto; use Pairity\Model\AbstractDto;
use Pairity\Orm\UnitOfWork; use Pairity\Orm\UnitOfWork;
use Pairity\Events\Events; use Pairity\Events\Events;
@ -11,8 +13,10 @@ use Pairity\Events\Events;
* *
* Usage: extend and implement collection() + dtoClass(). * Usage: extend and implement collection() + dtoClass().
*/ */
abstract class AbstractMongoDao abstract class AbstractMongoDao implements CacheableDaoInterface
{ {
use CanCache;
protected MongoConnectionInterface $connection; protected MongoConnectionInterface $connection;
/** @var array<int,string>|null */ /** @var array<int,string>|null */
@ -54,6 +58,11 @@ abstract class AbstractMongoDao
$this->connection = $connection; $this->connection = $connection;
} }
public function getTable(): string
{
return $this->collection();
}
/** Collection name (e.g., "users"). */ /** Collection name (e.g., "users"). */
abstract protected function collection(): string; abstract protected function collection(): string;
@ -144,6 +153,25 @@ abstract class AbstractMongoDao
public function findOneBy(array|Filter $filter): ?AbstractDto public function findOneBy(array|Filter $filter): ?AbstractDto
{ {
$filterArr = $this->normalizeFilterInput($filter); $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 // Events: dao.beforeFind (Mongo) — allow filter mutation
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {} try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {}
$this->applyRuntimeScopesToFilter($filterArr); $this->applyRuntimeScopesToFilter($filterArr);
@ -155,6 +183,15 @@ abstract class AbstractMongoDao
$row = $docs[0] ?? null; $row = $docs[0] ?? null;
$dto = $row ? $this->hydrate($row) : null; $dto = $row ? $this->hydrate($row) : null;
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {} 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; return $dto;
} }
@ -166,6 +203,33 @@ abstract class AbstractMongoDao
public function findAllBy(array|Filter $filter = [], array $options = []): array public function findAllBy(array|Filter $filter = [], array $options = []): array
{ {
$filterArr = $this->normalizeFilterInput($filter); $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) // Events: dao.beforeFind (Mongo)
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {} try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {}
$this->applyRuntimeScopesToFilter($filterArr); $this->applyRuntimeScopesToFilter($filterArr);
@ -180,6 +244,17 @@ abstract class AbstractMongoDao
$this->resetModifiers(); $this->resetModifiers();
$this->resetRuntimeScopes(); $this->resetRuntimeScopes();
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dtos' => $dtos]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {} 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; return $dtos;
} }
@ -192,7 +267,25 @@ abstract class AbstractMongoDao
return $managed; return $managed;
} }
} }
return $this->findOneBy(['_id' => $id]);
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<string,mixed> $data */ /** @param array<string,mixed> $data */
@ -212,6 +305,8 @@ abstract class AbstractMongoDao
/** @param array<string,mixed> $data */ /** @param array<string,mixed> $data */
public function update(string $id, array $data): AbstractDto public function update(string $id, array $data): AbstractDto
{ {
$this->removeFromCache($this->getCacheKeyForId($id));
$uow = UnitOfWork::current(); $uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) { if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $theId = $id; $payload = $data; $self = $this; $conn = $this->connection; $theId = $id; $payload = $data;
@ -243,6 +338,8 @@ abstract class AbstractMongoDao
public function deleteById(string $id): int public function deleteById(string $id): int
{ {
$this->removeFromCache($this->getCacheKeyForId($id));
$uow = UnitOfWork::current(); $uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) { if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $theId = $id; $self = $this; $conn = $this->connection; $theId = $id;
@ -268,6 +365,10 @@ abstract class AbstractMongoDao
/** @param array<string,mixed>|Filter $filter */ /** @param array<string,mixed>|Filter $filter */
public function deleteBy(array|Filter $filter): int public function deleteBy(array|Filter $filter): int
{ {
if ($this->cache !== null) {
$this->clearCache();
}
$uow = UnitOfWork::current(); $uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) { if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $flt = $this->normalizeFilterInput($filter); $self = $this; $conn = $this->connection; $flt = $this->normalizeFilterInput($filter);
@ -297,12 +398,16 @@ abstract class AbstractMongoDao
/** Upsert by id convenience. */ /** Upsert by id convenience. */
public function upsertById(string $id, array $data): string 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]); return $this->connection->upsertOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $data]);
} }
/** @param array<string,mixed>|Filter $filter @param array<string,mixed> $update */ /** @param array<string,mixed>|Filter $filter @param array<string,mixed> $update */
public function upsertBy(array|Filter $filter, array $update): string 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); return $this->connection->upsertOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $update);
} }