Compare commits
12 commits
80a640c241
...
117592cfe2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117592cfe2 | ||
|
|
7d4aea0a84 | ||
|
|
693e83625d | ||
|
|
cb1251ae14 | ||
|
|
95ba97808f | ||
|
|
f21df4f567 | ||
|
|
d8cae37a4d | ||
|
|
a5182ae282 | ||
|
|
5e007a72dd | ||
|
|
2c3b219d9c | ||
|
|
55d256506a | ||
|
|
ae80d9bde1 |
109
.github/workflows/ci.yml
vendored
Normal file
109
.github/workflows/ci.yml
vendored
Normal 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
39
.gitignore
vendored
|
|
@ -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
1
.phpunit.result.cache
Normal 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
15
CHANGELOG.md
Normal 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, per‑relation 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.
|
||||
- Join‑based eager loading (opt‑in, SQL, single‑level) with safe fallbacks.
|
||||
- Unit of Work (opt‑in): identity map; deferred updates/deletes; relation‑aware 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.1–8.3) with MySQL + Mongo services; guarded tests.
|
||||
301
bin/pairity
Normal file
301
bin/pairity
Normal 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
64
composer.json
Normal 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
1818
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
67
examples/events_audit.php
Normal file
67
examples/events_audit.php
Normal 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";
|
||||
29
examples/migrations/AlterUsersAddBio.php
Normal file
29
examples/migrations/AlterUsersAddBio.php
Normal 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
30
examples/migrations/CreateUsersTable.php
Normal file
30
examples/migrations/CreateUsersTable.php
Normal 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
72
examples/mysql_crud.php
Normal 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;
|
||||
106
examples/mysql_join_eager_demo.php
Normal file
106
examples/mysql_join_eager_demo.php
Normal 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";
|
||||
}
|
||||
94
examples/mysql_relations_pivot.php
Normal file
94
examples/mysql_relations_pivot.php
Normal 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";
|
||||
50
examples/nosql/mongo_crud.php
Normal file
50
examples/nosql/mongo_crud.php
Normal 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";
|
||||
62
examples/nosql/mongo_dao_crud.php
Normal file
62
examples/nosql/mongo_dao_crud.php
Normal 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";
|
||||
}
|
||||
74
examples/nosql/mongo_pagination.php
Normal file
74
examples/nosql/mongo_pagination.php
Normal 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";
|
||||
75
examples/nosql/mongo_relations_demo.php
Normal file
75
examples/nosql/mongo_relations_demo.php
Normal 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";
|
||||
}
|
||||
32
examples/run_migrations_sqlite.php
Normal file
32
examples/run_migrations_sqlite.php
Normal 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
95
examples/sqlite_crud.php
Normal 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";
|
||||
}
|
||||
83
examples/sqlite_pagination.php
Normal file
83
examples/sqlite_pagination.php
Normal 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";
|
||||
73
examples/uow_locking_snapshot.php
Normal file
73
examples/uow_locking_snapshot.php
Normal 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
17
phpunit.xml.dist
Normal 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>
|
||||
45
src/Contracts/ConnectionInterface.php
Normal file
45
src/Contracts/ConnectionInterface.php
Normal 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;
|
||||
}
|
||||
8
src/Contracts/DaoInterface.php
Normal file
8
src/Contracts/DaoInterface.php
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace Pairity\Contracts;
|
||||
|
||||
interface DaoInterface
|
||||
{
|
||||
public function getTable(): string;
|
||||
}
|
||||
15
src/Contracts/DtoInterface.php
Normal file
15
src/Contracts/DtoInterface.php
Normal 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;
|
||||
}
|
||||
19
src/Contracts/QueryBuilderInterface.php
Normal file
19
src/Contracts/QueryBuilderInterface.php
Normal 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;
|
||||
}
|
||||
85
src/Database/ConnectionManager.php
Normal file
85
src/Database/ConnectionManager.php
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
116
src/Database/PdoConnection.php
Normal file
116
src/Database/PdoConnection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/Events/EventDispatcher.php
Normal file
63
src/Events/EventDispatcher.php
Normal 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
21
src/Events/Events.php
Normal 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;
|
||||
}
|
||||
}
|
||||
15
src/Events/SubscriberInterface.php
Normal file
15
src/Events/SubscriberInterface.php
Normal 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;
|
||||
}
|
||||
11
src/Migrations/MigrationInterface.php
Normal file
11
src/Migrations/MigrationInterface.php
Normal 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;
|
||||
}
|
||||
38
src/Migrations/MigrationLoader.php
Normal file
38
src/Migrations/MigrationLoader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/Migrations/MigrationsRepository.php
Normal file
73
src/Migrations/MigrationsRepository.php
Normal 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
103
src/Migrations/Migrator.php
Normal 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
1614
src/Model/AbstractDao.php
Normal file
File diff suppressed because it is too large
Load diff
115
src/Model/AbstractDto.php
Normal file
115
src/Model/AbstractDto.php
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/Model/Casting/CasterInterface.php
Normal file
12
src/Model/Casting/CasterInterface.php
Normal 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;
|
||||
}
|
||||
751
src/NoSql/Mongo/AbstractMongoDao.php
Normal file
751
src/NoSql/Mongo/AbstractMongoDao.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/NoSql/Mongo/Filter.php
Normal file
90
src/NoSql/Mongo/Filter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
68
src/NoSql/Mongo/IndexManager.php
Normal file
68
src/NoSql/Mongo/IndexManager.php
Normal 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');
|
||||
}
|
||||
}
|
||||
215
src/NoSql/Mongo/MongoClientConnection.php
Normal file
215
src/NoSql/Mongo/MongoClientConnection.php
Normal 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;
|
||||
}
|
||||
}
|
||||
105
src/NoSql/Mongo/MongoConnection.php
Normal file
105
src/NoSql/Mongo/MongoConnection.php
Normal 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));
|
||||
}
|
||||
}
|
||||
30
src/NoSql/Mongo/MongoConnectionInterface.php
Normal file
30
src/NoSql/Mongo/MongoConnectionInterface.php
Normal 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;
|
||||
}
|
||||
58
src/NoSql/Mongo/MongoConnectionManager.php
Normal file
58
src/NoSql/Mongo/MongoConnectionManager.php
Normal 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);
|
||||
}
|
||||
}
|
||||
7
src/Orm/OptimisticLockException.php
Normal file
7
src/Orm/OptimisticLockException.php
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Pairity\Orm;
|
||||
|
||||
class OptimisticLockException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
449
src/Orm/UnitOfWork.php
Normal file
449
src/Orm/UnitOfWork.php
Normal 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
120
src/Query/QueryBuilder.php
Normal 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
170
src/Schema/Blueprint.php
Normal 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
86
src/Schema/Builder.php
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/Schema/ColumnDefinition.php
Normal file
29
src/Schema/ColumnDefinition.php
Normal 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; }
|
||||
}
|
||||
32
src/Schema/Grammars/Grammar.php
Normal file
32
src/Schema/Grammars/Grammar.php
Normal 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) . '`';
|
||||
}
|
||||
}
|
||||
144
src/Schema/Grammars/MySqlGrammar.php
Normal file
144
src/Schema/Grammars/MySqlGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
153
src/Schema/Grammars/OracleGrammar.php
Normal file
153
src/Schema/Grammars/OracleGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
135
src/Schema/Grammars/PostgresGrammar.php
Normal file
135
src/Schema/Grammars/PostgresGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
145
src/Schema/Grammars/SqlServerGrammar.php
Normal file
145
src/Schema/Grammars/SqlServerGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
148
src/Schema/Grammars/SqliteGrammar.php
Normal file
148
src/Schema/Grammars/SqliteGrammar.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
43
src/Schema/SchemaManager.php
Normal file
43
src/Schema/SchemaManager.php
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
147
src/Schema/SqliteTableRebuilder.php
Normal file
147
src/Schema/SqliteTableRebuilder.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
114
tests/BelongsToManyMysqlTest.php
Normal file
114
tests/BelongsToManyMysqlTest.php
Normal 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 . '`');
|
||||
}
|
||||
}
|
||||
96
tests/BelongsToManySqliteTest.php
Normal file
96
tests/BelongsToManySqliteTest.php
Normal 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'] ?? []);
|
||||
}
|
||||
}
|
||||
93
tests/CastersAndAccessorsSqliteTest.php
Normal file
93
tests/CastersAndAccessorsSqliteTest.php
Normal 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']));
|
||||
}
|
||||
}
|
||||
117
tests/EventSystemSqliteTest.php
Normal file
117
tests/EventSystemSqliteTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
125
tests/JoinEagerMysqlTest.php
Normal file
125
tests/JoinEagerMysqlTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
127
tests/JoinEagerSqliteTest.php
Normal file
127
tests/JoinEagerSqliteTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
77
tests/MongoAdapterTest.php
Normal file
77
tests/MongoAdapterTest.php
Normal 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
82
tests/MongoDaoTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
72
tests/MongoEventSystemTest.php
Normal file
72
tests/MongoEventSystemTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
tests/MongoOptimisticLockTest.php
Normal file
56
tests/MongoOptimisticLockTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
88
tests/MongoPaginationTest.php
Normal file
88
tests/MongoPaginationTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
90
tests/MongoRelationsTest.php
Normal file
90
tests/MongoRelationsTest.php
Normal 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
61
tests/MysqlSmokeTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
63
tests/OptimisticLockSqliteTest.php
Normal file
63
tests/OptimisticLockSqliteTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
99
tests/PaginationSqliteTest.php
Normal file
99
tests/PaginationSqliteTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
61
tests/PostgresSmokeTest.php
Normal file
61
tests/PostgresSmokeTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
112
tests/RelationsNestedConstraintsSqliteTest.php
Normal file
112
tests/RelationsNestedConstraintsSqliteTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
74
tests/SchemaBuilderSqliteTest.php
Normal file
74
tests/SchemaBuilderSqliteTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
105
tests/SoftDeletesTimestampsSqliteTest.php
Normal file
105
tests/SoftDeletesTimestampsSqliteTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
93
tests/UnitOfWorkCascadeMongoTest.php
Normal file
93
tests/UnitOfWorkCascadeMongoTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
83
tests/UnitOfWorkCascadeSqliteTest.php
Normal file
83
tests/UnitOfWorkCascadeSqliteTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
123
tests/UnitOfWorkMongoTest.php
Normal file
123
tests/UnitOfWorkMongoTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
88
tests/UnitOfWorkSqliteTest.php
Normal file
88
tests/UnitOfWorkSqliteTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue