Cache and some other things
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 10:56:40 -06:00
parent af5f5feb33
commit 68f3c05868
26 changed files with 1344 additions and 1247 deletions

2
.gitignore vendored
View file

@ -49,4 +49,4 @@ crashlytics.properties
crashlytics-build.properties crashlytics-build.properties
fabric.properties fabric.properties
.junie.json .junie/

62
MILESTONES.md Normal file
View file

@ -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

1015
README.md

File diff suppressed because it is too large Load diff

125
SPECS.md Normal file
View file

@ -0,0 +1,125 @@
# Pairity Specifications
## Architecture
Pairity is a partitionedmodel 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)
Tablefocused 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<Column>($value): ?DTO`
- `findAllBy<Column>($value): DTO[]`
- `updateBy<Column>($value, array $data): int`
- `deleteBy<Column>($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).

View file

@ -5,14 +5,14 @@ declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager; use Pairity\Console\MigrateCommand;
use Pairity\Migrations\Migrator; use Pairity\Console\RollbackCommand;
use Pairity\Migrations\MigrationLoader; use Pairity\Console\StatusCommand;
use Pairity\Console\ResetCommand;
// Simple CLI utility for migrations use Pairity\Console\MakeMigrationCommand;
use Pairity\Console\MongoIndexEnsureCommand;
function stderr(string $msg): void { fwrite(STDERR, $msg . PHP_EOL); } use Pairity\Console\MongoIndexDropCommand;
function stdout(string $msg): void { fwrite(STDOUT, $msg . PHP_EOL); } use Pairity\Console\MongoIndexListCommand;
function parseArgs(array $argv): array { function parseArgs(array $argv): array {
$args = ['_cmd' => $argv[1] ?? 'help']; $args = ['_cmd' => $argv[1] ?? 'help'];
@ -35,104 +35,17 @@ function parseArgs(array $argv): array {
return $args; 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 function cmd_help(): void
{ {
$help = <<<TXT $help = <<<TXT
Pairity CLI Pairity CLI
Usage: Usage:
pairity migrate [--path=DIR] [--config=FILE] pairity migrate [--path=DIR] [--config=FILE] [--pretend]
pairity rollback [--steps=N] [--config=FILE] pairity rollback [--steps=N] [--config=FILE] [--pretend]
pairity status [--path=DIR] [--config=FILE] pairity status [--path=DIR] [--config=FILE]
pairity reset [--config=FILE] pairity reset [--config=FILE] [--pretend]
pairity make:migration Name [--path=DIR] pairity make:migration Name [--path=DIR] [--template=FILE]
pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique] pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]
pairity mongo:index:drop DB COLLECTION NAME pairity mongo:index:drop DB COLLECTION NAME
pairity mongo:index:list DB COLLECTION pairity mongo:index:list DB COLLECTION
@ -142,160 +55,34 @@ Environment:
If --config is provided, it must be a PHP file returning the ConnectionManager config array. If --config is provided, it must be a PHP file returning the ConnectionManager config array.
TXT; TXT;
stdout($help); echo $help . PHP_EOL;
} }
$args = parseArgs($argv); $args = parseArgs($argv);
$cmd = $args['_cmd'] ?? 'help'; $cmd = $args['_cmd'] ?? 'help';
$commands = [
'migrate' => 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 { try {
switch ($cmd) { $class = $commands[$cmd];
case 'migrate': /** @var \Pairity\Console\CommandInterface $instance */
$config = loadConfig($args); $instance = new $class();
$conn = ConnectionManager::make($config); $instance->execute($args);
$dir = migrationsDir($args); } catch (\Throwable $e) {
$migrations = MigrationLoader::fromDirectory($dir); fwrite(STDERR, 'Error: ' . $e->getMessage() . PHP_EOL);
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'
<?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
{
// Example: create table
// $schema = SchemaManager::forConnection($connection);
// $schema->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());
exit(1); exit(1);
} }

View file

@ -40,7 +40,8 @@
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"ext-mongodb": "*", "ext-mongodb": "*",
"mongodb/mongodb": "^2.0" "mongodb/mongodb": "^2.0",
"psr/simple-cache": "^3.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

55
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "24e6da7d8a9daef39392b4ae7486292e", "content-hash": "64942e8c928a3d237f245d668b7c255b",
"packages": [ "packages": [
{ {
"name": "mongodb/mongodb", "name": "mongodb/mongodb",
@ -133,6 +133,57 @@
}, },
"time": "2024-09-11T13:17:53+00:00" "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", "name": "symfony/polyfill-php85",
"version": "v1.33.0", "version": "v1.33.0",
@ -1891,7 +1942,7 @@
"prefer-stable": true, "prefer-stable": true,
"prefer-lowest": false, "prefer-lowest": false,
"platform": { "platform": {
"php": ">=8.1", "php": "^8.2",
"ext-mongodb": "*" "ext-mongodb": "*"
}, },
"platform-dev": {}, "platform-dev": {},

View file

@ -0,0 +1,102 @@
<?php
namespace Pairity\Console;
use Pairity\Database\ConnectionManager;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
abstract class AbstractCommand implements CommandInterface
{
protected function stdout(string $msg): void
{
echo $msg . PHP_EOL;
}
protected function stderr(string $msg): void
{
fwrite(STDERR, $msg . PHP_EOL);
}
protected function getConnection(array $args): \Pairity\Contracts\ConnectionInterface
{
$config = $this->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;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Pairity\Console;
interface CommandInterface
{
/**
* Execute the command.
*
* @param array<string, mixed> $args
*/
public function execute(array $args): void;
}

View file

@ -0,0 +1,27 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\MigrationGenerator;
class MakeMigrationCommand extends AbstractCommand
{
public function execute(array $args): void
{
$name = $args[0] ?? null;
if (!$name) {
$this->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);
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
class MigrateCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->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));
}
}
}

View file

@ -0,0 +1,88 @@
<?php
namespace Pairity\Console;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\IndexManager;
abstract class AbstractMongoCommand extends AbstractCommand
{
protected function getMongoConnection(array $args): \Pairity\NoSql\Mongo\MongoConnectionInterface
{
$config = $this->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));
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
class ResetCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->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));
}
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\Migrator;
use Pairity\Migrations\MigrationLoader;
class RollbackCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->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));
}
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Pairity\Console;
use Pairity\Migrations\MigrationLoader;
use Pairity\Migrations\MigrationsRepository;
class StatusCommand extends AbstractCommand
{
public function execute(array $args): void
{
$conn = $this->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));
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Pairity\Contracts;
use Psr\SimpleCache\CacheInterface;
interface CacheableDaoInterface extends DaoInterface
{
/**
* Set the cache instance for this DAO.
*/
public function setCache(CacheInterface $cache): static;
/**
* Get the cache instance.
*/
public function getCache(): ?CacheInterface;
/**
* Get cache configuration (enabled, ttl, prefix).
*
* @return array{enabled: bool, ttl: ?int, prefix: string}
*/
public function cacheConfig(): array;
/**
* Clear all cache entries related to this DAO/Table.
*/
public function clearCache(): bool;
}

View file

@ -42,4 +42,12 @@ interface ConnectionInterface
* Get last inserted ID if supported. * Get last inserted ID if supported.
*/ */
public function lastInsertId(): ?string; public function lastInsertId(): ?string;
/**
* Run a callback without performing any persistent changes, returning the logged SQL.
*
* @param callable($this):void $callback
* @return array<int, array{sql: string, params: array<string, mixed>}>
*/
public function pretend(callable $callback): array;
} }

View file

@ -14,6 +14,10 @@ class PdoConnection implements ConnectionInterface
private int $stmtCacheSize = 100; // LRU bound private int $stmtCacheSize = 100; // LRU bound
/** @var null|callable */ /** @var null|callable */
private $queryLogger = null; // function(string $sql, array $params, float $ms): void private $queryLogger = null; // function(string $sql, array $params, float $ms): void
/** @var bool */
private bool $pretending = false;
/** @var array<int, array{sql: string, params: array<string, mixed>}> */
private array $pretendLog = [];
public function __construct(PDO $pdo) public function __construct(PDO $pdo)
{ {
@ -63,6 +67,10 @@ class PdoConnection implements ConnectionInterface
public function query(string $sql, array $params = []): array public function query(string $sql, array $params = []): array
{ {
if ($this->pretending) {
$this->pretendLog[] = ['sql' => $sql, 'params' => $params];
return [];
}
$t0 = microtime(true); $t0 = microtime(true);
$stmt = $this->prepare($sql); $stmt = $this->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
@ -76,6 +84,10 @@ class PdoConnection implements ConnectionInterface
public function execute(string $sql, array $params = []): int public function execute(string $sql, array $params = []): int
{ {
if ($this->pretending) {
$this->pretendLog[] = ['sql' => $sql, 'params' => $params];
return 0;
}
$t0 = microtime(true); $t0 = microtime(true);
$stmt = $this->prepare($sql); $stmt = $this->prepare($sql);
$stmt->execute($params); $stmt->execute($params);
@ -89,6 +101,9 @@ class PdoConnection implements ConnectionInterface
public function transaction(callable $callback): mixed public function transaction(callable $callback): mixed
{ {
if ($this->pretending) {
return $callback($this);
}
$this->pdo->beginTransaction(); $this->pdo->beginTransaction();
try { try {
$result = $callback($this); $result = $callback($this);
@ -113,4 +128,18 @@ class PdoConnection implements ConnectionInterface
return null; return null;
} }
} }
public function pretend(callable $callback): array
{
$this->pretending = true;
$this->pretendLog = [];
try {
$callback($this);
} finally {
$this->pretending = false;
}
return $this->pretendLog;
}
} }

View file

@ -0,0 +1,56 @@
<?php
namespace Pairity\Migrations;
use Pairity\Contracts\ConnectionInterface;
class MigrationGenerator
{
private string $template;
public function __construct(?string $template = null)
{
$this->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'
<?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
{
// Example: create table
// $schema = SchemaManager::forConnection($connection);
// $schema->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;
}
}

View file

@ -33,7 +33,22 @@ class Migrator
* @param array<string,MigrationInterface> $migrations An ordered map of name => instance * @param array<string,MigrationInterface> $migrations An ordered map of name => instance
* @return array<int,string> List of applied migration names * @return array<int,string> 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<string,MigrationInterface> $migrations
* @return array<int,string>|array<int, array{sql: string, params: array<string, mixed>}>
*/
private function runMigrations(array $migrations): array
{ {
$this->repository->ensureTable(); $this->repository->ensureTable();
$ran = array_flip($this->repository->getRan()); $ran = array_flip($this->repository->getRan());
@ -59,9 +74,23 @@ class Migrator
/** /**
* Roll back the last batch (or N steps of batches). * Roll back the last batch (or N steps of batches).
* *
* @return array<int,string> List of rolled back migration names * @return array<int,string>|array<int, array{sql: string, params: array<string, mixed>}>
*/ */
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<int,string>
*/
private function runRollback(int $steps): array
{ {
$this->repository->ensureTable(); $this->repository->ensureTable();
$rolled = []; $rolled = [];

View file

@ -4,12 +4,16 @@ namespace Pairity\Model;
use Pairity\Contracts\ConnectionInterface; use Pairity\Contracts\ConnectionInterface;
use Pairity\Contracts\DaoInterface; use Pairity\Contracts\DaoInterface;
use Pairity\Contracts\CacheableDaoInterface;
use Pairity\Orm\Traits\CanCache;
use Pairity\Orm\UnitOfWork; use Pairity\Orm\UnitOfWork;
use Pairity\Model\Casting\CasterInterface; use Pairity\Model\Casting\CasterInterface;
use Pairity\Events\Events; use Pairity\Events\Events;
abstract class AbstractDao implements DaoInterface abstract class AbstractDao implements DaoInterface, CacheableDaoInterface
{ {
use CanCache;
protected ConnectionInterface $connection; protected ConnectionInterface $connection;
protected string $primaryKey = 'id'; protected string $primaryKey = 'id';
/** @var array<int,string>|null */ /** @var array<int,string>|null */
@ -142,6 +146,25 @@ abstract class AbstractDao implements DaoInterface
/** @param array<string,mixed> $criteria */ /** @param array<string,mixed> $criteria */
public function findOneBy(array $criteria): ?AbstractDto 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) // Events: dao.beforeFind (criteria may be mutated)
try { try {
$ev = [ $ev = [
@ -184,6 +207,13 @@ abstract class AbstractDao implements DaoInterface
Events::dispatcher()->dispatch('dao.afterFind', $payload); Events::dispatcher()->dispatch('dao.afterFind', $payload);
} catch (\Throwable) { } catch (\Throwable) {
} }
if ($dto && $cacheKey) {
$this->putInCache($cacheKey, $dto);
$idCol = $this->getPrimaryKey();
$this->putInCache($this->getCacheKeyForId($dto->$idCol), $dto);
}
return $dto; return $dto;
} }
@ -196,7 +226,26 @@ abstract class AbstractDao implements DaoInterface
return $managed; 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 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) // Events: dao.beforeFind (criteria may be mutated)
try { try {
$ev = [ $ev = [
@ -245,6 +321,16 @@ abstract class AbstractDao implements DaoInterface
Events::dispatcher()->dispatch('dao.afterFind', $payload); Events::dispatcher()->dispatch('dao.afterFind', $payload);
} catch (\Throwable) { } 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; return $dtos;
} }
@ -378,6 +464,8 @@ abstract class AbstractDao implements DaoInterface
/** @param array<string,mixed> $data */ /** @param array<string,mixed> $data */
public function update(int|string $id, array $data): AbstractDto public function update(int|string $id, array $data): AbstractDto
{ {
$this->removeFromCache($this->getCacheKeyForId($id));
$uow = UnitOfWork::current(); $uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) { if ($uow && !UnitOfWork::isSuspended()) {
// Defer execution; return a synthesized DTO // Defer execution; return a synthesized DTO
@ -473,6 +561,8 @@ abstract class AbstractDao implements DaoInterface
public function deleteById(int|string $id): int public function deleteById(int|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;
@ -530,6 +620,10 @@ abstract class AbstractDao implements DaoInterface
/** @param array<string,mixed> $criteria */ /** @param array<string,mixed> $criteria */
public function deleteBy(array $criteria): int public function deleteBy(array $criteria): 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; $crit = $criteria; $self = $this; $conn = $this->connection; $crit = $criteria;
@ -571,6 +665,10 @@ abstract class AbstractDao implements DaoInterface
*/ */
public function updateBy(array $criteria, array $data): int public function updateBy(array $criteria, array $data): int
{ {
if ($this->cache !== null) {
$this->clearCache();
}
$uow = UnitOfWork::current(); $uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) { if ($uow && !UnitOfWork::isSuspended()) {
if (empty($data)) { return 0; } if (empty($data)) { return 0; }

View file

@ -4,7 +4,7 @@ namespace Pairity\Model;
use Pairity\Contracts\DtoInterface; use Pairity\Contracts\DtoInterface;
abstract class AbstractDto implements DtoInterface abstract class AbstractDto implements DtoInterface, \Serializable
{ {
/** @var array<string,mixed> */ /** @var array<string,mixed> */
protected array $attributes = []; 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<string,mixed> $data */ /** @param array<string,mixed> $data */
public static function fromArray(array $data): static public static function fromArray(array $data): static
{ {

132
src/Orm/Traits/CanCache.php Normal file
View file

@ -0,0 +1,132 @@
<?php
namespace Pairity\Orm\Traits;
use Psr\SimpleCache\CacheInterface;
trait CanCache
{
protected ?CacheInterface $cache = null;
/**
* @see \Pairity\Contracts\CacheableDaoInterface::setCache
*/
public function setCache(CacheInterface $cache): static
{
$this->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);
}
}

176
tests/CachingTest.php Normal file
View file

@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
use Pairity\Orm\UnitOfWork;
use Psr\SimpleCache\CacheInterface;
class TestDto extends AbstractDto {}
final class CachingTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => '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);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Pairity\Tests\Migrations;
use PHPUnit\Framework\TestCase;
use Pairity\Migrations\MigrationGenerator;
class MigrationGeneratorTest extends TestCase
{
public function testGeneratesFileInDirectory()
{
$dir = sys_get_temp_dir() . '/pairity_migrations_' . uniqid();
mkdir($dir);
$generator = new MigrationGenerator();
$file = $generator->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 = "<?php // custom template";
$generator = new MigrationGenerator($template);
$file = $generator->generate('Custom', $dir);
$this->assertEquals($template, file_get_contents($file));
unlink($file);
rmdir($dir);
}
}

45
tests/PretendTest.php Normal file
View file

@ -0,0 +1,45 @@
<?php
namespace Pairity\Tests\Database;
use PHPUnit\Framework\TestCase;
use Pairity\Database\PdoConnection;
use PDO;
class PretendTest extends TestCase
{
public function testPretendLogsQueriesWithoutExecuting()
{
$pdo = $this->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
}
}