Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
38 changes: 29 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -17,17 +17,23 @@ 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

```bash
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)

Expand Down Expand Up @@ -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)
```
Expand All @@ -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

Expand Down
10 changes: 7 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -9,6 +9,7 @@
"matview",
"doctrine",
"dbal",
"pdo",
"refresh",
"analytics",
"reporting",
Expand All @@ -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."
},
Expand All @@ -50,7 +54,7 @@
},
"extra": {
"branch-alias": {
"dev-main": "1.2.x-dev"
"dev-main": "1.3.x-dev"
}
},
"config": {
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 21 additions & 5 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand All @@ -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.

Expand Down
83 changes: 83 additions & 0 deletions docs/guide/connection-backends.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/guide/refresh-and-locking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading