Compare commits

...

12 commits

83 changed files with 11853 additions and 39 deletions

109
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,109 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: [ '8.1', '8.2', '8.3' ]
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pairity
ports:
- 3306:3306
options: >-
--health-cmd "mysqladmin ping -h 127.0.0.1 -proot"
--health-interval 10s
--health-timeout 5s
--health-retries 20
mongo:
image: mongo:6
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --eval 'db.runCommand({ ping: 1 })' || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 30
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: pairity
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: pdo, pdo_mysql, pdo_sqlite, pdo_pgsql, mongodb
coverage: none
- name: Install dependencies
run: |
composer install --no-interaction --prefer-dist
- name: Prepare MySQL
run: |
sudo apt-get update
# wait for mysql to be healthy
for i in {1..30}; do
if mysqladmin ping -h 127.0.0.1 -proot --silent; then
break
fi
sleep 2
done
mysql -h 127.0.0.1 -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS pairity;'
- name: Prepare Postgres
run: |
for i in {1..30}; do
if pg_isready -h 127.0.0.1 -p 5432 -U postgres; then
break
fi
sleep 2
done
- name: Run tests
env:
MYSQL_HOST: 127.0.0.1
MYSQL_PORT: 3306
MYSQL_DB: pairity
MYSQL_USER: root
MYSQL_PASS: root
MONGO_HOST: 127.0.0.1
MONGO_PORT: 27017
POSTGRES_HOST: 127.0.0.1
POSTGRES_PORT: 5432
POSTGRES_DB: pairity
POSTGRES_USER: postgres
POSTGRES_PASS: postgres
run: |
vendor/bin/phpunit --colors=always
- name: Static analysis (PHPStan)
run: |
if [ -f phpstan.neon.dist ]; then vendor/bin/phpstan analyse --no-progress || true; fi
- name: Style check (PHPCS)
run: |
if [ -f phpcs.xml.dist ]; then vendor/bin/phpcs || true; fi

39
.gitignore vendored
View file

@ -10,31 +10,7 @@ composer.phar
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
.idea/
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
@ -67,21 +43,8 @@ out/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser

1
.phpunit.result.cache Normal file
View file

@ -0,0 +1 @@
{"version":2,"defects":{"Pairity\\Tests\\BelongsToManyMysqlTest::testBelongsToManyEagerAndHelpers":1,"Pairity\\Tests\\BelongsToManySqliteTest::testBelongsToManyEagerAndPivotHelpers":8,"Pairity\\Tests\\CastersAndAccessorsSqliteTest::testCustomCasterAndDtoAccessorsMutators":7,"Pairity\\Tests\\JoinEagerMysqlTest::testJoinEagerHasManyAndBelongsTo":1,"Pairity\\Tests\\JoinEagerSqliteTest::testHasManyJoinEagerWithProjectionAndSoftDeleteScope":8,"Pairity\\Tests\\JoinEagerSqliteTest::testBelongsToJoinEagerSingleLevel":8,"Pairity\\Tests\\MongoAdapterTest::testCrudCycle":1,"Pairity\\Tests\\MongoDaoTest::testCrudViaDao":8,"Pairity\\Tests\\MongoOptimisticLockTest::testVersionIncrementOnUpdate":8,"Pairity\\Tests\\MongoPaginationTest::testPaginateAndSimplePaginateWithScopes":8,"Pairity\\Tests\\MongoRelationsTest::testEagerAndLazyRelations":8,"Pairity\\Tests\\MysqlSmokeTest::testCreateAndDropTable":1,"Pairity\\Tests\\PaginationSqliteTest::testPaginateAndSimplePaginateWithScopesAndRelations":8,"Pairity\\Tests\\RelationsNestedConstraintsSqliteTest::testNestedEagerAndPerRelationFieldsConstraint":8,"Pairity\\Tests\\SchemaBuilderSqliteTest::testCreateAlterDropCycle":8,"Pairity\\Tests\\UnitOfWorkCascadeMongoTest::testDeleteByIdCascadesToChildren":8,"Pairity\\Tests\\UnitOfWorkCascadeSqliteTest::testDeleteByIdCascadesToChildren":7,"Pairity\\Tests\\UnitOfWorkMongoTest::testDeferredUpdateAndDeleteCommit":8,"Pairity\\Tests\\UnitOfWorkMongoTest::testRollbackOnExceptionClearsOps":8},"times":{"Pairity\\Tests\\BelongsToManyMysqlTest::testBelongsToManyEagerAndHelpers":0.001,"Pairity\\Tests\\BelongsToManySqliteTest::testBelongsToManyEagerAndPivotHelpers":0.004,"Pairity\\Tests\\CastersAndAccessorsSqliteTest::testCustomCasterAndDtoAccessorsMutators":0.001,"Pairity\\Tests\\JoinEagerMysqlTest::testJoinEagerHasManyAndBelongsTo":0,"Pairity\\Tests\\JoinEagerSqliteTest::testHasManyJoinEagerWithProjectionAndSoftDeleteScope":0,"Pairity\\Tests\\JoinEagerSqliteTest::testBelongsToJoinEagerSingleLevel":0,"Pairity\\Tests\\MongoAdapterTest::testCrudCycle":0.002,"Pairity\\Tests\\MongoDaoTest::testCrudViaDao":0.001,"Pairity\\Tests\\MongoOptimisticLockTest::testVersionIncrementOnUpdate":0,"Pairity\\Tests\\MongoPaginationTest::testPaginateAndSimplePaginateWithScopes":0,"Pairity\\Tests\\MongoRelationsTest::testEagerAndLazyRelations":0,"Pairity\\Tests\\MysqlSmokeTest::testCreateAndDropTable":0,"Pairity\\Tests\\OptimisticLockSqliteTest::testVersionLockingIncrementsAndBlocksBulkUpdate":0,"Pairity\\Tests\\PaginationSqliteTest::testPaginateAndSimplePaginateWithScopesAndRelations":0.001,"Pairity\\Tests\\RelationsNestedConstraintsSqliteTest::testNestedEagerAndPerRelationFieldsConstraint":0,"Pairity\\Tests\\SchemaBuilderSqliteTest::testCreateAlterDropCycle":0.001,"Pairity\\Tests\\SoftDeletesTimestampsSqliteTest::testTimestampsAndSoftDeletesFlow":0.001,"Pairity\\Tests\\UnitOfWorkCascadeMongoTest::testDeleteByIdCascadesToChildren":0,"Pairity\\Tests\\UnitOfWorkCascadeSqliteTest::testDeleteByIdCascadesToChildren":0,"Pairity\\Tests\\UnitOfWorkMongoTest::testDeferredUpdateAndDeleteCommit":0,"Pairity\\Tests\\UnitOfWorkMongoTest::testRollbackOnExceptionClearsOps":0,"Pairity\\Tests\\UnitOfWorkSqliteTest::testDeferredUpdateAndDeleteCommit":0,"Pairity\\Tests\\UnitOfWorkSqliteTest::testRollbackOnExceptionClearsOps":0}}

15
CHANGELOG.md Normal file
View file

@ -0,0 +1,15 @@
### Changelog
All notable changes to this project will be documented in this file.
#### Unreleased
- Core ORM (DAO/DTO) with dynamic finders, `fields()` projection, relations (hasOne/hasMany/belongsTo), nested eager loading, perrelation constraints, and SQL `belongsToMany` with pivot helpers (`attach`, `detach`, `sync`).
- MongoDB production adapter (`ext-mongodb` + `mongodb/mongodb`) and Mongo DAO layer with relations (MVP), projections/sort/limit, pagination, and a small filter builder.
- Pagination helpers for SQL and Mongo: `paginate` and `simplePaginate`.
- Model metadata & schema mapping: column casts (incl. custom casters), timestamps, soft deletes.
- Migrations & Schema Builder (portable): create/drop/alter; CLI (`vendor/bin/pairity`) with migrate/rollback/status/reset/make:migration. Drivers: MySQL/MariaDB, SQLite, PostgreSQL, SQL Server, Oracle.
- Joinbased eager loading (optin, SQL, singlelevel) with safe fallbacks.
- Unit of Work (optin): identity map; deferred updates/deletes; relationaware delete cascades; optimistic locking; snapshot diffing (flagged); identity map controls; coalescing.
- Event system: DAO and UoW events; listeners/subscribers.
- CI: GitHub Actions matrix (PHP 8.18.3) with MySQL + Mongo services; guarded tests.

1009
README.md

File diff suppressed because it is too large Load diff

301
bin/pairity Normal file
View file

@ -0,0 +1,301 @@
#!/usr/bin/env php
<?php
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); }
function parseArgs(array $argv): array {
$args = ['_cmd' => $argv[1] ?? 'help'];
for ($i = 2; $i < count($argv); $i++) {
$a = $argv[$i];
if (str_starts_with($a, '--')) {
$eq = strpos($a, '=');
if ($eq !== false) {
$key = substr($a, 2, $eq - 2);
$val = substr($a, $eq + 1);
$args[$key] = $val;
} else {
$key = substr($a, 2);
$args[$key] = true;
}
} else {
$args[] = $a;
}
}
return $args;
}
function 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 status [--path=DIR] [--config=FILE]
pairity reset [--config=FILE]
pairity make:migration Name [--path=DIR]
pairity mongo:index:ensure DB COLLECTION KEYS_JSON [--unique]
pairity mongo:index:drop DB COLLECTION NAME
pairity mongo:index:list DB COLLECTION
Environment:
DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PATH (for sqlite)
If --config is provided, it must be a PHP file returning the ConnectionManager config array.
TXT;
stdout($help);
}
$args = parseArgs($argv);
$cmd = $args['_cmd'] ?? 'help';
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());
exit(1);
}

64
composer.json Normal file
View file

@ -0,0 +1,64 @@
{
"name": "getphred/pairity",
"description": "DAO/DTO-centric PHP ORM with Query Builder, relations (SQL + MongoDB), migrations/CLI, Unit of Work, and event system. Supports MySQL/MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, and MongoDB.",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Phred",
"email": "phred@getphred.com",
"homepage": "https://getphred.com",
"role": "Owner"
}
],
"keywords": [
"php",
"orm",
"dao",
"dto",
"query builder",
"migrations",
"schema builder",
"relations",
"unit of work",
"events",
"pdo",
"mysql",
"mariadb",
"postgresql",
"sqlite",
"sqlserver",
"oracle",
"mongodb",
"nosql"
],
"homepage": "https://github.com/getphred/pairity",
"support": {
"issues": "https://github.com/getphred/pairity/issues",
"source": "https://github.com/getphred/pairity"
},
"require": {
"php": ">=8.1",
"ext-mongodb": "*",
"mongodb/mongodb": "^1.19"
},
"autoload": {
"psr-4": {
"Pairity\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Pairity\\Tests\\": "tests/"
}
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"minimum-stability": "dev",
"prefer-stable": true
,
"bin": [
"bin/pairity"
]
}

1818
composer.lock generated Normal file

File diff suppressed because it is too large Load diff

67
examples/events_audit.php Normal file
View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
use Pairity\Events\Events;
// SQLite demo DB
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Ensure table
$conn->execute('CREATE TABLE IF NOT EXISTS audit_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT,
name TEXT,
status TEXT
)');
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'audit_users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>[
'id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string'],'status'=>['cast'=>'string']
]]; }
}
// Simple audit buffer
$audit = [];
// Register listeners
Events::dispatcher()->clear();
Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) {
if (($p['table'] ?? '') === 'audit_users') {
// normalize
$p['data']['email'] = strtolower((string)($p['data']['email'] ?? ''));
}
});
Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$audit) {
if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) {
$audit[] = '[afterInsert] id=' . ($p['dto']->toArray(false)['id'] ?? '?');
}
});
Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$audit) {
if (($p['table'] ?? '') === 'audit_users' && isset($p['dto'])) {
$audit[] = '[afterUpdate] id=' . ($p['dto']->toArray(false)['id'] ?? '?');
}
});
$dao = new UserDao($conn);
// Clean for demo
foreach ($dao->findAllBy() as $row) { $dao->deleteById((int)$row->toArray(false)['id']); }
// Perform some ops
$u = $dao->insert(['email' => 'AUDIT@EXAMPLE.COM', 'name' => 'Audit Me']);
$id = (int)($u->toArray(false)['id'] ?? 0);
$dao->update($id, ['name' => 'Audited']);
echo "Audit log:\n" . implode("\n", $audit) . "\n";

View file

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Pairity\Migrations\MigrationInterface;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
return new class implements MigrationInterface {
public function up(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->table('users', function (Blueprint $t) {
// Add a new nullable column and an index (on status)
$t->string('bio', 500)->nullable();
$t->index(['status'], 'users_status_index');
});
}
public function down(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->table('users', function (Blueprint $t) {
$t->dropIndex('users_status_index');
$t->dropColumn('bio');
});
}
};

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Pairity\Migrations\MigrationInterface;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
return new class implements MigrationInterface {
public function up(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->create('users', function (Blueprint $t) {
$t->increments('id');
$t->string('email', 190);
$t->unique(['email']);
$t->string('name', 255)->nullable();
$t->string('status', 50)->nullable();
$t->timestamps();
$t->datetime('deleted_at')->nullable();
});
}
public function down(ConnectionInterface $connection): void
{
$schema = SchemaManager::forConnection($connection);
$schema->dropIfExists('users');
}
};

72
examples/mysql_crud.php Normal file
View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// 1) Configure MySQL connection
$conn = ConnectionManager::make([
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'app',
'username' => 'root',
'password' => 'secret',
'charset' => 'utf8mb4',
]);
// 2) Define DTO, DAO, and Repository for `users` table
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao
{
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
// Demonstrate schema metadata (casts)
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'email' => ['cast' => 'string'],
'name' => ['cast' => 'string'],
'status' => ['cast' => 'string'],
],
// Uncomment if your table has these columns
// 'timestamps' => [ 'createdAt' => 'created_at', 'updatedAt' => 'updated_at' ],
// 'softDeletes' => [ 'enabled' => true, 'deletedAt' => 'deleted_at' ],
];
}
}
$dao = new UserDao($conn);
// 3) Create (INSERT)
$user = new UserDto([
'email' => 'alice@example.com',
'name' => 'Alice',
'status'=> 'active',
]);
$created = $dao->insert($user->toArray());
echo "Created user ID: " . ($created->toArray()['id'] ?? 'N/A') . PHP_EOL;
// 4) Read (SELECT)
$found = $dao->findOneBy(['email' => 'alice@example.com']);
echo 'Found: ' . json_encode($found?->toArray()) . PHP_EOL;
// 5) Update
$data = $found?->toArray() ?? [];
$data['name'] = 'Alice Updated';
$updated = $dao->update($data['id'], ['name' => 'Alice Updated']);
echo 'Updated: ' . json_encode($updated->toArray()) . PHP_EOL;
// 6) Delete
$deleted = $dao->deleteBy(['email' => 'alice@example.com']);
echo "Deleted rows: {$deleted}" . PHP_EOL;

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
// Configure MySQL connection (adjust credentials as needed)
$conn = ConnectionManager::make([
'driver' => 'mysql',
'host' => getenv('MYSQL_HOST') ?: '127.0.0.1',
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
'database' => getenv('MYSQL_DB') ?: 'app',
'username' => getenv('MYSQL_USER') ?: 'root',
'password' => getenv('MYSQL_PASS') ?: 'secret',
'charset' => 'utf8mb4',
]);
// Ensure demo tables (idempotent)
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS posts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(190) NOT NULL,
deleted_at DATETIME NULL
)');
class UserDto extends AbstractDto {}
class PostDto extends AbstractDto {}
class PostDao extends AbstractDao {
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return PostDto::class; }
protected function schema(): array { return [
'primaryKey' => 'id',
'columns' => [ 'id'=>['cast'=>'int'], 'user_id'=>['cast'=>'int'], 'title'=>['cast'=>'string'], 'deleted_at'=>['cast'=>'datetime'] ],
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
]; }
}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostDao::class,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
}
$userDao = new UserDao($conn);
$postDao = new PostDao($conn);
// Clean minimal (demo only)
foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); }
foreach ($postDao->findAllBy() as $p) { $postDao->deleteById((int)$p->toArray(false)['id']); }
// Seed
$u1 = $userDao->insert(['name' => 'Alice']);
$u2 = $userDao->insert(['name' => 'Bob']);
$uid1 = (int)$u1->toArray(false)['id'];
$uid2 = (int)$u2->toArray(false)['id'];
$postDao->insert(['user_id' => $uid1, 'title' => 'P1']);
$postDao->insert(['user_id' => $uid1, 'title' => 'P2']);
$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]);
// Baseline portable eager (batched IN)
$batched = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]);
echo "Batched eager: \n";
foreach ($batched as $u) {
$arr = $u->toArray(false);
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
}
// Join-based eager (global opt-in)
$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]);
echo "\nJoin eager (global): \n";
foreach ($joined as $u) {
$arr = $u->toArray(false);
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
}
// Per-relation join hint (equivalent in this single-rel case)
$hinted = $userDao->fields('id','name','posts.title')
->with(['posts' => ['strategy' => 'join']])
->findAllBy([]); // will fallback to batched if conditions not met
echo "\nJoin eager (per-relation hint): \n";
foreach ($hinted as $u) {
$arr = $u->toArray(false);
$titles = array_map(fn($p) => $p->toArray(false)['title'] ?? '', $arr['posts'] ?? []);
echo "- {$arr['name']} posts: " . implode(', ', $titles) . "\n";
}

View file

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
// Configure MySQL connection (adjust credentials as needed)
$conn = ConnectionManager::make([
'driver' => 'mysql',
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'app',
'username' => 'root',
'password' => 'secret',
'charset' => 'utf8mb4',
]);
// Ensure demo tables (idempotent)
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(190) NOT NULL
)');
$conn->execute('CREATE TABLE IF NOT EXISTS user_role (
user_id INT NOT NULL,
role_id INT NOT NULL
)');
class UserDto extends AbstractDto {}
class RoleDto extends AbstractDto {}
class RoleDao extends AbstractDao {
public function getTable(): string { return 'roles'; }
protected function dtoClass(): string { return RoleDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => RoleDao::class,
'pivot' => 'user_role',
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
}
$roleDao = new RoleDao($conn);
$userDao = new UserDao($conn);
// Clean minimal (demo only)
foreach ($userDao->findAllBy() as $u) { $userDao->deleteById((int)$u->toArray(false)['id']); }
foreach ($roleDao->findAllBy() as $r) { $roleDao->deleteById((int)$r->toArray(false)['id']); }
$conn->execute('DELETE FROM user_role');
// Seed
$admin = $roleDao->insert(['name' => 'admin']);
$editor = $roleDao->insert(['name' => 'editor']);
$u = $userDao->insert(['email' => 'pivot@example.com']);
$uid = (int)$u->toArray(false)['id'];
$ridAdmin = (int)$admin->toArray(false)['id'];
$ridEditor = (int)$editor->toArray(false)['id'];
// Attach roles
$userDao->attach('roles', $uid, [$ridAdmin, $ridEditor]);
$with = $userDao->with(['roles'])->findById($uid);
echo "User with roles: " . json_encode($with?->toArray(true)) . "\n";
// Detach one role
$userDao->detach('roles', $uid, [$ridAdmin]);
$with = $userDao->with(['roles'])->findById($uid);
echo "After detach: " . json_encode($with?->toArray(true)) . "\n";
// Sync to only [editor]
$userDao->sync('roles', $uid, [$ridEditor]);
$with = $userDao->with(['roles'])->findById($uid);
echo "After sync: " . json_encode($with?->toArray(true)) . "\n";

View file

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
// Configure via URI or discrete params
$conn = MongoConnectionManager::make([
// 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
'host' => '127.0.0.1',
'port' => 27017,
]);
$db = 'pairity_demo';
$col = 'users';
// Clean collection for demo
foreach ($conn->find($db, $col, []) as $doc) {
$conn->deleteOne($db, $col, ['_id' => $doc['_id']]);
}
// Insert
$id = $conn->insertOne($db, $col, [
'email' => 'mongo@example.com',
'name' => 'Mongo User',
'status'=> 'active',
]);
echo "Inserted _id={$id}\n";
// Find
$found = $conn->find($db, $col, ['_id' => $id]);
echo 'Found: ' . json_encode($found, JSON_UNESCAPED_SLASHES) . PHP_EOL;
// Update
$conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['name' => 'Updated Mongo User']]);
$after = $conn->find($db, $col, ['_id' => $id]);
echo 'After update: ' . json_encode($after, JSON_UNESCAPED_SLASHES) . PHP_EOL;
// Aggregate (simple match projection)
$agg = $conn->aggregate($db, $col, [
['$match' => ['status' => 'active']],
['$project' => ['email' => 1, 'name' => 1]],
]);
echo 'Aggregate: ' . json_encode($agg, JSON_UNESCAPED_SLASHES) . PHP_EOL;
// Delete
$deleted = $conn->deleteOne($db, $col, ['_id' => $id]);
echo "Deleted: {$deleted}\n";

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
// Connect via URI or discrete params
$conn = MongoConnectionManager::make([
// 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
'host' => '127.0.0.1',
'port' => 27017,
]);
class UserDoc extends AbstractDto {}
class UserMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.users'; }
protected function dtoClass(): string { return UserDoc::class; }
}
$dao = new UserMongoDao($conn);
// Clean for demo
foreach ($dao->findAllBy([]) as $dto) {
$id = (string)($dto->toArray(false)['_id'] ?? '');
if ($id) { $dao->deleteById($id); }
}
// Insert
$created = $dao->insert([
'email' => 'mongo@example.com',
'name' => 'Mongo User',
'status'=> 'active',
]);
echo 'Inserted: ' . json_encode($created->toArray(false)) . "\n";
// Find by dynamic helper
$found = $dao->findOneByEmail('mongo@example.com');
echo 'Found: ' . json_encode($found?->toArray(false)) . "\n";
// Update
if ($found) {
$id = (string)$found->toArray(false)['_id'];
$updated = $dao->update($id, ['name' => 'Updated Mongo User']);
echo 'Updated: ' . json_encode($updated->toArray(false)) . "\n";
}
// Projection + sort + limit
$list = $dao->fields('email', 'name')->sort(['email' => 1])->limit(10)->findAllBy(['status' => 'active']);
echo 'List (projected): ' . json_encode(array_map(fn($d) => $d->toArray(false), $list)) . "\n";
// Delete
if ($found) {
$id = (string)$found->toArray(false)['_id'];
$deleted = $dao->deleteById($id);
echo "Deleted: {$deleted}\n";
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
// Connect via URI or discrete params
$conn = MongoConnectionManager::make([
// 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin',
'host' => '127.0.0.1',
'port' => 27017,
]);
class UserDoc extends AbstractDto {}
class PostDoc extends AbstractDto {}
class PostMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.pg_posts'; }
protected function dtoClass(): string { return PostDoc::class; }
}
class UserMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.pg_users'; }
protected function dtoClass(): string { return UserDoc::class; }
protected function relations(): array
{
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostMongoDao::class,
'foreignKey' => 'user_id',
'localKey' => '_id',
],
];
}
}
$userDao = new UserMongoDao($conn);
$postDao = new PostMongoDao($conn);
// Clean collections for demo
foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } }
foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } }
// Seed 22 users; every 3rd has a post
for ($i=1; $i<=22; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "mp{$i}@example.com", 'status' => $status]);
$uid = (string)($u->toArray(false)['_id'] ?? '');
if ($i % 3 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Post '.$i]); }
}
// Paginate
$page1 = $userDao->paginate(1, 10, []);
echo "Page1 total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n";
// Simple paginate
$sp = $userDao->simplePaginate(3, 10, []);
echo 'Simple nextPage on page 3: ' . json_encode($sp['nextPage']) . "\n";
// Projection + sort + eager relation
$with = $userDao->fields('email','posts.title')->sort(['email' => 1])->with(['posts'])->paginate(1, 5, []);
echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n";
// Named scope example
$userDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 100, []);
echo "Active total: {$active['total']}\n";

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../../vendor/autoload.php';
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
class UserDoc extends AbstractDto {}
class PostDoc extends AbstractDto {}
class UserMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.users'; }
protected function dtoClass(): string { return UserDoc::class; }
protected function relations(): array
{
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostMongoDao::class,
'foreignKey' => 'user_id',
'localKey' => '_id',
],
];
}
}
class PostMongoDao extends AbstractMongoDao
{
protected function collection(): string { return 'pairity_demo.posts'; }
protected function dtoClass(): string { return PostDoc::class; }
protected function relations(): array
{
return [
'user' => [
'type' => 'belongsTo',
'dao' => UserMongoDao::class,
'foreignKey' => 'user_id',
'otherKey' => '_id',
],
];
}
}
$conn = MongoConnectionManager::make([
'host' => '127.0.0.1',
'port' => 27017,
]);
$userDao = new UserMongoDao($conn);
$postDao = new PostMongoDao($conn);
// Clean
foreach ($postDao->findAllBy([]) as $p) { $postDao->deleteById((string)$p->toArray(false)['_id']); }
foreach ($userDao->findAllBy([]) as $u) { $userDao->deleteById((string)$u->toArray(false)['_id']); }
// Seed
$u = $userDao->insert(['email' => 'mongo@example.com', 'name' => 'Alice']);
$uid = (string)$u->toArray(false)['_id'];
$p1 = $postDao->insert(['title' => 'First', 'user_id' => $uid]);
$p2 = $postDao->insert(['title' => 'Second', 'user_id' => $uid]);
// Eager load posts on users
$users = $userDao->fields('email', 'name', 'posts.title')->with(['posts'])->findAllBy([]);
echo 'Users with posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $users)) . "\n";
// Lazy load user on a post
$onePost = $postDao->findOneBy(['title' => 'First']);
if ($onePost) {
$postDao->load($onePost, 'user');
echo 'Post with user: ' . json_encode($onePost->toArray()) . "\n";
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Migrations\Migrator;
// SQLite connection (file db.sqlite in project root)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Load migrations (here we just include a PHP file returning a MigrationInterface instance)
$createUsers = require __DIR__ . '/migrations/CreateUsersTable.php';
$migrator = new Migrator($conn);
$migrator->setRegistry([
'CreateUsersTable' => $createUsers,
]);
// Apply outstanding migrations
$applied = $migrator->migrate([
'CreateUsersTable' => $createUsers,
]);
echo 'Applied: ' . json_encode($applied) . PHP_EOL;
// To roll back last batch, uncomment:
// $rolled = $migrator->rollback(1);
// echo 'Rolled back: ' . json_encode($rolled) . PHP_EOL;

95
examples/sqlite_crud.php Normal file
View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// 1) Configure SQLite connection (file db.sqlite in project root)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Create table for demo if not exists
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT,
status TEXT,
created_at TEXT NULL,
updated_at TEXT NULL,
deleted_at TEXT NULL
)');
// 2) Define DTO, DAO, and Repository for `users` table
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao
{
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
// Demonstrate schema metadata (casts)
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'email' => ['cast' => 'string'],
'name' => ['cast' => 'string'],
'status' => ['cast' => 'string'],
'created_at' => ['cast' => 'datetime'],
'updated_at' => ['cast' => 'datetime'],
'deleted_at' => ['cast' => 'datetime'],
],
'timestamps' => [ 'createdAt' => 'created_at', 'updatedAt' => 'updated_at' ],
'softDeletes' => [ 'enabled' => true, 'deletedAt' => 'deleted_at' ],
];
}
}
$dao = new UserDao($conn);
// 3) Create (INSERT)
$user = new UserDto([
'email' => 'bob@example.com',
'name' => 'Bob',
'status'=> 'active',
]);
$created = $dao->insert($user->toArray());
echo "Created user ID: " . ($created->toArray()['id'] ?? 'N/A') . PHP_EOL;
// 4) Read (SELECT)
$found = $dao->findOneBy(['email' => 'bob@example.com']);
echo 'Found: ' . json_encode($found?->toArray()) . PHP_EOL;
// 5) Update
$data = $found?->toArray() ?? [];
$data['name'] = 'Bob Updated';
$updated = $dao->update($data['id'], ['name' => 'Bob Updated']);
echo 'Updated: ' . json_encode($updated->toArray()) . PHP_EOL;
// 6) Delete
// 6) Soft Delete
$deleted = $dao->deleteBy(['email' => 'bob@example.com']);
echo "Soft-deleted rows: {$deleted}" . PHP_EOL;
// 7) Query scopes
$all = $dao->withTrashed()->findAllBy();
echo 'All (with trashed): ' . count($all) . PHP_EOL;
$trashedOnly = $dao->onlyTrashed()->findAllBy();
echo 'Only trashed: ' . count($trashedOnly) . PHP_EOL;
// 8) Restore then force delete
if ($found) {
$dao->restoreById($found->toArray()['id']);
echo "Restored ID {$found->toArray()['id']}\n";
$dao->forceDeleteById($found->toArray()['id']);
echo "Force-deleted ID {$found->toArray()['id']}\n";
}

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
// SQLite connection (file db.sqlite in project root)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Demo tables
$conn->execute('CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT,
status TEXT
)');
$conn->execute('CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
title TEXT
)');
class UserDto extends AbstractDto {}
class PostDto extends AbstractDto {}
class PostDao extends AbstractDao {
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return PostDto::class; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
}
class UserDao extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => PostDao::class,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; }
}
$userDao = new UserDao($conn);
$postDao = new PostDao($conn);
// Seed a few users if table is empty
$hasAny = $userDao->findAllBy();
if (!$hasAny) {
for ($i=1; $i<=25; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "p{$i}@example.com", 'status' => $status]);
$uid = (int)($u->toArray(false)['id'] ?? 0);
if ($i % 5 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'Hello '.$i]); }
}
}
// Paginate (page 1, perPage 10)
$page1 = $userDao->paginate(1, 10);
echo "Page 1: total={$page1['total']} lastPage={$page1['lastPage']} count=".count($page1['data'])."\n";
// Simple paginate (detect next page)
$sp = $userDao->simplePaginate(1, 10);
echo 'Simple nextPage: ' . json_encode($sp['nextPage']) . "\n";
// Projection + eager load
$with = $userDao->fields('id','email','posts.title')->with(['posts'])->paginate(1, 5);
echo 'With posts: ' . json_encode(array_map(fn($d) => $d->toArray(), $with['data'])) . "\n";
// Named scope
$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 50);
echo "Active total: {$active['total']}\n";

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
use Pairity\Orm\UnitOfWork;
// SQLite demo DB (local file)
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => __DIR__ . '/../db.sqlite',
]);
// Ensure table
$conn->execute('CREATE TABLE IF NOT EXISTS uow_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
version INTEGER NOT NULL DEFAULT 0
)');
class UserDto extends AbstractDto {}
class UserDao extends AbstractDao {
public function getTable(): string { return 'uow_users'; }
protected function dtoClass(): string { return UserDto::class; }
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'name' => ['cast' => 'string'],
'version' => ['cast' => 'int'],
],
// Enable optimistic locking on integer version column
'locking' => ['type' => 'version', 'column' => 'version'],
];
}
}
$dao = new UserDao($conn);
// Clean for demo
foreach ($dao->findAllBy() as $row) {
$dao->deleteById((int)($row->toArray(false)['id'] ?? 0));
}
// Create one
$u = $dao->insert(['name' => 'Alice']);
$id = (int)($u->toArray(false)['id'] ?? 0);
// Demonstrate UoW with snapshot diffing: modify DTO then commit
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
// Enable snapshot diffing (optional)
$uow->enableSnapshots(true);
// Load and modify the DTO directly (no explicit update call)
$user = $dao->findById($id);
if ($user) {
// mutate DTO attributes
$user->setRelation('name', 'Alice (uow)');
}
// Also stage an explicit update to show coalescing
$dao->update($id, ['name' => 'Alice (explicit)']);
});
$after = $dao->findById($id);
echo 'After UoW commit: ' . json_encode($after?->toArray(false)) . "\n";

17
phpunit.xml.dist Normal file
View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Pairity Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,45 @@
<?php
namespace Pairity\Contracts;
interface ConnectionInterface
{
/**
* Execute a SELECT (or any returning) statement and fetch all rows as associative arrays.
*
* @param string $sql
* @param array<string, mixed> $params
* @return array<int, array<string, mixed>>
*/
public function query(string $sql, array $params = []): array;
/**
* Execute a non-SELECT statement (INSERT/UPDATE/DELETE).
*
* @param string $sql
* @param array<string, mixed> $params
* @return int affected rows
*/
public function execute(string $sql, array $params = []): int;
/**
* Run a callback within a transaction.
* Rolls back on throwable and rethrows it.
*
* @template T
* @param callable($this):T $callback
* @return mixed
*/
public function transaction(callable $callback): mixed;
/**
* Return the underlying driver connection (e.g., PDO).
* @return mixed
*/
public function getNative(): mixed;
/**
* Get last inserted ID if supported.
*/
public function lastInsertId(): ?string;
}

View file

@ -0,0 +1,8 @@
<?php
namespace Pairity\Contracts;
interface DaoInterface
{
public function getTable(): string;
}

View file

@ -0,0 +1,15 @@
<?php
namespace Pairity\Contracts;
interface DtoInterface
{
/**
* Convert DTO to array.
* When $deep is true (default), convert any nested DTO relations to arrays as well.
*
* @param bool $deep
* @return array<string,mixed>
*/
public function toArray(bool $deep = true): array;
}

View file

@ -0,0 +1,19 @@
<?php
namespace Pairity\Contracts;
interface QueryBuilderInterface
{
public function select(array $columns): static;
public function from(string $table, ?string $alias = null): static;
public function join(string $type, string $table, string $on): static;
public function where(string $clause, array $bindings = []): static;
public function orderBy(string $orderBy): static;
public function groupBy(string $groupBy): static;
public function having(string $clause, array $bindings = []): static;
public function limit(int $limit): static;
public function offset(int $offset): static;
public function toSql(): string;
/** @return array<string, mixed> */
public function getBindings(): array;
}

View file

@ -0,0 +1,85 @@
<?php
namespace Pairity\Database;
use PDO;
use Pairity\Contracts\ConnectionInterface;
final class ConnectionManager
{
/**
* @param array<string,mixed> $config
*/
public static function make(array $config): ConnectionInterface
{
$driver = strtolower((string)($config['driver'] ?? ''));
if ($driver === '') {
throw new \InvalidArgumentException('Database config must include a driver');
}
[$dsn, $username, $password, $options] = self::buildDsn($driver, $config);
$pdo = new PDO($dsn, $username, $password, $options);
return new PdoConnection($pdo);
}
/**
* @param array<string,mixed> $config
* @return array{0:string,1:?string,2:?string,3:array<string,mixed>}
*/
private static function buildDsn(string $driver, array $config): array
{
$username = $config['username'] ?? null;
$password = $config['password'] ?? null;
$options = $config['options'] ?? [];
switch ($driver) {
case 'mysql':
case 'mariadb':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 3306);
$db = $config['database'] ?? '';
$charset = $config['charset'] ?? 'utf8mb4';
$dsn = "mysql:host={$host};port={$port};dbname={$db};charset={$charset}";
return [$dsn, $username, $password, $options];
case 'pgsql':
case 'postgres':
case 'postgresql':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 5432);
$db = $config['database'] ?? '';
$dsn = "pgsql:host={$host};port={$port};dbname={$db}";
return [$dsn, $username, $password, $options];
case 'sqlite':
$path = $config['path'] ?? ($config['database'] ?? ':memory:');
$dsn = str_starts_with($path, 'memory') || $path === ':memory:' ? 'sqlite::memory:' : 'sqlite:' . $path;
// For SQLite, username/password are typically null
return [$dsn, null, null, $options];
case 'sqlsrv':
case 'mssql':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 1433);
$db = $config['database'] ?? '';
$server = $port ? "$host,$port" : $host;
$dsn = "sqlsrv:Server={$server};Database={$db}";
if (!isset($options[PDO::SQLSRV_ATTR_ENCODING])) {
$options[PDO::SQLSRV_ATTR_ENCODING] = PDO::SQLSRV_ENCODING_UTF8;
}
return [$dsn, $username, $password, $options];
case 'oci':
case 'oracle':
$host = $config['host'] ?? '127.0.0.1';
$port = (int)($config['port'] ?? 1521);
$service = $config['service_name'] ?? ($config['sid'] ?? 'XE');
$charset = $config['charset'] ?? 'AL32UTF8';
$dsn = "oci:dbname=//{$host}:{$port}/{$service};charset={$charset}";
return [$dsn, $username, $password, $options];
default:
throw new \InvalidArgumentException("Unsupported driver: {$driver}");
}
}
}

View file

@ -0,0 +1,116 @@
<?php
namespace Pairity\Database;
use PDO;
use PDOException;
use Pairity\Contracts\ConnectionInterface;
class PdoConnection implements ConnectionInterface
{
private PDO $pdo;
/** @var array<string, \PDOStatement> */
private array $stmtCache = [];
private int $stmtCacheSize = 100; // LRU bound
/** @var null|callable */
private $queryLogger = null; // function(string $sql, array $params, float $ms): void
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
}
/** Enable/disable a bounded prepared statement cache (default size 100). */
public function setStatementCacheSize(int $size): void
{
$this->stmtCacheSize = max(0, $size);
if ($this->stmtCacheSize === 0) {
$this->stmtCache = [];
} else if (count($this->stmtCache) > $this->stmtCacheSize) {
// trim
$this->stmtCache = array_slice($this->stmtCache, -$this->stmtCacheSize, null, true);
}
}
/** Set a logger callable to receive [sql, params, ms] for each query/execute. */
public function setQueryLogger(?callable $logger): void
{
$this->queryLogger = $logger;
}
private function prepare(string $sql): \PDOStatement
{
if ($this->stmtCacheSize <= 0) {
return $this->pdo->prepare($sql);
}
if (isset($this->stmtCache[$sql])) {
// Touch for LRU by moving to end
$stmt = $this->stmtCache[$sql];
unset($this->stmtCache[$sql]);
$this->stmtCache[$sql] = $stmt;
return $stmt;
}
$stmt = $this->pdo->prepare($sql);
$this->stmtCache[$sql] = $stmt;
// Enforce LRU bound
if (count($this->stmtCache) > $this->stmtCacheSize) {
array_shift($this->stmtCache);
}
return $stmt;
}
public function query(string $sql, array $params = []): array
{
$t0 = microtime(true);
$stmt = $this->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll();
if ($this->queryLogger) {
$ms = (microtime(true) - $t0) * 1000.0;
try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {}
}
return $rows;
}
public function execute(string $sql, array $params = []): int
{
$t0 = microtime(true);
$stmt = $this->prepare($sql);
$stmt->execute($params);
$count = $stmt->rowCount();
if ($this->queryLogger) {
$ms = (microtime(true) - $t0) * 1000.0;
try { ($this->queryLogger)($sql, $params, $ms); } catch (\Throwable) {}
}
return $count;
}
public function transaction(callable $callback): mixed
{
$this->pdo->beginTransaction();
try {
$result = $callback($this);
$this->pdo->commit();
return $result;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function getNative(): mixed
{
return $this->pdo;
}
public function lastInsertId(): ?string
{
try {
return $this->pdo->lastInsertId() ?: null;
} catch (PDOException $e) {
return null;
}
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace Pairity\Events;
final class EventDispatcher
{
/** @var array<string, array<int, array{priority:int, listener:callable}>> */
private array $listeners = [];
/**
* Register a listener for an event.
* Listener signature: function(array &$payload): void
*/
public function listen(string $event, callable $listener, int $priority = 0): void
{
$this->listeners[$event][] = ['priority' => $priority, 'listener' => $listener];
// Sort by priority desc so higher runs first
usort($this->listeners[$event], function ($a, $b) { return $b['priority'] <=> $a['priority']; });
}
/** Register all listeners from a subscriber. */
public function subscribe(SubscriberInterface $subscriber): void
{
foreach ((array)$subscriber->getSubscribedEvents() as $event => $handler) {
$callable = null; $priority = 0;
if (is_array($handler) && isset($handler[0])) {
$callable = $handler[0];
$priority = (int)($handler[1] ?? 0);
} else {
$callable = $handler;
}
if (is_callable($callable)) {
$this->listen($event, $callable, $priority);
}
}
}
/**
* Dispatch an event with a mutable payload (passed by reference to listeners).
*
* @param string $event
* @param array<string,mixed> $payload
*/
public function dispatch(string $event, array &$payload = []): void
{
$list = $this->listeners[$event] ?? [];
if (!$list) return;
foreach ($list as $entry) {
try {
($entry['listener'])($payload);
} catch (\Throwable) {
// swallow listener exceptions to avoid breaking core flow
}
}
}
/** Remove all listeners for an event or all events. */
public function clear(?string $event = null): void
{
if ($event === null) { $this->listeners = []; return; }
unset($this->listeners[$event]);
}
}

21
src/Events/Events.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace Pairity\Events;
final class Events
{
private static ?EventDispatcher $dispatcher = null;
public static function dispatcher(): EventDispatcher
{
if (self::$dispatcher === null) {
self::$dispatcher = new EventDispatcher();
}
return self::$dispatcher;
}
public static function setDispatcher(?EventDispatcher $dispatcher): void
{
self::$dispatcher = $dispatcher;
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace Pairity\Events;
interface SubscriberInterface
{
/**
* Return an array of event => callable|array{0:callable,1:int priority}
* Example: return [
* 'dao.beforeInsert' => [[$this, 'onBeforeInsert'], 10],
* 'uow.afterCommit' => [$this, 'onAfterCommit'],
* ];
*/
public function getSubscribedEvents(): array;
}

View file

@ -0,0 +1,11 @@
<?php
namespace Pairity\Migrations;
use Pairity\Contracts\ConnectionInterface;
interface MigrationInterface
{
public function up(ConnectionInterface $connection): void;
public function down(ConnectionInterface $connection): void;
}

View file

@ -0,0 +1,38 @@
<?php
namespace Pairity\Migrations;
final class MigrationLoader
{
/**
* Load migrations from a directory.
* Each PHP file should return a MigrationInterface instance or define a class that implements it (autoloadable).
*
* @return array<string,MigrationInterface> Ordered map name => instance
*/
public static function fromDirectory(string $dir): array
{
$result = [];
if (!is_dir($dir)) {
return $result;
}
$files = glob(rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . '*.php') ?: [];
sort($files, SORT_STRING);
foreach ($files as $file) {
$name = pathinfo($file, PATHINFO_FILENAME);
$loaded = require $file;
if ($loaded instanceof MigrationInterface) {
$result[$name] = $loaded;
continue;
}
// If file didn't return an instance but defines a class with the same basename, try to instantiate.
if (class_exists($name)) {
$obj = new $name();
if ($obj instanceof MigrationInterface) {
$result[$name] = $obj;
}
}
}
return $result;
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace Pairity\Migrations;
use Pairity\Contracts\ConnectionInterface;
class MigrationsRepository
{
private ConnectionInterface $connection;
private string $table;
public function __construct(ConnectionInterface $connection, string $table = 'migrations')
{
$this->connection = $connection;
$this->table = $table;
}
public function ensureTable(): void
{
// Portable table with string PK works across MySQL & SQLite
$sql = "CREATE TABLE IF NOT EXISTS {$this->table} (
migration VARCHAR(255) PRIMARY KEY,
batch INT NOT NULL,
ran_at DATETIME NOT NULL
)";
$this->connection->execute($sql);
}
/**
* @return array<int,string> migration names already ran
*/
public function getRan(): array
{
$this->ensureTable();
$rows = $this->connection->query("SELECT migration FROM {$this->table} ORDER BY migration ASC");
return array_map(fn($r) => (string)$r['migration'], $rows);
}
public function getLastBatchNumber(): int
{
$this->ensureTable();
$rows = $this->connection->query("SELECT MAX(batch) AS b FROM {$this->table}");
$max = $rows[0]['b'] ?? 0;
return (int)($max ?: 0);
}
public function getNextBatchNumber(): int
{
return $this->getLastBatchNumber() + 1;
}
/** @return array<int,array{migration:string,batch:int,ran_at:string}> */
public function getMigrationsInBatch(int $batch): array
{
$this->ensureTable();
return $this->connection->query("SELECT migration, batch, ran_at FROM {$this->table} WHERE batch = :b ORDER BY migration DESC", ['b' => $batch]);
}
public function log(string $migration, int $batch): void
{
$this->ensureTable();
$this->connection->execute(
"INSERT INTO {$this->table} (migration, batch, ran_at) VALUES (:m, :b, :t)",
['m' => $migration, 'b' => $batch, 't' => gmdate('Y-m-d H:i:s')]
);
}
public function remove(string $migration): void
{
$this->ensureTable();
$this->connection->execute("DELETE FROM {$this->table} WHERE migration = :m", ['m' => $migration]);
}
}

103
src/Migrations/Migrator.php Normal file
View file

@ -0,0 +1,103 @@
<?php
namespace Pairity\Migrations;
use Pairity\Contracts\ConnectionInterface;
class Migrator
{
private ConnectionInterface $connection;
private MigrationsRepository $repository;
/** @var array<string,MigrationInterface> */
private array $registry = [];
public function __construct(ConnectionInterface $connection, ?MigrationsRepository $repository = null)
{
$this->connection = $connection;
$this->repository = $repository ?? new MigrationsRepository($connection);
}
/**
* Provide a registry (name => migration instance) used for rollback/reset resolution.
*
* @param array<string,MigrationInterface> $registry
*/
public function setRegistry(array $registry): void
{
$this->registry = $registry;
}
/**
* Run outstanding migrations.
*
* @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
{
$this->repository->ensureTable();
$ran = array_flip($this->repository->getRan());
$batch = $this->repository->getNextBatchNumber();
$applied = [];
foreach ($migrations as $name => $migration) {
if (isset($ran[$name])) {
continue; // already ran
}
// keep in registry for potential rollback in the same process
$this->registry[$name] = $migration;
$this->connection->transaction(function () use ($migration, $name, $batch, &$applied) {
$migration->up($this->connection);
$this->repository->log($name, $batch);
$applied[] = $name;
});
}
return $applied;
}
/**
* Roll back the last batch (or N steps of batches).
*
* @return array<int,string> List of rolled back migration names
*/
public function rollback(int $steps = 1): array
{
$this->repository->ensureTable();
$rolled = [];
for ($i = 0; $i < $steps; $i++) {
$batch = $this->repository->getLastBatchNumber();
if ($batch <= 0) { break; }
$items = $this->repository->getMigrationsInBatch($batch);
if (!$items) { break; }
foreach ($items as $row) {
$name = (string)$row['migration'];
$instance = $this->resolveMigration($name);
if (!$instance) { continue; }
$this->connection->transaction(function () use ($instance, $name, &$rolled) {
$instance->down($this->connection);
$this->repository->remove($name);
$rolled[] = $name;
});
}
}
return $rolled;
}
/**
* Resolve a migration by name from registry or instantiate by class name.
*/
private function resolveMigration(string $name): ?MigrationInterface
{
if (isset($this->registry[$name])) {
return $this->registry[$name];
}
if (class_exists($name)) {
$obj = new $name();
if ($obj instanceof MigrationInterface) {
return $obj;
}
}
return null;
}
}

1614
src/Model/AbstractDao.php Normal file

File diff suppressed because it is too large Load diff

115
src/Model/AbstractDto.php Normal file
View file

@ -0,0 +1,115 @@
<?php
namespace Pairity\Model;
use Pairity\Contracts\DtoInterface;
abstract class AbstractDto implements DtoInterface
{
/** @var array<string,mixed> */
protected array $attributes = [];
/** @param array<string,mixed> $attributes */
public function __construct(array $attributes = [])
{
// Apply mutators if defined
foreach ($attributes as $key => $value) {
$method = $this->mutatorMethod($key);
if (method_exists($this, $method)) {
// set{Name}Attribute($value): mixed
$value = $this->{$method}($value);
}
$this->attributes[$key] = $value;
}
}
/** @param array<string,mixed> $data */
public static function fromArray(array $data): static
{
return new static($data);
}
public function __get(string $name): mixed
{
$value = $this->attributes[$name] ?? null;
$method = $this->accessorMethod($name);
if (method_exists($this, $method)) {
// get{Name}Attribute($value): mixed
return $this->{$method}($value);
}
return $value;
}
public function __isset(string $name): bool
{
return array_key_exists($name, $this->attributes);
}
/**
* Attach a loaded relation or transient attribute to the DTO.
* Intended for internal ORM use (eager/lazy loading).
*/
public function setRelation(string $name, mixed $value): void
{
$this->attributes[$name] = $value;
}
/** @return array<string,mixed> */
public function toArray(bool $deep = true): array
{
if (!$deep) {
// Apply accessors at top level for scalar attributes
$out = [];
foreach ($this->attributes as $key => $value) {
$method = $this->accessorMethod($key);
if (method_exists($this, $method)) {
$out[$key] = $this->{$method}($value);
} else {
$out[$key] = $value;
}
}
return $out;
}
$result = [];
foreach ($this->attributes as $key => $value) {
// Apply accessor before deep conversion for scalars/arrays
$method = $this->accessorMethod($key);
if (method_exists($this, $method)) {
$value = $this->{$method}($value);
}
if ($value instanceof DtoInterface) {
$result[$key] = $value->toArray(true);
} elseif (is_array($value)) {
// Map arrays, converting any DTO elements to arrays as well
$result[$key] = array_map(function ($item) {
if ($item instanceof DtoInterface) {
return $item->toArray(true);
}
return $item;
}, $value);
} else {
$result[$key] = $value;
}
}
return $result;
}
private function accessorMethod(string $key): string
{
return 'get' . $this->studly($key) . 'Attribute';
}
private function mutatorMethod(string $key): string
{
return 'set' . $this->studly($key) . 'Attribute';
}
private function studly(string $value): string
{
$value = str_replace(['-', '_'], ' ', $value);
$value = ucwords($value);
return str_replace(' ', '', $value);
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace Pairity\Model\Casting;
interface CasterInterface
{
/** Convert a raw storage value (from DB/driver) to a PHP value for the DTO. */
public function fromStorage(mixed $value): mixed;
/** Convert a PHP value to a storage value suitable for persistence. */
public function toStorage(mixed $value): mixed;
}

View file

@ -0,0 +1,751 @@
<?php
namespace Pairity\NoSql\Mongo;
use Pairity\Model\AbstractDto;
use Pairity\Orm\UnitOfWork;
use Pairity\Events\Events;
/**
* Base DAO for MongoDB collections returning DTOs.
*
* Usage: extend and implement collection() + dtoClass().
*/
abstract class AbstractMongoDao
{
protected MongoConnectionInterface $connection;
/** @var array<int,string>|null */
private ?array $projection = null; // list of field names to include
/** @var array<string,int> */
private array $sortSpec = [];
private ?int $limitVal = null;
private ?int $skipVal = null;
/** @var array<int,string> */
private array $with = [];
/**
* Nested eager-loading tree for Mongo relations, built from with() paths.
* @var array<string, array<string,mixed>>
*/
private array $withTree = [];
/**
* Per relation (and nested path) constraints. Keys are relation paths like 'posts' or 'posts.comments'.
* Values are callables(AbstractMongoDao $dao): void
* @var array<string, callable>
*/
private array $withConstraints = [];
/** @var array<string, array<int,string>> */
private array $relationFields = [];
/** Scopes (MVP) */
/** @var array<int, callable> */
private array $runtimeScopes = [];
/** @var array<string, callable> */
private array $namedScopes = [];
/** Memoized relations */
private ?array $relationsCache = null;
/** Eager IN batching size for related lookups */
protected int $inBatchSize = 1000;
public function __construct(MongoConnectionInterface $connection)
{
$this->connection = $connection;
}
/** Collection name (e.g., "users"). */
abstract protected function collection(): string;
/** @return class-string<AbstractDto> */
abstract protected function dtoClass(): string;
/** Access to underlying connection. */
public function getConnection(): MongoConnectionInterface
{
return $this->connection;
}
/** Relation metadata (MVP). Override in concrete DAO. */
protected function relations(): array
{
return [];
}
/** Return memoized relations map. */
protected function getRelations(): array
{
if ($this->relationsCache !== null) {
return $this->relationsCache;
}
$this->relationsCache = $this->relations();
return $this->relationsCache;
}
/** Override point: set eager IN batching size (default 1000). */
public function setInBatchSize(int $size): static
{
$this->inBatchSize = max(1, $size);
return $this;
}
// ========= Query modifiers =========
/**
* Specify projection fields to include on base entity and optionally on relations via dot-notation.
* Example: fields('email','name','posts.title')
*/
public function fields(string ...$fields): static
{
$base = [];
foreach ($fields as $f) {
$f = (string)$f;
if ($f === '') continue;
if (str_contains($f, '.')) {
[$rel, $col] = explode('.', $f, 2);
if ($rel !== '' && $col !== '') {
$this->relationFields[$rel][] = $col;
}
} else {
$base[] = $f;
}
}
$this->projection = $base ?: null;
return $this;
}
/** Sorting spec, e.g., sort(['created_at' => -1]) */
public function sort(array $spec): static
{
// sanitize values to 1 or -1
$out = [];
foreach ($spec as $k => $v) {
$out[(string)$k] = ((int)$v) < 0 ? -1 : 1;
}
$this->sortSpec = $out;
return $this;
}
public function limit(int $n): static
{
$this->limitVal = max(0, $n);
return $this;
}
public function skip(int $n): static
{
$this->skipVal = max(0, $n);
return $this;
}
// ========= CRUD =========
/** @param array<string,mixed>|Filter $filter */
public function findOneBy(array|Filter $filter): ?AbstractDto
{
$filterArr = $this->normalizeFilterInput($filter);
// Events: dao.beforeFind (Mongo) — allow filter mutation
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {}
$this->applyRuntimeScopesToFilter($filterArr);
$opts = $this->buildOptions();
$opts['limit'] = 1;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts);
$this->resetModifiers();
$this->resetRuntimeScopes();
$row = $docs[0] ?? null;
$dto = $row ? $this->hydrate($row) : null;
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {}
return $dto;
}
/**
* @param array<string,mixed>|Filter $filter
* @param array<string,mixed> $options Additional options (merged after internal modifiers)
* @return array<int,AbstractDto>
*/
public function findAllBy(array|Filter $filter = [], array $options = []): array
{
$filterArr = $this->normalizeFilterInput($filter);
// Events: dao.beforeFind (Mongo)
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$filterArr]; Events::dispatcher()->dispatch('dao.beforeFind', $ev); } catch (\Throwable) {}
$this->applyRuntimeScopesToFilter($filterArr);
$opts = $this->buildOptions();
// external override/merge
foreach ($options as $k => $v) { $opts[$k] = $v; }
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filterArr, $opts);
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
if ($dtos && $this->with) {
$this->attachRelations($dtos);
}
$this->resetModifiers();
$this->resetRuntimeScopes();
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dtos' => $dtos]; Events::dispatcher()->dispatch('dao.afterFind', $payload); } catch (\Throwable) {}
return $dtos;
}
public function findById(string $id): ?AbstractDto
{
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$managed = $uow->get(static::class, (string)$id);
if ($managed instanceof AbstractDto) {
return $managed;
}
}
return $this->findOneBy(['_id' => $id]);
}
/** @param array<string,mixed> $data */
public function insert(array $data): AbstractDto
{
// Inserts remain immediate to obtain a real id, even under UoW
// Events: dao.beforeInsert (Mongo) — allow mutation of $data
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'data' => &$data]; Events::dispatcher()->dispatch('dao.beforeInsert', $ev); } catch (\Throwable) {}
$id = UnitOfWork::suspendDuring(function () use ($data) {
return $this->connection->insertOne($this->databaseName(), $this->collection(), $data);
});
$dto = $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id]));
try { $payload = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterInsert', $payload); } catch (\Throwable) {}
return $dto;
}
/** @param array<string,mixed> $data */
public function update(string $id, array $data): AbstractDto
{
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $theId = $id; $payload = $data;
// Events: dao.beforeUpdate (Mongo)
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'data' => &$payload]; Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); } catch (\Throwable) {}
$uow->enqueueWithMeta($conn, [
'type' => 'update',
'mode' => 'byId',
'dao' => $this,
'id' => (string)$id,
], function () use ($self, $theId, $payload) {
UnitOfWork::suspendDuring(function () use ($self, $theId, $payload) {
$self->doImmediateUpdateWithLock($theId, $payload);
});
});
$base = $this->findById($id)?->toArray(false) ?? [];
$result = array_merge($base, $data, ['_id' => $id]);
$dto = $this->hydrate($result);
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterUpdate', $p); } catch (\Throwable) {}
return $dto;
}
// Events: dao.beforeUpdate (Mongo)
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'data' => &$data]; Events::dispatcher()->dispatch('dao.beforeUpdate', $ev); } catch (\Throwable) {}
$this->doImmediateUpdateWithLock($id, $data);
$dto = $this->findById($id) ?? $this->hydrate(array_merge($data, ['_id' => $id]));
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'dto' => $dto]; Events::dispatcher()->dispatch('dao.afterUpdate', $p); } catch (\Throwable) {}
return $dto;
}
public function deleteById(string $id): int
{
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $theId = $id;
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
$uow->enqueueWithMeta($conn, [
'type' => 'delete',
'mode' => 'byId',
'dao' => $this,
'id' => (string)$id,
], function () use ($self, $theId) {
UnitOfWork::suspendDuring(function () use ($self, $theId) {
$self->getConnection()->deleteOne($self->databaseName(), $self->collection(), ['_id' => $theId]);
});
});
return 0;
}
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
$affected = $this->connection->deleteOne($this->databaseName(), $this->collection(), ['_id' => $id]);
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'id' => $id, 'affected' => $affected]; Events::dispatcher()->dispatch('dao.afterDelete', $p); } catch (\Throwable) {}
return $affected;
}
/** @param array<string,mixed>|Filter $filter */
public function deleteBy(array|Filter $filter): int
{
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$self = $this; $conn = $this->connection; $flt = $this->normalizeFilterInput($filter);
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$flt]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
$uow->enqueueWithMeta($conn, [
'type' => 'delete',
'mode' => 'byCriteria',
'dao' => $this,
'criteria' => $flt,
], function () use ($self, $flt) {
UnitOfWork::suspendDuring(function () use ($self, $flt) {
$self->getConnection()->deleteOne($self->databaseName(), $self->collection(), $flt);
});
});
return 0;
}
// For MVP provide deleteOne semantic; bulk deletes could be added later
$flt = $this->normalizeFilterInput($filter);
try { $ev = ['dao' => $this, 'collection' => $this->collection(), 'filter' => &$flt]; Events::dispatcher()->dispatch('dao.beforeDelete', $ev); } catch (\Throwable) {}
$this->applyRuntimeScopesToFilter($flt);
$res = $this->connection->deleteOne($this->databaseName(), $this->collection(), $flt);
try { $p = ['dao' => $this, 'collection' => $this->collection(), 'filter' => $flt, 'affected' => $res]; Events::dispatcher()->dispatch('dao.afterDelete', $p); } catch (\Throwable) {}
$this->resetRuntimeScopes();
return $res;
}
/** Upsert by id convenience. */
public function upsertById(string $id, array $data): string
{
return $this->connection->upsertOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $data]);
}
/** @param array<string,mixed>|Filter $filter @param array<string,mixed> $update */
public function upsertBy(array|Filter $filter, array $update): string
{
return $this->connection->upsertOne($this->databaseName(), $this->collection(), $this->normalizeFilterInput($filter), $update);
}
/**
* Fetch related docs where a field is within the given set of values.
* @param string $field
* @param array<int,string> $values
* @return array<int,AbstractDto>
*/
public function findAllWhereIn(string $field, array $values): array
{
if (!$values) return [];
// Normalize values (unique)
$values = array_values(array_unique($values));
$chunks = array_chunk($values, max(1, (int)$this->inBatchSize));
$dtos = [];
foreach ($chunks as $chunk) {
$filter = [ $field => ['$in' => $chunk] ];
$this->applyRuntimeScopesToFilter($filter);
$opts = $this->buildOptions();
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts);
$iter = is_iterable($docs) ? $docs : [];
foreach ($iter as $d) { $dtos[] = $this->hydrate($d); }
}
return $dtos;
}
// ========= Dynamic helpers =========
public function __call(string $name, array $arguments): mixed
{
if (preg_match('/^(findOneBy|findAllBy|updateBy|deleteBy)([A-Z][A-Za-z0-9_]*)$/', $name, $m)) {
$op = $m[1];
$col = $this->normalizeColumn($m[2]);
switch ($op) {
case 'findOneBy':
return $this->findOneBy([$col => $arguments[0] ?? null]);
case 'findAllBy':
return $this->findAllBy([$col => $arguments[0] ?? null]);
case 'updateBy':
$value = $arguments[0] ?? null;
$data = $arguments[1] ?? [];
if (!is_array($data)) {
throw new \InvalidArgumentException('updateBy* expects second argument as array $data');
}
$one = $this->findOneBy([$col => $value]);
if (!$one) { return 0; }
$id = (string)($one->toArray(false)['_id'] ?? '');
$this->update($id, $data);
return 1;
case 'deleteBy':
return $this->deleteBy([$col => $arguments[0] ?? null]);
}
}
// Named scope invocation
if (isset($this->namedScopes[$name]) && is_callable($this->namedScopes[$name])) {
$callable = $this->namedScopes[$name];
$this->runtimeScopes[] = function (&$filter) use ($callable, $arguments) {
$callable($filter, ...$arguments);
};
return $this;
}
throw new \BadMethodCallException(static::class . "::{$name} does not exist");
}
// ========= Internals =========
protected function normalizeColumn(string $studly): string
{
$snake = preg_replace('/(?<!^)[A-Z]/', '_$0', $studly) ?? $studly;
return strtolower($snake);
}
protected function hydrate(array $doc): AbstractDto
{
// Ensure _id is a string for DTO friendliness
if (isset($doc['_id']) && !is_string($doc['_id'])) {
$doc['_id'] = (string)$doc['_id'];
}
$class = $this->dtoClass();
/** @var AbstractDto $dto */
$dto = $class::fromArray($doc);
$uow = UnitOfWork::current();
if ($uow && !UnitOfWork::isSuspended()) {
$idVal = $doc['_id'] ?? null;
if ($idVal !== null) {
$uow->attach(static::class, (string)$idVal, $dto);
// Bind this DAO to allow snapshot diffing to emit updates
$uow->bindDao(static::class, (string)$idVal, $this);
}
}
return $dto;
}
/** @param array<string,mixed>|Filter $filter */
private function normalizeFilterInput(array|Filter $filter): array
{
if ($filter instanceof Filter) {
return $filter->toArray();
}
return $filter;
}
/** Build MongoDB driver options from current modifiers. */
private function buildOptions(): array
{
$opts = [];
if ($this->projection) {
$proj = [];
foreach ($this->projection as $f) { $proj[$f] = 1; }
$opts['projection'] = $proj;
}
if ($this->sortSpec) { $opts['sort'] = $this->sortSpec; }
if ($this->limitVal !== null) { $opts['limit'] = $this->limitVal; }
if ($this->skipVal !== null) { $opts['skip'] = $this->skipVal; }
return $opts;
}
/**
* Paginate results.
* @return array{data:array<int,AbstractDto>,total:int,perPage:int,currentPage:int,lastPage:int}
*/
public function paginate(int $page, int $perPage = 15, array|Filter $filter = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$flt = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($flt);
// Total via aggregation count
$pipeline = [];
if (!empty($flt)) { $pipeline[] = ['$match' => $flt]; }
$pipeline[] = ['$count' => 'cnt'];
$agg = $this->connection->aggregate($this->databaseName(), $this->collection(), $pipeline, []);
$arr = is_iterable($agg) ? iterator_to_array($agg, false) : (array)$agg;
$total = (int)($arr[0]['cnt'] ?? 0);
// Page data
$opts = $this->buildOptions();
$opts['limit'] = $perPage;
$opts['skip'] = ($page - 1) * $perPage;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts);
$dtos = array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
if ($dtos && $this->with) { $this->attachRelations($dtos); }
$this->resetModifiers();
$this->resetRuntimeScopes();
$lastPage = (int)max(1, (int)ceil($total / $perPage));
return [
'data' => $dtos,
'total' => $total,
'perPage' => $perPage,
'currentPage' => $page,
'lastPage' => $lastPage,
];
}
/** Simple pagination without total; returns nextPage if more likely exists. */
public function simplePaginate(int $page, int $perPage = 15, array|Filter $filter = []): array
{
$page = max(1, $page);
$perPage = max(1, $perPage);
$flt = $this->normalizeFilterInput($filter);
$this->applyRuntimeScopesToFilter($flt);
$opts = $this->buildOptions();
$opts['limit'] = $perPage + 1; // fetch one extra
$opts['skip'] = ($page - 1) * $perPage;
$docs = $this->connection->find($this->databaseName(), $this->collection(), $flt, $opts);
$docsArr = is_iterable($docs) ? iterator_to_array($docs, false) : (array)$docs;
$hasMore = count($docsArr) > $perPage;
if ($hasMore) { array_pop($docsArr); }
$dtos = array_map(fn($d) => $this->hydrate($d), $docsArr);
if ($dtos && $this->with) { $this->attachRelations($dtos); }
$this->resetModifiers();
$this->resetRuntimeScopes();
return [
'data' => $dtos,
'perPage' => $perPage,
'currentPage' => $page,
'nextPage' => $hasMore ? $page + 1 : null,
];
}
private function resetModifiers(): void
{
$this->projection = null;
$this->sortSpec = [];
$this->limitVal = null;
$this->skipVal = null;
$this->with = [];
$this->relationFields = [];
}
/** Resolve database name from collection string if provided as db.collection; else default to 'app'. */
private function databaseName(): string
{
// Allow subclasses to define "db.collection" in collection() if they want to target a specific DB quickly
$col = $this->collection();
if (str_contains($col, '.')) {
return explode('.', $col, 2)[0];
}
return 'app';
}
// ===== Relations (MVP) =====
/** Eager load relations on next find* call. */
public function with(array $relations): static
{
// Accept ['rel', 'rel.child'] or ['rel' => callable]
$tree = [];
foreach ($relations as $key => $value) {
if (is_int($key)) {
$this->insertRelationPath($tree, (string)$value);
} else {
$path = (string)$key;
if (is_callable($value)) { $this->withConstraints[$path] = $value; }
$this->insertRelationPath($tree, $path);
}
}
$this->withTree = $tree;
$this->with = array_keys($tree);
return $this;
}
/** Lazy load a single relation for one DTO. */
public function load(AbstractDto $dto, string $relation): void
{
$this->with([$relation]);
$this->attachRelations([$dto]);
// do not call resetModifiers here to avoid wiping user sort/limit; with() is cleared in attachRelations
}
/** @param array<int,AbstractDto> $dtos */
public function loadMany(array $dtos, string $relation): void
{
if (!$dtos) return;
$this->with([$relation]);
$this->attachRelations($dtos);
}
/** @param array<int,AbstractDto> $parents */
protected function attachRelations(array $parents): void
{
if (!$parents) return;
$relations = $this->getRelations();
foreach ($this->with as $name) {
if (!isset($relations[$name])) continue;
$cfg = $relations[$name];
$type = (string)($cfg['type'] ?? '');
$daoClass = $cfg['dao'] ?? null;
if (!is_string($daoClass) || $type === '') continue;
/** @var class-string<\Pairity\NoSql\Mongo\AbstractMongoDao> $daoClass */
$related = new $daoClass($this->connection);
// Apply per-relation constraint if provided
$constraint = $this->constraintForPath($name);
if (is_callable($constraint)) { $constraint($related); }
$relFields = $this->relationFields[$name] ?? null;
if ($relFields) { $related->fields(...$relFields); }
if ($type === 'hasMany' || $type === 'hasOne') {
$foreignKey = (string)($cfg['foreignKey'] ?? ''); // on child
$localKey = (string)($cfg['localKey'] ?? '_id'); // on parent
if ($foreignKey === '') continue;
$keys = [];
foreach ($parents as $p) {
$arr = $p->toArray(false);
if (isset($arr[$localKey])) { $keys[] = (string)$arr[$localKey]; }
}
if (!$keys) continue;
$children = $related->findAllWhereIn($foreignKey, $keys);
$grouped = [];
foreach ($children as $child) {
$fk = $child->toArray(false)[$foreignKey] ?? null;
if ($fk !== null) { $grouped[(string)$fk][] = $child; }
}
foreach ($parents as $p) {
$arr = $p->toArray(false);
$key = isset($arr[$localKey]) ? (string)$arr[$localKey] : null;
$list = ($key !== null && isset($grouped[$key])) ? $grouped[$key] : [];
if ($type === 'hasOne') {
$p->setRelation($name, $list[0] ?? null);
} else {
$p->setRelation($name, $list);
}
}
// Nested eager for children
$nested = $this->withTree[$name] ?? [];
if ($nested) {
$related->with($this->rebuildNestedForChild($name, $nested));
$allChildren = [];
foreach ($parents as $p) {
$val = $p->toArray(false)[$name] ?? null;
if ($val instanceof AbstractDto) { $allChildren[] = $val; }
elseif (is_array($val)) { foreach ($val as $c) { if ($c instanceof AbstractDto) { $allChildren[] = $c; } } }
}
if ($allChildren) { $related->attachRelations($allChildren); }
}
} elseif ($type === 'belongsTo') {
$foreignKey = (string)($cfg['foreignKey'] ?? ''); // on parent
$otherKey = (string)($cfg['otherKey'] ?? '_id'); // on related
if ($foreignKey === '') continue;
$ownerIds = [];
foreach ($parents as $p) {
$arr = $p->toArray(false);
if (isset($arr[$foreignKey])) { $ownerIds[] = (string)$arr[$foreignKey]; }
}
if (!$ownerIds) continue;
$owners = $related->findAllWhereIn($otherKey, $ownerIds);
$byId = [];
foreach ($owners as $o) {
$id = $o->toArray(false)[$otherKey] ?? null;
if ($id !== null) { $byId[(string)$id] = $o; }
}
foreach ($parents as $p) {
$arr = $p->toArray(false);
$fk = isset($arr[$foreignKey]) ? (string)$arr[$foreignKey] : null;
$p->setRelation($name, ($fk !== null && isset($byId[$fk])) ? $byId[$fk] : null);
}
// Nested eager for owner
$nested = $this->withTree[$name] ?? [];
if ($nested) {
$related->with($this->rebuildNestedForChild($name, $nested));
$allOwners = [];
foreach ($parents as $p) {
$val = $p->toArray(false)[$name] ?? null;
if ($val instanceof AbstractDto) { $allOwners[] = $val; }
}
if ($allOwners) { $related->attachRelations($allOwners); }
}
}
}
// reset eager-load request
$this->with = [];
$this->withTree = [];
$this->withConstraints = [];
// keep relationFields for potential subsequent relation loads within same high-level call
}
/** Expose relation metadata for UoW ordering/cascades. */
public function relationMap(): array
{
return $this->relations();
}
// ===== with()/nested helpers =====
private function insertRelationPath(array &$tree, string $path): void
{
$parts = array_values(array_filter(explode('.', $path), fn($p) => $p !== ''));
if (!$parts) return;
$level =& $tree;
foreach ($parts as $p) {
if (!isset($level[$p])) { $level[$p] = []; }
$level =& $level[$p];
}
}
private function rebuildNestedForChild(string $prefix, array $subtree): array
{
$out = [];
foreach ($subtree as $name => $child) {
$full = $prefix . '.' . $name;
if (isset($this->withConstraints[$full]) && is_callable($this->withConstraints[$full])) {
$out[$name] = $this->withConstraints[$full];
} else { $out[] = $name; }
}
return $out;
}
private function constraintForPath(string $path): mixed
{
return $this->withConstraints[$path] ?? null;
}
// ===== Scopes (MVP) =====
/** Register a named scope callable: function(array &$filter, ...$args): void */
public function registerScope(string $name, callable $fn): static
{
$this->namedScopes[$name] = $fn;
return $this;
}
/** Add an ad-hoc scope callable(array &$filter): void for next query. */
public function scope(callable $fn): static
{
$this->runtimeScopes[] = $fn;
return $this;
}
/** @param array<string,mixed> $filter */
private function applyRuntimeScopesToFilter(array &$filter): void
{
if (!$this->runtimeScopes) return;
foreach ($this->runtimeScopes as $fn) {
try { $fn($filter); } catch (\Throwable) {}
}
}
private function resetRuntimeScopes(): void
{
$this->runtimeScopes = [];
}
// ===== Optimistic locking (MVP) for Mongo =====
/**
* Override to enable locking. Example return:
* ['type' => 'version', 'column' => '_v']
* Currently only 'version' (numeric increment) is supported for Mongo.
* @return array{type:string,column:string}|array{}
*/
protected function locking(): array { return []; }
private function hasOptimisticLocking(): bool
{
$cfg = $this->locking();
return is_array($cfg) && isset($cfg['type'], $cfg['column']) && $cfg['type'] === 'version' && is_string($cfg['column']) && $cfg['column'] !== '';
}
private function doImmediateUpdateWithLock(string $id, array $payload): void
{
if (!$this->hasOptimisticLocking()) {
$this->connection->updateOne($this->databaseName(), $this->collection(), ['_id' => $id], ['$set' => $payload]);
return;
}
$cfg = $this->locking();
$col = (string)$cfg['column'];
// Fetch current version
$docs = $this->connection->find($this->databaseName(), $this->collection(), ['_id' => $id], ['limit' => 1, 'projection' => [$col => 1]]);
$cur = $docs[0][$col] ?? null;
$filter = ['_id' => $id];
if ($cur !== null) { $filter[$col] = $cur; }
$update = ['$set' => $payload, '$inc' => [$col => 1]];
$modified = $this->connection->updateOne($this->databaseName(), $this->collection(), $filter, $update);
if ($cur !== null && $modified === 0) {
throw new \Pairity\Orm\OptimisticLockException('Optimistic lock failed for ' . static::class . ' id=' . $id);
}
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace Pairity\NoSql\Mongo;
/**
* Minimal fluent builder for MongoDB filters.
*/
final class Filter
{
/** @var array<string,mixed> */
private array $query = [];
private function __construct(array $initial = [])
{
$this->query = $initial;
}
public static function make(): self
{
return new self();
}
/** @return array<string,mixed> */
public function toArray(): array
{
return $this->query;
}
public function whereEq(string $field, mixed $value): self
{
$this->query[$field] = $value;
return $this;
}
/** @param array<int,mixed> $values */
public function whereIn(string $field, array $values): self
{
$this->query[$field] = ['$in' => array_values($values)];
return $this;
}
public function gt(string $field, mixed $value): self
{
$this->op($field, '$gt', $value);
return $this;
}
public function gte(string $field, mixed $value): self
{
$this->op($field, '$gte', $value);
return $this;
}
public function lt(string $field, mixed $value): self
{
$this->op($field, '$lt', $value);
return $this;
}
public function lte(string $field, mixed $value): self
{
$this->op($field, '$lte', $value);
return $this;
}
/** Add an $or clause with an array of filters (arrays or Filter instances). */
public function orWhere(array $conditions): self
{
$ors = [];
foreach ($conditions as $c) {
if ($c instanceof self) {
$ors[] = $c->toArray();
} elseif (is_array($c)) {
$ors[] = $c;
}
}
if (!empty($ors)) {
$this->query['$or'] = $ors;
}
return $this;
}
private function op(string $field, string $op, mixed $value): void
{
$cur = $this->query[$field] ?? [];
if (!is_array($cur)) { $cur = []; }
$cur[$op] = $value;
$this->query[$field] = $cur;
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace Pairity\NoSql\Mongo;
use MongoDB\Client;
/**
* Simple Index manager for MongoDB collections.
*/
final class IndexManager
{
private MongoConnectionInterface $connection;
private string $database;
private string $collection;
public function __construct(MongoConnectionInterface $connection, string $database, string $collection)
{
$this->connection = $connection;
$this->database = $database;
$this->collection = $collection;
}
/**
* Ensure index on keys (e.g., ['email' => 1]) with options (e.g., ['unique' => true]).
* Returns index name.
* @param array<string,int> $keys
* @param array<string,mixed> $options
*/
public function ensureIndex(array $keys, array $options = []): string
{
$client = $this->getClient();
$mgr = $client->selectCollection($this->database, $this->collection)->createIndex($keys, $options);
return (string)$mgr;
}
/** Drop an index by name. */
public function dropIndex(string $name): void
{
$client = $this->getClient();
$client->selectCollection($this->database, $this->collection)->dropIndex($name);
}
/** @return array<int,array<string,mixed>> */
public function listIndexes(): array
{
$client = $this->getClient();
$it = $client->selectCollection($this->database, $this->collection)->listIndexes();
$out = [];
foreach ($it as $ix) {
$out[] = json_decode(json_encode($ix), true) ?? [];
}
return $out;
}
private function getClient(): Client
{
if ($this->connection instanceof MongoClientConnection) {
return $this->connection->getClient();
}
// Fallback: attempt to reflect getClient()
if (method_exists($this->connection, 'getClient')) {
/** @var Client $c */
$c = $this->connection->getClient();
return $c;
}
throw new \RuntimeException('IndexManager requires MongoClientConnection');
}
}

View file

@ -0,0 +1,215 @@
<?php
namespace Pairity\NoSql\Mongo;
use MongoDB\Client;
use MongoDB\BSON\ObjectId;
use MongoDB\Driver\Session;
/**
* Production MongoDB adapter wrapping mongodb/mongodb Client.
* Implements the existing MongoConnectionInterface methods.
*/
class MongoClientConnection implements MongoConnectionInterface
{
private Client $client;
public function __construct(Client $client)
{
$this->client = $client;
}
public function getClient(): Client
{
return $this->client;
}
public function find(string $database, string $collection, array $filter = [], array $options = []): iterable
{
$coll = $this->client->selectCollection($database, $collection);
$cursor = $coll->find($this->normalizeFilter($filter), $options);
$out = [];
foreach ($cursor as $doc) {
$out[] = $this->docToArray($doc);
}
return $out;
}
public function insertOne(string $database, string $collection, array $document): string
{
$coll = $this->client->selectCollection($database, $collection);
$result = $coll->insertOne($this->normalizeDocument($document));
$id = $result->getInsertedId();
return (string)$id;
}
public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int
{
$coll = $this->client->selectCollection($database, $collection);
$res = $coll->updateOne($this->normalizeFilter($filter), $update, $options);
return $res->getModifiedCount();
}
public function deleteOne(string $database, string $collection, array $filter, array $options = []): int
{
$coll = $this->client->selectCollection($database, $collection);
$res = $coll->deleteOne($this->normalizeFilter($filter), $options);
return $res->getDeletedCount();
}
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable
{
$coll = $this->client->selectCollection($database, $collection);
$cursor = $coll->aggregate($pipeline, $options);
$out = [];
foreach ($cursor as $doc) {
$out[] = $this->docToArray($doc);
}
return $out;
}
public function upsertOne(string $database, string $collection, array $filter, array $update): string
{
$coll = $this->client->selectCollection($database, $collection);
// Normalize _id in filter (supports $in handled by normalizeFilter)
$filter = $this->normalizeFilter($filter);
$res = $coll->updateOne($filter, $update, ['upsert' => true]);
$up = $res->getUpsertedId();
if ($up !== null) {
return (string)$up;
}
// Not an upsert (matched existing). Best-effort: fetch one doc and return its _id as string.
$doc = $coll->findOne($filter);
if ($doc) {
$arr = $this->docToArray($doc);
return isset($arr['_id']) ? (string)$arr['_id'] : '';
}
return '';
}
public function withSession(callable $callback): mixed
{
/** @var Session $session */
$session = $this->client->startSession();
try {
return $callback($this, $session);
} finally {
try { $session->endSession(); } catch (\Throwable) {}
}
}
public function withTransaction(callable $callback): mixed
{
/** @var Session $session */
$session = $this->client->startSession();
try {
$result = $session->startTransaction();
$ret = $callback($this, $session);
$session->commitTransaction();
return $ret;
} catch (\Throwable $e) {
try { $session->abortTransaction(); } catch (\Throwable) {}
throw $e;
} finally {
try { $session->endSession(); } catch (\Throwable) {}
}
}
/** @param array<string,mixed> $filter */
private function normalizeFilter(array $filter): array
{
// Recursively walk the filter and convert any _id string(s) that look like 24-hex to ObjectId
$walker = function (&$node, $key = null) use (&$walker) {
if (is_array($node)) {
foreach ($node as $k => &$v) {
$walker($v, $k);
}
return;
}
if ($key === '_id' && is_string($node) && preg_match('/^[a-f\d]{24}$/i', $node)) {
try { $node = new ObjectId($node); } catch (\Throwable) {}
}
};
$convertIdContainer = function (&$value) use (&$convertIdContainer) {
// Handle structures like ['_id' => ['$in' => ['...','...']]]
if (is_string($value) && preg_match('/^[a-f\d]{24}$/i', $value)) {
try { $value = new ObjectId($value); } catch (\Throwable) {}
return;
}
if (is_array($value)) {
foreach ($value as $k => &$v) {
$convertIdContainer($v);
}
}
};
// Top-level traversal
foreach ($filter as $k => &$v) {
if ($k === '_id') {
$convertIdContainer($v);
} elseif (is_array($v)) {
// Recurse into nested boolean operators ($and/$or) etc.
foreach ($v as $kk => &$vv) {
if ($kk === '_id') {
$convertIdContainer($vv);
} elseif (is_array($vv)) {
foreach ($vv as $kkk => &$vvv) {
if ($kkk === '_id') {
$convertIdContainer($vvv);
}
}
}
}
}
}
unset($v);
return $filter;
}
/** @param array<string,mixed> $doc */
private function normalizeDocument(array $doc): array
{
if (isset($doc['_id']) && is_string($doc['_id']) && preg_match('/^[a-f\d]{24}$/i', $doc['_id'])) {
try { $doc['_id'] = new ObjectId($doc['_id']); } catch (\Throwable) {}
}
return $doc;
}
/**
* Convert BSON document or array to a plain associative array, including ObjectId cast to string.
*/
private function docToArray(mixed $doc): array
{
if ($doc instanceof \MongoDB\Model\BSONDocument) {
$doc = $doc->getArrayCopy();
} elseif ($doc instanceof \ArrayObject) {
$doc = $doc->getArrayCopy();
}
if (!is_array($doc)) {
return [];
}
$out = [];
foreach ($doc as $k => $v) {
if ($v instanceof ObjectId) {
$out[$k] = (string)$v;
} elseif ($v instanceof \MongoDB\BSON\UTCDateTime) {
$out[$k] = $v->toDateTime()->format('c');
} elseif ($v instanceof \MongoDB\Model\BSONDocument || $v instanceof \ArrayObject) {
$out[$k] = $this->docToArray($v);
} elseif (is_array($v)) {
$out[$k] = array_map(function ($item) {
if ($item instanceof ObjectId) return (string)$item;
if ($item instanceof \MongoDB\Model\BSONDocument || $item instanceof \ArrayObject) {
return $this->docToArray($item);
}
return $item;
}, $v);
} else {
$out[$k] = $v;
}
}
return $out;
}
}

View file

@ -0,0 +1,105 @@
<?php
namespace Pairity\NoSql\Mongo;
/**
* Minimal MongoDB stub. This implementation keeps data in-memory for demo/testing
* without introducing external dependencies. It mimics a subset of operations.
* Replace with a real adapter wrapping `mongodb/mongodb` later.
*/
class MongoConnection implements MongoConnectionInterface
{
/**
* @var array<string, array<string, array<int, array<string, mixed>>>>
* $store[db][collection][] = document
*/
private array $store = [];
public function find(string $database, string $collection, array $filter = [], array $options = []): iterable
{
$docs = $this->getCollection($database, $collection);
$result = [];
foreach ($docs as $doc) {
if ($this->matches($doc, $filter)) {
$result[] = $doc;
}
}
return $result;
}
public function insertOne(string $database, string $collection, array $document): string
{
$document['_id'] = $document['_id'] ?? $this->generateId();
$this->store[$database][$collection][] = $document;
return (string)$document['_id'];
}
public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int
{
$docs =& $this->store[$database][$collection];
if (!is_array($docs)) {
$docs = [];
}
foreach ($docs as &$doc) {
if ($this->matches($doc, $filter)) {
// Very naive: support direct field set or $set operator
if (isset($update['$set']) && is_array($update['$set'])) {
foreach ($update['$set'] as $k => $v) {
$doc[$k] = $v;
}
} else {
foreach ($update as $k => $v) {
$doc[$k] = $v;
}
}
return 1;
}
}
return 0;
}
public function deleteOne(string $database, string $collection, array $filter, array $options = []): int
{
$docs =& $this->store[$database][$collection];
if (!is_array($docs)) {
$docs = [];
}
foreach ($docs as $i => $doc) {
if ($this->matches($doc, $filter)) {
array_splice($docs, $i, 1);
return 1;
}
}
return 0;
}
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable
{
// Stub: no real pipeline support; just return all docs
return $this->getCollection($database, $collection);
}
private function &getCollection(string $database, string $collection): array
{
if (!isset($this->store[$database][$collection])) {
$this->store[$database][$collection] = [];
}
return $this->store[$database][$collection];
}
/** @param array<string,mixed> $doc @param array<string,mixed> $filter */
private function matches(array $doc, array $filter): bool
{
foreach ($filter as $k => $v) {
if (!array_key_exists($k, $doc) || $doc[$k] !== $v) {
return false;
}
}
return true;
}
private function generateId(): string
{
return bin2hex(random_bytes(12));
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Pairity\NoSql\Mongo;
interface MongoConnectionInterface
{
/** @return iterable<int, array<string,mixed>> */
public function find(string $database, string $collection, array $filter = [], array $options = []): iterable;
/** @param array<string,mixed> $document */
public function insertOne(string $database, string $collection, array $document): string;
/** @param array<string,mixed> $filter @param array<string,mixed> $update */
public function updateOne(string $database, string $collection, array $filter, array $update, array $options = []): int;
/** @param array<string,mixed> $filter */
public function deleteOne(string $database, string $collection, array $filter, array $options = []): int;
/** @param array<int, array<string,mixed>> $pipeline */
public function aggregate(string $database, string $collection, array $pipeline, array $options = []): iterable;
/** @param array<string,mixed> $filter @param array<string,mixed> $update */
public function upsertOne(string $database, string $collection, array $filter, array $update): string;
/** Execute a callback with a client session; callback receives the connection instance and session as args. */
public function withSession(callable $callback): mixed;
/** Execute a callback wrapped in a driver transaction when supported. */
public function withTransaction(callable $callback): mixed;
}

View file

@ -0,0 +1,58 @@
<?php
namespace Pairity\NoSql\Mongo;
use MongoDB\Client;
final class MongoConnectionManager
{
/**
* Build a MongoClientConnection from config.
*
* Supported keys:
* - uri: full MongoDB URI (takes precedence)
* - hosts: string|array host(s) (default 127.0.0.1)
* - port: int (default 27017)
* - username, password
* - authSource: string
* - replicaSet: string
* - tls: bool
* - uriOptions: array (MongoDB URI options)
* - driverOptions: array (MongoDB driver options)
*
* @param array<string,mixed> $config
*/
public static function make(array $config): MongoClientConnection
{
$uri = (string)($config['uri'] ?? '');
$uriOptions = (array)($config['uriOptions'] ?? []);
$driverOptions = (array)($config['driverOptions'] ?? []);
if ($uri === '') {
$hosts = $config['hosts'] ?? ($config['host'] ?? '127.0.0.1');
$port = (int)($config['port'] ?? 27017);
$hostsStr = '';
if (is_array($hosts)) {
$parts = [];
foreach ($hosts as $h) { $parts[] = $h . ':' . $port; }
$hostsStr = implode(',', $parts);
} else {
$hostsStr = (string)$hosts . ':' . $port;
}
$user = isset($config['username']) ? (string)$config['username'] : '';
$pass = isset($config['password']) ? (string)$config['password'] : '';
$auth = ($user !== '' && $pass !== '') ? ($user . ':' . $pass . '@') : '';
$query = [];
if (!empty($config['authSource'])) { $query['authSource'] = (string)$config['authSource']; }
if (!empty($config['replicaSet'])) { $query['replicaSet'] = (string)$config['replicaSet']; }
if (isset($config['tls'])) { $query['tls'] = $config['tls'] ? 'true' : 'false'; }
$qs = $query ? ('?' . http_build_query($query)) : '';
$uri = 'mongodb://' . $auth . $hostsStr . '/' . $qs;
}
$client = new Client($uri, $uriOptions, $driverOptions);
return new MongoClientConnection($client);
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace Pairity\Orm;
class OptimisticLockException extends \RuntimeException
{
}

449
src/Orm/UnitOfWork.php Normal file
View file

@ -0,0 +1,449 @@
<?php
namespace Pairity\Orm;
use Closure;
use Pairity\Model\AbstractDao as SqlDao;
use Pairity\NoSql\Mongo\AbstractMongoDao as MongoDao;
use Pairity\Events\Events;
/**
* Opt-in Unit of Work (MVP):
* - Ambient/current context with begin()/run()/current()
* - Identity Map per DAO class + primary key (string)
* - Deferred operation queues grouped by connection object
* - commit() opens transactions/sessions per connection and executes queued ops
*/
final class UnitOfWork
{
/** @var UnitOfWork|null */
private static ?UnitOfWork $current = null;
/** @var bool */
private static bool $suspended = false; // when true, DAOs should execute immediately
/** @var array<string, array<string, object>> map[daoClass][id] = DTO */
private array $identityMap = [];
/** @var array<string, array<string, array<string,mixed>>> snapshots[daoClass][id] = array representation */
private array $snapshots = [];
/** @var array<string, array<string, object>> daoBind[daoClass][id] = DAO instance for updates */
private array $daoBind = [];
/** Enable snapshot diffing */
private bool $snapshotsEnabled = false;
/**
* Queues grouped by a connection hash key.
* Each entry: ['conn' => object, 'ops' => list<array{op:Closure, meta:array<string,mixed>}>]
* meta keys (MVP):
* - type: 'update'|'delete'|'raw'
* - mode: 'byId'|'byCriteria'|'raw'
* - dao: object (DAO instance)
* - id: string (for byId)
* - criteria: array (for byCriteria)
*
* @var array<string, array{conn:object, ops:array<int,array{op:Closure,meta:array<string,mixed>}>}>
*/
private array $queues = [];
private function __construct() {}
public static function begin(): UnitOfWork
{
if (self::$current !== null) {
return self::$current;
}
self::$current = new UnitOfWork();
return self::$current;
}
/**
* Run a Unit of Work and automatically commit or rollback on exception.
* @template T
* @param Closure(UnitOfWork):T $callback
* @return mixed
*/
public static function run(Closure $callback): mixed
{
$uow = self::begin();
try {
$result = $callback($uow);
$uow->commit();
return $result;
} catch (\Throwable $e) {
$uow->rollback();
throw $e;
}
}
public static function current(): ?UnitOfWork
{
return self::$current;
}
/** Temporarily suspend UoW interception so DAOs execute immediately within the callable. */
public static function suspendDuring(Closure $cb): mixed
{
$prev = self::$suspended;
self::$suspended = true;
try { return $cb(); } finally { self::$suspended = $prev; }
}
public static function isSuspended(): bool
{
return self::$suspended;
}
// ===== Identity Map =====
/** Attach a DTO to identity map. */
public function attach(string $daoClass, string $id, object $dto): void
{
$this->identityMap[$daoClass][$id] = $dto;
if ($this->snapshotsEnabled) {
// store shallow snapshot
$arr = [];
try {
if (method_exists($dto, 'toArray')) {
/** @var array<string,mixed> $arr */
$arr = $dto->toArray(false);
}
} catch (\Throwable) { $arr = []; }
$this->snapshots[$daoClass][$id] = is_array($arr) ? $arr : [];
}
}
/** Fetch an attached DTO if present. */
public function get(string $daoClass, string $id): ?object
{
return $this->identityMap[$daoClass][$id] ?? null;
}
/** Bind a DAO instance to a managed entity for potential snapshot diff updates. */
public function bindDao(string $daoClass, string $id, object $dao): void
{
$this->daoBind[$daoClass][$id] = $dao;
}
/** Enable/disable snapshot diffing. */
public function enableSnapshots(bool $flag = true): static
{
$this->snapshotsEnabled = $flag;
return $this;
}
public function isManaged(string $daoClass, string $id): bool
{
return isset($this->identityMap[$daoClass][$id]);
}
public function detach(string $daoClass, string $id): void
{
unset($this->identityMap[$daoClass][$id], $this->snapshots[$daoClass][$id], $this->daoBind[$daoClass][$id]);
}
public function clear(): void
{
$this->identityMap = [];
$this->snapshots = [];
$this->daoBind = [];
}
// ===== Defer operations =====
/** Enqueue a mutation for the given connection object (back-compat, raw op). */
public function enqueue(object $connection, Closure $operation): void
{
$key = spl_object_hash($connection);
if (!isset($this->queues[$key])) {
$this->queues[$key] = ['conn' => $connection, 'ops' => []];
}
$this->queues[$key]['ops'][] = ['op' => $operation, 'meta' => ['type' => 'raw', 'mode' => 'raw']];
}
/** Enqueue a mutation with metadata for relation-aware ordering/cascades. */
public function enqueueWithMeta(object $connection, array $meta, Closure $operation): void
{
$key = spl_object_hash($connection);
if (!isset($this->queues[$key])) {
$this->queues[$key] = ['conn' => $connection, 'ops' => []];
}
$this->queues[$key]['ops'][] = ['op' => $operation, 'meta' => $meta];
}
/** Execute all queued operations per connection within a transaction/session. */
public function commit(): void
{
// Ensure we run ops with DAO interception suspended to avoid re-enqueue
self::suspendDuring(function () {
// uow.beforeCommit
try { $payload = ['context' => 'uow']; Events::dispatcher()->dispatch('uow.beforeCommit', $payload); } catch (\Throwable) {}
// Grouped by connection type
foreach ($this->queues as $entry) {
$conn = $entry['conn'];
$ops = $this->expandAndOrder($entry['ops']);
// Inject snapshot-based updates for managed entities with diffs
$ops = $this->injectSnapshotDiffUpdates($ops);
// Coalesce multiple updates for the same entity and order update-before-delete
$ops = $this->coalesceAndOrderPerEntity($ops);
// PDO/SQL path: has transaction(callable)
if (method_exists($conn, 'transaction')) {
$conn->transaction(function () use ($ops) {
foreach ($ops as $o) { ($o['op'])(); }
return null;
});
}
// Mongo path: try withTransaction first, then withSession, else run directly
elseif (method_exists($conn, 'withTransaction')) {
$conn->withTransaction(function () use ($ops) {
foreach ($ops as $o) { ($o['op'])(); }
return null;
});
} elseif (method_exists($conn, 'withSession')) {
$conn->withSession(function () use ($ops) {
foreach ($ops as $o) { ($o['op'])(); }
return null;
});
} else {
// Fallback: no transaction API; just run
foreach ($ops as $o) { ($o['op'])(); }
}
}
// uow.afterCommit
try { $payload2 = ['context' => 'uow']; Events::dispatcher()->dispatch('uow.afterCommit', $payload2); } catch (\Throwable) {}
});
// Clear queues after successful commit
$this->queues = [];
// Do not clear identity map by default; keep for the scope
// Close UoW scope
self::$current = null;
}
/** Rollback just clears queues and current context; actual rollback is handled by transactions when run. */
public function rollback(): void
{
$this->queues = [];
self::$current = null;
}
/**
* Expand cascades and order ops so child deletes run before parent deletes.
* @param array<int,array{op:Closure,meta:array<string,mixed>}> $ops
* @return array<int,array{op:Closure,meta:array<string,mixed>}> ordered ops
*/
private function expandAndOrder(array $ops): array
{
$expanded = [];
foreach ($ops as $o) {
$meta = $o['meta'] ?? [];
// Detect deleteById on a DAO with cascade-enabled relations
if (($meta['type'] ?? '') === 'delete' && ($meta['mode'] ?? '') === 'byId' && isset($meta['dao']) && is_object($meta['dao'])) {
$dao = $meta['dao'];
$parentId = (string)($meta['id'] ?? '');
if ($parentId !== '') {
// Determine relations and cascade flags
$rels = $this->readRelations($dao);
foreach ($rels as $name => $cfg) {
$type = (string)($cfg['type'] ?? '');
$cascade = false;
if (isset($cfg['cascadeDelete'])) {
$cascade = (bool)$cfg['cascadeDelete'];
} elseif (isset($cfg['cascade']['delete'])) {
$cascade = (bool)$cfg['cascade']['delete'];
}
if (!$cascade) { continue; }
if ($type === 'hasMany' || $type === 'hasOne') {
$childDaoClass = $cfg['dao'] ?? null;
$foreignKey = (string)($cfg['foreignKey'] ?? '');
$localKey = (string)($cfg['localKey'] ?? 'id');
if (!is_string($childDaoClass) || $foreignKey === '') { continue; }
// Instantiate child DAO sharing same connection
try {
/** @var object $childDao */
$childDao = new $childDaoClass($dao->getConnection());
} catch (\Throwable) {
continue;
}
// Create a child delete op to run before parent
$childOp = function () use ($childDao, $foreignKey, $parentId) {
self::suspendDuring(function () use ($childDao, $foreignKey, $parentId) {
// delete children by FK
if ($childDao instanceof SqlDao) {
$childDao->deleteBy([$foreignKey => $parentId]);
} elseif ($childDao instanceof MongoDao) {
$childDao->deleteBy([$foreignKey => $parentId]);
}
});
};
$expanded[] = ['op' => $childOp, 'meta' => ['type' => 'delete', 'mode' => 'byCriteria', 'dao' => $childDao]];
}
}
}
}
// Then the original op
$expanded[] = $o;
}
// Basic stable order is fine since cascades were inserted before parent.
return $expanded;
}
/**
* Inject snapshot-based updates for managed entities that have changed but were not explicitly updated.
* Only when snapshots are enabled.
* @param array<int,array{op:Closure,meta:array<string,mixed>}> $ops
* @return array<int,array{op:Closure,meta:array<string,mixed>}> $ops
*/
private function injectSnapshotDiffUpdates(array $ops): array
{
if (!$this->snapshotsEnabled) {
return $ops;
}
// Build a set of entities already scheduled for update/delete
$scheduled = [];
foreach ($ops as $o) {
$m = $o['meta'] ?? [];
if (!isset($m['dao']) || !is_object($m['dao'])) continue;
$daoClass = get_class($m['dao']);
$id = (string)($m['id'] ?? '');
if ($id !== '') {
$scheduled[$daoClass][$id] = true;
}
}
// For each managed entity, if changed and not scheduled, enqueue update
foreach ($this->identityMap as $daoClass => $entities) {
foreach ($entities as $id => $dto) {
if (!$this->snapshotsEnabled) break;
$snap = $this->snapshots[$daoClass][$id] ?? null;
if ($snap === null) continue;
$now = [];
try { if (method_exists($dto, 'toArray')) { $now = $dto->toArray(false); } } catch (\Throwable) { $now = []; }
if (!is_array($now)) $now = [];
$diff = $this->diffAssoc($snap, $now);
if (!$diff) continue;
if (isset($scheduled[$daoClass][$id])) continue;
$dao = $this->daoBind[$daoClass][$id] ?? null;
if (!$dao) continue;
// Build op that performs immediate update under suspension
$op = function () use ($dao, $id, $diff) {
self::suspendDuring(function () use ($dao, $id, $diff) {
if (method_exists($dao, 'update')) { $dao->update($id, $diff); }
});
};
$ops[] = ['op' => $op, 'meta' => ['type' => 'update', 'mode' => 'byId', 'dao' => $dao, 'id' => (string)$id, 'payload' => $diff]];
}
}
return $ops;
}
/**
* Merge multiple updates for the same entity and ensure update happens before delete for that entity.
* @param array<int,array{op:Closure,meta:array<string,mixed>}> $ops
* @return array<int,array{op:Closure,meta:array<string,mixed>}> $ops
*/
private function coalesceAndOrderPerEntity(array $ops): array
{
// Group updates by daoClass+id
$updateMap = [];
$deleteSet = [];
foreach ($ops as $o) {
$m = $o['meta'] ?? [];
if (!isset($m['dao']) || !is_object($m['dao'])) continue;
$dao = $m['dao'];
$daoClass = get_class($dao);
$id = (string)($m['id'] ?? '');
if (($m['type'] ?? '') === 'update' && ($m['mode'] ?? '') === 'byId' && $id !== '') {
$key = $daoClass . '#' . $id;
$payload = (array)($m['payload'] ?? []);
if (!isset($updateMap[$key])) { $updateMap[$key] = ['dao' => $dao, 'id' => $id, 'payload' => []]; }
// merge (last write wins)
$updateMap[$key]['payload'] = array_merge($updateMap[$key]['payload'], $payload);
}
if (($m['type'] ?? '') === 'delete' && ($m['mode'] ?? '') === 'byId' && $id !== '') {
$deleteSet[$daoClass . '#' . $id] = ['dao' => $dao, 'id' => $id];
}
}
if (!$updateMap) { return $ops; }
// Rebuild ops: for each original op, skip individual updates; add one merged update before a delete for the same entity
$result = [];
$emittedUpdate = [];
foreach ($ops as $o) {
$m = $o['meta'] ?? [];
$emit = true;
$dao = $m['dao'] ?? null;
$daoClass = is_object($dao) ? get_class($dao) : null;
$id = (string)($m['id'] ?? '');
$key = ($daoClass && $id !== '') ? ($daoClass . '#' . $id) : null;
if (($m['type'] ?? '') === 'update' && ($m['mode'] ?? '') === 'byId' && $key && isset($updateMap[$key])) {
// skip individual update; we'll emit merged one once
$emit = false;
}
if (($m['type'] ?? '') === 'delete' && ($m['mode'] ?? '') === 'byId' && $key && isset($updateMap[$key]) && !isset($emittedUpdate[$key])) {
// emit merged update before delete
$merged = $updateMap[$key];
$updOp = function () use ($merged) {
self::suspendDuring(function () use ($merged) {
$dao = $merged['dao']; $id = $merged['id']; $payload = $merged['payload'];
if (method_exists($dao, 'update')) { $dao->update($id, $payload); }
});
};
$result[] = ['op' => $updOp, 'meta' => ['type' => 'update', 'mode' => 'byId', 'dao' => $merged['dao'], 'id' => (string)$merged['id'], 'payload' => $merged['payload']]];
$emittedUpdate[$key] = true;
}
if ($emit) {
$result[] = $o;
}
}
// For any remaining merged updates not emitted (no delete op), append them at the end
foreach ($updateMap as $k => $merged) {
if (isset($emittedUpdate[$k])) continue;
$updOp = function () use ($merged) {
self::suspendDuring(function () use ($merged) {
$dao = $merged['dao']; $id = $merged['id']; $payload = $merged['payload'];
if (method_exists($dao, 'update')) { $dao->update($id, $payload); }
});
};
$result[] = ['op' => $updOp, 'meta' => ['type' => 'update', 'mode' => 'byId', 'dao' => $merged['dao'], 'id' => (string)$merged['id'], 'payload' => $merged['payload']]];
}
return $result;
}
/** @param array<string,mixed> $a @param array<string,mixed> $b */
private function diffAssoc(array $a, array $b): array
{
$diff = [];
foreach ($b as $k => $v) {
// only simple scalar/array comparisons; nested DTOs are out of scope
$av = $a[$k] ?? null;
if ($av !== $v) {
$diff[$k] = $v;
}
}
// Skip keys present in $a but removed in $b to avoid unintended nulling
return $diff;
}
/**
* Read relations metadata from DAO instance if available.
* @return array<string,mixed>
*/
private function readRelations(object $dao): array
{
// Prefer a public relationMap() accessor if provided
if (method_exists($dao, 'relationMap')) {
try { $rels = $dao->relationMap(); if (is_array($rels)) return $rels; } catch (\Throwable) {}
}
// Fallback: try calling protected relations() via reflection
try {
$ref = new \ReflectionObject($dao);
if ($ref->hasMethod('relations')) {
$m = $ref->getMethod('relations');
$m->setAccessible(true);
$rels = $m->invoke($dao);
if (is_array($rels)) return $rels;
}
} catch (\Throwable) {}
return [];
}
}

120
src/Query/QueryBuilder.php Normal file
View file

@ -0,0 +1,120 @@
<?php
namespace Pairity\Query;
use Pairity\Contracts\QueryBuilderInterface;
class QueryBuilder implements QueryBuilderInterface
{
private array $columns = ['*'];
private string $from = '';
private ?string $alias = null;
private array $joins = [];
private array $wheres = [];
private array $groupBys = [];
private array $havings = [];
private array $orderBys = [];
private ?int $limitVal = null;
private ?int $offsetVal = null;
/** @var array<string,mixed> */
private array $bindings = [];
public function select(array $columns): static
{
$this->columns = $columns ?: ['*'];
return $this;
}
public function from(string $table, ?string $alias = null): static
{
$this->from = $table;
$this->alias = $alias;
return $this;
}
public function join(string $type, string $table, string $on): static
{
$this->joins[] = trim(strtoupper($type)) . " JOIN {$table} ON {$on}";
return $this;
}
public function where(string $clause, array $bindings = []): static
{
$this->wheres[] = $clause;
foreach ($bindings as $k => $v) {
$this->bindings[$k] = $v;
}
return $this;
}
public function orderBy(string $orderBy): static
{
$this->orderBys[] = $orderBy;
return $this;
}
public function groupBy(string $groupBy): static
{
$this->groupBys[] = $groupBy;
return $this;
}
public function having(string $clause, array $bindings = []): static
{
$this->havings[] = $clause;
foreach ($bindings as $k => $v) {
$this->bindings[$k] = $v;
}
return $this;
}
public function limit(int $limit): static
{
$this->limitVal = $limit;
return $this;
}
public function offset(int $offset): static
{
$this->offsetVal = $offset;
return $this;
}
public function toSql(): string
{
$sql = 'SELECT ' . implode(', ', $this->columns);
if ($this->from) {
$sql .= ' FROM ' . $this->from;
if ($this->alias) {
$sql .= ' ' . $this->alias;
}
}
if ($this->joins) {
$sql .= ' ' . implode(' ', $this->joins);
}
if ($this->wheres) {
$sql .= ' WHERE ' . implode(' AND ', $this->wheres);
}
if ($this->groupBys) {
$sql .= ' GROUP BY ' . implode(', ', $this->groupBys);
}
if ($this->havings) {
$sql .= ' HAVING ' . implode(' AND ', $this->havings);
}
if ($this->orderBys) {
$sql .= ' ORDER BY ' . implode(', ', $this->orderBys);
}
if ($this->limitVal !== null) {
$sql .= ' LIMIT ' . $this->limitVal;
}
if ($this->offsetVal !== null) {
$sql .= ' OFFSET ' . $this->offsetVal;
}
return $sql;
}
public function getBindings(): array
{
return $this->bindings;
}
}

170
src/Schema/Blueprint.php Normal file
View file

@ -0,0 +1,170 @@
<?php
namespace Pairity\Schema;
class Blueprint
{
public string $table;
public bool $creating = false;
public bool $altering = false;
/** @var array<int,ColumnDefinition> */
public array $columns = [];
/** @var array<int,string> */
public array $primary = [];
/** @var array<int,array{columns:array<int,string>,name:?string}> */
public array $uniques = [];
/** @var array<int,array{columns:array<int,string>,name:?string}> */
public array $indexes = [];
// Alter support (MVP)
/** @var array<int,string> */
public array $dropColumns = [];
/** @var array<int,array{from:string,to:string}> */
public array $renameColumns = [];
public ?string $renameTo = null;
/** @var array<int,string> */
public array $dropUniqueNames = [];
/** @var array<int,string> */
public array $dropIndexNames = [];
public function __construct(string $table)
{
$this->table = $table;
}
public function create(): void { $this->creating = true; }
public function alter(): void { $this->altering = true; }
// Column helpers
public function increments(string $name = 'id'): ColumnDefinition
{
$col = new ColumnDefinition($name, 'increments');
$col->autoIncrement(true);
$this->columns[] = $col;
$this->primary([$name]);
return $col;
}
public function bigIncrements(string $name = 'id'): ColumnDefinition
{
$col = new ColumnDefinition($name, 'bigincrements');
$col->autoIncrement(true);
$this->columns[] = $col;
$this->primary([$name]);
return $col;
}
public function integer(string $name, bool $unsigned = false): ColumnDefinition
{
$col = new ColumnDefinition($name, 'integer');
$col->unsigned($unsigned);
$this->columns[] = $col;
return $col;
}
public function bigInteger(string $name, bool $unsigned = false): ColumnDefinition
{
$col = new ColumnDefinition($name, 'biginteger');
$col->unsigned($unsigned);
$this->columns[] = $col;
return $col;
}
public function string(string $name, int $length = 255): ColumnDefinition
{
$col = new ColumnDefinition($name, 'string');
$col->length($length);
$this->columns[] = $col;
return $col;
}
public function text(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'text');
$this->columns[] = $col;
return $col;
}
public function boolean(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'boolean');
$this->columns[] = $col;
return $col;
}
public function json(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'json');
$this->columns[] = $col;
return $col;
}
public function datetime(string $name): ColumnDefinition
{
$col = new ColumnDefinition($name, 'datetime');
$this->columns[] = $col;
return $col;
}
public function decimal(string $name, int $precision, int $scale = 0): ColumnDefinition
{
$col = new ColumnDefinition($name, 'decimal');
$col->precision($precision, $scale);
$this->columns[] = $col;
return $col;
}
public function timestamps(string $created = 'created_at', string $updated = 'updated_at'): void
{
$this->datetime($created)->nullable();
$this->datetime($updated)->nullable();
}
// Index helpers
/** @param array<int,string> $columns */
public function primary(array $columns): void
{
$this->primary = $columns;
}
/** @param array<int,string> $columns */
public function unique(array $columns, ?string $name = null): void
{
$this->uniques[] = ['columns' => $columns, 'name' => $name];
}
/** @param array<int,string> $columns */
public function index(array $columns, ?string $name = null): void
{
$this->indexes[] = ['columns' => $columns, 'name' => $name];
}
// Alter helpers (MVP)
/** @param array<int,string> $names */
public function dropColumn(string ...$names): void
{
foreach ($names as $n) {
if ($n !== '') $this->dropColumns[] = $n;
}
}
public function renameColumn(string $from, string $to): void
{
$this->renameColumns[] = ['from' => $from, 'to' => $to];
}
public function rename(string $newName): void
{
$this->renameTo = $newName;
}
public function dropUnique(string $name): void
{
$this->dropUniqueNames[] = $name;
}
public function dropIndex(string $name): void
{
$this->dropIndexNames[] = $name;
}
}

86
src/Schema/Builder.php Normal file
View file

@ -0,0 +1,86 @@
<?php
namespace Pairity\Schema;
use Closure;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\Grammars\Grammar;
use Pairity\Schema\Grammars\SqliteGrammar;
class Builder
{
private ConnectionInterface $connection;
private Grammar $grammar;
public function __construct(ConnectionInterface $connection, Grammar $grammar)
{
$this->connection = $connection;
$this->grammar = $grammar;
}
public function create(string $table, Closure $callback): void
{
$blueprint = new Blueprint($table);
$blueprint->create();
$callback($blueprint);
$this->run($this->grammar->compileCreate($blueprint));
}
public function drop(string $table): void
{
$this->run($this->grammar->compileDrop($table));
}
public function dropIfExists(string $table): void
{
$this->run($this->grammar->compileDropIfExists($table));
}
/**
* Alter an existing table using the blueprint alter helpers.
*/
public function table(string $table, Closure $callback): void
{
$blueprint = new Blueprint($table);
$blueprint->alter();
$callback($blueprint);
// If SQLite and operation requires rebuild on legacy versions, perform rebuild
if ($this->grammar instanceof SqliteGrammar && ($blueprint->dropColumns || $blueprint->renameColumns)) {
$version = $this->detectSqliteVersion();
$needsRebuild = false;
if ($blueprint->renameColumns) {
// RENAME COLUMN requires >= 3.25
$needsRebuild = $needsRebuild || version_compare($version, '3.25.0', '<');
}
if ($blueprint->dropColumns) {
// DROP COLUMN requires >= 3.35
$needsRebuild = $needsRebuild || version_compare($version, '3.35.0', '<');
}
if ($needsRebuild) {
SqliteTableRebuilder::rebuild($this->connection, $blueprint, $this->grammar);
return;
}
}
$this->run($this->grammar->compileAlter($blueprint));
}
/** @param array<int,string> $sqls */
private function run(array $sqls): void
{
foreach ($sqls as $sql) {
$this->connection->execute($sql);
}
}
private function detectSqliteVersion(): string
{
try {
$rows = $this->connection->query('select sqlite_version() as v');
$v = $rows[0]['v'] ?? '3.0.0';
return is_string($v) ? $v : '3.0.0';
} catch (\Throwable) {
return '3.0.0';
}
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace Pairity\Schema;
class ColumnDefinition
{
public string $name;
public string $type;
public ?int $length = null;
public ?int $precision = null;
public ?int $scale = null;
public bool $unsigned = false;
public bool $nullable = false;
public mixed $default = null;
public bool $autoIncrement = false;
public function __construct(string $name, string $type)
{
$this->name = $name;
$this->type = $type;
}
public function length(int $length): static { $this->length = $length; return $this; }
public function precision(int $precision, int $scale = 0): static { $this->precision = $precision; $this->scale = $scale; return $this; }
public function unsigned(bool $flag = true): static { $this->unsigned = $flag; return $this; }
public function nullable(bool $flag = true): static { $this->nullable = $flag; return $this; }
public function default(mixed $value): static { $this->default = $value; return $this; }
public function autoIncrement(bool $flag = true): static { $this->autoIncrement = $flag; return $this; }
}

View file

@ -0,0 +1,32 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
abstract class Grammar
{
/**
* Compile a CREATE TABLE statement and any required additional index statements.
* @return array<int,string> SQL statements to execute in order
*/
abstract public function compileCreate(Blueprint $blueprint): array;
/** @return array<int,string> */
abstract public function compileDrop(string $table): array;
/** @return array<int,string> */
abstract public function compileDropIfExists(string $table): array;
/**
* Compile ALTER TABLE statements based on a Blueprint in alter mode.
* @return array<int,string>
*/
abstract public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array;
protected function wrap(string $identifier): string
{
// Default simple wrap with backticks; override in driver if different
return '`' . str_replace('`', '``', $identifier) . '`';
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class MySqlGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
// Indexes as separate statements
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['DROP TABLE IF EXISTS ' . $this->wrap($table)];
}
public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// Add columns
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col);
}
// Drop columns
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Rename columns
foreach ($blueprint->renameColumns as $pair) {
// MySQL 8+: RENAME COLUMN; older: CHANGE old new TYPE ...
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
// Add uniques
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
// Add indexes
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
// Drop unique/index by name
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP INDEX ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX ' . $this->wrap($n) . ' ON ' . $table;
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'RENAME TABLE ' . $table . ' TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'INT',
'bigincrements' => 'BIGINT',
'integer' => 'INT',
'biginteger' => 'BIGINT',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'TINYINT(1)',
'json' => 'JSON',
'datetime' => 'DATETIME',
'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
if (in_array($c->type, ['integer','biginteger','increments','bigincrements','decimal'], true) && $c->unsigned) {
$parts[] = 'UNSIGNED';
}
if ($c->autoIncrement) {
$parts[] = 'AUTO_INCREMENT';
}
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,153 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class OracleGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'CONSTRAINT ' . ($name ? $this->wrap($name) : $this->wrap($this->makeName($blueprint->table, $u['columns'], 'uk'))) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? $this->makeName($blueprint->table, $i['columns'], 'ix');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
// Oracle lacks IF EXISTS; use anonymous PL/SQL block
$tbl = $this->wrap($table);
$plsql = "BEGIN\n EXECUTE IMMEDIATE 'DROP TABLE {$tbl}';\nEXCEPTION\n WHEN OTHERS THEN\n IF SQLCODE != -942 THEN RAISE; END IF;\nEND;";
return [$plsql];
}
public function compileAlter(Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// Add columns
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD (' . $this->compileColumn($col) . ')';
}
// Drop columns
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Rename columns
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
// Add uniques
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? $this->makeName($blueprint->table, $u['columns'], 'uk');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
// Add indexes
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? $this->makeName($blueprint->table, $i['columns'], 'ix');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
// Drop unique/index by name
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX ' . $this->wrap($n);
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'NUMBER(10)',
'bigincrements' => 'NUMBER(19)',
'integer' => 'NUMBER(10)',
'biginteger' => 'NUMBER(19)',
'string' => 'VARCHAR2(' . ($c->length ?? 255) . ')',
'text' => 'CLOB',
'boolean' => 'NUMBER(1)', // store 0/1
'json' => 'CLOB', // Oracle JSON type (21c+) not assumed; use CLOB
'datetime' => 'TIMESTAMP',
'decimal' => 'NUMBER(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
protected function wrap(string $identifier): string
{
return '"' . str_replace('"', '""', $identifier) . '"';
}
private function makeName(string $table, array $columns, string $suffix): string
{
$base = $table . '_' . implode('_', $columns) . '_' . $suffix;
// Oracle identifier max length is 30. Shorten deterministically if needed.
if (strlen($base) <= 30) return $base;
$hash = substr(sha1($base), 0, 8);
$short = substr($table, 0, 10) . '_' . substr($columns[0] ?? 'col', 0, 5) . '_' . $suffix . '_' . $hash;
return substr($short, 0, 30);
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,135 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class PostgresGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['DROP TABLE IF EXISTS ' . $this->wrap($table)];
}
public function compileAlter(Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// Add columns
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col);
}
// Drop columns
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Rename columns
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
// Add uniques
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
// Add indexes
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
// Drop unique/index by name
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n);
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'SERIAL',
'bigincrements' => 'BIGSERIAL',
'integer' => 'INTEGER',
'biginteger' => 'BIGINT',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'BOOLEAN',
'json' => 'JSONB',
'datetime' => 'TIMESTAMP(0) WITHOUT TIME ZONE',
'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? 'TRUE' : 'FALSE';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,145 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class SqlServerGrammar extends Grammar
{
public function compileCreate(BLueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col);
}
$inline = [];
if ($blueprint->primary) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'CONSTRAINT ' . ($name ? $this->wrap($name) : $this->wrap($blueprint->table . '_' . implode('_', $u['columns']) . '_unique')) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['IF OBJECT_ID(N' . $this->quote($table) . ", 'U') IS NOT NULL DROP TABLE " . $this->wrap($table)];
}
public function compileAlter(Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD ' . $this->compileColumn($col);
}
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'EXEC sp_rename ' . $this->quote($blueprint->table . '.' . $pair['from']) . ', ' . $this->quote($pair['to']) . ', ' . $this->quote('COLUMN');
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'ALTER TABLE ' . $table . ' ADD CONSTRAINT ' . $this->wrap($name) . ' UNIQUE (' . $this->columnList($u['columns']) . ')';
}
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP CONSTRAINT ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX ' . $this->wrap($n) . ' ON ' . $table;
}
if ($blueprint->renameTo) {
$stmts[] = 'EXEC sp_rename ' . $this->quote($blueprint->table) . ', ' . $this->quote($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c): string
{
$type = match ($c->type) {
'increments' => 'INT',
'bigincrements' => 'BIGINT',
'integer' => 'INT',
'biginteger' => 'BIGINT',
'string' => 'NVARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'NVARCHAR(MAX)',
'boolean' => 'BIT',
'json' => 'NVARCHAR(MAX)',
'datetime' => 'DATETIME2',
'decimal' => 'DECIMAL(' . ($c->precision ?? 8) . ',' . ($c->scale ?? 2) . ')',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
if (in_array($c->type, ['integer','biginteger','increments','bigincrements','decimal'], true) && $c->unsigned) {
// SQL Server has no UNSIGNED integers; ignore.
}
if ($c->autoIncrement) {
// IDENTITY(1,1) for auto-increment
$parts[] = 'IDENTITY(1,1)';
}
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
protected function wrap(string $identifier): string
{
return '[' . str_replace([']'], [']]'], $identifier) . ']';
}
private function quote(string $value): string
{
return "'" . str_replace("'", "''", $value) . "'";
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,148 @@
<?php
namespace Pairity\Schema\Grammars;
use Pairity\Schema\Blueprint;
use Pairity\Schema\ColumnDefinition;
class SqliteGrammar extends Grammar
{
public function compileCreate(Blueprint $blueprint): array
{
$cols = [];
foreach ($blueprint->columns as $col) {
$cols[] = $this->compileColumn($col, $blueprint);
}
$inline = [];
if ($blueprint->primary) {
// In SQLite, INTEGER PRIMARY KEY on a single column should be on the column itself for autoincrement.
// For composite PKs, use table constraint.
if (count($blueprint->primary) > 1) {
$inline[] = 'PRIMARY KEY (' . $this->columnList($blueprint->primary) . ')';
}
}
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? null;
$inline[] = 'UNIQUE' . ($name ? ' ' . $this->wrap($name) : '') . ' (' . $this->columnList($u['columns']) . ')';
}
$definition = implode(",\n ", array_merge($cols, $inline));
$sql = 'CREATE TABLE ' . $this->wrap($blueprint->table) . " (\n {$definition}\n)";
$statements = [$sql];
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$statements[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $this->wrap($blueprint->table) . ' (' . $this->columnList($i['columns']) . ')';
}
return $statements;
}
public function compileDrop(string $table): array
{
return ['DROP TABLE ' . $this->wrap($table)];
}
public function compileDropIfExists(string $table): array
{
return ['DROP TABLE IF EXISTS ' . $this->wrap($table)];
}
public function compileAlter(\Pairity\Schema\Blueprint $blueprint): array
{
$table = $this->wrap($blueprint->table);
$stmts = [];
// SQLite supports ADD COLUMN straightforwardly
foreach ($blueprint->columns as $col) {
$stmts[] = 'ALTER TABLE ' . $table . ' ADD COLUMN ' . $this->compileColumn($col, $blueprint);
}
// RENAME COLUMN and DROP COLUMN are supported in modern SQLite (3.25+ and 3.35+). We will emit statements; if not supported by the runtime, DB will error.
foreach ($blueprint->renameColumns as $pair) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME COLUMN ' . $this->wrap($pair['from']) . ' TO ' . $this->wrap($pair['to']);
}
foreach ($blueprint->dropColumns as $name) {
$stmts[] = 'ALTER TABLE ' . $table . ' DROP COLUMN ' . $this->wrap($name);
}
// Unique/index operations in SQLite generally require CREATE/DROP INDEX statements
foreach ($blueprint->uniques as $u) {
$name = $u['name'] ?? ($blueprint->table . '_' . implode('_', $u['columns']) . '_unique');
$stmts[] = 'CREATE UNIQUE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($u['columns']) . ')';
}
foreach ($blueprint->indexes as $i) {
$name = $i['name'] ?? ($blueprint->table . '_' . implode('_', $i['columns']) . '_index');
$stmts[] = 'CREATE INDEX ' . $this->wrap($name) . ' ON ' . $table . ' (' . $this->columnList($i['columns']) . ')';
}
foreach ($blueprint->dropUniqueNames as $n) {
$stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n);
}
foreach ($blueprint->dropIndexNames as $n) {
$stmts[] = 'DROP INDEX IF EXISTS ' . $this->wrap($n);
}
// Rename table
if ($blueprint->renameTo) {
$stmts[] = 'ALTER TABLE ' . $table . ' RENAME TO ' . $this->wrap($blueprint->renameTo);
}
return $stmts ?: ['-- no-op'];
}
private function compileColumn(ColumnDefinition $c, Blueprint $bp): string
{
// SQLite type affinities
$type = match ($c->type) {
'increments' => 'INTEGER',
'bigincrements' => 'INTEGER',
'integer' => 'INTEGER',
'biginteger' => 'INTEGER',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'INTEGER',
'json' => 'TEXT',
'datetime' => 'TEXT',
'decimal' => 'NUMERIC',
default => strtoupper($c->type),
};
$parts = [$this->wrap($c->name), $type];
// AUTOINCREMENT style: only valid for a single-column integer primary key
$isPk = (count($bp->primary) === 1 && $bp->primary[0] === $c->name) || ($c->autoIncrement === true);
if ($isPk && in_array($c->type, ['increments','bigincrements','integer','biginteger'], true)) {
$parts[] = 'PRIMARY KEY';
if ($c->autoIncrement) {
$parts[] = 'AUTOINCREMENT';
}
}
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . $this->quoteDefault($c->default);
}
return implode(' ', $parts);
}
private function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => $this->wrap($c), $cols));
}
protected function wrap(string $identifier): string
{
return '"' . str_replace('"', '""', $identifier) . '"';
}
private function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Pairity\Schema;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\Grammars\Grammar;
use Pairity\Schema\Grammars\MySqlGrammar;
use Pairity\Schema\Grammars\SqliteGrammar;
use Pairity\Schema\Grammars\PostgresGrammar;
use Pairity\Schema\Grammars\SqlServerGrammar;
use Pairity\Schema\Grammars\OracleGrammar;
use PDO;
final class SchemaManager
{
public static function forConnection(ConnectionInterface $connection): Builder
{
$grammar = self::detectGrammar($connection);
return new Builder($connection, $grammar);
}
private static function detectGrammar(ConnectionInterface $connection): Grammar
{
$native = $connection->getNative();
$driver = null;
if ($native instanceof PDO) {
try {
$driver = $native->getAttribute(PDO::ATTR_DRIVER_NAME);
} catch (\Throwable) {
$driver = null;
}
}
$driver = is_string($driver) ? strtolower($driver) : '';
return match ($driver) {
'sqlite' => new SqliteGrammar(),
'pgsql' => new PostgresGrammar(),
'sqlsrv' => new SqlServerGrammar(),
'oci' => new OracleGrammar(),
'oracle' => new OracleGrammar(),
default => new MySqlGrammar(), // default to MySQL-style grammar
};
}
}

View file

@ -0,0 +1,147 @@
<?php
namespace Pairity\Schema;
use Pairity\Contracts\ConnectionInterface;
use Pairity\Schema\Grammars\SqliteGrammar;
/**
* Best-effort table rebuild strategy for SQLite to emulate unsupported ALTER operations
* (drop column, rename column) across older SQLite versions.
*
* Limitations: complex constraints, triggers, foreign keys, and advanced index options are not preserved.
* Indexes declared in the provided Blueprint (indexes/uniques/dropIndex/dropUnique) will be applied after rebuild.
*/
final class SqliteTableRebuilder
{
public static function rebuild(ConnectionInterface $connection, Blueprint $blueprint, SqliteGrammar $grammar): void
{
$table = $blueprint->table;
// Read existing columns
$columns = $connection->query('PRAGMA table_info(' . self::wrapIdent($table) . ')');
if (!$columns) {
throw new \RuntimeException('Table not found for rebuild: ' . $table);
}
// Build rename map
$renameMap = [];
foreach ($blueprint->renameColumns as $pair) {
$renameMap[$pair['from']] = $pair['to'];
}
$dropSet = array_flip($blueprint->dropColumns);
// Build new column definitions from existing columns (apply drop/rename)
$newCols = [];
$sourceToTarget = [];
foreach ($columns as $col) {
$name = (string)$col['name'];
if (isset($dropSet[$name])) continue; // drop
$targetName = $renameMap[$name] ?? $name;
$type = (string)($col['type'] ?? 'TEXT');
$notnull = ((int)($col['notnull'] ?? 0)) === 1 ? 'NOT NULL' : 'NULL';
$default = $col['dflt_value'] ?? null; // already SQL literal in PRAGMA output
$pk = ((int)($col['pk'] ?? 0)) === 1 ? 'PRIMARY KEY' : '';
$defParts = [self::wrap($targetName), $type, $notnull];
if ($default !== null && $default !== '') {
$defParts[] = 'DEFAULT ' . $default;
}
if ($pk !== '') {
$defParts[] = $pk;
}
$newCols[$targetName] = implode(' ', array_filter($defParts));
$sourceToTarget[$name] = $targetName;
}
// Add newly declared columns from Blueprint (with their definitions via grammar)
foreach ($blueprint->columns as $def) {
$newCols[$def->name] = self::compileColumnSqlite($def, $grammar);
}
// Temp table name
$tmp = $table . '_rebuild_' . substr(sha1((string)microtime(true)), 0, 6);
// Create temp table
$create = 'CREATE TABLE ' . self::wrap($tmp) . ' (' . implode(', ', array_values($newCols)) . ')';
$connection->execute($create);
// Build INSERT INTO tmp (...) SELECT ... FROM table
$targetCols = array_keys($newCols);
$selectExprs = [];
foreach ($targetCols as $colName) {
// If this column existed before, map from old source name (pre-rename), else insert NULL
$sourceName = array_search($colName, $sourceToTarget, true);
if ($sourceName !== false) {
$selectExprs[] = self::wrap($sourceName) . ' AS ' . self::wrap($colName);
} else {
$selectExprs[] = 'NULL AS ' . self::wrap($colName);
}
}
$insert = 'INSERT INTO ' . self::wrap($tmp) . ' (' . self::columnList($targetCols) . ') SELECT ' . implode(', ', $selectExprs) . ' FROM ' . self::wrap($table);
$connection->execute($insert);
// Replace original table
$connection->execute('DROP TABLE ' . self::wrap($table));
$connection->execute('ALTER TABLE ' . self::wrap($tmp) . ' RENAME TO ' . self::wrap($table));
// Apply index/unique operations from the blueprint (post-rebuild)
$post = new Blueprint($table);
// Carry over index ops only
$post->uniques = $blueprint->uniques;
$post->indexes = $blueprint->indexes;
$post->dropUniqueNames = $blueprint->dropUniqueNames;
$post->dropIndexNames = $blueprint->dropIndexNames;
$sqls = $grammar->compileAlter($post);
foreach ($sqls as $sql) {
$connection->execute($sql);
}
}
private static function compileColumnSqlite(ColumnDefinition $c, SqliteGrammar $grammar): string
{
// Minimal re-use: instantiate a throwaway Blueprint to access protected compile via public path is not possible; duplicate minimal mapping here
$type = match ($c->type) {
'increments', 'bigincrements', 'integer', 'biginteger' => 'INTEGER',
'string' => 'VARCHAR(' . ($c->length ?? 255) . ')',
'text' => 'TEXT',
'boolean' => 'INTEGER',
'json' => 'TEXT',
'datetime' => 'TEXT',
'decimal' => 'NUMERIC',
default => strtoupper($c->type),
};
$parts = [self::wrap($c->name), $type];
$parts[] = $c->nullable ? 'NULL' : 'NOT NULL';
if ($c->default !== null) {
$parts[] = 'DEFAULT ' . self::quoteDefault($c->default);
}
return implode(' ', $parts);
}
private static function wrap(string $ident): string
{
return '"' . str_replace('"', '""', $ident) . '"';
}
private static function wrapIdent(string $ident): string
{
// For PRAGMA table_info(<name>) we should not quote with double quotes; wrap in simple name if needed
return '"' . str_replace('"', '""', $ident) . '"';
}
private static function columnList(array $cols): string
{
return implode(', ', array_map(fn($c) => self::wrap($c), $cols));
}
private static function quoteDefault(mixed $value): string
{
if (is_numeric($value)) return (string)$value;
if (is_bool($value)) return $value ? '1' : '0';
if ($value === null) return 'NULL';
return "'" . str_replace("'", "''", (string)$value) . "'";
}
}

View file

@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
final class BelongsToManyMysqlTest extends TestCase
{
private function mysqlConfig(): array
{
$host = getenv('MYSQL_HOST') ?: null;
if (!$host) {
$this->markTestSkipped('MYSQL_HOST not set; skipping MySQL belongsToMany test');
}
return [
'driver' => 'mysql',
'host' => $host,
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
'database' => getenv('MYSQL_DB') ?: 'pairity',
'username' => getenv('MYSQL_USER') ?: 'root',
'password' => getenv('MYSQL_PASS') ?: 'root',
'charset' => 'utf8mb4',
];
}
public function testBelongsToManyEagerAndHelpers(): void
{
$cfg = $this->mysqlConfig();
$conn = ConnectionManager::make($cfg);
$schema = SchemaManager::forConnection($conn);
// unique table names per run
$suffix = substr(sha1((string)microtime(true)), 0, 6);
$usersT = 'u_btm_' . $suffix;
$rolesT = 'r_btm_' . $suffix;
$pivotT = 'ur_btm_' . $suffix;
// Create tables
$schema->create($usersT, function (Blueprint $t) { $t->increments('id'); $t->string('email', 190); });
$schema->create($rolesT, function (Blueprint $t) { $t->increments('id'); $t->string('name', 190); });
$conn->execute("CREATE TABLE `{$pivotT}` (user_id INT NOT NULL, role_id INT NOT NULL)");
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$RoleDto = new class([]) extends AbstractDto {};
$userDto = get_class($UserDto); $roleDto = get_class($RoleDto);
// DAOs
$RoleDao = new class($conn, $rolesT, $roleDto) extends AbstractDao {
private string $table; private string $dto;
public function __construct($c, string $table, string $dto) { parent::__construct($c); $this->table = $table; $this->dto = $dto; }
public function getTable(): string { return $this->table; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$UserDao = new class($conn, $usersT, $userDto, get_class($RoleDao), $pivotT) extends AbstractDao {
private string $table; private string $dto; private string $roleDaoClass; private string $pivot;
public function __construct($c, string $table, string $dto, string $roleDaoClass, string $pivot) { parent::__construct($c); $this->table=$table; $this->dto=$dto; $this->roleDaoClass=$roleDaoClass; $this->pivot=$pivot; }
public function getTable(): string { return $this->table; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => $this->roleDaoClass,
'pivot' => $this->pivot,
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
};
$roleDao = new $RoleDao($conn, $rolesT, $roleDto);
$userDao = new $UserDao($conn, $usersT, $userDto, get_class($roleDao), $pivotT);
// Seed
$u = $userDao->insert(['email' => 'b@example.com']);
$uid = (int)($u->toArray(false)['id'] ?? 0);
$r1 = $roleDao->insert(['name' => 'admin']);
$r2 = $roleDao->insert(['name' => 'editor']);
$rid1 = (int)($r1->toArray(false)['id'] ?? 0); $rid2 = (int)($r2->toArray(false)['id'] ?? 0);
$userDao->attach('roles', $uid, [$rid1, $rid2]);
$loaded = $userDao->with(['roles'])->findById($uid);
$this->assertNotNull($loaded);
$this->assertCount(2, $loaded->toArray(false)['roles'] ?? []);
$userDao->detach('roles', $uid, [$rid1]);
$re = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $re->toArray(false)['roles'] ?? []);
$userDao->sync('roles', $uid, [$rid2]);
$re2 = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $re2->toArray(false)['roles'] ?? []);
// Cleanup
$schema->drop($usersT);
$schema->drop($rolesT);
$conn->execute('DROP TABLE `' . $pivotT . '`');
}
}

View file

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
final class BelongsToManySqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testBelongsToManyEagerAndPivotHelpers(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT)');
$conn->execute('CREATE TABLE roles (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
$conn->execute('CREATE TABLE user_role (user_id INTEGER, role_id INTEGER)');
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$RoleDto = new class([]) extends AbstractDto {};
$userDtoClass = get_class($UserDto);
$roleDtoClass = get_class($RoleDto);
// DAOs
$RoleDao = new class($conn, $roleDtoClass) extends AbstractDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'roles'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$UserDao = new class($conn, $userDtoClass, get_class($RoleDao)) extends AbstractDao {
private string $dto; private string $roleDaoClass;
public function __construct($c, string $dto, string $roleDaoClass) { parent::__construct($c); $this->dto = $dto; $this->roleDaoClass = $roleDaoClass; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
protected function relations(): array {
return [
'roles' => [
'type' => 'belongsToMany',
'dao' => $this->roleDaoClass,
'pivot' => 'user_role',
'foreignPivotKey' => 'user_id',
'relatedPivotKey' => 'role_id',
'localKey' => 'id',
'relatedKey' => 'id',
],
];
}
};
$roleDao = new $RoleDao($conn, $roleDtoClass);
$userDao = new $UserDao($conn, $userDtoClass, get_class($roleDao));
// seed
$u = $userDao->insert(['email' => 'p@example.com']);
$uid = (int)($u->toArray(false)['id'] ?? 0);
$r1 = $roleDao->insert(['name' => 'admin']);
$r2 = $roleDao->insert(['name' => 'editor']);
$rid1 = (int)($r1->toArray(false)['id'] ?? 0);
$rid2 = (int)($r2->toArray(false)['id'] ?? 0);
// attach via helper
$userDao->attach('roles', $uid, [$rid1, $rid2]);
// eager load roles
$loaded = $userDao->with(['roles'])->findById($uid);
$this->assertNotNull($loaded);
$roles = $loaded->toArray(false)['roles'] ?? [];
$this->assertIsArray($roles);
$this->assertCount(2, $roles);
// detach one
$det = $userDao->detach('roles', $uid, [$rid1]);
$this->assertGreaterThanOrEqual(1, $det);
$reloaded = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $reloaded->toArray(false)['roles'] ?? []);
// sync to only [rid2]
$res = $userDao->sync('roles', $uid, [$rid2]);
$this->assertIsArray($res);
$synced = $userDao->with(['roles'])->findById($uid);
$this->assertCount(1, $synced->toArray(false)['roles'] ?? []);
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
use Pairity\Model\Casting\CasterInterface;
final class CastersAndAccessorsSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testCustomCasterAndDtoAccessorsMutators(): void
{
$conn = $this->conn();
// simple schema
$conn->execute('CREATE TABLE widgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
price_cents INTEGER,
meta TEXT
)');
// Custom caster for money cents <-> Money object (array for simplicity)
$moneyCasterClass = new class implements CasterInterface {
public function fromStorage(mixed $value): mixed { return ['cents' => (int)$value]; }
public function toStorage(mixed $value): mixed {
if (is_array($value) && isset($value['cents'])) { return (int)$value['cents']; }
return (int)$value;
}
};
$moneyCasterFqcn = get_class($moneyCasterClass);
// DTO with accessor/mutator for name (capitalize on get, trim on set)
$Dto = new class([]) extends AbstractDto {
protected function getNameAttribute($value): mixed { return is_string($value) ? strtoupper($value) : $value; }
protected function setNameAttribute($value): mixed { return is_string($value) ? trim($value) : $value; }
};
$dtoClass = get_class($Dto);
$Dao = new class($conn, $dtoClass, $moneyCasterFqcn) extends AbstractDao {
private string $dto; private string $caster;
public function __construct($c, string $dto, string $caster) { parent::__construct($c); $this->dto = $dto; $this->caster = $caster; }
public function getTable(): string { return 'widgets'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'name' => ['cast' => 'string'],
'price_cents' => ['cast' => $this->caster], // custom caster
'meta' => ['cast' => 'json'],
],
];
}
};
$dao = new $Dao($conn, $dtoClass, $moneyCasterFqcn);
// Insert with mutator (name will be trimmed) and caster (price array -> storage int)
$created = $dao->insert([
'name' => ' gizmo ',
'price_cents' => ['cents' => 1234],
'meta' => ['color' => 'red']
]);
$arr = $created->toArray(false);
$this->assertSame('GIZMO', $arr['name']); // accessor uppercases
$this->assertIsArray($arr['price_cents']);
$this->assertSame(1234, $arr['price_cents']['cents']); // fromStorage via caster
$this->assertSame('red', $arr['meta']['color']);
$id = $arr['id'];
// Update with caster value
$updated = $dao->update($id, ['price_cents' => ['cents' => 1999]]);
$this->assertSame(1999, $updated->toArray(false)['price_cents']['cents']);
// Verify raw storage is int (select directly)
$raw = $conn->query('SELECT price_cents, meta, name FROM widgets WHERE id = :id', ['id' => $id])[0] ?? [];
$this->assertSame(1999, (int)$raw['price_cents']);
$this->assertIsString($raw['meta']);
$this->assertSame('gizmo', strtolower((string)$raw['name']));
}
}

View file

@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
use Pairity\Events\Events;
use Pairity\Orm\UnitOfWork;
final class EventSystemSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testDaoEventsForInsertUpdateDeleteAndFind(): void
{
$conn = $this->conn();
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, status TEXT)');
$Dto = new class([]) extends AbstractDto {};
$dtoClass = get_class($Dto);
$Dao = new class($conn, $dtoClass) extends AbstractDao {
private string $dto; public function __construct($c,string $d){ parent::__construct($c); $this->dto=$d; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; }
};
$dao = new $Dao($conn, $dtoClass);
$beforeInsertData = null; $afterInsertName = null; $afterUpdateName = null; $afterDeleteAffected = null; $afterFindCount = null;
Events::dispatcher()->clear();
Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) use (&$beforeInsertData) {
if (($p['table'] ?? '') === 'users') {
// mutate data
$p['data']['status'] = 'mutated';
$beforeInsertData = $p['data'];
}
});
Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$afterInsertName) {
if (($p['table'] ?? '') === 'users' && $p['dto'] instanceof AbstractDto) {
$afterInsertName = $p['dto']->toArray(false)['name'] ?? null;
}
});
Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$afterUpdateName) {
if (($p['table'] ?? '') === 'users' && $p['dto'] instanceof AbstractDto) {
$afterUpdateName = $p['dto']->toArray(false)['name'] ?? null;
}
});
Events::dispatcher()->listen('dao.afterDelete', function(array &$p) use (&$afterDeleteAffected) {
if (($p['table'] ?? '') === 'users') { $afterDeleteAffected = (int)($p['affected'] ?? 0); }
});
Events::dispatcher()->listen('dao.afterFind', function(array &$p) use (&$afterFindCount) {
if (($p['table'] ?? '') === 'users') {
if (isset($p['dto'])) { $afterFindCount = ($p['dto'] ? 1 : 0); }
if (isset($p['dtos'])) { $afterFindCount = is_array($p['dtos']) ? count($p['dtos']) : 0; }
}
});
// Insert (beforeInsert should set status)
$created = $dao->insert(['name' => 'Alice']);
$arr = $created->toArray(false);
$this->assertSame('mutated', $arr['status'] ?? null);
$this->assertSame('Alice', $afterInsertName);
// Update
$id = (int)$arr['id'];
$updated = $dao->update($id, ['name' => 'Alice2']);
$this->assertSame('Alice2', $afterUpdateName);
// Find
$one = $dao->findById($id);
$this->assertSame(1, $afterFindCount);
// Delete
$aff = $dao->deleteById($id);
$this->assertSame($aff, $afterDeleteAffected);
}
public function testUowBeforeAfterCommitEvents(): void
{
$conn = $this->conn();
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
$Dto = new class([]) extends AbstractDto {};
$dtoClass = get_class($Dto);
$Dao = new class($conn, $dtoClass) extends AbstractDao {
private string $dto; public function __construct($c,string $d){ parent::__construct($c); $this->dto=$d; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$dao = new $Dao($conn, $dtoClass);
$before = 0; $after = 0;
Events::dispatcher()->clear();
Events::dispatcher()->listen('uow.beforeCommit', function(array &$p) use (&$before) { $before++; });
Events::dispatcher()->listen('uow.afterCommit', function(array &$p) use (&$after) { $after++; });
$row = $dao->insert(['name' => 'X']);
$id = (int)($row->toArray(false)['id'] ?? 0);
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
$dao->update($id, ['name' => 'Y']);
$dao->deleteBy(['id' => $id]);
});
$this->assertSame(1, $before);
$this->assertSame(1, $after);
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
final class JoinEagerMysqlTest extends TestCase
{
private function mysqlConfig(): array
{
$host = getenv('MYSQL_HOST') ?: null;
if (!$host) {
$this->markTestSkipped('MYSQL_HOST not set; skipping MySQL join eager test');
}
return [
'driver' => 'mysql',
'host' => $host,
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
'database' => getenv('MYSQL_DB') ?: 'pairity',
'username' => getenv('MYSQL_USER') ?: 'root',
'password' => getenv('MYSQL_PASS') ?: 'root',
'charset' => 'utf8mb4',
];
}
public function testJoinEagerHasManyAndBelongsTo(): void
{
$cfg = $this->mysqlConfig();
$conn = ConnectionManager::make($cfg);
$schema = SchemaManager::forConnection($conn);
// Unique table names per run
$suf = substr(sha1((string)microtime(true)), 0, 6);
$usersT = 'je_users_' . $suf;
$postsT = 'je_posts_' . $suf;
// Create tables
$schema->create($usersT, function (Blueprint $t) { $t->increments('id'); $t->string('name', 190); });
$schema->create($postsT, function (Blueprint $t) { $t->increments('id'); $t->integer('user_id'); $t->string('title', 190); $t->datetime('deleted_at')->nullable(); });
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$PostDto = new class([]) extends AbstractDto {};
$uClass = get_class($UserDto); $pClass = get_class($PostDto);
// DAOs
$PostDao = new class($conn, $postsT, $pClass) extends AbstractDao {
private string $table; private string $dto;
public function __construct($c, string $table, string $dto) { parent::__construct($c); $this->table=$table; $this->dto=$dto; }
public function getTable(): string { return $this->table; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return [
'primaryKey' => 'id',
'columns' => [ 'id'=>['cast'=>'int'], 'user_id'=>['cast'=>'int'], 'title'=>['cast'=>'string'], 'deleted_at'=>['cast'=>'datetime'] ],
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
]; }
};
$UserDao = new class($conn, $usersT, $uClass, get_class($PostDao)) extends AbstractDao {
private string $table; private string $dto; private string $postDaoClass;
public function __construct($c, string $table, string $dto, string $postDaoClass) { parent::__construct($c); $this->table=$table; $this->dto=$dto; $this->postDaoClass=$postDaoClass; }
public function getTable(): string { return $this->table; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [ 'posts' => [ 'type'=>'hasMany', 'dao'=>$this->postDaoClass, 'foreignKey'=>'user_id', 'localKey'=>'id' ] ]; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$postDao = new $PostDao($conn, $postsT, $pClass);
$userDao = new $UserDao($conn, $usersT, $uClass, get_class($postDao));
// Seed
$u1 = $userDao->insert(['name' => 'Alice']);
$u2 = $userDao->insert(['name' => 'Bob']);
$uid1 = (int)$u1->toArray(false)['id'];
$uid2 = (int)$u2->toArray(false)['id'];
$postDao->insert(['user_id' => $uid1, 'title' => 'P1']);
$postDao->insert(['user_id' => $uid1, 'title' => 'P2']);
// soft-deleted child for Bob
$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]);
// Baseline batched eager
$baseline = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]);
$this->assertCount(2, $baseline);
$postsAlice = $baseline[0]->toArray(false)['posts'] ?? [];
$this->assertIsArray($postsAlice);
$this->assertCount(2, $postsAlice);
// Join-based eager (global)
$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]);
$this->assertCount(2, $joined);
foreach ($joined as $u) {
$posts = $u->toArray(false)['posts'] ?? [];
foreach ($posts as $p) {
$this->assertNotSame('Hidden', $p->toArray(false)['title'] ?? null);
}
}
// belongsTo join: Posts -> User
$UserDao2 = get_class($userDao);
$PostDao2 = new class($conn, $postsT, $pClass, $UserDao2, $usersT, $uClass) extends AbstractDao {
private string $pTable; private string $dto; private string $userDaoClass; private string $uTable; private string $uDto;
public function __construct($c,string $pTable,string $dto,string $userDaoClass,string $uTable,string $uDto){ parent::__construct($c); $this->pTable=$pTable; $this->dto=$dto; $this->userDaoClass=$userDaoClass; $this->uTable=$uTable; $this->uDto=$uDto; }
public function getTable(): string { return $this->pTable; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [ 'user' => [ 'type'=>'belongsTo', 'dao'=>get_class(new class($this->getConnection(), $this->uTable, $this->uDto) extends AbstractDao { private string $t; private string $d; public function __construct($c,string $t,string $d){ parent::__construct($c); $this->t=$t; $this->d=$d; } public function getTable(): string { return $this->t; } protected function dtoClass(): string { return $this->d; } protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; } }), 'foreignKey'=>'user_id', 'otherKey'=>'id' ] ]; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
};
$postDaoJ = new $PostDao2($conn, $postsT, $pClass, $UserDao2, $usersT, $uClass);
$rows = $postDaoJ->fields('id','title','user.name')->useJoinEager()->with(['user'])->findAllBy([]);
$this->assertNotEmpty($rows);
$arr = $rows[0]->toArray(false);
$this->assertArrayHasKey('user', $arr);
// Cleanup
$schema->drop($usersT);
$schema->drop($postsT);
}
}

View file

@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
final class JoinEagerSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testHasManyJoinEagerWithProjectionAndSoftDeleteScope(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, deleted_at TEXT NULL)');
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$PostDto = new class([]) extends AbstractDto {};
$uClass = get_class($UserDto); $pClass = get_class($PostDto);
// DAOs
$PostDao = new class($conn, $pClass) extends AbstractDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return [
'primaryKey' => 'id',
'columns' => [ 'id'=>['cast'=>'int'], 'user_id'=>['cast'=>'int'], 'title'=>['cast'=>'string'], 'deleted_at'=>['cast'=>'datetime'] ],
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
]; }
};
$UserDao = new class($conn, $uClass, get_class($PostDao)) extends AbstractDao {
private string $dto; private string $postDaoClass; public function __construct($c,string $dto,string $p){ parent::__construct($c); $this->dto=$dto; $this->postDaoClass=$p; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [
'posts' => [ 'type' => 'hasMany', 'dao' => $this->postDaoClass, 'foreignKey' => 'user_id', 'localKey' => 'id' ],
]; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$postDao = new $PostDao($conn, $pClass);
$userDao = new $UserDao($conn, $uClass, get_class($postDao));
// seed
$u1 = $userDao->insert(['name' => 'Alice']);
$u2 = $userDao->insert(['name' => 'Bob']);
$uid1 = (int)$u1->toArray(false)['id'];
$uid2 = (int)$u2->toArray(false)['id'];
$postDao->insert(['user_id' => $uid1, 'title' => 'P1']);
$postDao->insert(['user_id' => $uid1, 'title' => 'P2']);
$postDao->insert(['user_id' => $uid2, 'title' => 'Hidden', 'deleted_at' => gmdate('Y-m-d H:i:s')]); // soft-deleted
// Batched (subquery) for baseline
$baseline = $userDao->fields('id','name','posts.title')->with(['posts'])->findAllBy([]);
$this->assertCount(2, $baseline);
$alice = $baseline[0]->toArray(false);
$this->assertIsArray($alice['posts'] ?? null);
$this->assertCount(2, $alice['posts']);
// Join-based eager (opt-in). Requires relation field projection.
$joined = $userDao->fields('id','name','posts.title')->useJoinEager()->with(['posts'])->findAllBy([]);
$this->assertCount(2, $joined);
$aliceJ = $joined[0]->toArray(false);
$this->assertIsArray($aliceJ['posts'] ?? null);
$this->assertCount(2, $aliceJ['posts']);
// Ensure soft-deleted child was filtered out via ON condition
foreach ($joined as $u) {
$posts = $u->toArray(false)['posts'] ?? [];
foreach ($posts as $p) {
$this->assertNotSame('Hidden', $p->toArray(false)['title'] ?? null);
}
}
}
public function testBelongsToJoinEagerSingleLevel(): void
{
$conn = $this->conn();
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)');
$UserDto = new class([]) extends AbstractDto {};
$PostDto = new class([]) extends AbstractDto {};
$uClass = get_class($UserDto); $pClass = get_class($PostDto);
$UserDao = new class($conn, $uClass) extends AbstractDao {
private string $dto; public function __construct($c,string $dto){ parent::__construct($c); $this->dto=$dto; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$PostDao = new class($conn, $pClass, get_class($UserDao)) extends AbstractDao {
private string $dto; private string $userDaoClass; public function __construct($c,string $dto,string $u){ parent::__construct($c); $this->dto=$dto; $this->userDaoClass=$u; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [
'user' => [ 'type' => 'belongsTo', 'dao' => $this->userDaoClass, 'foreignKey' => 'user_id', 'otherKey' => 'id' ],
]; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
};
$userDao = new $UserDao($conn, $uClass);
$postDao = new $PostDao($conn, $pClass, get_class($userDao));
$u = $userDao->insert(['name' => 'Alice']);
$uid = (int)$u->toArray(false)['id'];
$p = $postDao->insert(['user_id' => $uid, 'title' => 'Hello']);
$rows = $postDao->fields('id','title','user.name')->useJoinEager()->with(['user'])->findAllBy([]);
$this->assertNotEmpty($rows);
$arr = $rows[0]->toArray(false);
$this->assertSame('Hello', $arr['title']);
$this->assertSame('Alice', $arr['user']->toArray(false)['name'] ?? null);
}
}

View file

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
final class MongoAdapterTest extends TestCase
{
private function hasMongoExt(): bool
{
return extension_loaded('mongodb');
}
public function testCrudCycle(): void
{
if (!$this->hasMongoExt()) {
$this->markTestSkipped('ext-mongodb not loaded');
}
// Attempt connection; skip if server is unavailable
try {
$conn = MongoConnectionManager::make([
'host' => getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
$db = 'pairity_test';
$col = 'widgets';
// Clean up any leftovers
try {
foreach ($conn->find($db, $col, []) as $doc) {
$conn->deleteOne($db, $col, ['_id' => $doc['_id']]);
}
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo operations unavailable: ' . $e->getMessage());
}
// Insert
$id = $conn->insertOne($db, $col, [
'name' => 'Widget',
'qty' => 5,
'tags' => ['a','b'],
]);
$this->assertNotEmpty($id, 'Inserted _id should be returned');
// Find by id
$found = $conn->find($db, $col, ['_id' => $id]);
$this->assertNotEmpty($found, 'Should find inserted doc');
$this->assertSame('Widget', $found[0]['name'] ?? null);
// Update
$modified = $conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['qty' => 7]]);
$this->assertGreaterThanOrEqual(0, $modified);
$after = $conn->find($db, $col, ['_id' => $id]);
$this->assertSame(7, $after[0]['qty'] ?? null);
// Aggregate pipeline
$agg = $conn->aggregate($db, $col, [
['$match' => ['qty' => 7]],
['$project' => ['name' => 1, 'qty' => 1]],
]);
$this->assertNotEmpty($agg);
// Delete
$deleted = $conn->deleteOne($db, $col, ['_id' => $id]);
$this->assertGreaterThanOrEqual(1, $deleted);
$remaining = $conn->find($db, $col, ['_id' => $id]);
$this->assertCount(0, $remaining);
}
}

82
tests/MongoDaoTest.php Normal file
View file

@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
final class MongoDaoTest extends TestCase
{
private function hasMongoExt(): bool
{
return extension_loaded('mongodb');
}
public function testCrudViaDao(): void
{
if (!$this->hasMongoExt()) {
$this->markTestSkipped('ext-mongodb not loaded');
}
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
// Define DTO/DAO inline for test
$dtoClass = new class([]) extends AbstractDto {};
$dtoFqcn = get_class($dtoClass);
$dao = new class($conn, $dtoFqcn) extends AbstractMongoDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.widgets'; }
protected function dtoClass(): string { return $this->dto; }
};
// Clean collection
foreach ($dao->findAllBy([]) as $doc) {
$id = (string)($doc->toArray(false)['_id'] ?? '');
if ($id !== '') { $dao->deleteById($id); }
}
// Insert
$created = $dao->insert(['name' => 'Widget', 'qty' => 5, 'tags' => ['a','b']]);
$arr = $created->toArray(false);
$this->assertArrayHasKey('_id', $arr);
$id = (string)$arr['_id'];
$this->assertNotEmpty($id);
// Find by id
$found = $dao->findById($id);
$this->assertNotNull($found);
$this->assertSame('Widget', $found->toArray(false)['name'] ?? null);
// Update
$updated = $dao->update($id, ['qty' => 7]);
$this->assertSame(7, $updated->toArray(false)['qty'] ?? null);
// Projection, sorting, limit/skip
$list = $dao->fields('name')->sort(['name' => 1])->limit(10)->skip(0)->findAllBy([]);
$this->assertNotEmpty($list);
$this->assertArrayHasKey('name', $list[0]->toArray(false));
// Dynamic helper findOneByName
$one = $dao->findOneByName('Widget');
$this->assertNotNull($one);
// Delete
$deleted = $dao->deleteById($id);
$this->assertGreaterThanOrEqual(1, $deleted);
$this->assertNull($dao->findById($id));
}
}

View file

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
use Pairity\Events\Events;
final class MongoEventSystemTest extends TestCase
{
private function hasMongoExt(): bool { return \extension_loaded('mongodb'); }
public function testDaoEventsFireOnCrud(): void
{
if (!$this->hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); }
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
$Dto = new class([]) extends AbstractDto {};
$dtoClass = \get_class($Dto);
$Dao = new class($conn, $dtoClass) extends AbstractMongoDao {
private string $dto; public function __construct($c,string $d){ parent::__construct($c); $this->dto=$d; }
protected function collection(): string { return 'pairity_test.events_users'; }
protected function dtoClass(): string { return $this->dto; }
};
$dao = new $Dao($conn, $dtoClass);
// Clean
foreach ($dao->findAllBy([]) as $doc) { $id = (string)($doc->toArray(false)['_id'] ?? ''); if ($id) { $dao->deleteById($id); } }
$beforeInsert = null; $afterInsert = false; $afterUpdate = false; $afterDelete = 0; $afterFind = 0;
Events::dispatcher()->clear();
Events::dispatcher()->listen('dao.beforeInsert', function(array &$p) use (&$beforeInsert){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $p['data']['tag'] = 'x'; $beforeInsert = $p['data']; }});
Events::dispatcher()->listen('dao.afterInsert', function(array &$p) use (&$afterInsert){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterInsert = true; }});
Events::dispatcher()->listen('dao.afterUpdate', function(array &$p) use (&$afterUpdate){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterUpdate = true; }});
Events::dispatcher()->listen('dao.afterDelete', function(array &$p) use (&$afterDelete){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterDelete += (int)($p['affected'] ?? 0); }});
Events::dispatcher()->listen('dao.afterFind', function(array &$p) use (&$afterFind){ if (($p['collection'] ?? '') === 'pairity_test.events_users'){ $afterFind += isset($p['dto']) ? (int)!!$p['dto'] : (is_array($p['dtos'] ?? null) ? count($p['dtos']) : 0); }});
// Insert
$created = $dao->insert(['email' => 'e@x.com']);
$this->assertTrue($afterInsert);
$this->assertSame('x', $created->toArray(false)['tag'] ?? null);
// Update
$id = (string)($created->toArray(false)['_id'] ?? '');
$dao->update($id, ['email' => 'e2@x.com']);
$this->assertTrue($afterUpdate);
// Find
$one = $dao->findById($id);
$this->assertNotNull($one);
$this->assertGreaterThanOrEqual(1, $afterFind);
// Delete
$aff = $dao->deleteById($id);
$this->assertSame($aff, $afterDelete);
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
final class MongoOptimisticLockTest extends TestCase
{
private function hasMongoExt(): bool { return \extension_loaded('mongodb'); }
public function testVersionIncrementOnUpdate(): void
{
if (!$this->hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); }
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
$dto = new class([]) extends AbstractDto {};
$dtoClass = \get_class($dto);
$Dao = new class($conn, $dtoClass) extends AbstractMongoDao {
private string $dto; public function __construct($c, string $d){ parent::__construct($c); $this->dto=$d; }
protected function collection(): string { return 'pairity_test.lock_users'; }
protected function dtoClass(): string { return $this->dto; }
protected function locking(): array { return ['type' => 'version', 'column' => 'version']; }
};
$dao = new $Dao($conn, $dtoClass);
// Clean
foreach ($dao->findAllBy([]) as $doc) { $id = (string)($doc->toArray(false)['_id'] ?? ''); if ($id) { $dao->deleteById($id); } }
// Insert with initial version 0
$created = $dao->insert(['email' => 'lock@example.com', 'version' => 0]);
$id = (string)($created->toArray(false)['_id'] ?? '');
$this->assertNotEmpty($id);
// Update should bump version to 1
$dao->update($id, ['email' => 'lock2@example.com']);
$after = $dao->findById($id);
$this->assertNotNull($after);
$this->assertSame(1, (int)($after->toArray(false)['version'] ?? -1));
}
}

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
final class MongoPaginationTest extends TestCase
{
private function hasMongoExt(): bool { return \extension_loaded('mongodb'); }
public function testPaginateAndSimplePaginateWithScopes(): void
{
if (!$this->hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); }
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
// Inline DTO and DAOs
$userDto = new class([]) extends AbstractDto {};
$userDtoClass = \get_class($userDto);
$postDto = new class([]) extends AbstractDto {};
$postDtoClass = \get_class($postDto);
$PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao {
private string $dto; public function __construct($c, string $dto){ parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.pg_posts'; }
protected function dtoClass(): string { return $this->dto; }
};
$UserDao = new class($conn, $userDtoClass, get_class($PostDao)) extends AbstractMongoDao {
private string $dto; private string $postDaoClass; public function __construct($c,string $dto,string $p){ parent::__construct($c); $this->dto=$dto; $this->postDaoClass=$p; }
protected function collection(): string { return 'pairity_test.pg_users'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [
'posts' => [ 'type' => 'hasMany', 'dao' => $this->postDaoClass, 'foreignKey' => 'user_id', 'localKey' => '_id' ],
]; }
};
$postDao = new $PostDao($conn, $postDtoClass);
$userDao = new $UserDao($conn, $userDtoClass, get_class($postDao));
// Clean
foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } }
foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } }
// Seed 26 users; attach posts to some
for ($i=1; $i<=26; $i++) {
$status = $i % 2 === 0 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "m{$i}@ex.com", 'status' => $status]);
$uid = (string)($u->toArray(false)['_id'] ?? '');
if ($i % 4 === 0) { $postDao->insert(['user_id' => $uid, 'title' => 'T'.$i]); }
}
// Paginate
$page = $userDao->paginate(2, 10, []);
$this->assertSame(26, $page['total']);
$this->assertCount(10, $page['data']);
$this->assertSame(3, $page['lastPage']);
// Simple paginate last page nextPage null
$sp = $userDao->simplePaginate(3, 10, []);
$this->assertNull($sp['nextPage']);
// fields + sort + with on paginate
$with = $userDao->fields('email','posts.title')->sort(['email' => 1])->with(['posts'])->paginate(1, 5);
$this->assertNotEmpty($with['data']);
$first = $with['data'][0]->toArray(false);
$this->assertArrayHasKey('email', $first);
$this->assertArrayHasKey('posts', $first);
// Scopes
$userDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; });
$active = $userDao->active()->paginate(1, 100, []);
// Half of 26 rounded down
$this->assertSame(13, $active['total']);
}
}

View file

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Model\AbstractDto;
final class MongoRelationsTest extends TestCase
{
private function hasMongoExt(): bool { return \extension_loaded('mongodb'); }
public function testEagerAndLazyRelations(): void
{
if (!$this->hasMongoExt()) { $this->markTestSkipped('ext-mongodb not loaded'); }
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
// Inline DTO classes
$userDto = new class([]) extends AbstractDto {};
$userDtoClass = \get_class($userDto);
$postDto = new class([]) extends AbstractDto {};
$postDtoClass = \get_class($postDto);
// Inline DAOs with relations
$UserDao = new class($conn, $userDtoClass) extends AbstractMongoDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.users_rel'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [
'posts' => [ 'type' => 'hasMany', 'dao' => get_class($this->makePostDao()), 'foreignKey' => 'user_id', 'localKey' => '_id' ],
]; }
private function makePostDao(): object { return new class($this->getConnection(), 'stdClass') extends AbstractMongoDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.posts_rel'; }
protected function dtoClass(): string { return $this->dto; }
}; }
};
$PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.posts_rel'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array { return [
'user' => [ 'type' => 'belongsTo', 'dao' => get_class($this->makeUserDao()), 'foreignKey' => 'user_id', 'otherKey' => '_id' ],
]; }
private function makeUserDao(): object { return new class($this->getConnection(), 'stdClass') extends AbstractMongoDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.users_rel'; }
protected function dtoClass(): string { return $this->dto; }
}; }
};
// Instantiate concrete DAOs for use
$userDao = new $UserDao($conn, $userDtoClass);
$postDao = new $PostDao($conn, $postDtoClass);
// Clean
foreach ($postDao->findAllBy([]) as $p) { $postDao->deleteById((string)($p->toArray(false)['_id'] ?? '')); }
foreach ($userDao->findAllBy([]) as $u) { $userDao->deleteById((string)($u->toArray(false)['_id'] ?? '')); }
// Seed one user and two posts
$u = $userDao->insert(['email' => 'r@example.com', 'name' => 'Rel']);
$uid = (string)$u->toArray(false)['_id'];
$postDao->insert(['title' => 'A', 'user_id' => $uid]);
$postDao->insert(['title' => 'B', 'user_id' => $uid]);
// Eager load posts on users
$users = $userDao->with(['posts'])->findAllBy([]);
$this->assertNotEmpty($users);
$this->assertIsArray($users[0]->toArray(false)['posts'] ?? null);
// Lazy load belongsTo on a post
$one = $postDao->findOneBy(['title' => 'A']);
$this->assertNotNull($one);
$postDao->load($one, 'user');
$this->assertNotNull($one->toArray(false)['user'] ?? null);
}
}

61
tests/MysqlSmokeTest.php Normal file
View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
final class MysqlSmokeTest extends TestCase
{
private function mysqlConfig(): array
{
$host = getenv('MYSQL_HOST') ?: null;
if (!$host) {
$this->markTestSkipped('MYSQL_HOST not set; skipping MySQL smoke test');
}
return [
'driver' => 'mysql',
'host' => $host,
'port' => (int)(getenv('MYSQL_PORT') ?: 3306),
'database' => getenv('MYSQL_DB') ?: 'pairity',
'username' => getenv('MYSQL_USER') ?: 'root',
'password' => getenv('MYSQL_PASS') ?: 'root',
'charset' => 'utf8mb4',
];
}
public function testCreateAndDropTable(): void
{
$cfg = $this->mysqlConfig();
$conn = ConnectionManager::make($cfg);
$schema = SchemaManager::forConnection($conn);
$table = 'pairity_smoke_' . substr(sha1((string)microtime(true)), 0, 6);
$schema->create($table, function (Blueprint $t) {
$t->increments('id');
$t->string('name', 50);
});
$rows = $conn->query('SHOW TABLES LIKE :t', ['t' => $table]);
$this->assertNotEmpty($rows, 'Table should be created');
// Alter add column
$schema->table($table, function (Blueprint $t) {
$t->integer('qty');
});
$cols = $conn->query('SHOW COLUMNS FROM `' . $table . '`');
$names = array_map(fn($r) => $r['Field'] ?? $r['field'] ?? $r['COLUMN_NAME'] ?? '', $cols);
$this->assertContains('qty', $names);
// Drop
$schema->drop($table);
$rows = $conn->query('SHOW TABLES LIKE :t', ['t' => $table]);
$this->assertEmpty($rows, 'Table should be dropped');
}
}

View file

@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
use Pairity\Orm\OptimisticLockException;
final class OptimisticLockSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testVersionLockingIncrementsAndBlocksBulkUpdate(): void
{
$conn = $this->conn();
// schema with version column
$conn->execute('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
version INTEGER NOT NULL DEFAULT 0
)');
$UserDto = new class([]) extends AbstractDto {};
$dtoClass = get_class($UserDto);
$UserDao = new class($conn, $dtoClass) extends AbstractDao {
private string $dto; public function __construct($c,string $dto){ parent::__construct($c); $this->dto=$dto; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array {
return [
'primaryKey' => 'id',
'columns' => [ 'id'=>['cast'=>'int'], 'name'=>['cast'=>'string'], 'version'=>['cast'=>'int'] ],
'locking' => ['type' => 'version', 'column' => 'version'],
];
}
};
$dao = new $UserDao($conn, $dtoClass);
// Insert
$created = $dao->insert(['name' => 'A']);
$arr = $created->toArray(false);
$id = (int)$arr['id'];
// First update: should succeed and bump version to 1
$dao->update($id, ['name' => 'A1']);
$row = $conn->query('SELECT name, version FROM users WHERE id = :id', ['id' => $id])[0] ?? [];
$this->assertSame('A1', (string)($row['name'] ?? ''));
$this->assertSame(1, (int)($row['version'] ?? 0));
// Bulk update should throw while locking enabled
$this->expectException(OptimisticLockException::class);
$dao->updateBy(['id' => $id], ['name' => 'A2']);
}
}

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
final class PaginationSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testPaginateAndSimplePaginateWithScopesAndRelations(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, status TEXT)');
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)');
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$PostDto = new class([]) extends AbstractDto {};
$uClass = get_class($UserDto); $pClass = get_class($PostDto);
// DAOs
$PostDao = new class($conn, $pClass) extends AbstractDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
};
$UserDao = new class($conn, $uClass, get_class($PostDao)) extends AbstractDao {
private string $dto; private string $postDaoClass; public function __construct($c,string $dto,string $p){ parent::__construct($c); $this->dto=$dto; $this->postDaoClass=$p; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => $this->postDaoClass,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'status'=>['cast'=>'string']]]; }
};
$postDao = new $PostDao($conn, $pClass);
$userDao = new $UserDao($conn, $uClass, get_class($postDao));
// seed 35 users (20 active, 15 inactive)
for ($i=1; $i<=35; $i++) {
$status = $i <= 20 ? 'active' : 'inactive';
$u = $userDao->insert(['email' => "u{$i}@example.com", 'status' => $status]);
$uid = (int)($u->toArray(false)['id'] ?? 0);
if ($i % 5 === 0) {
$postDao->insert(['user_id' => $uid, 'title' => 'P'.$i]);
}
}
// paginate page 2 of size 10
$page = $userDao->paginate(2, 10, []);
$this->assertSame(35, $page['total']);
$this->assertSame(10, count($page['data']));
$this->assertSame(4, $page['lastPage']);
$this->assertSame(2, $page['currentPage']);
// simplePaginate last page should have nextPage null
$simple = $userDao->simplePaginate(4, 10, []);
$this->assertNull($simple['nextPage']);
$this->assertSame(10, $simple['perPage']);
// fields() projection + with() eager on paginated results
$with = $userDao->fields('id', 'email', 'posts.title')->with(['posts'])->paginate(1, 10);
$this->assertNotEmpty($with['data']);
$first = $with['data'][0]->toArray(false);
$this->assertArrayHasKey('id', $first);
$this->assertArrayHasKey('email', $first);
$this->assertArrayHasKey('posts', $first);
// scopes: named scope to filter active users only
$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; });
$activePage = $userDao->active()->paginate(1, 50);
$this->assertSame(20, $activePage['total']);
// ad-hoc scope combining additional condition (no-op example)
$combined = $userDao->scope(function (&$criteria) { if (!isset($criteria['status'])) { $criteria['status'] = 'inactive'; } })
->paginate(1, 100);
$this->assertSame(15, $combined['total']);
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
final class PostgresSmokeTest extends TestCase
{
private function pgConfig(): array
{
$host = getenv('POSTGRES_HOST') ?: null;
if (!$host) {
$this->markTestSkipped('POSTGRES_HOST not set; skipping Postgres smoke test');
}
return [
'driver' => 'pgsql',
'host' => $host,
'port' => (int)(getenv('POSTGRES_PORT') ?: 5432),
'database' => getenv('POSTGRES_DB') ?: 'pairity',
'username' => getenv('POSTGRES_USER') ?: 'postgres',
'password' => getenv('POSTGRES_PASS') ?: 'postgres',
];
}
public function testCreateAlterDropCycle(): void
{
$cfg = $this->pgConfig();
$conn = ConnectionManager::make($cfg);
$schema = SchemaManager::forConnection($conn);
$suffix = substr(sha1((string)microtime(true)), 0, 6);
$table = 'pg_smoke_' . $suffix;
// Create
$schema->create($table, function (Blueprint $t) {
$t->increments('id');
$t->string('name', 100);
});
$rows = $conn->query('SELECT tablename FROM pg_tables WHERE tablename = :t', ['t' => $table]);
$this->assertNotEmpty($rows, 'Table should be created');
// Alter add column
$schema->table($table, function (Blueprint $t) {
$t->integer('qty');
});
$cols = $conn->query('SELECT column_name FROM information_schema.columns WHERE table_name = :t', ['t' => $table]);
$names = array_map(fn($r) => $r['column_name'] ?? '', $cols);
$this->assertContains('qty', $names);
// Drop
$schema->drop($table);
$rows = $conn->query('SELECT tablename FROM pg_tables WHERE tablename = :t', ['t' => $table]);
$this->assertEmpty($rows, 'Table should be dropped');
}
}

View file

@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDao;
use Pairity\Model\AbstractDto;
final class RelationsNestedConstraintsSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testNestedEagerAndPerRelationFieldsConstraint(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)');
$conn->execute('CREATE TABLE comments (id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER, body TEXT)');
// DTOs
$UserDto = new class([]) extends AbstractDto {};
$PostDto = new class([]) extends AbstractDto {};
$CommentDto = new class([]) extends AbstractDto {};
$uClass = get_class($UserDto); $pClass = get_class($PostDto); $cClass = get_class($CommentDto);
// DAOs
$CommentDao = new class($conn, $cClass) extends AbstractDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'comments'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'post_id'=>['cast'=>'int'],'body'=>['cast'=>'string']]]; }
};
$PostDao = new class($conn, $pClass, get_class($CommentDao)) extends AbstractDao {
private string $dto; private string $commentDaoClass;
public function __construct($c, string $dto, string $cd) { parent::__construct($c); $this->dto = $dto; $this->commentDaoClass = $cd; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array {
return [
'comments' => [
'type' => 'hasMany',
'dao' => $this->commentDaoClass,
'foreignKey' => 'post_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
};
$UserDao = new class($conn, $uClass, get_class($PostDao)) extends AbstractDao {
private string $dto; private string $postDaoClass;
public function __construct($c, string $dto, string $pd) { parent::__construct($c); $this->dto = $dto; $this->postDaoClass = $pd; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => $this->postDaoClass,
'foreignKey' => 'user_id',
'localKey' => 'id',
],
];
}
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'name'=>['cast'=>'string']]]; }
};
$commentDao = new $CommentDao($conn, $cClass);
$postDao = new $PostDao($conn, $pClass, get_class($commentDao));
$userDao = new $UserDao($conn, $uClass, get_class($postDao));
// seed
$u = $userDao->insert(['name' => 'Alice']);
$uid = (int)($u->toArray(false)['id'] ?? 0);
$p1 = $postDao->insert(['user_id' => $uid, 'title' => 'P1']);
$p2 = $postDao->insert(['user_id' => $uid, 'title' => 'P2']);
$pid1 = (int)($p1->toArray(false)['id'] ?? 0); $pid2 = (int)($p2->toArray(false)['id'] ?? 0);
$commentDao->insert(['post_id' => $pid1, 'body' => 'c1']);
$commentDao->insert(['post_id' => $pid1, 'body' => 'c2']);
$commentDao->insert(['post_id' => $pid2, 'body' => 'c3']);
// nested eager with per-relation fields constraint (SQL supports fields projection)
$users = $userDao
->with([
'posts' => function (AbstractDao $dao) { $dao->fields('id', 'title'); },
'posts.comments' // nested
])
->findAllBy(['id' => $uid]);
$this->assertNotEmpty($users);
$user = $users[0];
$posts = $user->toArray(false)['posts'] ?? [];
$this->assertIsArray($posts);
$this->assertCount(2, $posts);
// ensure projection respected on posts (no user_id expected)
$this->assertArrayHasKey('title', $posts[0]->toArray(false));
$this->assertArrayNotHasKey('user_id', $posts[0]->toArray(false));
// nested comments should exist
$cm = $posts[0]->toArray(false)['comments'] ?? [];
$this->assertIsArray($cm);
$this->assertNotEmpty($cm);
}
}

View file

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Schema\SchemaManager;
use Pairity\Schema\Blueprint;
final class SchemaBuilderSqliteTest extends TestCase
{
public function testCreateAlterDropCycle(): void
{
$conn = ConnectionManager::make([
'driver' => 'sqlite',
'path' => ':memory:',
]);
$schema = SchemaManager::forConnection($conn);
// Create table
$schema->create('widgets', function (Blueprint $t) {
$t->increments('id');
$t->string('name', 100)->nullable();
$t->integer('qty');
$t->unique(['name'], 'widgets_name_uk');
$t->index(['qty'], 'widgets_qty_idx');
});
// Verify table exists
$tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets'");
$this->assertNotEmpty($tables, 'widgets table should exist');
// Alter: add column
$schema->table('widgets', function (Blueprint $t) {
$t->string('desc', 255)->nullable();
});
$cols = $conn->query("PRAGMA table_info('widgets')");
$colNames = array_map(fn($r) => $r['name'], $cols);
$this->assertContains('desc', $colNames);
// Alter: rename column qty -> quantity
$schema->table('widgets', function (Blueprint $t) {
$t->renameColumn('qty', 'quantity');
});
$cols = $conn->query("PRAGMA table_info('widgets')");
$colNames = array_map(fn($r) => $r['name'], $cols);
$this->assertContains('quantity', $colNames);
$this->assertNotContains('qty', $colNames);
// Alter: drop column desc
$schema->table('widgets', function (Blueprint $t) {
$t->dropColumn('desc');
});
$cols = $conn->query("PRAGMA table_info('widgets')");
$colNames = array_map(fn($r) => $r['name'], $cols);
$this->assertNotContains('desc', $colNames);
// Rename table
$schema->table('widgets', function (Blueprint $t) {
$t->rename('widgets_new');
});
$tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets_new'");
$this->assertNotEmpty($tables, 'widgets_new table should exist after rename');
// Drop
$schema->drop('widgets_new');
$tables = $conn->query("SELECT name FROM sqlite_master WHERE type='table' AND name='widgets_new'");
$this->assertEmpty($tables, 'widgets_new table should be dropped');
}
}

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\Database\ConnectionManager;
use Pairity\Model\AbstractDto;
use Pairity\Model\AbstractDao;
final class SoftDeletesTimestampsSqliteTest extends TestCase
{
private function makeConnection()
{
return ConnectionManager::make([
'driver' => 'sqlite',
'path' => ':memory:',
]);
}
public function testTimestampsAndSoftDeletesFlow(): void
{
$conn = $this->makeConnection();
// Create table
$conn->execute('CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT NULL,
status TEXT NULL,
created_at TEXT NULL,
updated_at TEXT NULL,
deleted_at TEXT NULL
)');
// Define DTO/DAO
$dto = new class([]) extends AbstractDto {};
$dao = new class($conn) extends AbstractDao {
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return get_class(new class([]) extends AbstractDto {}); }
protected function schema(): array
{
return [
'primaryKey' => 'id',
'columns' => [
'id' => ['cast' => 'int'],
'email' => ['cast' => 'string'],
'name' => ['cast' => 'string'],
'status' => ['cast' => 'string'],
'created_at' => ['cast' => 'datetime'],
'updated_at' => ['cast' => 'datetime'],
'deleted_at' => ['cast' => 'datetime'],
],
'timestamps' => ['createdAt' => 'created_at', 'updatedAt' => 'updated_at'],
'softDeletes' => ['enabled' => true, 'deletedAt' => 'deleted_at'],
];
}
};
// Insert (created_at & updated_at auto)
$created = $dao->insert(['email' => 't@example.com', 'name' => 'T', 'status' => 'active']);
$arr = $created->toArray();
$this->assertArrayHasKey('id', $arr);
$this->assertNotEmpty($arr['id']);
$this->assertNotNull($arr['created_at'] ?? null);
$this->assertNotNull($arr['updated_at'] ?? null);
// Update via update() should change updated_at
$id = $arr['id'];
$prevUpdated = $arr['updated_at'];
// sleep(1) not reliable; just ensure it is a value and after call it exists
$dao->update($id, ['name' => 'T2']);
$after = $dao->findById($id)?->toArray();
$this->assertNotNull($after);
$this->assertNotNull($after['updated_at'] ?? null);
// Update via updateBy() also sets updated_at
$dao->updateBy(['id' => $id], ['status' => 'inactive']);
$after2 = $dao->findById($id)?->toArray();
$this->assertEquals('inactive', $after2['status']);
$this->assertNotNull($after2['updated_at'] ?? null);
// Default scope excludes soft-deleted
$dao->deleteById($id);
$list = $dao->findAllBy();
$this->assertCount(0, $list, 'Soft-deleted should be excluded by default');
// withTrashed includes, onlyTrashed returns only deleted
$with = $dao->withTrashed()->findAllBy();
$this->assertCount(1, $with);
$only = $dao->onlyTrashed()->findAllBy();
$this->assertCount(1, $only);
$this->assertNotNull($only[0]->toArray()['deleted_at'] ?? null);
// Restore
$dao->restoreById($id);
$afterRestore = $dao->findById($id);
$this->assertNotNull($afterRestore);
$this->assertNull($afterRestore->toArray()['deleted_at'] ?? null);
// Force delete
$dao->forceDeleteById($id);
$this->assertNull($dao->findById($id));
}
}

View file

@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Orm\UnitOfWork;
use Pairity\Model\AbstractDto;
final class UnitOfWorkCascadeMongoTest extends TestCase
{
private function hasMongoExt(): bool
{
return \extension_loaded('mongodb');
}
public function testDeleteByIdCascadesToChildren(): void
{
if (!$this->hasMongoExt()) {
$this->markTestSkipped('ext-mongodb not loaded');
}
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
// Inline DTOs
$userDto = new class([]) extends AbstractDto {};
$userDtoClass = \get_class($userDto);
$postDto = new class([]) extends AbstractDto {};
$postDtoClass = \get_class($postDto);
// Inline DAOs with relation and cascadeDelete=true
$UserDao = new class($conn, $userDtoClass, $postDtoClass) extends AbstractMongoDao {
private string $userDto; private string $postDto; public function __construct($c, string $u, string $p) { parent::__construct($c); $this->userDto = $u; $this->postDto = $p; }
protected function collection(): string { return 'pairity_test.uow_users_cascade'; }
protected function dtoClass(): string { return $this->userDto; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => get_class(new class($this->getConnection(), $this->postDto) extends AbstractMongoDao {
private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; }
protected function collection(): string { return 'pairity_test.uow_posts_cascade'; }
protected function dtoClass(): string { return $this->dto; }
}),
'foreignKey' => 'user_id',
'localKey' => '_id',
'cascadeDelete' => true,
],
];
}
};
$PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao {
private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; }
protected function collection(): string { return 'pairity_test.uow_posts_cascade'; }
protected function dtoClass(): string { return $this->dto; }
};
$userDao = new $UserDao($conn, $userDtoClass, $postDtoClass);
$postDao = new $PostDao($conn, $postDtoClass);
// Clean
foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } }
foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } }
// Seed
$u = $userDao->insert(['email' => 'c@example.com']);
$uid = (string)($u->toArray(false)['_id'] ?? '');
$postDao->insert(['user_id' => $uid, 'title' => 'A']);
$postDao->insert(['user_id' => $uid, 'title' => 'B']);
// UoW: delete parent -> children should be deleted first
UnitOfWork::run(function() use ($userDao, $uid) {
$userDao->deleteById($uid);
});
// Verify
$children = $postDao->findAllBy(['user_id' => $uid]);
$this->assertCount(0, $children, 'Child posts should be deleted via cascade');
$this->assertNull($userDao->findById($uid));
}
}

View file

@ -0,0 +1,83 @@
<?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;
final class UnitOfWorkCascadeSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testDeleteByIdCascadesToChildren(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT)');
$conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)');
// DTOs
$userDto = new class([]) extends AbstractDto {};
$postDto = new class([]) extends AbstractDto {};
$userDtoClass = get_class($userDto);
$postDtoClass = get_class($postDto);
// DAOs with hasMany relation and cascadeDelete=true
$UserDao = new class($conn, $userDtoClass, $postDtoClass) extends AbstractDao {
private string $userDto; private string $postDto;
public function __construct($c, string $u, string $p) { parent::__construct($c); $this->userDto = $u; $this->postDto = $p; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->userDto; }
protected function relations(): array {
return [
'posts' => [
'type' => 'hasMany',
'dao' => get_class(new class($this->getConnection(), $this->postDto) extends AbstractDao {
private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
}),
'foreignKey' => 'user_id',
'localKey' => 'id',
'cascadeDelete' => true,
],
];
}
protected function schema(): array { return ['primaryKey' => 'id', 'columns' => ['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; }
};
$PostDao = new class($conn, $postDtoClass) extends AbstractDao {
private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; }
public function getTable(): string { return 'posts'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; }
};
$userDao = new $UserDao($conn, $userDtoClass, $postDtoClass);
$postDao = new $PostDao($conn, $postDtoClass);
// seed
$u = $userDao->insert(['email' => 'c@example.com']);
$uid = (int)($u->toArray(false)['id'] ?? 0);
$postDao->insert(['user_id' => $uid, 'title' => 'A']);
$postDao->insert(['user_id' => $uid, 'title' => 'B']);
// UoW: delete user; expect posts to be deleted first via cascade
UnitOfWork::run(function() use ($userDao, $uid) {
$userDao->deleteById($uid);
});
// verify posts gone and user gone
$remainingPosts = $postDao->findAllBy(['user_id' => $uid]);
$this->assertCount(0, $remainingPosts, 'Child posts should be deleted via cascade');
$this->assertNull($userDao->findById($uid));
}
}

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Orm\UnitOfWork;
use Pairity\Model\AbstractDto;
final class UnitOfWorkMongoTest extends TestCase
{
private function hasMongoExt(): bool
{
return \extension_loaded('mongodb');
}
public function testDeferredUpdateAndDeleteCommit(): void
{
if (!$this->hasMongoExt()) {
$this->markTestSkipped('ext-mongodb not loaded');
}
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
// Inline DTO and DAO
$dto = new class([]) extends AbstractDto {};
$dtoClass = \get_class($dto);
$dao = new class($conn, $dtoClass) extends AbstractMongoDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.uow_docs'; }
protected function dtoClass(): string { return $this->dto; }
};
// Clean collection
foreach ($dao->findAllBy([]) as $doc) {
$id = (string)($doc->toArray(false)['_id'] ?? '');
if ($id !== '') { $dao->deleteById($id); }
}
// Insert a document (immediate)
$created = $dao->insert(['name' => 'Widget', 'qty' => 1]);
$id = (string)($created->toArray(false)['_id'] ?? '');
$this->assertNotEmpty($id);
// Run UoW with deferred update then delete
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
$one = $dao->findById($id);
$this->assertNotNull($one);
// defer update
$dao->update($id, ['qty' => 2]);
// defer delete
$dao->deleteById($id);
// commit at end of run()
});
// After commit, it should be deleted
$this->assertNull($dao->findById($id));
}
public function testRollbackOnExceptionClearsOps(): void
{
if (!$this->hasMongoExt()) {
$this->markTestSkipped('ext-mongodb not loaded');
}
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
$dto = new class([]) extends AbstractDto {};
$dtoClass = \get_class($dto);
$dao = new class($conn, $dtoClass) extends AbstractMongoDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.uow_docs'; }
protected function dtoClass(): string { return $this->dto; }
};
// Clean
foreach ($dao->findAllBy([]) as $doc) {
$id = (string)($doc->toArray(false)['_id'] ?? '');
if ($id !== '') { $dao->deleteById($id); }
}
// Insert and capture id
$created = $dao->insert(['name' => 'Widget', 'qty' => 1]);
$id = (string)($created->toArray(false)['_id'] ?? '');
// Attempt a UoW that throws
try {
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
$dao->update($id, ['qty' => 99]);
throw new \RuntimeException('boom');
});
$this->fail('Exception expected');
} catch (\RuntimeException $e) {
// expected
}
// Update should not have been applied due to rollback
$after = $dao->findById($id);
$this->assertSame(1, $after?->toArray(false)['qty'] ?? null);
}
}

View file

@ -0,0 +1,88 @@
<?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;
final class UnitOfWorkSqliteTest extends TestCase
{
private function conn()
{
return ConnectionManager::make(['driver' => 'sqlite', 'path' => ':memory:']);
}
public function testDeferredUpdateAndDeleteCommit(): void
{
$conn = $this->conn();
// schema
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, name TEXT)');
// DAO/DTO inline
$dto = new class([]) extends AbstractDto {};
$dtoClass = get_class($dto);
$dao = new class($conn, $dtoClass) extends AbstractDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey' => 'id', 'columns' => ['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string']]]; }
};
// Insert immediate
$created = $dao->insert(['email' => 'u@example.com', 'name' => 'User']);
$id = (int)($created->toArray(false)['id'] ?? 0);
$this->assertGreaterThan(0, $id);
// Run UoW with deferred update and delete
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
$one = $dao->findById($id); // attaches to identity map
$this->assertNotNull($one);
// defer update
$dao->update($id, ['name' => 'Changed']);
// defer deleteBy criteria (will be executed after update)
$dao->deleteBy(['id' => $id]);
// commit done by run()
});
// After commit, record should be deleted
$this->assertNull($dao->findById($id));
}
public function testRollbackOnExceptionClearsOps(): void
{
$conn = $this->conn();
$conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT, name TEXT)');
$dto = new class([]) extends AbstractDto {};
$dtoClass = get_class($dto);
$dao = new class($conn, $dtoClass) extends AbstractDao {
private string $dto; public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
public function getTable(): string { return 'users'; }
protected function dtoClass(): string { return $this->dto; }
protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'email'=>['cast'=>'string'],'name'=>['cast'=>'string']]]; }
};
$created = $dao->insert(['email' => 'x@example.com', 'name' => 'X']);
$id = (int)($created->toArray(false)['id'] ?? 0);
try {
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
$dao->update($id, ['name' => 'Won\'t Persist']);
throw new \RuntimeException('boom');
});
$this->fail('Exception expected');
} catch (\RuntimeException $e) {
// ok
}
// Update should not be applied due to rollback
$after = $dao->findById($id);
$this->assertSame('X', $after?->toArray(false)['name'] ?? null);
}
}