diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8180aa..93888cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,19 @@ jobs: --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 @@ -42,7 +55,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: pdo, pdo_mysql, pdo_sqlite, mongodb + extensions: pdo, pdo_mysql, pdo_sqlite, pdo_pgsql, mongodb coverage: none - name: Install dependencies @@ -61,6 +74,15 @@ jobs: 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 @@ -70,5 +92,18 @@ jobs: 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6db3658 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index ecf2a6f..c29312a 100644 --- a/README.md +++ b/README.md @@ -971,6 +971,28 @@ final class AuditSubscriber implements SubscriberInterface 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 - Relations enhancements: diff --git a/examples/events_audit.php b/examples/events_audit.php new file mode 100644 index 0000000..ee0cb7e --- /dev/null +++ b/examples/events_audit.php @@ -0,0 +1,67 @@ + '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"; diff --git a/examples/uow_locking_snapshot.php b/examples/uow_locking_snapshot.php new file mode 100644 index 0000000..347eaac --- /dev/null +++ b/examples/uow_locking_snapshot.php @@ -0,0 +1,73 @@ + '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"; diff --git a/src/Database/PdoConnection.php b/src/Database/PdoConnection.php index 440b812..4b3b4a0 100644 --- a/src/Database/PdoConnection.php +++ b/src/Database/PdoConnection.php @@ -9,6 +9,11 @@ use Pairity\Contracts\ConnectionInterface; class PdoConnection implements ConnectionInterface { private PDO $pdo; + /** @var array */ + 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) { @@ -17,18 +22,69 @@ class PdoConnection implements ConnectionInterface $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 { - $stmt = $this->pdo->prepare($sql); + $t0 = microtime(true); + $stmt = $this->prepare($sql); $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 { - $stmt = $this->pdo->prepare($sql); + $t0 = microtime(true); + $stmt = $this->prepare($sql); $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 diff --git a/src/Model/AbstractDao.php b/src/Model/AbstractDao.php index 4136ba2..0368a54 100644 --- a/src/Model/AbstractDao.php +++ b/src/Model/AbstractDao.php @@ -51,6 +51,11 @@ abstract class AbstractDao implements DaoInterface * 'join' opts in to join-based eager loading for supported SQL relations (single level). */ 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) { @@ -94,6 +99,13 @@ abstract class AbstractDao implements DaoInterface 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 { $schema = $this->getSchema(); @@ -482,7 +494,7 @@ abstract class AbstractDao implements DaoInterface /** Expose relation metadata for UoW ordering/cascades. */ public function relationMap(): array { - return $this->relations(); + return $this->getRelations(); } /** @@ -530,21 +542,26 @@ abstract class AbstractDao implements DaoInterface return []; } $values = array_values(array_unique($values, SORT_REGULAR)); - $placeholders = []; - $bindings = []; - foreach ($values as $i => $val) { - $ph = "in_{$i}"; - $placeholders[] = ":{$ph}"; - $bindings[$ph] = $val; + $chunks = array_chunk($values, max(1, (int)$this->inBatchSize)); + $selectList = $selectFields && $selectFields !== ['*'] ? implode(', ', $selectFields) : $this->selectList(); + $dtos = []; + foreach ($chunks as $chunkIdx => $chunk) { + $placeholders = []; + $bindings = []; + foreach ($chunk as $i => $val) { + $ph = "in_{$chunkIdx}_{$i}"; + $placeholders[] = ":{$ph}"; + $bindings[$ph] = $val; + } + $where = $column . ' IN (' . implode(', ', $placeholders) . ')'; + $where = $this->appendScopedWhere($where); + $sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where; + $rows = $this->connection->query($sql, $bindings); + foreach ($rows as $r) { + $dtos[] = $this->hydrate($this->castRowFromStorage($r)); + } } - $selectList = $selectFields && $selectFields !== ['*'] - ? implode(', ', $selectFields) - : $this->selectList(); - $where = $column . ' IN (' . implode(', ', $placeholders) . ')'; - $where = $this->appendScopedWhere($where); - $sql = 'SELECT ' . $selectList . ' FROM ' . $this->getTable() . ' WHERE ' . $where; - $rows = $this->connection->query($sql, $bindings); - return array_map(fn($r) => $this->hydrate($this->castRowFromStorage($r)), $rows); + return $dtos; } /** @@ -660,7 +677,7 @@ abstract class AbstractDao implements DaoInterface protected function attachRelations(array $parents): void { if (!$parents) return; - $relations = $this->relations(); + $relations = $this->getRelations(); foreach ($this->with as $name) { if (!isset($relations[$name])) { continue; // silently ignore unknown @@ -904,7 +921,7 @@ abstract class AbstractDao implements DaoInterface $joins = []; $meta = [ 'rels' => [] ]; - $relations = $this->relations(); + $relations = $this->getRelations(); $aliasIndex = 1; foreach ($this->with as $name) { 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 { if (!$relatedIds) return 0; - $cfg = $this->relations()[$relationName] ?? null; + $cfg = $this->getRelations()[$relationName] ?? null; if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { 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 { - $cfg = $this->relations()[$relationName] ?? null; + $cfg = $this->getRelations()[$relationName] ?? null; if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { 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 { - $cfg = $this->relations()[$relationName] ?? null; + $cfg = $this->getRelations()[$relationName] ?? null; if (!is_array($cfg) || ($cfg['type'] ?? '') !== 'belongsToMany') { throw new \InvalidArgumentException("Relation '{$relationName}' is not a belongsToMany relation"); } @@ -1240,7 +1257,21 @@ abstract class AbstractDao implements DaoInterface 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 diff --git a/src/NoSql/Mongo/AbstractMongoDao.php b/src/NoSql/Mongo/AbstractMongoDao.php index be7e825..1eadac0 100644 --- a/src/NoSql/Mongo/AbstractMongoDao.php +++ b/src/NoSql/Mongo/AbstractMongoDao.php @@ -44,6 +44,11 @@ abstract class AbstractMongoDao /** @var array */ 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; @@ -67,6 +72,23 @@ abstract class AbstractMongoDao 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 ========= /** @@ -295,11 +317,17 @@ abstract class AbstractMongoDao if (!$values) return []; // Normalize values (unique) $values = array_values(array_unique($values)); - $filter = [ $field => ['$in' => $values] ]; - $this->applyRuntimeScopesToFilter($filter); - $opts = $this->buildOptions(); - $docs = $this->connection->find($this->databaseName(), $this->collection(), $filter, $opts); - return array_map(fn($d) => $this->hydrate($d), is_iterable($docs) ? $docs : []); + $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 ========= @@ -523,7 +551,7 @@ abstract class AbstractMongoDao protected function attachRelations(array $parents): void { if (!$parents) return; - $relations = $this->relations(); + $relations = $this->getRelations(); foreach ($this->with as $name) { if (!isset($relations[$name])) continue; $cfg = $relations[$name]; diff --git a/tests/PostgresSmokeTest.php b/tests/PostgresSmokeTest.php new file mode 100644 index 0000000..1f762cb --- /dev/null +++ b/tests/PostgresSmokeTest.php @@ -0,0 +1,61 @@ +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'); + } +}