diff --git a/CHANGELOG.md b/CHANGELOG.md index 44bab79..1b00e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,39 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.0] + +### Added +- **Doctrine DBAL is now optional — the core runs on any connection backend.** + The engine talks to PostgreSQL through a new + `Th3Mouk\MaterializedView\Core\Database\Connection` port + (`executeStatement` / `fetchOne` / `fetchAllAssociative` / `fetchAssociative` / + `transactional` / `ensureConnectedToPrimary`), so `Core` no longer depends on + Doctrine DBAL. Two adapters ship with the package: + - `Th3Mouk\MaterializedView\Dbal\DbalConnection` — wraps a Doctrine DBAL + connection and keeps its primary/replica routing, middlewares and profiling. + - `Th3Mouk\MaterializedView\Pdo\PdoConnection` — wraps a bare `PDO` handle (the + `pdo_pgsql` driver) for projects that do not run Doctrine. + + New factories on `MaterializedViewManager`: `forDriver(Connection $connection, …)` + (framework-agnostic) and `forPdo(PDO $pdo, …)` (bare PDO). Backend failures are + normalised to `Th3Mouk\MaterializedView\Core\Database\DatabaseException`, which + carries the SQLSTATE so missing-dependency handling keeps working on either + backend. See [Connection backends](docs/guide/connection-backends.md). + +### Changed +- **`doctrine/dbal` moved from `require` to `suggest`.** The core now requires only + `php` and `psr/log`. `MaterializedViewManager::forConnection(Doctrine\DBAL\Connection)` + is unchanged and keeps working exactly as before — it now wraps the connection in + `DbalConnection` internally — so **existing Doctrine code (and the Symfony bundle) + needs no changes**. Install `doctrine/dbal` for the recommended, natively-supported + Doctrine backend, or rely on `ext-pdo_pgsql` with `forPdo()` and no extra Composer + dependency. `IdentifierQuoter` is now PostgreSQL-native (it no longer needs a DBAL + platform); its internal `forConnection()` / `forPlatform()` factories were removed. + The optional reactive-conflict helpers (`PostgresDependencyConflict::fromDriverException()`, + `DependencyConflictSqlState::isDependencyConflict()`) remain Doctrine-oriented; on a + bare PDO backend, classify with `PostgresDependencyConflict::fromRawError()`. + ## [1.2.0] - 2026-06-09 ### Added @@ -100,6 +133,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 doubles configured without expectations use `createStub()` / `getStubBuilder()` as required by PHPUnit 13. +[1.3.0]: https://github.com/Th3Mouk/materialized-view/compare/v1.2.0...HEAD [1.2.0]: https://github.com/Th3Mouk/materialized-view/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/Th3Mouk/materialized-view/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/Th3Mouk/materialized-view/releases/tag/v1.0.0 diff --git a/README.md b/README.md index 23b8011..3099287 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # th3mouk/materialized-view -> Declarative, PostgreSQL-native management of **materialized views** on top of Doctrine DBAL. +> Declarative, PostgreSQL-native management of **materialized views** — on Doctrine DBAL **or** a bare PDO connection. Define a materialized view once, as a **versioned `.sql` file plus a small PHP definition**, and let the library create it, detect drift, rebuild it safely, refresh it (including `CONCURRENTLY`), and let your app read it without surprises. No ORM owns the DDL; PostgreSQL stays the source of truth for the physical object. -This is the **framework-agnostic core** (depends only on `doctrine/dbal`). For Symfony — autoconfiguration, console commands, the locked deploy lane and async refresh — use [`th3mouk/materialized-view-bundle`](../materialized-view-bundle). +This is the **framework-agnostic core**. It talks to PostgreSQL through a tiny `Connection` port, so **Doctrine is optional**: run it on a plain **PDO** handle, or hand it a **Doctrine DBAL** connection to additionally get primary/replica routing, middlewares, profiling and read-only ORM mapping. For Symfony — autoconfiguration, console commands, the locked deploy lane and async refresh — use [`th3mouk/materialized-view-bundle`](../materialized-view-bundle). ## Why this library exists -A materialized view is a **physical PostgreSQL object with its own lifecycle**: `CREATE MATERIALIZED VIEW … AS …`, `REFRESH MATERIALIZED VIEW [CONCURRENTLY]`, no `CREATE OR REPLACE`, destructive redefinition, unique-index preconditions for concurrent refresh, dependency ordering. Doctrine ORM can *read* the rows as a read-only projection, but it cannot *express* any of that — and at the time of writing **no maintained, framework-agnostic PHP library** manages PostgreSQL materialized views. This library fills that gap, DBAL-first. +A materialized view is a **physical PostgreSQL object with its own lifecycle**: `CREATE MATERIALIZED VIEW … AS …`, `REFRESH MATERIALIZED VIEW [CONCURRENTLY]`, no `CREATE OR REPLACE`, destructive redefinition, unique-index preconditions for concurrent refresh, dependency ordering. Doctrine ORM can *read* the rows as a read-only projection, but it cannot *express* any of that — and at the time of writing **no maintained, framework-agnostic PHP library** manages PostgreSQL materialized views. This library fills that gap — natively on Doctrine DBAL, or on a bare PDO connection when you don't run an ORM. See [`docs/internals/design-rationale.md`](docs/internals/design-rationale.md) for the full reasoning and the competitive landscape. @@ -17,9 +17,10 @@ See [`docs/internals/design-rationale.md`](docs/internals/design-rationale.md) f - **Declarative**: SQL lives in `db/matviews/*.sql`; a tiny PHP class declares name, indexes, rebuild & population policy. - **Drift detection** via a canonical hash stored in `COMMENT ON MATERIALIZED VIEW` (travels with database clones). - **Safe rebuilds**: `drop_create` and a low-lock `side_by_side` strategy, with **index and GRANT re-application**. -- **Refresh runtime**: `CONCURRENTLY` with precondition validation, primary/replica awareness, `lock_timeout`/`statement_timeout`, advisory locks, `ANALYZE`. +- **Refresh runtime**: `CONCURRENTLY` with precondition validation, primary/replica awareness (on the Doctrine backend), `lock_timeout`/`statement_timeout`, advisory locks, `ANALYZE`. - **Catalog-derived dependency ordering** (`pg_depend`/`pg_rewrite`) — no hand-maintained graph, no drift. -- **Read-only ORM mapping** (optional) with a write guard and an unpopulated-read readiness guard. +- **Doctrine optional**: the engine runs on a bare `PDO` handle with zero extra Composer dependencies; a Doctrine DBAL connection is natively supported and adds primary/replica routing, middlewares, profiling and read-only ORM mapping. See [`docs/guide/connection-backends.md`](docs/guide/connection-backends.md). +- **Read-only ORM mapping** (optional, Doctrine ORM) with a write guard and an unpopulated-read readiness guard. ## Installation @@ -27,7 +28,12 @@ See [`docs/internals/design-rationale.md`](docs/internals/design-rationale.md) f composer require th3mouk/materialized-view ``` -Requirements: **PHP ≥ 8.4**, **Doctrine DBAL ≥ 4.4**, **PostgreSQL** (12+; tested against 17). The optional ORM layer requires `doctrine/orm` ≥ 3.6. +Requirements: **PHP ≥ 8.4**, **PostgreSQL** (12+; tested against 17), and one connection backend: + +- **Doctrine DBAL ≥ 4.4** — `composer require doctrine/dbal`. Recommended: unlocks primary/replica routing, middlewares, profiling, and the optional read-only ORM mapping (`doctrine/orm` ≥ 3.6). +- **or the `pdo_pgsql` extension** — no extra Composer dependency; wire the manager with `MaterializedViewManager::forPdo()`. + +The core itself depends only on `php` and `psr/log`; the backend is your choice. ## 60-second example (framework-agnostic) @@ -55,7 +61,21 @@ $definition = MaterializedViewDefinition::create('public.sales_by_category') )); $registry = MaterializedViewRegistry::fromDefinitions([$definition]); +``` + +Wire the manager onto your connection backend: + +```php +use Th3Mouk\MaterializedView\Core\MaterializedViewManager; + +// With Doctrine DBAL (recommended — primary/replica routing, middlewares, profiling): +$manager = MaterializedViewManager::forConnection($dbalConnection); +// …or without Doctrine, on a bare PDO connection: +$manager = MaterializedViewManager::forPdo(new PDO($dsn, $user, $password)); +``` + +```php $manager->syncAll($registry); // create / rebuild on drift $manager->refresh($definition); // REFRESH MATERIALIZED VIEW (CONCURRENTLY when possible) ``` @@ -72,11 +92,11 @@ A full table of contents is in [`docs/README.md`](docs/README.md). ## Compatibility -| This library | PHP | Doctrine DBAL | Doctrine ORM (optional) | PostgreSQL | +| This library | PHP | Doctrine DBAL *(optional)* | Doctrine ORM *(optional)* | PostgreSQL | |---|---|---|---|---| -| `^1.0` | ≥ 8.4 | ^4.4 | ^3.6 | 12 – 17 | +| `^1.x` | ≥ 8.4 | ^4.4 | ^3.6 | 12 – 17 | -We track DBAL major versions deliberately and **do not pin a tight upper bound that would strand the library** (see [`docs/internals/compatibility-and-evolution.md`](docs/internals/compatibility-and-evolution.md)). +The core needs only a PostgreSQL connection — **Doctrine DBAL or `pdo_pgsql`**. Doctrine is optional and natively supported; it adds primary/replica routing, middlewares, profiling and read-only ORM mapping. We track DBAL major versions deliberately and **do not pin a tight upper bound that would strand the library** (see [`docs/internals/compatibility-and-evolution.md`](docs/internals/compatibility-and-evolution.md)). ## License diff --git a/composer.json b/composer.json index 689ff2e..0a29c43 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "th3mouk/materialized-view", - "description": "Declarative, PostgreSQL-native management of materialized views on top of Doctrine DBAL: define views as versioned SQL, synchronise, rebuild, refresh (concurrently) and read them safely.", + "description": "Declarative, PostgreSQL-native management of materialized views: define views as versioned SQL, synchronise, rebuild, refresh (concurrently) and read them safely. Runs on Doctrine DBAL or a bare PDO connection.", "type": "library", "keywords": [ "postgresql", @@ -9,6 +9,7 @@ "matview", "doctrine", "dbal", + "pdo", "refresh", "analytics", "reporting", @@ -24,17 +25,20 @@ ], "require": { "php": ">=8.4", - "doctrine/dbal": "^4.4", "psr/log": "^3.0" }, "require-dev": { + "doctrine/dbal": "^4.4", "doctrine/orm": "^3.6", + "ext-pdo": "*", "phpunit/phpunit": "^13.0", "phpstan/phpstan": "^2.1", "friendsofphp/php-cs-fixer": "^3.64", "rector/rector": "^2.0" }, "suggest": { + "doctrine/dbal": "To wire the manager with MaterializedViewManager::forConnection() and keep DBAL's primary/replica routing, middlewares and profiling.", + "ext-pdo_pgsql": "To run on a bare PDO connection (no Doctrine) via MaterializedViewManager::forPdo().", "doctrine/orm": "To map a materialized view as a read-only entity via Th3Mouk\\MaterializedView\\DoctrineOrm.", "th3mouk/materialized-view-bundle": "Symfony integration: autoconfiguration, console commands and the locked deploy lane." }, @@ -50,7 +54,7 @@ }, "extra": { "branch-alias": { - "dev-main": "1.2.x-dev" + "dev-main": "1.3.x-dev" } }, "config": { diff --git a/docs/README.md b/docs/README.md index d285f59..39fe0c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ Set the library up and ship your first materialized view fast. One focused page per non-trivial concept. Read what you need. +- [Connection backends](guide/connection-backends.md) — Doctrine DBAL vs. bare PDO, the `Connection` port, what Doctrine adds, error handling - [Defining views](guide/defining-views.md) — the definition model, SQL files, indexes, generated SQL, naming/versioning - [Population & read safety](guide/population-and-readiness.md) — `WITH NO DATA`, the unpopulated-read error, population policies, the boot triangle - [Rebuild strategies](guide/rebuild-strategies.md) — `drop_create`, `side_by_side`, index re-application, external dependents diff --git a/docs/getting-started.md b/docs/getting-started.md index 5b53719..0638158 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,7 +8,7 @@ This page gets a materialized view managed by the library in a few minutes, **wi composer require th3mouk/materialized-view ``` -Requirements: **PHP ≥ 8.4**, **Doctrine DBAL ≥ 4.4**, **PostgreSQL** (12+, tested to 17). +Requirements: **PHP ≥ 8.4**, **PostgreSQL** (12+, tested to 17), and one connection backend — **Doctrine DBAL ≥ 4.4** (`composer require doctrine/dbal`, recommended) **or** the **`pdo_pgsql`** extension. Doctrine is optional and natively supported; see [Connection backends](guide/connection-backends.md). ## 2. Write the view's SQL @@ -61,7 +61,23 @@ final class SalesByCategoryView > The **unique index** is not optional if you ever want `REFRESH … CONCURRENTLY`: PostgreSQL requires one. See [Refresh runtime & locking](guide/refresh-and-locking.md). -## 4. Synchronise and refresh +## 4. Build the manager + +Wire the manager onto your connection backend. Doctrine is optional: + +```php +use Th3Mouk\MaterializedView\Core\MaterializedViewManager; + +// Recommended — keeps DBAL's primary/replica routing, middlewares and profiling: +$manager = MaterializedViewManager::forConnection($dbalConnection); + +// …or, without Doctrine, on a bare PDO connection (the pdo_pgsql driver): +$manager = MaterializedViewManager::forPdo(new PDO($dsn, $user, $password)); +``` + +Both return the same `MaterializedViewManager`; everything below behaves identically. + +## 5. Synchronise and refresh ```php use Th3Mouk\MaterializedView\Core\Registry\MaterializedViewRegistry; @@ -78,11 +94,11 @@ $manager->syncAll($registry); $manager->refresh(SalesByCategoryView::definition()); ``` -`$manager` is a `Th3Mouk\MaterializedView\Core\MaterializedViewManager` built from your DBAL `Connection`. The bundle builds and wires it for you; framework-agnostic construction is shown in [Architecture](internals/architecture.md). +On Symfony, the bundle builds and wires `$manager` for you (one per connection). The construction options are detailed in [Connection backends](guide/connection-backends.md). -## 5. Read it +## 6. Read it -A materialized view is just a relation: query it with DBAL, or map a **read-only** Doctrine entity onto it — see [Doctrine ORM integration](guide/doctrine-orm-integration.md). +A materialized view is just a relation: query it directly, or map a **read-only** Doctrine entity onto it — see [Doctrine ORM integration](guide/doctrine-orm-integration.md). > ⚠️ A view created `WITH NO DATA` raises a hard error if you read it before its first refresh. Pick a [population policy](guide/population-and-readiness.md) that matches how the view is consumed. diff --git a/docs/guide/connection-backends.md b/docs/guide/connection-backends.md new file mode 100644 index 0000000..dff0e94 --- /dev/null +++ b/docs/guide/connection-backends.md @@ -0,0 +1,83 @@ +# Connection backends + +The library manages PostgreSQL through a tiny **connection port**, not through a specific database library. `Core` depends only on `php` and `psr/log`; you choose the backend. **Doctrine is optional** — and natively supported. + +## The port + +Everything the engine needs from a connection is declared by one interface: + +```php +namespace Th3Mouk\MaterializedView\Core\Database; + +interface Connection +{ + public function executeStatement(string $sql, array $params = [], array $types = []): int; + public function fetchOne(string $sql, array $params = [], array $types = []): mixed; + public function fetchAllAssociative(string $sql, array $params = [], array $types = []): array; + public function fetchAssociative(string $sql, array $params = [], array $types = []): array|false; + public function transactional(\Closure $operation): mixed; + public function ensureConnectedToPrimary(): void; +} +``` + +`$types` entries are `Th3Mouk\MaterializedView\Core\Database\ParameterType` cases (`Integer`, `String`, `Boolean`); each adapter maps them to its native binding types. Any failure is surfaced as `Th3Mouk\MaterializedView\Core\Database\DatabaseException`, which carries the five-character **SQLSTATE** — that is how missing-dependency handling (`42P01` / `3F000`) stays backend-independent. + +Two adapters ship with the package. + +## Doctrine DBAL (recommended) + +```php +use Th3Mouk\MaterializedView\Core\MaterializedViewManager; + +$manager = MaterializedViewManager::forConnection($dbalConnection); +``` + +`forConnection()` wraps the Doctrine connection in `Th3Mouk\MaterializedView\Dbal\DbalConnection`. This is the recommended backend because the DBAL connection brings real operational value the engine reuses: + +- **Primary/replica routing** — `ensureConnectedToPrimary()` switches a `PrimaryReadReplicaConnection` to the primary before any DDL or refresh, so writes never hit a replica. +- **Middlewares & profiling** — your DBAL stack (logging middleware, the Symfony profiler, Datadog/OpenTelemetry tracing, …) sees every statement the library runs. +- **The read-only ORM mapping** — map a materialized view as a read-only Doctrine entity, guarded against writes and unpopulated reads. See [Doctrine ORM integration](doctrine-orm-integration.md). +- **The Symfony bundle** — autoconfiguration, console commands, the locked deploy lane and async refresh, via [`th3mouk/materialized-view-bundle`](../../../materialized-view-bundle). + +Install it explicitly (it is a `suggest`, not a hard dependency): + +```bash +composer require doctrine/dbal # and doctrine/orm for the read layer +``` + +## Bare PDO (no Doctrine) + +For a project that does not run Doctrine, hand the manager a `PDO` connected to PostgreSQL: + +```php +use Th3Mouk\MaterializedView\Core\MaterializedViewManager; + +$pdo = new PDO('pgsql:host=localhost;dbname=app', $user, $password); + +$manager = MaterializedViewManager::forPdo($pdo); +``` + +`forPdo()` wraps the handle in `Th3Mouk\MaterializedView\Pdo\PdoConnection`. It requires only the **`pdo_pgsql`** extension — no extra Composer dependency. The adapter forces `PDO::ERRMODE_EXCEPTION` and translates `PDOException` to `DatabaseException`. There is no primary/replica routing or middleware layer, so `ensureConnectedToPrimary()` is a no-op; everything else — create, sync, drift detection, rebuilds, `REFRESH … CONCURRENTLY`, advisory locks, `ANALYZE` — behaves identically. + +## Your own backend + +`forDriver()` accepts any `Connection` implementation, so you can wrap a connection pool, a test double, or another database library: + +```php +$manager = MaterializedViewManager::forDriver($myConnection); +``` + +`forConnection()` and `forPdo()` are thin conveniences over `forDriver()`. + +## Choosing + +| | Doctrine DBAL (`forConnection`) | Bare PDO (`forPdo`) | +|---|---|---| +| Composer dependency | `doctrine/dbal` | none (`ext-pdo_pgsql`) | +| Primary/replica routing | ✅ | — (no-op) | +| Middlewares / profiling | ✅ | — | +| Read-only ORM mapping | ✅ (`doctrine/orm`) | — | +| Symfony bundle | ✅ | — | +| Core management features | ✅ | ✅ | + +Use Doctrine DBAL if your application already has it (most do) — it is the richer, natively-supported path. Reach for PDO when you want the materialized-view engine without pulling Doctrine into the project. diff --git a/docs/guide/refresh-and-locking.md b/docs/guide/refresh-and-locking.md index e20fc63..771cb9a 100644 --- a/docs/guide/refresh-and-locking.md +++ b/docs/guide/refresh-and-locking.md @@ -5,7 +5,7 @@ ## What `refresh()` does, in order 1. Resolve the definition. -2. **Force the primary connection** if the connection is a `PrimaryReadReplicaConnection` (`ensureConnectedToPrimary()`). DDL/refresh must never hit a replica. +2. **Force the primary connection** (`ensureConnectedToPrimary()`). On the Doctrine DBAL backend this switches a `PrimaryReadReplicaConnection` to the primary so DDL/refresh never hits a replica; on a bare PDO connection there is no read/write split and this is a no-op. See [Connection backends](connection-backends.md). 3. Apply `lock_timeout` and `statement_timeout`. 4. Take a **per-view advisory lock**: `pg_advisory_lock(lock_namespace, view_key)`. 5. Validate `CONCURRENTLY` preconditions: view populated, a complete UNIQUE index (no predicate, no expression). diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 0ae5a81..9eecf29 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -6,11 +6,13 @@ This page maps the design to the source tree and shows the main data flows. It i ```text th3mouk/materialized-view -├── src/Core # framework-free; depends only on doctrine/dbal +├── src/Core # framework-free; talks to PostgreSQL only through Core\Database\Connection (no Doctrine) +├── src/Dbal # optional adapter; wraps a Doctrine DBAL connection +├── src/Pdo # optional adapter; wraps a bare PDO handle (pdo_pgsql) └── src/DoctrineOrm # optional; depends on doctrine/orm (read-only mapping) ``` -Hard rule: **`src/Core` must never import Symfony or Doctrine ORM.** Symfony integration is a separate package (`th3mouk/materialized-view-bundle`). The ORM read layer is isolated in `src/DoctrineOrm` and only loaded when `doctrine/orm` is present. +Hard rule: **the `src/Core` engine talks to PostgreSQL only through the `Core\Database\Connection` port and never imports Symfony or Doctrine ORM.** The DBAL and PDO adapters live in `src/Dbal` / `src/Pdo`; the ORM read layer is isolated in `src/DoctrineOrm` (loaded only when `doctrine/orm` is present); Symfony integration is a separate package (`th3mouk/materialized-view-bundle`). The only DBAL names remaining in `Core` are the `MaterializedViewManager::forConnection()` BC convenience and the `RefreshTargetResolver` async-refresh contract — neither is loaded on the PDO path. ## Class map (folder → planned classes) @@ -20,7 +22,8 @@ Hard rule: **`src/Core` must never import Symfony or Doctrine ORM.** Symfony int |---|---|---| | `Core/Definition` | `MaterializedViewDefinition`, `MaterializedViewName`, `SqlSource` (iface), `SqlFileSource`, `InlineSqlSource`, `MaterializedViewIndex`, `PopulationPolicy` (enum), `RebuildStrategy` (enum), `RefreshOptions` | Immutable declaration of a view | | `Core/Registry` | `MaterializedViewRegistry` | Typed collection of declared definitions | -| `Core/Sql` | `PostgreSqlMaterializedViewSqlGenerator`, `IdentifierQuoter` | Generates `CREATE`/`DROP`/`REFRESH`/`CREATE INDEX`/`COMMENT`/`ALTER … RENAME`; structured quoting | +| `Core/Database` | `Connection` (iface), `ParameterType` (enum), `DatabaseException` | The execution port the engine runs on; backend-agnostic binding types and failures (carries SQLSTATE) | +| `Core/Sql` | `PostgreSqlMaterializedViewSqlGenerator`, `IdentifierQuoter` | Generates `CREATE`/`DROP`/`REFRESH`/`CREATE INDEX`/`COMMENT`/`ALTER … RENAME`; PostgreSQL-native quoting (no DBAL platform) | | `Core/Introspection` | `PostgreSqlMaterializedViewIntrospector`, `ReadinessChecker` | Reads `pg_matviews`/`pg_class`/`pg_namespace`/`pg_indexes`, comments, `relispopulated` | | `Core/Dependency` | `CatalogDependencyResolver`, `ExternalDependencyGuard` | Derives the matview graph from `pg_depend`/`pg_rewrite`; blocks unsafe drops | | `Core/Rebuild` | `SideBySideRebuilder`, `IndexSnapshotter` | Rebuild strategies; index capture/replay | @@ -31,7 +34,9 @@ Hard rule: **`src/Core` must never import Symfony or Doctrine ORM.** Symfony int | `Core/Sync` | `MaterializedViewSynchronizer`, `MaterializedViewComparator` | The declarative reconcile engine | | `Core/Migration` | `MaterializedViewMigrationSql` | Static helper for migration-owned mode | | `Core/Exception` | domain exceptions | Named, business-meaningful failures | -| `Core` (root) | `MaterializedViewManager` | Runtime façade: `create()/drop()/refresh()/sync()/refreshAll()` | +| `Core` (root) | `MaterializedViewManager` | Runtime façade: `create()/drop()/refresh()/sync()/refreshAll()`; factories `forDriver()` (port), `forConnection()` (DBAL), `forPdo()` (PDO) | +| `Dbal` | `DbalConnection` | `Connection` adapter over Doctrine DBAL — primary/replica routing, middlewares, profiling | +| `Pdo` | `PdoConnection` | `Connection` adapter over a bare `PDO` handle (`pdo_pgsql`) | | `DoctrineOrm/Mapping` | `#[MaterializedViewEntity]`, `MaterializedViewMetadataReader` | Link entity ↔ definition | | `DoctrineOrm/Listener` | `MaterializedViewWriteGuard`, `MaterializedViewPostLoadListener` | Enforce read-only | | `DoctrineOrm/Repository` | `MaterializedViewRepository` | Read-only base repository | @@ -39,15 +44,24 @@ Hard rule: **`src/Core` must never import Symfony or Doctrine ORM.** Symfony int ## Construction (framework-agnostic) +The manager runs on any `Core\Database\Connection`. Two adapters ship with the package; pick a backend: + ```php use Doctrine\DBAL\DriverManager; use Th3Mouk\MaterializedView\Core\MaterializedViewManager; +// Doctrine DBAL — keeps primary/replica routing, middlewares, profiling: $connection = DriverManager::getConnection(['url' => $databaseUrl]); $manager = MaterializedViewManager::forConnection($connection /*, LoggerInterface $logger = null */); + +// …or a bare PDO handle (pdo_pgsql), no Doctrine: +$manager = MaterializedViewManager::forPdo(new PDO($dsn, $user, $password)); + +// …or any custom Connection adapter: +$manager = MaterializedViewManager::forDriver($connection); ``` -The bundle wires this as a service per connection. +`forConnection()` and `forPdo()` are thin wrappers over `forDriver()` that build a `Dbal\DbalConnection` / `Pdo\PdoConnection`. See [Connection backends](../guide/connection-backends.md). The bundle wires this as a service per connection. ## Data flow — `sync` diff --git a/docs/internals/design-rationale.md b/docs/internals/design-rationale.md index cee799a..b68292a 100644 --- a/docs/internals/design-rationale.md +++ b/docs/internals/design-rationale.md @@ -4,7 +4,7 @@ Why the library is shaped the way it is, the trade-offs taken, and the guarantee ## The twelve decisions -1. **DBAL first.** The core depends on Doctrine DBAL, not Symfony nor the ORM. +1. **Connection port; Doctrine optional.** The core runs on a tiny `Core\Database\Connection` port, not on Doctrine directly. A Doctrine DBAL adapter (`Dbal\DbalConnection`) is the recommended, natively-supported backend; a `Pdo\PdoConnection` adapter runs the engine without Doctrine. Never Symfony, never the ORM, in `Core`. 2. **PostgreSQL only at MVP.** Materialized views have PostgreSQL-specific semantics. 3. **SQL source of truth = versioned `.sql` files.** No large PHP heredoc as the recommended mode. 4. **Declarative synchronisation by default.** `matview:sync`, plus an optional Doctrine Migrations lane for Symfony apps. @@ -19,7 +19,7 @@ Why the library is shaped the way it is, the trade-offs taken, and the guarantee ## Why these choices -- **Why DBAL** — it knows the real connection, the primary/replica wrappers, middlewares, profiling, transactions and the PostgreSQL driver. It is the right level to run DDL and refresh. +- **Why a connection port, DBAL optional** — the engine only needs to run statements, fetch rows and open a transaction; that is a tiny surface. Expressing it as a port keeps `Core` free of any database library, so a project without Doctrine can drive materialized views over a bare PDO connection. DBAL stays the recommended backend because it knows the real connection, the primary/replica wrappers, middlewares, profiling, transactions and the PostgreSQL driver — its adapter reuses all of that; the PDO adapter trades those operational features for a zero-dependency footprint. Quoting is PostgreSQL-native PHP (identical output to DBAL's platform), so it needs no driver at all. - **Why a `.sql` file over a PHP heredoc** — syntax highlighting, direct `psql` execution, readable Git diffs, simpler review, easy scaffolding, better team adoption. The PHP class stays for the name, indexes, options and strategy. - **Why declarative by default** — it is what makes the boot lane safe when the same managed views run across many databases/connections: drop managed projections before table DDL, recreate them after migrations, so a developer never has to guess which migration must drop which view. - **Why not extend DBAL `Schema`** — a materialized view change is not a plain schema diff: no `CREATE OR REPLACE`, potentially destructive rebuild, runtime refresh, `CONCURRENTLY` constraints, indexes and dependencies. A dedicated system keeps those decisions explicit. diff --git a/src/Core/Database/Connection.php b/src/Core/Database/Connection.php new file mode 100644 index 0000000..5e1d0a7 --- /dev/null +++ b/src/Core/Database/Connection.php @@ -0,0 +1,85 @@ +|string, mixed> $params + * @param array|string, ParameterType> $types + * + * @return int number of affected rows + * + * @throws DatabaseException + */ + public function executeStatement(string $sql, array $params = [], array $types = []): int; + + /** + * @param array|string, mixed> $params + * @param array|string, ParameterType> $types + * + * @return mixed the first column of the first row, or false when there is no row + * + * @throws DatabaseException + */ + public function fetchOne(string $sql, array $params = [], array $types = []): mixed; + + /** + * @param array|string, mixed> $params + * @param array|string, ParameterType> $types + * + * @return list> + * + * @throws DatabaseException + */ + public function fetchAllAssociative(string $sql, array $params = [], array $types = []): array; + + /** + * @param array|string, mixed> $params + * @param array|string, ParameterType> $types + * + * @return array|false the first row, or false when there is no row + * + * @throws DatabaseException + */ + public function fetchAssociative(string $sql, array $params = [], array $types = []): array|false; + + /** + * Runs the operation inside a single transaction, committing on success and + * rolling back on any throwable. The operation receives this same connection. + * + * @template T + * + * @param Closure(self): T $operation + * + * @return T + * + * @throws DatabaseException + */ + public function transactional(Closure $operation): mixed; + + /** + * Pins subsequent statements to the primary node before any DDL or refresh. + * + * A no-op for connections without read/write splitting (e.g. a bare PDO handle). + * + * @throws DatabaseException + */ + public function ensureConnectedToPrimary(): void; +} diff --git a/src/Core/Database/DatabaseException.php b/src/Core/Database/DatabaseException.php new file mode 100644 index 0000000..4383195 --- /dev/null +++ b/src/Core/Database/DatabaseException.php @@ -0,0 +1,35 @@ +sqlState; + } +} diff --git a/src/Core/Database/ParameterType.php b/src/Core/Database/ParameterType.php new file mode 100644 index 0000000..fb97961 --- /dev/null +++ b/src/Core/Database/ParameterType.php @@ -0,0 +1,18 @@ +connection->executeStatement( 'SELECT pg_advisory_lock(?)', [$this->laneNamespace], - [ParameterType::INTEGER], + [ParameterType::Integer], ); $this->logger->debug('Acquired deploy-lane advisory lock.', ['lock_key' => $this->laneNamespace]); } /** - * @throws Exception + * @throws DatabaseException */ public function tryAcquire(): bool { $acquired = (bool) $this->connection->fetchOne( 'SELECT pg_try_advisory_lock(?)', [$this->laneNamespace], - [ParameterType::INTEGER], + [ParameterType::Integer], ); if (!$acquired) { @@ -62,14 +62,14 @@ public function tryAcquire(): bool } /** - * @throws Exception + * @throws DatabaseException */ public function release(): bool { $released = (bool) $this->connection->fetchOne( 'SELECT pg_advisory_unlock(?)', [$this->laneNamespace], - [ParameterType::INTEGER], + [ParameterType::Integer], ); $this->logger->debug('Released deploy-lane advisory lock.', [ diff --git a/src/Core/Lock/PrimaryConnectionGuard.php b/src/Core/Lock/PrimaryConnectionGuard.php index 815705b..ed9b88a 100644 --- a/src/Core/Lock/PrimaryConnectionGuard.php +++ b/src/Core/Lock/PrimaryConnectionGuard.php @@ -4,9 +4,8 @@ namespace Th3Mouk\MaterializedView\Core\Lock; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection; -use Doctrine\DBAL\Exception; +use Th3Mouk\MaterializedView\Core\Database\Connection; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; final readonly class PrimaryConnectionGuard { @@ -16,14 +15,15 @@ public function __construct( } /** - * @throws Exception + * Pins subsequent statements to the primary before any DDL or refresh. + * + * The connection decides what this means: a Doctrine primary/replica + * connection switches to the primary, a bare PDO handle does nothing. + * + * @throws DatabaseException */ public function ensureConnectedToPrimary(): void { - if (!$this->connection instanceof PrimaryReadReplicaConnection) { - return; - } - $this->connection->ensureConnectedToPrimary(); } } diff --git a/src/Core/Lock/ViewRefreshLock.php b/src/Core/Lock/ViewRefreshLock.php index 49ba70f..7c43c40 100644 --- a/src/Core/Lock/ViewRefreshLock.php +++ b/src/Core/Lock/ViewRefreshLock.php @@ -4,11 +4,11 @@ namespace Th3Mouk\MaterializedView\Core\Lock; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception; -use Doctrine\DBAL\ParameterType; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; +use Th3Mouk\MaterializedView\Core\Database\ParameterType; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; final readonly class ViewRefreshLock @@ -24,7 +24,7 @@ public function __construct( } /** - * @throws Exception + * @throws DatabaseException */ public function acquire(MaterializedViewName $name): void { @@ -33,7 +33,7 @@ public function acquire(MaterializedViewName $name): void $this->connection->executeStatement( 'SELECT pg_advisory_lock(?, ?)', [$lockKey->namespace, $lockKey->key], - [ParameterType::INTEGER, ParameterType::INTEGER], + [ParameterType::Integer, ParameterType::Integer], ); $this->logger->debug('Acquired refresh advisory lock for materialized view "{view}".', [ @@ -43,7 +43,7 @@ public function acquire(MaterializedViewName $name): void } /** - * @throws Exception + * @throws DatabaseException */ public function tryAcquire(MaterializedViewName $name): bool { @@ -52,7 +52,7 @@ public function tryAcquire(MaterializedViewName $name): bool $acquired = (bool) $this->connection->fetchOne( 'SELECT pg_try_advisory_lock(?, ?)', [$lockKey->namespace, $lockKey->key], - [ParameterType::INTEGER, ParameterType::INTEGER], + [ParameterType::Integer, ParameterType::Integer], ); if (!$acquired) { @@ -73,7 +73,7 @@ public function tryAcquire(MaterializedViewName $name): bool } /** - * @throws Exception + * @throws DatabaseException */ public function release(MaterializedViewName $name): bool { @@ -82,7 +82,7 @@ public function release(MaterializedViewName $name): bool $released = (bool) $this->connection->fetchOne( 'SELECT pg_advisory_unlock(?, ?)', [$lockKey->namespace, $lockKey->key], - [ParameterType::INTEGER, ParameterType::INTEGER], + [ParameterType::Integer, ParameterType::Integer], ); $this->logger->debug('Released refresh advisory lock for materialized view "{view}".', [ diff --git a/src/Core/MaterializedViewManager.php b/src/Core/MaterializedViewManager.php index 06a0388..d85acca 100644 --- a/src/Core/MaterializedViewManager.php +++ b/src/Core/MaterializedViewManager.php @@ -4,9 +4,11 @@ namespace Th3Mouk\MaterializedView\Core; -use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Connection as DoctrineConnection; +use PDO; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Dependency\CatalogDependencyResolver; @@ -36,6 +38,8 @@ use Th3Mouk\MaterializedView\Core\Sync\MaterializedViewSynchronizer; use Th3Mouk\MaterializedView\Core\Sync\SyncOptions; use Th3Mouk\MaterializedView\Core\Sync\SyncOutcome; +use Th3Mouk\MaterializedView\Dbal\DbalConnection; +use Th3Mouk\MaterializedView\Pdo\PdoConnection; use Throwable; final readonly class MaterializedViewManager @@ -52,18 +56,23 @@ private function __construct( private ViewRefreshLock $refreshLock, private DefinitionHasher $hasher, private MaterializedViewSynchronizer $synchronizer, + private IdentifierQuoter $quoter, private LoggerInterface $logger, ) { } - public static function forConnection( + /** + * Framework-agnostic factory: wire the manager onto any {@see Connection} + * adapter (Doctrine DBAL, bare PDO, or your own). + */ + public static function forDriver( Connection $connection, ?LoggerInterface $logger = null, int $refreshLockNamespace = self::DEFAULT_REFRESH_LOCK_NAMESPACE, ): self { $logger ??= new NullLogger(); - $quoter = IdentifierQuoter::forConnection($connection); + $quoter = new IdentifierQuoter(); $sqlGenerator = new PostgreSqlMaterializedViewSqlGenerator($quoter); $introspector = new PostgreSqlMaterializedViewIntrospector($connection); $hasher = DefinitionHasher::create(); @@ -81,6 +90,7 @@ public static function forConnection( sqlGenerator: $sqlGenerator, introspector: $introspector, hasher: $hasher, + quoter: $quoter, logger: $logger, ); @@ -94,10 +104,38 @@ public static function forConnection( refreshLock: new ViewRefreshLock($connection, new StableLockKeyGenerator($refreshLockNamespace), $logger), hasher: $hasher, synchronizer: $synchronizer, + quoter: $quoter, logger: $logger, ); } + /** + * Convenience factory for Doctrine DBAL users. + * + * Wraps the connection so the manager keeps DBAL's primary/replica routing, + * middlewares and profiling. Requires `doctrine/dbal`; projects without + * Doctrine should use {@see self::forPdo()} instead. + */ + public static function forConnection( + DoctrineConnection $connection, + ?LoggerInterface $logger = null, + int $refreshLockNamespace = self::DEFAULT_REFRESH_LOCK_NAMESPACE, + ): self { + return self::forDriver(new DbalConnection($connection), $logger, $refreshLockNamespace); + } + + /** + * Convenience factory for projects without Doctrine: pass a bare PDO handle + * connected to PostgreSQL. Requires `ext-pdo_pgsql`. + */ + public static function forPdo( + PDO $pdo, + ?LoggerInterface $logger = null, + int $refreshLockNamespace = self::DEFAULT_REFRESH_LOCK_NAMESPACE, + ): self { + return self::forDriver(new PdoConnection($pdo), $logger, $refreshLockNamespace); + } + public function create(MaterializedViewDefinition $definition): void { $this->primaryConnectionGuard->ensureConnectedToPrimary(); @@ -344,14 +382,14 @@ private function applyTimeouts(RefreshOptions $options): void if (null !== $options->lockTimeout) { $this->connection->executeStatement(\sprintf( 'SET lock_timeout = %s', - $this->connection->getDatabasePlatform()->quoteStringLiteral($options->lockTimeout), + $this->quoter->quoteStringLiteral($options->lockTimeout), )); } if (null !== $options->statementTimeout) { $this->connection->executeStatement(\sprintf( 'SET statement_timeout = %s', - $this->connection->getDatabasePlatform()->quoteStringLiteral($options->statementTimeout), + $this->quoter->quoteStringLiteral($options->statementTimeout), )); } } @@ -371,8 +409,8 @@ private function analyzeStatement(MaterializedViewName $name): string { return \sprintf( 'ANALYZE %s.%s', - $this->connection->quoteSingleIdentifier($name->schema), - $this->connection->quoteSingleIdentifier($name->name), + $this->quoter->quoteIdentifier($name->schema), + $this->quoter->quoteIdentifier($name->name), ); } diff --git a/src/Core/Migration/MaterializedViewMigrationSql.php b/src/Core/Migration/MaterializedViewMigrationSql.php index 19891d2..76df1c9 100644 --- a/src/Core/Migration/MaterializedViewMigrationSql.php +++ b/src/Core/Migration/MaterializedViewMigrationSql.php @@ -4,10 +4,9 @@ namespace Th3Mouk\MaterializedView\Core\Migration; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewIndex; -use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; +use Th3Mouk\MaterializedView\Core\Sql\IdentifierQuoter; final class MaterializedViewMigrationSql { @@ -16,14 +15,14 @@ final class MaterializedViewMigrationSql */ public static function create(MaterializedViewDefinition $definition): iterable { - $platform = new PostgreSQLPlatform(); - $quotedName = self::quoteQualifiedName($definition->name(), $platform); + $quoter = new IdentifierQuoter(); + $quotedName = $quoter->quoteQualifiedName($definition->name()); yield self::dropStatement($quotedName); yield self::createStatement($quotedName, $definition); foreach ($definition->indexes() as $index) { - yield self::createIndexStatement($quotedName, $index, $platform); + yield self::createIndexStatement($quotedName, $index, $quoter); } } @@ -32,9 +31,9 @@ public static function create(MaterializedViewDefinition $definition): iterable */ public static function drop(MaterializedViewDefinition $definition): iterable { - $platform = new PostgreSQLPlatform(); + $quoter = new IdentifierQuoter(); - yield self::dropStatement(self::quoteQualifiedName($definition->name(), $platform)); + yield self::dropStatement($quoter->quoteQualifiedName($definition->name())); } private static function dropStatement(string $quotedName): string @@ -55,7 +54,7 @@ private static function createStatement(string $quotedName, MaterializedViewDefi private static function createIndexStatement( string $quotedViewName, MaterializedViewIndex $index, - PostgreSQLPlatform $platform, + IdentifierQuoter $quoter, ): string { $statement = 'CREATE '; @@ -69,17 +68,17 @@ private static function createIndexStatement( $statement .= 'CONCURRENTLY '; } - $statement .= $platform->quoteSingleIdentifier($index->name); + $statement .= $quoter->quoteIdentifier($index->name); $statement .= ' ON '.$quotedViewName; if (null !== $index->method) { - $statement .= ' USING '.$platform->quoteSingleIdentifier($index->method); + $statement .= ' USING '.$quoter->quoteIdentifier($index->method); } - $statement .= ' ('.self::quoteColumnList($index->columns, $platform).')'; + $statement .= ' ('.$quoter->quoteColumnList($index->columns).')'; if ([] !== $index->include) { - $statement .= ' INCLUDE ('.self::quoteColumnList($index->include, $platform).')'; + $statement .= ' INCLUDE ('.$quoter->quoteColumnList($index->include).')'; } if (null !== $index->where) { @@ -89,21 +88,6 @@ private static function createIndexStatement( return $statement; } - private static function quoteQualifiedName(MaterializedViewName $name, PostgreSQLPlatform $platform): string - { - return $platform->quoteSingleIdentifier($name->schema) - .'.' - .$platform->quoteSingleIdentifier($name->name); - } - - /** - * @param list $columns - */ - private static function quoteColumnList(array $columns, PostgreSQLPlatform $platform): string - { - return implode(', ', array_map($platform->quoteSingleIdentifier(...), $columns)); - } - private static function normalizedBody(MaterializedViewDefinition $definition): string { $body = trim($definition->sqlSource()->sql()); diff --git a/src/Core/Privilege/PrivilegeReplayer.php b/src/Core/Privilege/PrivilegeReplayer.php index d10ac3d..2d8fbcd 100644 --- a/src/Core/Privilege/PrivilegeReplayer.php +++ b/src/Core/Privilege/PrivilegeReplayer.php @@ -4,9 +4,9 @@ namespace Th3Mouk\MaterializedView\Core\Privilege; -use Doctrine\DBAL\Connection; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; final readonly class PrivilegeReplayer { diff --git a/src/Core/Privilege/PrivilegeSnapshotter.php b/src/Core/Privilege/PrivilegeSnapshotter.php index 914ef6b..bf897f9 100644 --- a/src/Core/Privilege/PrivilegeSnapshotter.php +++ b/src/Core/Privilege/PrivilegeSnapshotter.php @@ -4,9 +4,9 @@ namespace Th3Mouk\MaterializedView\Core\Privilege; -use Doctrine\DBAL\Connection; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; final readonly class PrivilegeSnapshotter diff --git a/src/Core/Rebuild/DropCreateRebuilder.php b/src/Core/Rebuild/DropCreateRebuilder.php index 0c5a1a3..0951c51 100644 --- a/src/Core/Rebuild/DropCreateRebuilder.php +++ b/src/Core/Rebuild/DropCreateRebuilder.php @@ -4,9 +4,9 @@ namespace Th3Mouk\MaterializedView\Core\Rebuild; -use Doctrine\DBAL\Connection; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\RebuildStrategy; use Th3Mouk\MaterializedView\Core\Exception\UnmanagedDependentFound; @@ -22,7 +22,7 @@ public function __construct( private Connection $connection, ?LoggerInterface $logger = null, ) { - $this->statements = new RebuildStatementFactory(IdentifierQuoter::forConnection($connection)); + $this->statements = new RebuildStatementFactory(new IdentifierQuoter()); $this->logger = $logger ?? new NullLogger(); } diff --git a/src/Core/Rebuild/IndexSnapshotter.php b/src/Core/Rebuild/IndexSnapshotter.php index 10149f3..66ebcb6 100644 --- a/src/Core/Rebuild/IndexSnapshotter.php +++ b/src/Core/Rebuild/IndexSnapshotter.php @@ -4,7 +4,7 @@ namespace Th3Mouk\MaterializedView\Core\Rebuild; -use Doctrine\DBAL\Connection; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; final readonly class IndexSnapshotter diff --git a/src/Core/Rebuild/SideBySideRebuilder.php b/src/Core/Rebuild/SideBySideRebuilder.php index 697bbd0..28fdea1 100644 --- a/src/Core/Rebuild/SideBySideRebuilder.php +++ b/src/Core/Rebuild/SideBySideRebuilder.php @@ -4,9 +4,9 @@ namespace Th3Mouk\MaterializedView\Core\Rebuild; -use Doctrine\DBAL\Connection; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Definition\RebuildStrategy; @@ -24,7 +24,7 @@ public function __construct( private string $swapToken, ?LoggerInterface $logger = null, ) { - $this->statements = new RebuildStatementFactory(IdentifierQuoter::forConnection($connection)); + $this->statements = new RebuildStatementFactory(new IdentifierQuoter()); $this->logger = $logger ?? new NullLogger(); } diff --git a/src/Core/Sql/IdentifierQuoter.php b/src/Core/Sql/IdentifierQuoter.php index 4d83c3e..8f0109d 100644 --- a/src/Core/Sql/IdentifierQuoter.php +++ b/src/Core/Sql/IdentifierQuoter.php @@ -4,30 +4,21 @@ namespace Th3Mouk\MaterializedView\Core\Sql; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; +/** + * PostgreSQL-native identifier and literal quoting. + * + * Quoting is deterministic for PostgreSQL, so the library does it in plain PHP + * instead of going through a database driver — this keeps `Core` free of any + * connection dependency. The output is byte-for-byte identical to Doctrine + * DBAL's `PostgreSQLPlatform`. + */ final readonly class IdentifierQuoter { - private function __construct( - private AbstractPlatform $platform, - ) { - } - - public static function forConnection(Connection $connection): self - { - return new self($connection->getDatabasePlatform()); - } - - public static function forPlatform(AbstractPlatform $platform): self - { - return new self($platform); - } - public function quoteIdentifier(string $identifier): string { - return $this->platform->quoteSingleIdentifier($identifier); + return '"'.str_replace('"', '""', $identifier).'"'; } public function quoteQualifiedName(MaterializedViewName $name): string @@ -45,6 +36,6 @@ public function quoteColumnList(array $columns): string public function quoteStringLiteral(string $value): string { - return $this->platform->quoteStringLiteral($value); + return "'".str_replace("'", "''", $value)."'"; } } diff --git a/src/Core/Sql/MissingDependencySqlState.php b/src/Core/Sql/MissingDependencySqlState.php index f6bac69..ce3f657 100644 --- a/src/Core/Sql/MissingDependencySqlState.php +++ b/src/Core/Sql/MissingDependencySqlState.php @@ -4,7 +4,7 @@ namespace Th3Mouk\MaterializedView\Core\Sql; -use Doctrine\DBAL\Driver\Exception as DriverException; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; use Throwable; final readonly class MissingDependencySqlState @@ -23,12 +23,8 @@ public static function isMissingDependency(Throwable $throwable): bool private static function resolveSqlState(Throwable $throwable): ?string { for ($current = $throwable; null !== $current; $current = $current->getPrevious()) { - if ($current instanceof DriverException) { - $sqlState = $current->getSQLState(); - - if (null !== $sqlState) { - return $sqlState; - } + if ($current instanceof DatabaseException && null !== $current->sqlState()) { + return $current->sqlState(); } } diff --git a/src/Core/Sync/MaterializedViewSynchronizer.php b/src/Core/Sync/MaterializedViewSynchronizer.php index 0223bf8..77b5be9 100644 --- a/src/Core/Sync/MaterializedViewSynchronizer.php +++ b/src/Core/Sync/MaterializedViewSynchronizer.php @@ -4,10 +4,10 @@ namespace Th3Mouk\MaterializedView\Core\Sync; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception as DbalException; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Definition\RebuildStrategy; @@ -26,6 +26,7 @@ use Th3Mouk\MaterializedView\Core\Rebuild\Rebuilder; use Th3Mouk\MaterializedView\Core\Rebuild\SideBySideRebuilder; use Th3Mouk\MaterializedView\Core\Registry\MaterializedViewRegistry; +use Th3Mouk\MaterializedView\Core\Sql\IdentifierQuoter; use Th3Mouk\MaterializedView\Core\Sql\ManagementMarker; use Th3Mouk\MaterializedView\Core\Sql\MissingDependencySqlState; use Th3Mouk\MaterializedView\Core\Sql\PostgreSqlMaterializedViewSqlGenerator; @@ -46,6 +47,7 @@ public function __construct( private PostgreSqlMaterializedViewSqlGenerator $sqlGenerator, private PostgreSqlMaterializedViewIntrospector $introspector, private DefinitionHasher $hasher, + private IdentifierQuoter $quoter, ?LoggerInterface $logger = null, ) { $this->logger = $logger ?? new NullLogger(); @@ -100,7 +102,7 @@ public function synchronize(MaterializedViewRegistry $registry, ?SyncOptions $op try { $this->buildView($definition, $registry, $graph, $privilegeSnapshots[$qualifiedName], $options); $this->applyPopulationAndAnalyze($definition, $options); - } catch (DbalException $exception) { + } catch (DatabaseException $exception) { if (!$this->shouldSkipMissingDependency($exception, $options)) { $this->logger->error( 'Materialized view synchronisation aborted while building "{view}".', @@ -162,7 +164,7 @@ public function synchronize(MaterializedViewRegistry $registry, ?SyncOptions $op return $outcome; } - private function shouldSkipMissingDependency(DbalException $exception, SyncOptions $options): bool + private function shouldSkipMissingDependency(DatabaseException $exception, SyncOptions $options): bool { return MissingDependencyPolicy::Skip === $options->missingDependencyPolicy && MissingDependencySqlState::isMissingDependency($exception); @@ -447,8 +449,8 @@ private function analyzeStatement(MaterializedViewName $name): string { return \sprintf( 'ANALYZE %s.%s', - $this->connection->quoteSingleIdentifier($name->schema), - $this->connection->quoteSingleIdentifier($name->name), + $this->quoter->quoteIdentifier($name->schema), + $this->quoter->quoteIdentifier($name->name), ); } @@ -456,8 +458,8 @@ private function refreshWithDataStatement(MaterializedViewName $name): string { return \sprintf( 'REFRESH MATERIALIZED VIEW %s.%s WITH DATA', - $this->connection->quoteSingleIdentifier($name->schema), - $this->connection->quoteSingleIdentifier($name->name), + $this->quoter->quoteIdentifier($name->schema), + $this->quoter->quoteIdentifier($name->name), ); } diff --git a/src/Dbal/DbalConnection.php b/src/Dbal/DbalConnection.php new file mode 100644 index 0000000..1e35b8e --- /dev/null +++ b/src/Dbal/DbalConnection.php @@ -0,0 +1,123 @@ +connection->executeStatement($sql, $params, self::mapTypes($types)); + } catch (DbalException $exception) { + throw self::wrap($exception); + } + } + + public function fetchOne(string $sql, array $params = [], array $types = []): mixed + { + try { + return $this->connection->fetchOne($sql, $params, self::mapTypes($types)); + } catch (DbalException $exception) { + throw self::wrap($exception); + } + } + + public function fetchAllAssociative(string $sql, array $params = [], array $types = []): array + { + try { + return $this->connection->fetchAllAssociative($sql, $params, self::mapTypes($types)); + } catch (DbalException $exception) { + throw self::wrap($exception); + } + } + + public function fetchAssociative(string $sql, array $params = [], array $types = []): array|false + { + try { + return $this->connection->fetchAssociative($sql, $params, self::mapTypes($types)); + } catch (DbalException $exception) { + throw self::wrap($exception); + } + } + + public function transactional(Closure $operation): mixed + { + try { + return $this->connection->transactional(fn (): mixed => $operation($this)); + } catch (DbalException $exception) { + throw self::wrap($exception); + } + } + + public function ensureConnectedToPrimary(): void + { + if (!$this->connection instanceof PrimaryReadReplicaConnection) { + return; + } + + try { + $this->connection->ensureConnectedToPrimary(); + } catch (DbalException $exception) { + throw self::wrap($exception); + } + } + + /** + * @param array|string, ParameterType> $types + * + * @return array|string, DbalParameterType> + */ + private static function mapTypes(array $types): array + { + return array_map(static fn (ParameterType $type): DbalParameterType => match ($type) { + ParameterType::String => DbalParameterType::STRING, + ParameterType::Integer => DbalParameterType::INTEGER, + ParameterType::Boolean => DbalParameterType::BOOLEAN, + }, $types); + } + + private static function wrap(DbalException $exception): DatabaseException + { + return new DatabaseException($exception->getMessage(), self::sqlStateOf($exception), $exception); + } + + private static function sqlStateOf(Throwable $throwable): ?string + { + for ($current = $throwable; null !== $current; $current = $current->getPrevious()) { + if ($current instanceof DriverException) { + $sqlState = $current->getSQLState(); + + if (null !== $sqlState) { + return $sqlState; + } + } + } + + return null; + } +} diff --git a/src/Pdo/PdoConnection.php b/src/Pdo/PdoConnection.php new file mode 100644 index 0000000..a06dd04 --- /dev/null +++ b/src/Pdo/PdoConnection.php @@ -0,0 +1,159 @@ +pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + } + + public function executeStatement(string $sql, array $params = [], array $types = []): int + { + return $this->execute($sql, $params, $types)->rowCount(); + } + + public function fetchOne(string $sql, array $params = [], array $types = []): mixed + { + return $this->execute($sql, $params, $types)->fetchColumn(0); + } + + public function fetchAllAssociative(string $sql, array $params = [], array $types = []): array + { + /** @var list> $rows */ + $rows = $this->execute($sql, $params, $types)->fetchAll(PDO::FETCH_ASSOC); + + return $rows; + } + + public function fetchAssociative(string $sql, array $params = [], array $types = []): array|false + { + /** @var array|false $row */ + $row = $this->execute($sql, $params, $types)->fetch(PDO::FETCH_ASSOC); + + return $row; + } + + public function transactional(Closure $operation): mixed + { + $this->guard(fn (): bool => $this->pdo->beginTransaction()); + + try { + $result = $operation($this); + } catch (Throwable $throwable) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + + throw $throwable; + } + + $this->guard(fn (): bool => $this->pdo->commit()); + + return $result; + } + + public function ensureConnectedToPrimary(): void + { + // A bare PDO handle has no read/replica routing; nothing to pin. + } + + /** + * @param array|string, mixed> $params + * @param array|string, ParameterType> $types + */ + private function execute(string $sql, array $params, array $types): PDOStatement + { + return $this->guard(function () use ($sql, $params, $types): PDOStatement { + $statement = $this->pdo->prepare($sql); + + foreach ($params as $key => $value) { + $statement->bindValue(self::placeholder($key), $value, self::pdoType($types[$key] ?? null, $value)); + } + + $statement->execute(); + + return $statement; + }); + } + + private static function placeholder(int|string $key): int|string + { + if (\is_int($key)) { + return $key + 1; + } + + return str_starts_with($key, ':') ? $key : ':'.$key; + } + + /** + * @template T + * + * @param Closure(): T $operation + * + * @return T + */ + private function guard(Closure $operation): mixed + { + try { + return $operation(); + } catch (PDOException $exception) { + throw self::wrap($exception); + } + } + + private static function pdoType(?ParameterType $type, mixed $value): int + { + return match ($type) { + ParameterType::Integer => PDO::PARAM_INT, + ParameterType::Boolean => PDO::PARAM_BOOL, + ParameterType::String => PDO::PARAM_STR, + null => self::inferType($value), + }; + } + + private static function inferType(mixed $value): int + { + return match (true) { + null === $value => PDO::PARAM_NULL, + \is_int($value) => PDO::PARAM_INT, + \is_bool($value) => PDO::PARAM_BOOL, + default => PDO::PARAM_STR, + }; + } + + private static function wrap(PDOException $exception): DatabaseException + { + return new DatabaseException($exception->getMessage(), self::sqlStateOf($exception), $exception); + } + + private static function sqlStateOf(PDOException $exception): ?string + { + $errorInfo = $exception->errorInfo; + $sqlState = \is_array($errorInfo) && isset($errorInfo[0]) + ? (string) $errorInfo[0] + : (string) $exception->getCode(); + + return '' !== $sqlState && '00000' !== $sqlState ? $sqlState : null; + } +} diff --git a/tests/Integration/Dependency/ConflictClosureIntegrationTest.php b/tests/Integration/Dependency/ConflictClosureIntegrationTest.php index 2997b59..0574b1b 100644 --- a/tests/Integration/Dependency/ConflictClosureIntegrationTest.php +++ b/tests/Integration/Dependency/ConflictClosureIntegrationTest.php @@ -17,6 +17,7 @@ use Th3Mouk\MaterializedView\Core\MaterializedViewManager; use Th3Mouk\MaterializedView\Core\Sql\ManagementMarker; use Th3Mouk\MaterializedView\Core\Sql\QualifiedName; +use Th3Mouk\MaterializedView\Dbal\DbalConnection; #[Group('dependency')] #[Group('integration')] @@ -80,7 +81,7 @@ public function testResolvesTheManagedDropClosureFromTheBlockedTable(): void $this->createManagedMatview('order_totals', 'SELECT id, total FROM orders'); $this->createManagedMatview('order_totals_rollup', 'SELECT id FROM order_totals'); - $closure = new CatalogDependencyResolver($this->connection) + $closure = new CatalogDependencyResolver(new DbalConnection($this->connection)) ->resolveConflictClosure(new QualifiedName(self::SCHEMA, 'orders')); self::assertFalse($closure->hasUnmanagedDependents()); diff --git a/tests/Integration/Introspection/PostgreSqlIntrospectionTest.php b/tests/Integration/Introspection/PostgreSqlIntrospectionTest.php index 1d26e4a..c5d7839 100644 --- a/tests/Integration/Introspection/PostgreSqlIntrospectionTest.php +++ b/tests/Integration/Introspection/PostgreSqlIntrospectionTest.php @@ -13,6 +13,7 @@ use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Introspection\PostgreSqlMaterializedViewIntrospector; use Th3Mouk\MaterializedView\Core\Introspection\ReadinessChecker; +use Th3Mouk\MaterializedView\Dbal\DbalConnection; #[Group('integration')] #[Group('introspection')] @@ -52,8 +53,9 @@ protected function tearDown(): void public function testIntrospectsAnUnpopulatedThenPopulatedMaterializedView(): void { $name = MaterializedViewName::create(self::SCHEMA, self::VIEW); - $introspector = new PostgreSqlMaterializedViewIntrospector($this->connection); - $readiness = new ReadinessChecker($this->connection); + $driver = new DbalConnection($this->connection); + $introspector = new PostgreSqlMaterializedViewIntrospector($driver); + $readiness = new ReadinessChecker($driver); $this->connection->executeStatement(\sprintf( 'CREATE MATERIALIZED VIEW %s.%s AS SELECT 1 AS id, 42 AS score WITH NO DATA', @@ -104,7 +106,7 @@ public function testIntrospectsAnUnpopulatedThenPopulatedMaterializedView(): voi public function testFindReturnsNullForAbsentView(): void { - $introspector = new PostgreSqlMaterializedViewIntrospector($this->connection); + $introspector = new PostgreSqlMaterializedViewIntrospector(new DbalConnection($this->connection)); self::assertNull($introspector->find(MaterializedViewName::create(self::SCHEMA, 'matview_absent_probe'))); self::assertFalse($introspector->exists(MaterializedViewName::create(self::SCHEMA, 'matview_absent_probe'))); diff --git a/tests/Integration/Lock/LaneLockTest.php b/tests/Integration/Lock/LaneLockTest.php index 214cb3d..d271509 100644 --- a/tests/Integration/Lock/LaneLockTest.php +++ b/tests/Integration/Lock/LaneLockTest.php @@ -11,6 +11,7 @@ use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Th3Mouk\MaterializedView\Core\Lock\LaneLock; +use Th3Mouk\MaterializedView\Dbal\DbalConnection; #[Group('lock')] #[Group('integration')] @@ -52,8 +53,8 @@ protected function tearDown(): void */ public function testHeldLaneBlocksAnotherSession(): void { - $heldLane = new LaneLock($this->holder, self::LANE_NAMESPACE); - $contendingLane = new LaneLock($this->contender, self::LANE_NAMESPACE); + $heldLane = new LaneLock(new DbalConnection($this->holder), self::LANE_NAMESPACE); + $contendingLane = new LaneLock(new DbalConnection($this->contender), self::LANE_NAMESPACE); $heldLane->acquire(); @@ -76,7 +77,7 @@ public function testHeldLaneBlocksAnotherSession(): void */ public function testReleaseReportsWhetherLockWasHeld(): void { - $lane = new LaneLock($this->holder, self::LANE_NAMESPACE); + $lane = new LaneLock(new DbalConnection($this->holder), self::LANE_NAMESPACE); self::assertFalse($lane->release(), 'Releasing a lane lock never taken returns false.'); diff --git a/tests/Integration/Lock/ViewRefreshLockTest.php b/tests/Integration/Lock/ViewRefreshLockTest.php index 7d52147..695a913 100644 --- a/tests/Integration/Lock/ViewRefreshLockTest.php +++ b/tests/Integration/Lock/ViewRefreshLockTest.php @@ -14,6 +14,7 @@ use Th3Mouk\MaterializedView\Core\Lock\LaneLock; use Th3Mouk\MaterializedView\Core\Lock\StableLockKeyGenerator; use Th3Mouk\MaterializedView\Core\Lock\ViewRefreshLock; +use Th3Mouk\MaterializedView\Dbal\DbalConnection; #[Group('lock')] #[Group('integration')] @@ -62,8 +63,8 @@ public function testSameViewSerializesAcrossSessions(): void { $view = MaterializedViewName::fromString('public.sales_by_category'); - $heldLock = new ViewRefreshLock($this->holder, $this->keyGenerator); - $contendingLock = new ViewRefreshLock($this->contender, $this->keyGenerator); + $heldLock = new ViewRefreshLock(new DbalConnection($this->holder), $this->keyGenerator); + $contendingLock = new ViewRefreshLock(new DbalConnection($this->contender), $this->keyGenerator); $heldLock->acquire($view); @@ -85,8 +86,8 @@ public function testDistinctViewsDoNotBlockEachOther(): void $first = MaterializedViewName::fromString('public.sales_by_category'); $second = MaterializedViewName::fromString('public.orders'); - $firstLock = new ViewRefreshLock($this->holder, $this->keyGenerator); - $secondLock = new ViewRefreshLock($this->contender, $this->keyGenerator); + $firstLock = new ViewRefreshLock(new DbalConnection($this->holder), $this->keyGenerator); + $secondLock = new ViewRefreshLock(new DbalConnection($this->contender), $this->keyGenerator); $firstLock->acquire($first); @@ -105,8 +106,8 @@ public function testDistinctViewsDoNotBlockEachOther(): void public function testRefreshAndLaneKeySpacesDoNotCollide(): void { $view = MaterializedViewName::fromString('public.sales_by_category'); - $refreshLock = new ViewRefreshLock($this->holder, $this->keyGenerator); - $laneLock = new LaneLock($this->contender, self::LANE_NAMESPACE); + $refreshLock = new ViewRefreshLock(new DbalConnection($this->holder), $this->keyGenerator); + $laneLock = new LaneLock(new DbalConnection($this->contender), self::LANE_NAMESPACE); $refreshLock->acquire($view); diff --git a/tests/Integration/Pdo/PdoConnectionTest.php b/tests/Integration/Pdo/PdoConnectionTest.php new file mode 100644 index 0000000..9cb4cb2 --- /dev/null +++ b/tests/Integration/Pdo/PdoConnectionTest.php @@ -0,0 +1,124 @@ +pdo = self::pdoFromUrl($dsn); + $this->pdo->query('SELECT 1'); + } catch (PDOException $exception) { + self::markTestSkipped('PostgreSQL is not reachable: '.$exception->getMessage()); + } + } + + public function testRunsQueriesThroughTheBarePdoHandle(): void + { + $connection = new PdoConnection($this->pdo); + + self::assertSame(1, (int) $connection->fetchOne('SELECT 1')); + + $rows = $connection->fetchAllAssociative( + 'SELECT id, label FROM (VALUES (1, :label)) AS t(id, label)', + ['label' => 'matview'], + ); + + self::assertCount(1, $rows); + self::assertSame('matview', $rows[0]['label']); + self::assertSame(1, (int) $rows[0]['id']); + + self::assertFalse($connection->fetchAssociative('SELECT 1 WHERE false')); + } + + public function testTypedParametersAndTransactionalCommit(): void + { + $connection = new PdoConnection($this->pdo); + + $count = $connection->transactional(static function (Connection $tx): int { + $tx->executeStatement('CREATE TEMP TABLE matview_pdo_probe (id int) ON COMMIT DROP'); + $tx->executeStatement('INSERT INTO matview_pdo_probe (id) VALUES (?)', [7], [ParameterType::Integer]); + + return (int) $tx->fetchOne('SELECT count(*) FROM matview_pdo_probe WHERE id = ?', [7], [ParameterType::Integer]); + }); + + self::assertSame(1, $count); + } + + public function testRollsBackWhenTheOperationThrows(): void + { + $connection = new PdoConnection($this->pdo); + + try { + $connection->transactional(static function (Connection $tx): void { + $tx->executeStatement('CREATE TEMP TABLE matview_pdo_rollback (id int)'); + + throw new RuntimeException('boom'); + }); + } catch (RuntimeException $exception) { + self::assertSame('boom', $exception->getMessage()); + } + + self::assertFalse($this->pdo->inTransaction(), 'The failed transaction must have been rolled back.'); + } + + public function testWrapsFailuresAsDatabaseExceptionWithSqlState(): void + { + $connection = new PdoConnection($this->pdo); + + try { + $connection->executeStatement('REFRESH MATERIALIZED VIEW matview_pdo_absent'); + self::fail('Expected a DatabaseException.'); + } catch (DatabaseException $exception) { + self::assertSame('42P01', $exception->sqlState()); + } + } + + public function testEnsureConnectedToPrimaryIsANoOp(): void + { + new PdoConnection($this->pdo)->ensureConnectedToPrimary(); + + $this->addToAssertionCount(1); + } + + private static function pdoFromUrl(string $url): PDO + { + $parts = parse_url($url); + + if (false === $parts || !isset($parts['host'])) { + self::markTestSkipped('MATVIEW_TEST_DATABASE_URL could not be parsed into a PDO DSN.'); + } + + $dsn = \sprintf( + 'pgsql:host=%s;port=%d;dbname=%s', + $parts['host'], + $parts['port'] ?? 5432, + ltrim($parts['path'] ?? '', '/'), + ); + + return new PDO($dsn, $parts['user'] ?? null, isset($parts['pass']) ? rawurldecode($parts['pass']) : null); + } +} diff --git a/tests/Unit/Dbal/DbalConnectionTest.php b/tests/Unit/Dbal/DbalConnectionTest.php new file mode 100644 index 0000000..ba959a7 --- /dev/null +++ b/tests/Unit/Dbal/DbalConnectionTest.php @@ -0,0 +1,110 @@ +createMock(DoctrineConnection::class); + $inner->expects(self::once()) + ->method('executeStatement') + ->with( + 'SELECT pg_advisory_lock(?, ?)', + [10, 20], + [DbalParameterType::INTEGER, DbalParameterType::INTEGER], + ) + ->willReturn('3'); + + $affected = new DbalConnection($inner)->executeStatement( + 'SELECT pg_advisory_lock(?, ?)', + [10, 20], + [ParameterType::Integer, ParameterType::Integer], + ); + + self::assertSame(3, $affected); + } + + public function testFetchHelpersDelegateToTheConnection(): void + { + $inner = $this->createStub(DoctrineConnection::class); + $inner->method('fetchOne')->willReturn('t'); + $inner->method('fetchAllAssociative')->willReturn([['id' => 1]]); + $inner->method('fetchAssociative')->willReturn(['id' => 1]); + + $connection = new DbalConnection($inner); + + self::assertSame('t', $connection->fetchOne('SELECT 1')); + self::assertSame([['id' => 1]], $connection->fetchAllAssociative('SELECT 1')); + self::assertSame(['id' => 1], $connection->fetchAssociative('SELECT 1')); + } + + public function testTransactionalRunsTheOperationWithTheAdapterItself(): void + { + $inner = $this->createStub(DoctrineConnection::class); + $inner->method('transactional')->willReturnCallback(static fn (Closure $fn): mixed => $fn($inner)); + + $connection = new DbalConnection($inner); + + $received = null; + $result = $connection->transactional(function (Connection $passed) use (&$received): string { + $received = $passed; + + return 'done'; + }); + + self::assertSame('done', $result); + self::assertSame($connection, $received); + } + + public function testWrapsDriverFailureAsDatabaseExceptionCarryingSqlState(): void + { + $inner = $this->createStub(DoctrineConnection::class); + $inner->method('executeStatement')->willThrowException( + new DriverException( + new class('relation does not exist', '42P01') extends DriverAbstractException {}, + null, + ), + ); + + try { + new DbalConnection($inner)->executeStatement('REFRESH MATERIALIZED VIEW x'); + self::fail('Expected a DatabaseException.'); + } catch (DatabaseException $exception) { + self::assertSame('42P01', $exception->sqlState()); + } + } + + public function testEnsureConnectedToPrimaryRoutesPrimaryReplicaConnections(): void + { + $inner = $this->createMock(PrimaryReadReplicaConnection::class); + $inner->expects(self::once())->method('ensureConnectedToPrimary'); + + new DbalConnection($inner)->ensureConnectedToPrimary(); + } + + public function testEnsureConnectedToPrimaryIsANoOpForPlainConnections(): void + { + $inner = $this->createStub(DoctrineConnection::class); + + new DbalConnection($inner)->ensureConnectedToPrimary(); + + $this->addToAssertionCount(1); + } +} diff --git a/tests/Unit/Dependency/CatalogDependencyResolverTest.php b/tests/Unit/Dependency/CatalogDependencyResolverTest.php index 963c962..a2e15e0 100644 --- a/tests/Unit/Dependency/CatalogDependencyResolverTest.php +++ b/tests/Unit/Dependency/CatalogDependencyResolverTest.php @@ -4,9 +4,9 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Dependency; -use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Dependency\CatalogDependencyResolver; use Th3Mouk\MaterializedView\Core\Registry\MaterializedViewRegistry; diff --git a/tests/Unit/Dependency/ExternalDependencyGuardTest.php b/tests/Unit/Dependency/ExternalDependencyGuardTest.php index cd70768..c11511a 100644 --- a/tests/Unit/Dependency/ExternalDependencyGuardTest.php +++ b/tests/Unit/Dependency/ExternalDependencyGuardTest.php @@ -4,9 +4,9 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Dependency; -use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Dependency\CatalogDependencyResolver; diff --git a/tests/Unit/DoctrineOrm/Readiness/MaterializedViewReadinessGuardTest.php b/tests/Unit/DoctrineOrm/Readiness/MaterializedViewReadinessGuardTest.php index b472ad6..ebf20fd 100644 --- a/tests/Unit/DoctrineOrm/Readiness/MaterializedViewReadinessGuardTest.php +++ b/tests/Unit/DoctrineOrm/Readiness/MaterializedViewReadinessGuardTest.php @@ -4,11 +4,11 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\DoctrineOrm\Readiness; -use Doctrine\DBAL\Connection; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Exception\ViewNotPopulated; use Th3Mouk\MaterializedView\Core\Introspection\ReadinessChecker; use Th3Mouk\MaterializedView\DoctrineOrm\Exception\NotAMaterializedViewEntity; diff --git a/tests/Unit/Introspection/PostgreSqlMaterializedViewIntrospectorTest.php b/tests/Unit/Introspection/PostgreSqlMaterializedViewIntrospectorTest.php index 0242236..1e6ae22 100644 --- a/tests/Unit/Introspection/PostgreSqlMaterializedViewIntrospectorTest.php +++ b/tests/Unit/Introspection/PostgreSqlMaterializedViewIntrospectorTest.php @@ -4,10 +4,10 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Introspection; -use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Introspection\PostgreSqlMaterializedViewIntrospector; diff --git a/tests/Unit/Introspection/ReadinessCheckerTest.php b/tests/Unit/Introspection/ReadinessCheckerTest.php index 6874db9..12beb38 100644 --- a/tests/Unit/Introspection/ReadinessCheckerTest.php +++ b/tests/Unit/Introspection/ReadinessCheckerTest.php @@ -4,10 +4,10 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Introspection; -use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Exception\ViewNotPopulated; use Th3Mouk\MaterializedView\Core\Introspection\ReadinessChecker; diff --git a/tests/Unit/Lock/PrimaryConnectionGuardTest.php b/tests/Unit/Lock/PrimaryConnectionGuardTest.php index 483caba..e512b29 100644 --- a/tests/Unit/Lock/PrimaryConnectionGuardTest.php +++ b/tests/Unit/Lock/PrimaryConnectionGuardTest.php @@ -4,39 +4,19 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Lock; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Connections\PrimaryReadReplicaConnection; -use Doctrine\DBAL\Exception; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Lock\PrimaryConnectionGuard; #[Group('lock')] final class PrimaryConnectionGuardTest extends TestCase { - /** - * @throws Exception - */ - public function testRoutesPrimaryReplicaConnectionToPrimary(): void - { - $connection = $this->createMock(PrimaryReadReplicaConnection::class); - $connection->expects(self::once())->method('ensureConnectedToPrimary'); - - $guard = new PrimaryConnectionGuard($connection); - - $guard->ensureConnectedToPrimary(); - } - - /** - * @throws Exception - */ - public function testLeavesPlainConnectionUntouched(): void + public function testDelegatesToTheConnection(): void { $connection = $this->createMock(Connection::class); - $connection->expects(self::never())->method(self::anything()); - - $guard = new PrimaryConnectionGuard($connection); + $connection->expects(self::once())->method('ensureConnectedToPrimary'); - $guard->ensureConnectedToPrimary(); + new PrimaryConnectionGuard($connection)->ensureConnectedToPrimary(); } } diff --git a/tests/Unit/Lock/ViewRefreshLockTest.php b/tests/Unit/Lock/ViewRefreshLockTest.php index a433136..b7e523b 100644 --- a/tests/Unit/Lock/ViewRefreshLockTest.php +++ b/tests/Unit/Lock/ViewRefreshLockTest.php @@ -4,10 +4,10 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Lock; -use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Psr\Log\LogLevel; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Lock\StableLockKeyGenerator; use Th3Mouk\MaterializedView\Core\Lock\ViewRefreshLock; diff --git a/tests/Unit/MaterializedViewManagerTest.php b/tests/Unit/MaterializedViewManagerTest.php index 6947afb..45b8e15 100644 --- a/tests/Unit/MaterializedViewManagerTest.php +++ b/tests/Unit/MaterializedViewManagerTest.php @@ -4,10 +4,10 @@ namespace Th3Mouk\MaterializedView\Tests\Unit; -use Doctrine\DBAL\Exception\DriverException; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Psr\Log\LogLevel; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; use Th3Mouk\MaterializedView\Core\Definition\InlineSqlSource; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewIndex; @@ -28,7 +28,7 @@ final class MaterializedViewManagerTest extends TestCase public function testCreateEmitsDropCreateIndexCommentSequence(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this, [], [], $executed), ); @@ -55,7 +55,7 @@ public function testCreateEmitsDropCreateIndexCommentSequence(): void public function testDropEmitsDropIfExists(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this, [], [], $executed), ); @@ -67,7 +67,7 @@ public function testDropEmitsDropIfExists(): void public function testDropRefusesWhenAnUnmanagedDependentExists(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, executed: $executed, @@ -89,7 +89,7 @@ public function testDropRefusesWhenAnUnmanagedDependentExists(): void public function testRefuseDropPolicyEmitsNoCascadeWhenAnUnmanagedDependentExists(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, executed: $executed, @@ -111,7 +111,7 @@ public function testRefuseDropPolicyEmitsNoCascadeWhenAnUnmanagedDependentExists public function testCascadeDropPolicyEmitsDropCascadeAndDoesNotThrowDespiteAnUnmanagedDependent(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, executed: $executed, @@ -127,7 +127,7 @@ public function testCascadeDropPolicyEmitsDropCascadeAndDoesNotThrowDespiteAnUnm public function testCreateRefusesWhenAnUnmanagedDependentExists(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, executed: $executed, @@ -149,7 +149,7 @@ public function testCreateRefusesWhenAnUnmanagedDependentExists(): void public function testNonConcurrentRefreshLocksRefreshesThenAnalyzes(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this, [], [], $executed), ); @@ -168,7 +168,7 @@ public function testNonConcurrentRefreshLocksRefreshesThenAnalyzes(): void public function testRefreshWithNoDataDoesNotAnalyzeTheUnpopulatedView(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this, [], [], $executed), ); @@ -183,7 +183,7 @@ public function testRefreshWithNoDataDoesNotAnalyzeTheUnpopulatedView(): void public function testRefreshAppliesAndResetsTimeouts(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this, [], [], $executed), ); @@ -200,7 +200,7 @@ public function testRefreshAppliesAndResetsTimeouts(): void public function testConcurrentRefreshRefusesWhenViewIsAbsent(): void { - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this), ); @@ -211,7 +211,7 @@ public function testConcurrentRefreshRefusesWhenViewIsAbsent(): void public function testConcurrentRefreshRefusesWhenViewExistsButIsNotPopulated(): void { - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, ['public' => [FakeConnectionFactory::matviewRow('public', 'summary', null, isPopulated: false)]], @@ -226,7 +226,7 @@ public function testConcurrentRefreshRefusesWhenViewExistsButIsNotPopulated(): v public function testConcurrentRefreshRefusesWhenNoFullUniqueIndexIsDeclared(): void { - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, ['public' => [FakeConnectionFactory::matviewRow('public', 'summary', null)]], @@ -242,7 +242,7 @@ public function testConcurrentRefreshRefusesWhenNoFullUniqueIndexIsDeclared(): v public function testConcurrentRefreshEmitsConcurrentlyWhenPreconditionsHold(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, ['public' => [FakeConnectionFactory::matviewRow('public', 'summary', null)]], @@ -259,7 +259,7 @@ public function testConcurrentRefreshEmitsConcurrentlyWhenPreconditionsHold(): v public function testSyncDelegatesToTheSynchronizerAndCreatesAbsentView(): void { $executed = []; - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this, [], [], $executed), ); @@ -273,7 +273,7 @@ public function testRefreshEmitsAnInfoWithDurationMilliseconds(): void { $executed = []; $logger = new CollectingLogger(); - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create($this, [], [], $executed), $logger, ); @@ -294,7 +294,7 @@ public function testRefreshAllEmitsStartedAndCompletedRollups(): void { $executed = []; $logger = new CollectingLogger(); - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, executed: $executed, @@ -330,7 +330,7 @@ public function testRefreshAllLogsTheFailingViewAndPartialRollupBeforeRethrowing { $executed = []; $logger = new CollectingLogger(); - $manager = MaterializedViewManager::forConnection( + $manager = MaterializedViewManager::forDriver( FakeConnectionFactory::create( $this, executed: $executed, @@ -346,7 +346,7 @@ public function testRefreshAllLogsTheFailingViewAndPartialRollupBeforeRethrowing $this->rollupDefinition(), ])); self::fail('Expected the refresh failure to propagate to the caller.'); - } catch (DriverException) { + } catch (DatabaseException) { // expected: refresh-all aborts at the failing view and the error propagates. } diff --git a/tests/Unit/Privilege/GrantStatementGeneratorTest.php b/tests/Unit/Privilege/GrantStatementGeneratorTest.php index 117a9fc..8e3ae6c 100644 --- a/tests/Unit/Privilege/GrantStatementGeneratorTest.php +++ b/tests/Unit/Privilege/GrantStatementGeneratorTest.php @@ -4,7 +4,6 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Privilege; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; @@ -20,9 +19,7 @@ final class GrantStatementGeneratorTest extends TestCase protected function setUp(): void { - $this->generator = new GrantStatementGenerator( - IdentifierQuoter::forPlatform(new PostgreSQLPlatform()), - ); + $this->generator = new GrantStatementGenerator(new IdentifierQuoter()); } public function testGeneratesNoStatementsForEmptySnapshot(): void diff --git a/tests/Unit/Privilege/PrivilegeReplayerTest.php b/tests/Unit/Privilege/PrivilegeReplayerTest.php index c525fef..fd8bf9f 100644 --- a/tests/Unit/Privilege/PrivilegeReplayerTest.php +++ b/tests/Unit/Privilege/PrivilegeReplayerTest.php @@ -4,10 +4,9 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Privilege; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Privilege\GrantStatementGenerator; use Th3Mouk\MaterializedView\Core\Privilege\ObjectPrivilege; @@ -48,7 +47,7 @@ public function testReplaysEveryGeneratedGrantStatement(): void ], ); - $replayer = new PrivilegeReplayer($this->connection, new GrantStatementGenerator(IdentifierQuoter::forPlatform(new PostgreSQLPlatform()))); + $replayer = new PrivilegeReplayer($this->connection, new GrantStatementGenerator(new IdentifierQuoter())); $count = $replayer->replay($snapshot); @@ -66,7 +65,7 @@ public function testEmptySnapshotExecutesNothing(): void { $snapshot = PrivilegeSnapshot::empty(MaterializedViewName::create('public', 'sales_by_category')); - $replayer = new PrivilegeReplayer($this->connection, new GrantStatementGenerator(IdentifierQuoter::forPlatform(new PostgreSQLPlatform()))); + $replayer = new PrivilegeReplayer($this->connection, new GrantStatementGenerator(new IdentifierQuoter())); $count = $replayer->replay($snapshot); diff --git a/tests/Unit/Privilege/PrivilegeSnapshotterTest.php b/tests/Unit/Privilege/PrivilegeSnapshotterTest.php index a11f09e..b945200 100644 --- a/tests/Unit/Privilege/PrivilegeSnapshotterTest.php +++ b/tests/Unit/Privilege/PrivilegeSnapshotterTest.php @@ -4,9 +4,9 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Privilege; -use Doctrine\DBAL\Connection; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Privilege\ObjectPrivilege; use Th3Mouk\MaterializedView\Core\Privilege\PrivilegeSnapshotter; diff --git a/tests/Unit/Rebuild/RebuildTestConnectionFactory.php b/tests/Unit/Rebuild/RebuildTestConnectionFactory.php index da8b278..db292f7 100644 --- a/tests/Unit/Rebuild/RebuildTestConnectionFactory.php +++ b/tests/Unit/Rebuild/RebuildTestConnectionFactory.php @@ -5,30 +5,15 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Rebuild; use Closure; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; final readonly class RebuildTestConnectionFactory { public static function create(TestCase $testCase): Connection&Stub { - $platform = new PostgreSQLPlatform(); - - $connection = self::mock($testCase); - - $connection - ->method('getDatabasePlatform') - ->willReturn($platform); - - $connection - ->method('quoteSingleIdentifier') - ->willReturnCallback( - static fn (string $identifier): string => $platform->quoteSingleIdentifier($identifier), - ); - - return $connection; + return self::mock($testCase); } /** @@ -70,16 +55,7 @@ public static function recording(TestCase $testCase, array &$sink): Connection&S private static function mock(TestCase $testCase): Connection&Stub { $build = Closure::bind( - static fn (TestCase $case): Connection&Stub => $case->getStubBuilder(Connection::class) - ->disableOriginalConstructor() - ->onlyMethods([ - 'getDatabasePlatform', - 'quoteSingleIdentifier', - 'fetchAllAssociative', - 'executeStatement', - 'transactional', - ]) - ->getStub(), + static fn (TestCase $case): Connection&Stub => $case->getStubBuilder(Connection::class)->getStub(), null, TestCase::class, ); diff --git a/tests/Unit/Sql/IdentifierQuoterTest.php b/tests/Unit/Sql/IdentifierQuoterTest.php index 548e69c..e2f7cb5 100644 --- a/tests/Unit/Sql/IdentifierQuoterTest.php +++ b/tests/Unit/Sql/IdentifierQuoterTest.php @@ -4,7 +4,6 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Sql; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; @@ -17,7 +16,7 @@ final class IdentifierQuoterTest extends TestCase protected function setUp(): void { - $this->quoter = IdentifierQuoter::forPlatform(new PostgreSQLPlatform()); + $this->quoter = new IdentifierQuoter(); } public function testQuotesSimpleIdentifier(): void diff --git a/tests/Unit/Sql/MissingDependencySqlStateTest.php b/tests/Unit/Sql/MissingDependencySqlStateTest.php index 3f121ca..e4a8588 100644 --- a/tests/Unit/Sql/MissingDependencySqlStateTest.php +++ b/tests/Unit/Sql/MissingDependencySqlStateTest.php @@ -4,12 +4,11 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Sql; -use Doctrine\DBAL\Driver\AbstractException as DriverAbstractException; -use Doctrine\DBAL\Exception\DriverException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use RuntimeException; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; use Th3Mouk\MaterializedView\Core\Sql\MissingDependencySqlState; #[Group('sync')] @@ -18,9 +17,9 @@ final class MissingDependencySqlStateTest extends TestCase #[DataProvider('missingDependencySqlStateProvider')] public function testRecognisesMissingDependencySqlStates(string $sqlState): void { - $exception = new DriverException(self::driverException($sqlState), null); - - self::assertTrue(MissingDependencySqlState::isMissingDependency($exception)); + self::assertTrue(MissingDependencySqlState::isMissingDependency( + new DatabaseException('driver failure', $sqlState), + )); } /** @@ -32,39 +31,31 @@ public static function missingDependencySqlStateProvider(): iterable yield 'invalid schema name 3F000' => ['3F000']; } - public function testInspectsTheSqlStateThroughTheExceptionChainNotTheMessage(): void + public function testInspectsTheSqlStateThroughTheException(): void { - $exception = new DriverException(self::driverException('42P01', 'totally unrelated wording'), null); - - self::assertTrue(MissingDependencySqlState::isMissingDependency($exception)); + self::assertTrue(MissingDependencySqlState::isMissingDependency( + new DatabaseException('totally unrelated wording', '42P01'), + )); } public function testDoesNotMatchOnMessageTextWhenSqlStateIsUnrelated(): void { - $exception = new DriverException( - self::driverException('42703', 'relation "x" does not exist undefined_table 42P01'), - null, - ); - - self::assertFalse(MissingDependencySqlState::isMissingDependency($exception)); + self::assertFalse(MissingDependencySqlState::isMissingDependency( + new DatabaseException('relation "x" does not exist undefined_table 42P01', '42703'), + )); } public function testFindsTheSqlStateEvenWhenWrappedDeepInTheChain(): void { - $driver = self::driverException('3F000'); - $wrapped = new DriverException($driver, null); - $outer = new RuntimeException('synchronizer failed', 0, $wrapped); + $inner = new DatabaseException('driver failure', '3F000'); + $outer = new RuntimeException('synchronizer failed', 0, $inner); self::assertTrue(MissingDependencySqlState::isMissingDependency($outer)); } - public function testIgnoresPlainThrowablesWithoutAnySqlState(): void + public function testIgnoresThrowablesWithoutASqlState(): void { self::assertFalse(MissingDependencySqlState::isMissingDependency(new RuntimeException('boom'))); - } - - private static function driverException(string $sqlState, string $message = 'driver failure'): DriverAbstractException - { - return new class($message, $sqlState) extends DriverAbstractException {}; + self::assertFalse(MissingDependencySqlState::isMissingDependency(new DatabaseException('boom'))); } } diff --git a/tests/Unit/Sql/PostgreSqlMaterializedViewSqlGeneratorTest.php b/tests/Unit/Sql/PostgreSqlMaterializedViewSqlGeneratorTest.php index 653fae6..fea37e7 100644 --- a/tests/Unit/Sql/PostgreSqlMaterializedViewSqlGeneratorTest.php +++ b/tests/Unit/Sql/PostgreSqlMaterializedViewSqlGeneratorTest.php @@ -4,7 +4,6 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Sql; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Th3Mouk\MaterializedView\Core\Definition\InlineSqlSource; @@ -27,9 +26,7 @@ final class PostgreSqlMaterializedViewSqlGeneratorTest extends TestCase protected function setUp(): void { - $this->generator = new PostgreSqlMaterializedViewSqlGenerator( - IdentifierQuoter::forPlatform(new PostgreSQLPlatform()), - ); + $this->generator = new PostgreSqlMaterializedViewSqlGenerator(new IdentifierQuoter()); } public function testCreateWithNoData(): void diff --git a/tests/Unit/Support/FakeConnectionFactory.php b/tests/Unit/Support/FakeConnectionFactory.php index c8e31c4..0b5a441 100644 --- a/tests/Unit/Support/FakeConnectionFactory.php +++ b/tests/Unit/Support/FakeConnectionFactory.php @@ -5,12 +5,10 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Support; use Closure; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Driver\AbstractException as DriverAbstractException; -use Doctrine\DBAL\Exception\DriverException; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; +use Th3Mouk\MaterializedView\Core\Database\Connection; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; final readonly class FakeConnectionFactory { @@ -34,33 +32,14 @@ public static function create( array $createFailureSqlStateByView = [], array $refreshFailureSqlStateByView = [], ): Connection&Stub { - $platform = new PostgreSQLPlatform(); - $buildConnection = Closure::bind( - static fn (TestCase $case): Connection&Stub => $case->getStubBuilder(Connection::class) - ->disableOriginalConstructor() - ->onlyMethods([ - 'getDatabasePlatform', - 'quoteSingleIdentifier', - 'fetchAllAssociative', - 'fetchAssociative', - 'fetchOne', - 'executeStatement', - 'transactional', - ]) - ->getStub(), + static fn (TestCase $case): Connection&Stub => $case->getStubBuilder(Connection::class)->getStub(), null, TestCase::class, ); $connection = $buildConnection($testCase); - $connection->method('getDatabasePlatform')->willReturn($platform); - - $connection - ->method('quoteSingleIdentifier') - ->willReturnCallback(static fn (string $identifier): string => $platform->quoteSingleIdentifier($identifier)); - $connection ->method('fetchAllAssociative') ->willReturnCallback( @@ -125,7 +104,7 @@ static function (string $sql, array $params = []) use (&$executed, $matviewRowsB if (str_contains($sql, 'CREATE MATERIALIZED VIEW')) { foreach ($createFailureSqlStateByView as $view => $sqlState) { if (str_contains($sql, self::quoteQualified($view))) { - throw self::dbalDriverException($sqlState); + throw new DatabaseException('relation does not exist', $sqlState); } } } @@ -133,7 +112,7 @@ static function (string $sql, array $params = []) use (&$executed, $matviewRowsB if (str_contains($sql, 'REFRESH MATERIALIZED VIEW')) { foreach ($refreshFailureSqlStateByView as $view => $sqlState) { if (str_contains($sql, self::quoteQualified($view))) { - throw self::dbalDriverException($sqlState); + throw new DatabaseException('relation does not exist', $sqlState); } } } @@ -256,11 +235,4 @@ private static function quoteQualified(string $qualifiedName): string return \sprintf('"%s"."%s"', $schema, $name); } - - private static function dbalDriverException(string $sqlState): DriverException - { - $driverException = new class('relation does not exist', $sqlState) extends DriverAbstractException {}; - - return new DriverException($driverException, null); - } } diff --git a/tests/Unit/Sync/MaterializedViewSynchronizerTest.php b/tests/Unit/Sync/MaterializedViewSynchronizerTest.php index a194045..42498ea 100644 --- a/tests/Unit/Sync/MaterializedViewSynchronizerTest.php +++ b/tests/Unit/Sync/MaterializedViewSynchronizerTest.php @@ -4,15 +4,14 @@ namespace Th3Mouk\MaterializedView\Tests\Unit\Sync; -use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Exception\DriverException; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\Stub; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Psr\Log\LogLevel; use Psr\Log\NullLogger; +use Th3Mouk\MaterializedView\Core\Database\Connection; +use Th3Mouk\MaterializedView\Core\Database\DatabaseException; use Th3Mouk\MaterializedView\Core\Definition\InlineSqlSource; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewIndex; @@ -353,7 +352,7 @@ public function testFailPolicyRethrowsWhenAReferencedTableIsMissing(): void createFailureSqlStateByView: ['public.summary' => '42P01'], ); - $this->expectException(DriverException::class); + $this->expectException(DatabaseException::class); $this->synchronizerFor($connection)->synchronize( MaterializedViewRegistry::fromDefinitions([$this->summaryDefinition()]), @@ -380,7 +379,7 @@ public function testFailPolicyLogsTheFailingViewAndAnAggregateRollupBeforeRethro MaterializedViewRegistry::fromDefinitions([$broken, $this->summaryDefinition()]), ); self::fail('Expected the missing dependency to propagate under the default fail policy.'); - } catch (DriverException) { + } catch (DatabaseException) { // expected: under the fail policy the error propagates to the caller untouched. } @@ -453,7 +452,7 @@ public function testSkipPolicyStillRethrowsUnrelatedDatabaseErrors(): void createFailureSqlStateByView: ['public.summary' => '42703'], ); - $this->expectException(DriverException::class); + $this->expectException(DatabaseException::class); $this->synchronizerFor($connection)->synchronize( MaterializedViewRegistry::fromDefinitions([$this->summaryDefinition()]), @@ -543,17 +542,18 @@ private function synchronizerFor(Connection&Stub $connection, ?LoggerInterface $ dependencyResolver: $resolver, externalDependencyGuard: new ExternalDependencyGuard($resolver), privilegeSnapshotter: new PrivilegeSnapshotter($connection), - grantStatementGenerator: new GrantStatementGenerator(IdentifierQuoter::forConnection($connection)), - sqlGenerator: $this->sqlGenerator($connection), + grantStatementGenerator: new GrantStatementGenerator(new IdentifierQuoter()), + sqlGenerator: $this->sqlGenerator(), introspector: $introspector, hasher: $this->hasher, + quoter: new IdentifierQuoter(), logger: $logger ?? new NullLogger(), ); } - private function sqlGenerator(Connection&Stub $connection): PostgreSqlMaterializedViewSqlGenerator + private function sqlGenerator(): PostgreSqlMaterializedViewSqlGenerator { - return new PostgreSqlMaterializedViewSqlGenerator(IdentifierQuoter::forConnection($connection)); + return new PostgreSqlMaterializedViewSqlGenerator(new IdentifierQuoter()); } private function markerJson(MaterializedViewDefinition $definition): string @@ -569,7 +569,7 @@ private function quotedMarker(MaterializedViewDefinition $definition): string source: $definition->hasSqlSource() ? $definition->sqlSource()->identifier() : null, ); - return new PostgreSQLPlatform()->quoteStringLiteral($marker->toJson()); + return new IdentifierQuoter()->quoteStringLiteral($marker->toJson()); } private function summaryDefinition(): MaterializedViewDefinition