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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Only observability is added — the exception still propagates unchanged, so the
caller keeps full control over exit behaviour while operators get the failing
view and partial progress in the logs.
- **Reactive dependency-conflict primitives**, enabling a consumer (such as the
Symfony bundle's deploy lane) to clear a migration blocked by a managed
materialized view by dropping *only* the conflicting closure instead of every
managed view:
- `Core\Sql\DependencyConflictSqlState` recognises the two SQLSTATEs a blocking
view raises — `2BP01` (`dependent_objects_still_exist`, a blocked
`DROP TABLE` / `DROP COLUMN`) and `0A000` (`feature_not_supported`, a blocked
`ALTER COLUMN ... TYPE`) — by walking the DBAL driver-exception chain, never
the locale-dependent message text.
- `Core\Dependency\PostgresDependencyConflict` parses such an error
(best-effort, locale-aware) into the blocked relation and the dependent
objects it names; `Core\Sql\QualifiedName` adds the quote-aware identifier
scanner this requires.
- `Core\Dependency\CatalogDependencyResolver::resolveConflictClosure()` resolves,
from the system catalog, the transitive **managed** matview dependents of an
arbitrary relation (typically a plain table, which is not a node in the
managed graph) in safe drop order, plus any unmanaged dependents found.
- `Core\MaterializedViewManager::dropConflictClosure()` drops that closure in
order, refusing (`UnmanagedDependentFound`) when it contains an unmanaged
dependent unless `DropDependentPolicy::Cascade` is given. The drop set is
always confirmed against the catalog, never from the error text alone.
- `Core\Sql\ManagementMarker` gains read-side helpers (`isManagedComment()`,
`readHash()`) so a catalog comment can be classified as library-managed.

These are framework-agnostic primitives only; no existing behaviour changes.

## [1.1.0] - 2026-06-05

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
},
"extra": {
"branch-alias": {
"dev-main": "1.1.x-dev"
"dev-main": "1.2.x-dev"
}
},
"config": {
Expand Down
9 changes: 9 additions & 0 deletions docs/internals/postgresql-references.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ Without `deptype = 'n'` and the self-exclusion, the `_RETURN` rule reports the v
- **DROP MATERIALIZED VIEW** — <https://www.postgresql.org/docs/17/sql-dropmaterializedview.html>
- Without `CASCADE`, the drop **fails** if any object depends on the view. The library never adds `CASCADE` implicitly; `ExternalDependencyGuard` refuses first.

### Reactive dependency conflicts (migration-blocked DDL)

When a schema migration tries to `DROP TABLE`/`DROP COLUMN` or `ALTER COLUMN … TYPE` on a relation a materialized view reads, PostgreSQL blocks it with one of two SQLSTATEs. `DependencyConflictSqlState` gates on the SQLSTATE (read from the DBAL driver-exception chain), never the message text — the wording is emitted in the server's `lc_messages`, which the application role cannot pin (`SET`/`PGOPTIONS` are superuser-only).

- **`2BP01`** `dependent_objects_still_exist` — a blocked `DROP TABLE` / `DROP COLUMN`. The `ERROR` line names the blocked relation; `DETAIL` lists the dependents (`<materialized view|view> <name> depends on …`).
- **`0A000`** `feature_not_supported` — a blocked `ALTER COLUMN … TYPE`. The `ERROR` line names neither table nor column; only `DETAIL` names the object, via its rewrite rule (`rule _RETURN on <materialized view|view> <name> …`). `0A000` is also raised by unrelated statements, so a gated SQLSTATE is necessary but never sufficient.

`PostgresDependencyConflict` parses the error best-effort (the `DETAIL` list is capped at 100 entries — "and N other objects" — so it is only ever a *seed*). The authoritative closure is always resolved from the catalog by `CatalogDependencyResolver::resolveConflictClosure()`: it resolves the seed once via **`to_regclass`** and then walks `pg_depend` → `pg_rewrite` by OID. Unlike the managed-graph query above, this walk seeds from an **arbitrary relation** (typically a plain table, `relkind 'r'`, which is *not* a node in the managed graph) and classifies each dependent as managed (matview carrying the `ManagementMarker` comment) or unmanaged. The precise blockers are the `DETAIL` dependents (the views actually using the column), so the consumer seeds the closure from each of them; the blocked-table walk is the fallback when `DETAIL` is unparsable. `MaterializedViewManager::dropConflictClosure()` drops the managed closure in dependents-first order and refuses (`UnmanagedDependentFound`) if any unmanaged dependent is present.

## Privileges & comments

- **GRANT** — <https://www.postgresql.org/docs/17/sql-grant.html> — dropping an object removes its privileges; a rebuild must re-apply them. See [Privileges](../guide/privileges.md).
Expand Down
220 changes: 220 additions & 0 deletions src/Core/Dependency/CatalogDependencyResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Doctrine\DBAL\Connection;
use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName;
use Th3Mouk\MaterializedView\Core\Registry\MaterializedViewRegistry;
use Th3Mouk\MaterializedView\Core\Sql\ManagementMarker;
use Th3Mouk\MaterializedView\Core\Sql\QualifiedName;

final readonly class CatalogDependencyResolver
{
Expand All @@ -32,6 +34,36 @@
AND referenced_class.relkind IN ('m', 'v')
SQL;

private const string DIRECT_DEPENDENTS_BY_OID_SQL = <<<'SQL'
SELECT DISTINCT
dependent.oid AS oid,
dependent_ns.nspname AS schema_name,
dependent.relname AS object_name,
dependent.relkind AS relkind,
COALESCE(obj_description(dependent.oid, 'pg_class'), '') AS comment
FROM pg_depend dep
JOIN pg_rewrite rule ON rule.oid = dep.objid AND dep.classid = 'pg_rewrite'::regclass
JOIN pg_class dependent ON dependent.oid = rule.ev_class
JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent.relnamespace
WHERE dep.refclassid = 'pg_class'::regclass
AND dep.refobjid = :oid
AND dep.deptype = 'n'
AND dependent.oid <> dep.refobjid
AND dependent.relkind IN ('m', 'v')
ORDER BY dependent_ns.nspname, dependent.relname
SQL;

private const string DESCRIBE_RELATION_SQL = <<<'SQL'
SELECT
relation_ns.nspname AS schema_name,
relation.relname AS object_name,
relation.relkind AS relkind,
COALESCE(obj_description(relation.oid, 'pg_class'), '') AS comment
FROM pg_class relation
JOIN pg_namespace relation_ns ON relation_ns.oid = relation.relnamespace
WHERE relation.oid = :oid
SQL;

public function __construct(
private Connection $connection,
) {
Expand Down Expand Up @@ -101,6 +133,194 @@ public function orderedForDrop(MaterializedViewRegistry $registry): array
return $this->graphForManagedViews($registry)->reverseTopologicallySorted();
}

/**
* Resolve, from the catalog, the transitive managed materialized-view dependents of an
* arbitrary relation (typically a plain table named in a 2BP01 DDL conflict — which is
* NOT a node in {@see graphForManagedViews}), in safe drop order (a view appears before
* the views it depends on), together with any unmanaged dependents found in the closure.
*
* The walk is by OID: the seed is resolved once via to_regclass(), then pg_depend is
* followed hop by hop. Plain-view intermediates are traversed so a matview → view →
* matview chain is not missed; a managed matview reachable only beyond an unmanaged
* object is still listed, and the unmanaged object is reported — the caller is the
* single chokepoint that refuses the whole drop when any unmanaged dependent is present.
*
* When $includeSeed is true the seed itself is classified and added to the closure: this
* is the 0A000 case, where the blocked object is the view named in the rewrite rule and
* must itself be dropped (it is ordered last, since its own dependents depend on it).
*/
public function resolveConflictClosure(QualifiedName $seed, bool $includeSeed = false): ConflictDropClosure
{
$seedOid = $this->resolveRelationOid($seed->render());

if (null === $seedOid) {
return ConflictDropClosure::empty();
}

/** @var array<int, true> $visited */
$visited = [$seedOid => true];
/** @var array<string, MaterializedViewName> $managed */
$managed = [];
/** @var array<string, true> $unmanaged */
$unmanaged = [];
/** @var list<array{dependent: string, referenced: string}> $edges */
$edges = [];
/** @var array<int, string|null> $managedNodeByOid */
$managedNodeByOid = [$seedOid => null];

if ($includeSeed) {
$seedRelation = $this->describeRelation($seedOid);

if (null !== $seedRelation) {
$managedNodeByOid[$seedOid] = $this->record($seedRelation, $managed, $unmanaged);
}
}

$queue = [$seedOid];

while ([] !== $queue) {
$currentOid = array_shift($queue);
$parentManagedNode = $managedNodeByOid[$currentOid] ?? null;

foreach ($this->directDependents($currentOid) as $dependent) {
$node = $this->record($dependent, $managed, $unmanaged);

if (null !== $node && null !== $parentManagedNode) {
$edges[] = ['dependent' => $node, 'referenced' => $parentManagedNode];
}

if (isset($visited[$dependent['oid']])) {
continue;
}

$visited[$dependent['oid']] = true;
$managedNodeByOid[$dependent['oid']] = $node;
$queue[] = $dependent['oid'];
}
}

return ConflictDropClosure::of(
$this->managedDropOrder($managed, $edges),
$this->sortedNames($unmanaged),
);
}

/**
* Classify a catalog row and add it to the managed or unmanaged set. Returns the
* managed node key (qualified name) when the row is a managed materialized view, or
* null otherwise. Idempotent: keyed by qualified name.
*
* @param array{oid: int, schema: string, name: string, relkind: string, comment: string} $relation
* @param array<string, MaterializedViewName> $managed
* @param array<string, true> $unmanaged
*/
private function record(array $relation, array &$managed, array &$unmanaged): ?string
{
$qualifiedName = $relation['schema'].'.'.$relation['name'];

if ('m' === $relation['relkind'] && ManagementMarker::isManagedComment($relation['comment'])) {
$managed[$qualifiedName] ??= MaterializedViewName::create($relation['schema'], $relation['name']);

return $qualifiedName;
}

$unmanaged[$qualifiedName] = true;

return null;
}

/**
* @return list<array{oid: int, schema: string, name: string, relkind: string, comment: string}>
*/
private function directDependents(int $oid): array
{
$rows = $this->connection->fetchAllAssociative(self::DIRECT_DEPENDENTS_BY_OID_SQL, ['oid' => $oid]);

$dependents = [];
foreach ($rows as $row) {
$dependents[] = [
'oid' => (int) $row['oid'],
'schema' => (string) $row['schema_name'],
'name' => (string) $row['object_name'],
'relkind' => (string) $row['relkind'],
'comment' => (string) $row['comment'],
];
}

return $dependents;
}

/**
* @return array{oid: int, schema: string, name: string, relkind: string, comment: string}|null
*/
private function describeRelation(int $oid): ?array
{
$row = $this->connection->fetchAssociative(self::DESCRIBE_RELATION_SQL, ['oid' => $oid]);

if (false === $row) {
return null;
}

return [
'oid' => $oid,
'schema' => (string) $row['schema_name'],
'name' => (string) $row['object_name'],
'relkind' => (string) $row['relkind'],
'comment' => (string) $row['comment'],
];
}

private function resolveRelationOid(string $relation): ?int
{
$oid = $this->connection->fetchOne('SELECT to_regclass(:relation)::oid', ['relation' => $relation]);

if (null === $oid || false === $oid) {
return null;
}

return (int) $oid;
}

/**
* @param array<string, MaterializedViewName> $managed
* @param list<array{dependent: string, referenced: string}> $edges
*
* @return list<MaterializedViewName>
*/
private function managedDropOrder(array $managed, array $edges): array
{
if ([] === $managed) {
return [];
}

$managedEdges = [];
foreach ($edges as $edge) {
if (isset($managed[$edge['dependent']], $managed[$edge['referenced']])) {
$managedEdges[] = $edge;
}
}

$order = [];
foreach (DependencyGraph::fromEdges(array_keys($managed), $managedEdges)->reverseTopologicallySorted() as $qualifiedName) {
$order[] = $managed[$qualifiedName];
}

return $order;
}

/**
* @param array<string, true> $unmanaged
*
* @return list<string>
*/
private function sortedNames(array $unmanaged): array
{
$names = array_keys($unmanaged);
sort($names);

return $names;
}

/**
* @param list<CatalogDependencyEdge> $edges
*
Expand Down
87 changes: 87 additions & 0 deletions src/Core/Dependency/ConflictDropClosure.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace Th3Mouk\MaterializedView\Core\Dependency;

use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName;

/**
* The catalog-resolved closure of a dependency conflict: the managed materialized views
* that must be dropped (in safe, dependents-first order) to clear the conflict, together
* with any unmanaged dependents found in the same closure.
*
* The presence of any unmanaged dependent means the closure cannot be cleared safely:
* the caller refuses the drop (the library never drops objects it does not manage, and
* CASCADE is never implicit).
*/
final readonly class ConflictDropClosure
{
/**
* @param list<MaterializedViewName> $managedDropOrder
* @param list<string> $unmanagedDependents
*/
private function __construct(
public array $managedDropOrder,
public array $unmanagedDependents,
) {
}

/**
* @param list<MaterializedViewName> $managedDropOrder
* @param list<string> $unmanagedDependents
*/
public static function of(array $managedDropOrder, array $unmanagedDependents): self
{
return new self($managedDropOrder, $unmanagedDependents);
}

public static function empty(): self
{
return new self([], []);
}

public function hasUnmanagedDependents(): bool
{
return [] !== $this->unmanagedDependents;
}

public function isEmpty(): bool
{
return [] === $this->managedDropOrder && [] === $this->unmanagedDependents;
}

/**
* Concatenate another closure after this one, de-duplicating managed views by
* qualified name (first occurrence wins, preserving each seed's dependents-first
* order) and unioning the unmanaged dependents.
*/
public function merge(self $other): self
{
$managed = $this->managedDropOrder;
$seen = [];
foreach ($managed as $name) {
$seen[$name->qualifiedName()] = true;
}

foreach ($other->managedDropOrder as $name) {
if (isset($seen[$name->qualifiedName()])) {
continue;
}

$seen[$name->qualifiedName()] = true;
$managed[] = $name;
}

$unmanaged = $this->unmanagedDependents;
foreach ($other->unmanagedDependents as $dependent) {
if (!\in_array($dependent, $unmanaged, true)) {
$unmanaged[] = $dependent;
}
}

sort($unmanaged);

return new self($managed, $unmanaged);
}
}
Loading
Loading