Cache and some other things
This commit is contained in:
parent
af5f5feb33
commit
68f3c05868
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -49,4 +49,4 @@ crashlytics.properties
|
|||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
.junie.json
|
||||
.junie/
|
||||
62
MILESTONES.md
Normal file
62
MILESTONES.md
Normal 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
|
||||
125
SPECS.md
Normal file
125
SPECS.md
Normal file
|
|
@ -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<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).
|
||||
283
bin/pairity
283
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 = <<<TXT
|
||||
Pairity CLI
|
||||
|
||||
Usage:
|
||||
pairity migrate [--path=DIR] [--config=FILE]
|
||||
pairity rollback [--steps=N] [--config=FILE]
|
||||
pairity migrate [--path=DIR] [--config=FILE] [--pretend]
|
||||
pairity rollback [--steps=N] [--config=FILE] [--pretend]
|
||||
pairity status [--path=DIR] [--config=FILE]
|
||||
pairity reset [--config=FILE]
|
||||
pairity make:migration Name [--path=DIR]
|
||||
pairity reset [--config=FILE] [--pretend]
|
||||
pairity make:migration Name [--path=DIR] [--template=FILE]
|
||||
pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]
|
||||
pairity mongo:index:drop DB COLLECTION NAME
|
||||
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.
|
||||
TXT;
|
||||
stdout($help);
|
||||
echo $help . PHP_EOL;
|
||||
}
|
||||
|
||||
$args = parseArgs($argv);
|
||||
$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 {
|
||||
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'
|
||||
<?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());
|
||||
$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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
55
composer.lock
generated
55
composer.lock
generated
|
|
@ -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": {},
|
||||
|
|
|
|||
102
src/Console/AbstractCommand.php
Normal file
102
src/Console/AbstractCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/Console/CommandInterface.php
Normal file
13
src/Console/CommandInterface.php
Normal 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;
|
||||
}
|
||||
27
src/Console/MakeMigrationCommand.php
Normal file
27
src/Console/MakeMigrationCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
38
src/Console/MigrateCommand.php
Normal file
38
src/Console/MigrateCommand.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/Console/MongoCommands.php
Normal file
88
src/Console/MongoCommands.php
Normal 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));
|
||||
}
|
||||
}
|
||||
42
src/Console/ResetCommand.php
Normal file
42
src/Console/ResetCommand.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Console/RollbackCommand.php
Normal file
36
src/Console/RollbackCommand.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/Console/StatusCommand.php
Normal file
23
src/Console/StatusCommand.php
Normal 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));
|
||||
}
|
||||
}
|
||||
30
src/Contracts/CacheableDaoInterface.php
Normal file
30
src/Contracts/CacheableDaoInterface.php
Normal 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;
|
||||
}
|
||||
|
|
@ -42,4 +42,12 @@ interface ConnectionInterface
|
|||
* Get last inserted ID if supported.
|
||||
*/
|
||||
public function lastInsertId(): ?string;
|
||||
|
||||
/**
|
||||
* Run a callback without performing any persistent changes, returning the logged SQL.
|
||||
*
|
||||
* @param callable($this):void $callback
|
||||
* @return array<int, array{sql: string, params: array<string, mixed>}>
|
||||
*/
|
||||
public function pretend(callable $callback): array;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<int, array{sql: string, params: array<string, mixed>}> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
src/Migrations/MigrationGenerator.php
Normal file
56
src/Migrations/MigrationGenerator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,22 @@ class Migrator
|
|||
* @param array<string,MigrationInterface> $migrations An ordered map of name => instance
|
||||
* @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();
|
||||
$ran = array_flip($this->repository->getRan());
|
||||
|
|
@ -59,9 +74,23 @@ class Migrator
|
|||
/**
|
||||
* 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();
|
||||
$rolled = [];
|
||||
|
|
|
|||
|
|
@ -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<int,string>|null */
|
||||
|
|
@ -142,6 +146,25 @@ abstract class AbstractDao implements DaoInterface
|
|||
/** @param array<string,mixed> $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<string,mixed> $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<string,mixed> $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; }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace Pairity\Model;
|
|||
|
||||
use Pairity\Contracts\DtoInterface;
|
||||
|
||||
abstract class AbstractDto implements DtoInterface
|
||||
abstract class AbstractDto implements DtoInterface, \Serializable
|
||||
{
|
||||
/** @var array<string,mixed> */
|
||||
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 */
|
||||
public static function fromArray(array $data): static
|
||||
{
|
||||
|
|
|
|||
132
src/Orm/Traits/CanCache.php
Normal file
132
src/Orm/Traits/CanCache.php
Normal 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
176
tests/CachingTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
42
tests/MigrationGeneratorTest.php
Normal file
42
tests/MigrationGeneratorTest.php
Normal 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
45
tests/PretendTest.php
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue