Docs/examples/CI polish
This commit is contained in:
parent
693e83625d
commit
7d4aea0a84
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
|
|
@ -34,6 +34,19 @@ jobs:
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 30
|
--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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
@ -42,7 +55,7 @@ jobs:
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: ${{ matrix.php }}
|
php-version: ${{ matrix.php }}
|
||||||
extensions: pdo, pdo_mysql, pdo_sqlite, mongodb
|
extensions: pdo, pdo_mysql, pdo_sqlite, pdo_pgsql, mongodb
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|
@ -61,6 +74,15 @@ jobs:
|
||||||
done
|
done
|
||||||
mysql -h 127.0.0.1 -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS pairity;'
|
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
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
MYSQL_HOST: 127.0.0.1
|
MYSQL_HOST: 127.0.0.1
|
||||||
|
|
@ -70,5 +92,18 @@ jobs:
|
||||||
MYSQL_PASS: root
|
MYSQL_PASS: root
|
||||||
MONGO_HOST: 127.0.0.1
|
MONGO_HOST: 127.0.0.1
|
||||||
MONGO_PORT: 27017
|
MONGO_PORT: 27017
|
||||||
|
POSTGRES_HOST: 127.0.0.1
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_DB: pairity
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASS: postgres
|
||||||
run: |
|
run: |
|
||||||
vendor/bin/phpunit --colors=always
|
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
|
||||||
|
|
|
||||||
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.
|
||||||
22
README.md
22
README.md
|
|
@ -971,6 +971,28 @@ final class AuditSubscriber implements SubscriberInterface
|
||||||
Events::dispatcher()->subscribe(new AuditSubscriber());
|
Events::dispatcher()->subscribe(new AuditSubscriber());
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Performance knobs (Milestone G)
|
||||||
|
|
||||||
|
Pairity includes a few opt‑in performance features. Defaults remain conservative and portable.
|
||||||
|
|
||||||
|
- PDO prepared‑statement cache (bounded LRU):
|
||||||
|
- Internals: `Pairity\Database\PdoConnection` caches prepared statements by SQL string.
|
||||||
|
- API: `$conn->setStatementCacheSize(100);` (0 disables). Default: 100.
|
||||||
|
|
||||||
|
- Query timing hook:
|
||||||
|
- API: `$conn->setQueryLogger(function(string $sql, array $params, float $ms) { /* log */ });`
|
||||||
|
- Called for both `query()` and `execute()`; zero overhead when unset.
|
||||||
|
|
||||||
|
- Eager loader IN‑batching (SQL + Mongo):
|
||||||
|
- DAOs chunk large `IN (...)` / `$in` lookups to avoid huge parameter lists.
|
||||||
|
- API: `$dao->setInBatchSize(1000);` (default 1000) — affects internal relation fetches and `findAllWhereIn()`.
|
||||||
|
|
||||||
|
- Metadata memoization:
|
||||||
|
- DAOs memoize `schema()` and `relations()` per instance to reduce repeated array building.
|
||||||
|
- No user action required; available automatically.
|
||||||
|
|
||||||
|
Example UoW + locking + snapshots demo: see `examples/uow_locking_snapshot.php`.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- Relations enhancements:
|
- Relations enhancements:
|
||||||
|
|
|
||||||
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";
|
||||||
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";
|
||||||
|
|
@ -9,6 +9,11 @@ use Pairity\Contracts\ConnectionInterface;
|
||||||
class PdoConnection implements ConnectionInterface
|
class PdoConnection implements ConnectionInterface
|
||||||
{
|
{
|
||||||
private PDO $pdo;
|
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)
|
public function __construct(PDO $pdo)
|
||||||
{
|
{
|
||||||
|
|
@ -17,18 +22,69 @@ class PdoConnection implements ConnectionInterface
|
||||||
$this->pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
$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
|
public function query(string $sql, array $params = []): array
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$t0 = microtime(true);
|
||||||
|
$stmt = $this->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
return $stmt->fetchAll();
|
$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
|
public function execute(string $sql, array $params = []): int
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$t0 = microtime(true);
|
||||||
|
$stmt = $this->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
return $stmt->rowCount();
|
$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
|
public function transaction(callable $callback): mixed
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,11 @@ abstract class AbstractDao implements DaoInterface
|
||||||
* 'join' opts in to join-based eager loading for supported SQL relations (single level).
|
* 'join' opts in to join-based eager loading for supported SQL relations (single level).
|
||||||
*/
|
*/
|
||||||
private ?string $eagerStrategy = null;
|
private ?string $eagerStrategy = null;
|
||||||
|
/** Memoized schema/relations for perf */
|
||||||
|
private ?array $schemaCache = null;
|
||||||
|
private ?array $relationsCache = null;
|
||||||
|
/** Eager IN batching size for related queries */
|
||||||
|
protected int $inBatchSize = 1000;
|
||||||
|
|
||||||
public function __construct(ConnectionInterface $connection)
|
public function __construct(ConnectionInterface $connection)
|
||||||
{
|
{
|
||||||
|
|
@ -94,6 +99,13 @@ abstract class AbstractDao implements DaoInterface
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Override point: set eager IN batching size (default 1000). */
|
||||||
|
public function setInBatchSize(int $size): static
|
||||||
|
{
|
||||||
|
$this->inBatchSize = max(1, $size);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getPrimaryKey(): string
|
public function getPrimaryKey(): string
|
||||||
{
|
{
|
||||||
$schema = $this->getSchema();
|
$schema = $this->getSchema();
|
||||||
|
|
@ -482,7 +494,7 @@ abstract class AbstractDao implements DaoInterface
|
||||||
/** Expose relation metadata for UoW ordering/cascades. */
|
/** Expose relation metadata for UoW ordering/cascades. */
|
||||||
public function relationMap(): array
|
public function relationMap(): array
|
||||||
{
|
{
|
||||||
return $this->relations();
|
return $this->getRelations();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -530,21 +542,26 @@ abstract class AbstractDao implements DaoInterface
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
$values = array_values(array_unique($values, SORT_REGULAR));
|
$values = array_values(array_unique($values, SORT_REGULAR));
|
||||||
|
$chunks = array_chunk($values, max(1, (int)$this->inBatchSize));
|
||||||
|
$selectList = $selectFields && $selectFields !== ['*'] ? implode(', ', $selectFields) : $this->selectList();
|
||||||
|
$dtos = [];
|
||||||
|
foreach ($chunks as $chunkIdx => $chunk) {
|
||||||
$placeholders = [];
|
$placeholders = [];
|
||||||
$bindings = [];
|
$bindings = [];
|
||||||
foreach ($values as $i => $val) {
|
foreach ($chunk as $i => $val) {
|
||||||
$ph = "in_{$i}";
|
$ph = "in_{$chunkIdx}_{$i}";
|
||||||
$placeholders[] = ":{$ph}";
|
$placeholders[] = ":{$ph}";
|
||||||
$bindings[$ph] = $val;
|
$bindings[$ph] = $val;
|
||||||
}
|
}
|
||||||
$selectList = $selectFields && $selectFields !== ['*']
|
|
||||||
? implode(', ', $selectFields)
|
|
||||||
: $this->selectList();
|
|
||||||
$where = $column . ' IN (' . implode(', ', $placeholders) . ')';
|
$where = $column . ' IN (' . implode(', ', $placeholders) . ')';
|
||||||
$where = $this->appendScopedWhere($where);
|
$where = $this->appendScopedWhere($where);
|
||||||
$sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where;
|
$sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where;
|
||||||
$rows = $this->connection->query($sql, $bindings);
|
$rows = $this->connection->query($sql, $bindings);
|
||||||
return array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows);
|
foreach ($rows as $r) {
|
||||||
|
$dtos[] = $this->hydrate($this->castRowFromStorage($r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $dtos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -660,7 +677,7 @@ abstract class AbstractDao implements DaoInterface
|
||||||
protected function attachRelations(array $parents): void
|
protected function attachRelations(array $parents): void
|
||||||
{
|
{
|
||||||
if (!$parents) return;
|
if (!$parents) return;
|
||||||
$relations = $this->relations();
|
$relations = $this->getRelations();
|
||||||
foreach ($this->with as $name) {
|
foreach ($this->with as $name) {
|
||||||
if (!isset($relations[$name])) {
|
if (!isset($relations[$name])) {
|
||||||
continue; // silently ignore unknown
|
continue; // silently ignore unknown
|
||||||
|
|
@ -904,7 +921,7 @@ abstract class AbstractDao implements DaoInterface
|
||||||
$joins = [];
|
$joins = [];
|
||||||
$meta = [ 'rels' => [] ];
|
$meta = [ 'rels' => [] ];
|
||||||
|
|
||||||
$relations = $this->relations();
|
$relations = $this->getRelations();
|
||||||
$aliasIndex = 1;
|
$aliasIndex = 1;
|
||||||
foreach ($this->with as $name) {
|
foreach ($this->with as $name) {
|
||||||
if (!isset($relations[$name])) continue;
|
if (!isset($relations[$name])) continue;
|
||||||
|
|
@ -1041,7 +1058,7 @@ abstract class AbstractDao implements DaoInterface
|
||||||
public function attach(string $relationName, int|string $parentId, array $relatedIds): int
|
public function attach(string $relationName, int|string $parentId, array $relatedIds): int
|
||||||
{
|
{
|
||||||
if (!$relatedIds) return 0;
|
if (!$relatedIds) return 0;
|
||||||
$cfg = $this->relations()[$relationName] ?? null;
|
$cfg = $this->getRelations()[$relationName] ?? null;
|
||||||
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
|
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
|
||||||
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
|
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
|
||||||
}
|
}
|
||||||
|
|
@ -1071,7 +1088,7 @@ abstract class AbstractDao implements DaoInterface
|
||||||
*/
|
*/
|
||||||
public function detach(string $relationName, int|string $parentId, array $relatedIds = []): int
|
public function detach(string $relationName, int|string $parentId, array $relatedIds = []): int
|
||||||
{
|
{
|
||||||
$cfg = $this->relations()[$relationName] ?? null;
|
$cfg = $this->getRelations()[$relationName] ?? null;
|
||||||
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
|
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
|
||||||
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
|
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
|
||||||
}
|
}
|
||||||
|
|
@ -1100,7 +1117,7 @@ abstract class AbstractDao implements DaoInterface
|
||||||
*/
|
*/
|
||||||
public function sync(string $relationName, int|string $parentId, array $relatedIds): array
|
public function sync(string $relationName, int|string $parentId, array $relatedIds): array
|
||||||
{
|
{
|
||||||
$cfg = $this->relations()[$relationName] ?? null;
|
$cfg = $this->getRelations()[$relationName] ?? null;
|
||||||
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
|
if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') {
|
||||||
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
|
throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation");
|
||||||
}
|
}
|
||||||
|
|
@ -1240,7 +1257,21 @@ abstract class AbstractDao implements DaoInterface
|
||||||
|
|
||||||
protected function getSchema(): array
|
protected function getSchema(): array
|
||||||
{
|
{
|
||||||
return $this->schema();
|
if ($this->schemaCache !== null) {
|
||||||
|
return $this->schemaCache;
|
||||||
|
}
|
||||||
|
$this->schemaCache = $this->schema();
|
||||||
|
return $this->schemaCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return memoized relations metadata. */
|
||||||
|
protected function getRelations(): array
|
||||||
|
{
|
||||||
|
if ($this->relationsCache !== null) {
|
||||||
|
return $this->relationsCache;
|
||||||
|
}
|
||||||
|
$this->relationsCache = $this->relations();
|
||||||
|
return $this->relationsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function hasSoftDeletes(): bool
|
protected function hasSoftDeletes(): bool
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ abstract class AbstractMongoDao
|
||||||
/** @var array<string, callable> */
|
/** @var array<string, callable> */
|
||||||
private array $namedScopes = [];
|
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)
|
public function __construct(MongoConnectionInterface $connection)
|
||||||
{
|
{
|
||||||
$this->connection = $connection;
|
$this->connection = $connection;
|
||||||
|
|
@ -67,6 +72,23 @@ abstract class AbstractMongoDao
|
||||||
return [];
|
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 =========
|
// ========= Query modifiers =========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -295,11 +317,17 @@ abstract class AbstractMongoDao
|
||||||
if (!$values) return [];
|
if (!$values) return [];
|
||||||
// Normalize values (unique)
|
// Normalize values (unique)
|
||||||
$values = array_values(array_unique($values));
|
$values = array_values(array_unique($values));
|
||||||
$filter = [ $field => ['$in' => $values] ];
|
$chunks = array_chunk($values, max(1, (int)$this->inBatchSize));
|
||||||
|
$dtos = [];
|
||||||
|
foreach ($chunks as $chunk) {
|
||||||
|
$filter = [ $field => ['$in' => $chunk] ];
|
||||||
$this->applyRuntimeScopesToFilter($filter);
|
$this->applyRuntimeScopesToFilter($filter);
|
||||||
$opts = $this->buildOptions();
|
$opts = $this->buildOptions();
|
||||||
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts);
|
$docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts);
|
||||||
return array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []);
|
$iter = is_iterable($docs) ? $docs : [];
|
||||||
|
foreach ($iter as $d) { $dtos[] = $this->hydrate($d); }
|
||||||
|
}
|
||||||
|
return $dtos;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========= Dynamic helpers =========
|
// ========= Dynamic helpers =========
|
||||||
|
|
@ -523,7 +551,7 @@ abstract class AbstractMongoDao
|
||||||
protected function attachRelations(array $parents): void
|
protected function attachRelations(array $parents): void
|
||||||
{
|
{
|
||||||
if (!$parents) return;
|
if (!$parents) return;
|
||||||
$relations = $this->relations();
|
$relations = $this->getRelations();
|
||||||
foreach ($this->with as $name) {
|
foreach ($this->with as $name) {
|
||||||
if (!isset($relations[$name])) continue;
|
if (!isset($relations[$name])) continue;
|
||||||
$cfg = $relations[$name];
|
$cfg = $relations[$name];
|
||||||
|
|
|
||||||
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue