diff --git a/CHANGELOG.md b/CHANGELOG.md index 4524bb0..adaab53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/composer.json b/composer.json index cd55480..689ff2e 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ }, "extra": { "branch-alias": { - "dev-main": "1.1.x-dev" + "dev-main": "1.2.x-dev" } }, "config": { diff --git a/docs/internals/postgresql-references.md b/docs/internals/postgresql-references.md index 5caf1e4..231a5b8 100644 --- a/docs/internals/postgresql-references.md +++ b/docs/internals/postgresql-references.md @@ -83,6 +83,15 @@ Without `deptype = 'n'` and the self-exclusion, the `_RETURN` rule reports the v - **DROP MATERIALIZED VIEW** — - 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 (` 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 …`). `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** — — dropping an object removes its privileges; a rebuild must re-apply them. See [Privileges](../guide/privileges.md). diff --git a/src/Core/Dependency/CatalogDependencyResolver.php b/src/Core/Dependency/CatalogDependencyResolver.php index f72082c..3b295f1 100644 --- a/src/Core/Dependency/CatalogDependencyResolver.php +++ b/src/Core/Dependency/CatalogDependencyResolver.php @@ -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 { @@ -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, ) { @@ -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 $visited */ + $visited = [$seedOid => true]; + /** @var array $managed */ + $managed = []; + /** @var array $unmanaged */ + $unmanaged = []; + /** @var list $edges */ + $edges = []; + /** @var array $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 $managed + * @param array $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 + */ + 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 $managed + * @param list $edges + * + * @return list + */ + 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 $unmanaged + * + * @return list + */ + private function sortedNames(array $unmanaged): array + { + $names = array_keys($unmanaged); + sort($names); + + return $names; + } + /** * @param list $edges * diff --git a/src/Core/Dependency/ConflictDropClosure.php b/src/Core/Dependency/ConflictDropClosure.php new file mode 100644 index 0000000..7bcb030 --- /dev/null +++ b/src/Core/Dependency/ConflictDropClosure.php @@ -0,0 +1,87 @@ + $managedDropOrder + * @param list $unmanagedDependents + */ + private function __construct( + public array $managedDropOrder, + public array $unmanagedDependents, + ) { + } + + /** + * @param list $managedDropOrder + * @param list $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); + } +} diff --git a/src/Core/Dependency/ParsedDependent.php b/src/Core/Dependency/ParsedDependent.php new file mode 100644 index 0000000..8fea33b --- /dev/null +++ b/src/Core/Dependency/ParsedDependent.php @@ -0,0 +1,21 @@ + depends on ...". + * 0A000 (feature_not_supported) — ALTER COLUMN ... TYPE blocked by a view/rule. + * The ERROR line names neither the table nor the column; only DETAIL names the + * object, via its rewrite rule: "rule _RETURN on ...". + * + * The ERROR/DETAIL text is emitted in the server's lc_messages locale, which the + * application role cannot pin at runtime (SET and PGOPTIONS are superuser-only). Parsing + * is therefore best-effort: a gated error whose text is absent or non-English yields + * isParsed() === false with an empty dependent list, rather than throwing or guessing, + * so the caller falls back (e.g. drop-all). The authoritative dependent closure must + * still be resolved from the system catalog: DETAIL is only a seed and PostgreSQL caps + * the printed list at 100 entries ("and N other objects"). + */ +final readonly class PostgresDependencyConflict +{ + /** + * @param list $dependents + */ + private function __construct( + private string $sqlState, + private ?QualifiedName $blockedRelation, + private array $dependents, + private bool $truncated, + ) { + } + + /** + * Build from a DBAL exception, or null when its SQLSTATE is not one we react to. + * The raw PostgreSQL text is read from the deepest \PDOException in the chain + * (errorInfo[2]); the chain depth is not hard-coded, and getMessage() is the + * fallback. SQLSTATE comes from getSQLState(), falling back to errorInfo[0]. + */ + public static function fromDriverException(DriverException $exception): ?self + { + $pdoException = self::deepestPdoException($exception); + + $sqlState = $exception->getSQLState(); + $rawErrorText = null; + + if (null !== $pdoException) { + $errorInfo = $pdoException->errorInfo; + + if (\is_array($errorInfo)) { + if ((null === $sqlState || '' === $sqlState) && isset($errorInfo[0]) && \is_string($errorInfo[0])) { + $sqlState = $errorInfo[0]; + } + + if (isset($errorInfo[2]) && \is_string($errorInfo[2])) { + $rawErrorText = $errorInfo[2]; + } + } + } + + if (null === $sqlState || !DependencyConflictSqlState::isDependencyConflictState($sqlState)) { + return null; + } + + return self::parse($sqlState, $rawErrorText ?? $exception->getMessage()); + } + + /** + * Build from a raw SQLSTATE and PostgreSQL error payload, or null when not gated. + * The payload may be either the raw errorInfo[2] form (beginning at "ERROR:") or + * the DBAL-wrapped getMessage() form (with a leading prefix): the parser anchors on + * the "ERROR:"/"DETAIL:" markers and tolerates either. + */ + public static function fromRawError(string $sqlState, string $rawErrorText): ?self + { + if (!DependencyConflictSqlState::isDependencyConflictState($sqlState)) { + return null; + } + + return self::parse($sqlState, $rawErrorText); + } + + public function sqlState(): string + { + return $this->sqlState; + } + + public function blockedRelation(): ?QualifiedName + { + return $this->blockedRelation; + } + + /** + * @return list + */ + public function dependents(): array + { + return $this->dependents; + } + + /** + * Whether at least one dependent object was extracted from DETAIL. False means the + * text was absent or not in a recognised (English) shape, and the caller must fall + * back (e.g. drop-all). This is independent of blockedRelation(): for 2BP01 the + * blocked relation can be a usable catalog seed even when isParsed() is false. + */ + public function isParsed(): bool + { + return [] !== $this->dependents; + } + + public function wasTruncated(): bool + { + return $this->truncated; + } + + private static function parse(string $sqlState, string $rawErrorText): self + { + $text = str_replace(["\r\n", "\r"], "\n", $rawErrorText); + + $blockedRelation = null; + + if (DependencyConflictSqlState::DEPENDENT_OBJECTS_STILL_EXIST === $sqlState) { + $errorPrimary = self::matchGroup('/ERROR:[ \t]*([^\n]*)/', $text); + + if (null !== $errorPrimary) { + $blockedRelation = self::parseBlockedRelation($errorPrimary); + } + } + + $dependents = []; + $truncated = false; + + $detailBlock = self::matchGroup('/^DETAIL:[ \t]*(.*?)(?=^HINT:|\z)/ms', $text); + + if (null !== $detailBlock) { + foreach (explode("\n", $detailBlock) as $rawLine) { + $line = trim($rawLine); + + if ('' === $line) { + continue; + } + + if (1 === preg_match('/^and \d+ other object/', $line)) { + $truncated = true; + + continue; + } + + $dependent = self::parseDependentLine($sqlState, $line); + + if (null !== $dependent) { + $dependents[] = $dependent; + } + } + } + + return new self($sqlState, $blockedRelation, $dependents, $truncated); + } + + private static function parseBlockedRelation(string $errorPrimary): ?QualifiedName + { + $dropTable = self::markerOffset($errorPrimary, 'cannot drop table '); + + if (null !== $dropTable) { + $end = 0; + + return QualifiedName::scan($errorPrimary, $dropTable, $end); + } + + // "cannot drop column of table …": skip the column name quote-aware + // before locating " of table ", so a column whose name embeds that substring + // (e.g. "x of table y") cannot misdirect the search to the wrong relation. + $dropColumn = self::markerOffset($errorPrimary, 'cannot drop column '); + + if (null === $dropColumn) { + return null; + } + + $columnEnd = 0; + + if (null === QualifiedName::scan($errorPrimary, $dropColumn, $columnEnd)) { + return null; + } + + $ofTable = self::markerOffset(substr($errorPrimary, $columnEnd), ' of table '); + + if (null === $ofTable) { + return null; + } + + $end = 0; + + return QualifiedName::scan($errorPrimary, $columnEnd + $ofTable, $end); + } + + private static function parseDependentLine(string $sqlState, string $line): ?ParsedDependent + { + if (DependencyConflictSqlState::FEATURE_NOT_SUPPORTED === $sqlState) { + return self::parseRuleDependentLine($line); + } + + return self::parseDirectDependentLine($line); + } + + private static function parseDirectDependentLine(string $line): ?ParsedDependent + { + $materializedViewPrefix = 'materialized view '; + + if (str_starts_with($line, $materializedViewPrefix)) { + return self::scanDependent($line, \strlen($materializedViewPrefix), ParsedDependentKind::MaterializedView); + } + + $viewPrefix = 'view '; + + if (str_starts_with($line, $viewPrefix)) { + return self::scanDependent($line, \strlen($viewPrefix), ParsedDependentKind::View); + } + + return null; + } + + private static function parseRuleDependentLine(string $line): ?ParsedDependent + { + if (!str_starts_with($line, 'rule ')) { + return null; + } + + $materializedViewMarker = ' on materialized view '; + $position = strpos($line, $materializedViewMarker); + + if (false !== $position) { + return self::scanDependent($line, $position + \strlen($materializedViewMarker), ParsedDependentKind::MaterializedView); + } + + $viewMarker = ' on view '; + $position = strpos($line, $viewMarker); + + if (false !== $position) { + return self::scanDependent($line, $position + \strlen($viewMarker), ParsedDependentKind::View); + } + + return null; + } + + private static function scanDependent(string $line, int $offset, ParsedDependentKind $kind): ?ParsedDependent + { + $end = 0; + $name = QualifiedName::scan($line, $offset, $end); + + if (null === $name) { + return null; + } + + if (!str_starts_with(substr($line, $end), ' depends on')) { + return null; + } + + return new ParsedDependent($kind, $name); + } + + private static function deepestPdoException(Throwable $throwable): ?PDOException + { + $found = null; + $current = $throwable; + $guard = 0; + + while (null !== $current && $guard < 20) { + if ($current instanceof PDOException) { + $found = $current; + } + + $current = $current->getPrevious(); + ++$guard; + } + + return $found; + } + + private static function matchGroup(string $pattern, string $subject): ?string + { + if (1 === preg_match($pattern, $subject, $matches) && isset($matches[1])) { + return $matches[1]; + } + + return null; + } + + private static function markerOffset(string $haystack, string $marker): ?int + { + $position = strpos($haystack, $marker); + + if (false === $position) { + return null; + } + + return $position + \strlen($marker); + } +} diff --git a/src/Core/Exception/UnmanagedDependentFound.php b/src/Core/Exception/UnmanagedDependentFound.php index b505dcb..b1d80ff 100644 --- a/src/Core/Exception/UnmanagedDependentFound.php +++ b/src/Core/Exception/UnmanagedDependentFound.php @@ -32,4 +32,16 @@ public static function blockingRebuild(MaterializedViewName $name, array $depend '' === implode(', ', $dependentNames) ? 'unknown' : implode(', ', $dependentNames), )); } + + /** + * @param list $dependentNames + */ + public static function blockingConflictDrop(array $dependentNames): self + { + return new self(\sprintf( + 'Refusing to reactively drop materialized views to clear a migration dependency conflict: ' + .'the conflict closure includes unmanaged dependents (%s). Drop or migrate them first; CASCADE is never implicit.', + '' === implode(', ', $dependentNames) ? 'unknown' : implode(', ', $dependentNames), + )); + } } diff --git a/src/Core/MaterializedViewManager.php b/src/Core/MaterializedViewManager.php index cb00051..06a0388 100644 --- a/src/Core/MaterializedViewManager.php +++ b/src/Core/MaterializedViewManager.php @@ -10,9 +10,12 @@ use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewDefinition; use Th3Mouk\MaterializedView\Core\Definition\MaterializedViewName; use Th3Mouk\MaterializedView\Core\Dependency\CatalogDependencyResolver; +use Th3Mouk\MaterializedView\Core\Dependency\ConflictDropClosure; use Th3Mouk\MaterializedView\Core\Dependency\DropDependentPolicy; use Th3Mouk\MaterializedView\Core\Dependency\ExternalDependencyGuard; +use Th3Mouk\MaterializedView\Core\Dependency\PostgresDependencyConflict; use Th3Mouk\MaterializedView\Core\Exception\MissingUniqueIndexForConcurrentRefresh; +use Th3Mouk\MaterializedView\Core\Exception\UnmanagedDependentFound; use Th3Mouk\MaterializedView\Core\Exception\ViewNotPopulated; use Th3Mouk\MaterializedView\Core\Hashing\DefinitionHasher; use Th3Mouk\MaterializedView\Core\Introspection\PostgreSqlMaterializedViewIntrospector; @@ -149,6 +152,77 @@ public function drop( $this->logger->info('Dropped materialized view "{view}".', ['view' => $name->qualifiedName()]); } + /** + * Reactively clear a migration DDL conflict by dropping ONLY the managed materialized + * views that block it (and their managed dependents), in safe drop order. The blocked + * relation is read from the parsed PostgreSQL error, but the drop closure is resolved + * from the system catalog — never from the locale-dependent error text alone. + * + * Refuses (throws {@see UnmanagedDependentFound}) when the closure contains an unmanaged + * dependent, unless the policy is Cascade. Returns the views dropped, in drop order; an + * empty array means nothing was resolved (e.g. an unparsable 0A000) and the caller must + * fall back. Opens no transaction of its own: the caller wraps the call so the surgical + * drop and the migration retry share one unit of work. + * + * @return list + */ + public function dropConflictClosure( + PostgresDependencyConflict $conflict, + DropDependentPolicy $dropDependentPolicy = DropDependentPolicy::Refuse, + ): array { + $this->primaryConnectionGuard->ensureConnectedToPrimary(); + + $closure = $this->resolveConflictClosure($conflict); + + if (DropDependentPolicy::Cascade !== $dropDependentPolicy && $closure->hasUnmanagedDependents()) { + throw UnmanagedDependentFound::blockingConflictDrop($closure->unmanagedDependents); + } + + $cascade = DropDependentPolicy::Cascade === $dropDependentPolicy; + $dropped = []; + + foreach ($closure->managedDropOrder as $name) { + $this->connection->executeStatement($this->sqlGenerator->drop($name, true, $cascade)); + $this->readinessChecker->forget($name); + $dropped[] = $name; + + $this->logger->info('Reactively dropped materialized view "{view}" to clear a migration dependency conflict ({sql_state}).', [ + 'view' => $name->qualifiedName(), + 'sql_state' => $conflict->sqlState(), + ]); + } + + return $dropped; + } + + private function resolveConflictClosure(PostgresDependencyConflict $conflict): ConflictDropClosure + { + $resolver = new CatalogDependencyResolver($this->connection); + + // The DETAIL dependents are the *precise* blockers (the views actually using the + // dropped column / altered type), so seed from each of them — including the seed + // itself, which must be dropped — rather than from the blocked table, whose other + // dependents may not block this DDL at all. Fall back to the table walk only when + // the (locale-dependent) DETAIL could not be parsed. + if ($conflict->isParsed()) { + $closure = ConflictDropClosure::empty(); + + foreach ($conflict->dependents() as $dependent) { + $closure = $closure->merge($resolver->resolveConflictClosure($dependent->name, true)); + } + + return $closure; + } + + $blockedRelation = $conflict->blockedRelation(); + + if (null === $blockedRelation) { + return ConflictDropClosure::empty(); + } + + return $resolver->resolveConflictClosure($blockedRelation); + } + public function refresh(MaterializedViewDefinition $definition, ?RefreshOptions $options = null): void { $options ??= RefreshOptions::default(); diff --git a/src/Core/Sql/DependencyConflictSqlState.php b/src/Core/Sql/DependencyConflictSqlState.php new file mode 100644 index 0000000..60a20ea --- /dev/null +++ b/src/Core/Sql/DependencyConflictSqlState.php @@ -0,0 +1,58 @@ +getPrevious()) { + if ($current instanceof DriverException) { + $sqlState = $current->getSQLState(); + + if (null !== $sqlState) { + return $sqlState; + } + } + } + + return null; + } +} diff --git a/src/Core/Sql/ManagementMarker.php b/src/Core/Sql/ManagementMarker.php index c5a8a64..9a874e2 100644 --- a/src/Core/Sql/ManagementMarker.php +++ b/src/Core/Sql/ManagementMarker.php @@ -4,6 +4,8 @@ namespace Th3Mouk\MaterializedView\Core\Sql; +use JsonException; + final readonly class ManagementMarker { public const string MARKER_KEY = 'th3mouk_materialized_view'; @@ -36,4 +38,49 @@ public function toJson(): string \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE, ); } + + /** + * Whether a catalog comment marks a library-managed view. Decodes the JSON and checks + * for the marker key at the top level — never a substring match, so an unmanaged + * object whose free-text comment merely mentions the key is not misclassified. + */ + public static function isManagedComment(?string $comment): bool + { + return null !== self::decodePayload($comment); + } + + public static function readHash(?string $comment): ?string + { + $payload = self::decodePayload($comment); + + if (null === $payload || !isset($payload['hash']) || !\is_string($payload['hash'])) { + return null; + } + + return $payload['hash']; + } + + /** + * @return array|null + */ + private static function decodePayload(?string $comment): ?array + { + if (null === $comment || '' === $comment) { + return null; + } + + try { + $decoded = json_decode($comment, true, 512, \JSON_THROW_ON_ERROR); + } catch (JsonException) { + return null; + } + + if (!\is_array($decoded) || !\array_key_exists(self::MARKER_KEY, $decoded)) { + return null; + } + + $payload = $decoded[self::MARKER_KEY]; + + return \is_array($payload) ? $payload : null; + } } diff --git a/src/Core/Sql/QualifiedName.php b/src/Core/Sql/QualifiedName.php new file mode 100644 index 0000000..2b730d1 --- /dev/null +++ b/src/Core/Sql/QualifiedName.php @@ -0,0 +1,159 @@ +schema === $other->schema && $this->name === $other->name; + } + + public function render(): string + { + $rendered = self::quoteIfNeeded($this->name); + + if (null !== $this->schema) { + return self::quoteIfNeeded($this->schema).'.'.$rendered; + } + + return $rendered; + } + + /** + * Scan one qualified identifier from $input starting at $offset, writing the + * position just past it into $offsetAfter. + * + * Quote-aware: an unquoted "." separates schema from name and unquoted whitespace + * ends the identifier, while a double-quoted run may legally contain ".", spaces, + * or escaped quotes ("") — so the delimiter is never matched by naive string + * search. Returns null when no complete identifier can be read (an unterminated + * quote, an empty unquoted token, or end-of-input), letting callers degrade + * gracefully instead of throwing. + */ + public static function scan(string $input, int $offset, int &$offsetAfter): ?self + { + $length = \strlen($input); + $position = $offset; + $parts = []; + + while (true) { + if ($position >= $length) { + return null; + } + + if ('"' === $input[$position]) { + $part = self::scanQuotedPart($input, $length, $position); + + if (null === $part) { + return null; + } + + $parts[] = $part; + } else { + $start = $position; + + while ($position < $length && !self::isUnquotedDelimiter($input[$position])) { + ++$position; + } + + if ($position === $start) { + return null; + } + + $parts[] = substr($input, $start, $position - $start); + } + + if ($position < $length && '.' === $input[$position]) { + ++$position; + + continue; + } + + break; + } + + $offsetAfter = $position; + $count = \count($parts); + $name = $parts[$count - 1]; + $schema = $count > 1 ? $parts[$count - 2] : null; + + return new self($schema, $name); + } + + /** + * Consume a double-quoted run starting at the opening quote ($position), advancing + * $position past the closing quote. Returns the de-escaped content, or null when + * the quote is never closed. + */ + private static function scanQuotedPart(string $input, int $length, int &$position): ?string + { + ++$position; + $buffer = ''; + + while ($position < $length) { + if ('"' === $input[$position]) { + if ($position + 1 < $length && '"' === $input[$position + 1]) { + $buffer .= '"'; + $position += 2; + + continue; + } + + ++$position; + + return $buffer; + } + + $buffer .= $input[$position]; + ++$position; + } + + return null; + } + + private static function isUnquotedDelimiter(string $character): bool + { + return '.' === $character + || '"' === $character + || ' ' === $character + || "\t" === $character + || "\n" === $character; + } + + private static function quoteIfNeeded(string $identifier): string + { + if ('' !== $identifier && 1 === preg_match('/\A[a-z_][a-z0-9_$]*\z/', $identifier)) { + return $identifier; + } + + return '"'.str_replace('"', '""', $identifier).'"'; + } +} diff --git a/tests/Integration/Dependency/ConflictClosureIntegrationTest.php b/tests/Integration/Dependency/ConflictClosureIntegrationTest.php new file mode 100644 index 0000000..2997b59 --- /dev/null +++ b/tests/Integration/Dependency/ConflictClosureIntegrationTest.php @@ -0,0 +1,180 @@ + 'pdo_pgsql', 'postgres' => 'pdo_pgsql'])->parse($dsn); + $this->connection = DriverManager::getConnection($parameters); + + $this->connection->executeStatement('DROP SCHEMA IF EXISTS '.self::SCHEMA.' CASCADE'); + $this->connection->executeStatement('CREATE SCHEMA '.self::SCHEMA); + $this->connection->executeStatement('SET search_path TO '.self::SCHEMA); + } + + protected function tearDown(): void + { + if (isset($this->connection)) { + $this->connection->executeStatement('DROP SCHEMA IF EXISTS '.self::SCHEMA.' CASCADE'); + $this->connection->close(); + } + } + + public function testParsesAndSurgicallyClearsADropColumnConflict(): void + { + $this->connection->executeStatement('CREATE TABLE orders (id int PRIMARY KEY, total numeric)'); + $this->createManagedMatview('order_totals', 'SELECT id, total FROM orders'); + + $conflict = $this->provoke('ALTER TABLE orders DROP COLUMN total'); + + self::assertSame('2BP01', $conflict->sqlState()); + self::assertTrue($conflict->isParsed()); + self::assertSame('orders', $conflict->blockedRelation()?->name); + self::assertCount(1, $conflict->dependents()); + self::assertSame(ParsedDependentKind::MaterializedView, $conflict->dependents()[0]->kind); + self::assertSame('order_totals', $conflict->dependents()[0]->name->name); + + $dropped = MaterializedViewManager::forConnection($this->connection)->dropConflictClosure($conflict); + + self::assertCount(1, $dropped); + self::assertSame('mv_conflict_it.order_totals', $dropped[0]->qualifiedName()); + self::assertNull($this->relationOid('order_totals'), 'The conflicting managed matview must be gone.'); + + $this->connection->executeStatement('ALTER TABLE orders DROP COLUMN total'); + self::assertNull($this->columnType('orders', 'total'), 'The previously blocked column drop now succeeds.'); + } + + public function testResolvesTheManagedDropClosureFromTheBlockedTable(): void + { + $this->connection->executeStatement('CREATE TABLE orders (id int PRIMARY KEY, total numeric)'); + $this->createManagedMatview('order_totals', 'SELECT id, total FROM orders'); + $this->createManagedMatview('order_totals_rollup', 'SELECT id FROM order_totals'); + + $closure = new CatalogDependencyResolver($this->connection) + ->resolveConflictClosure(new QualifiedName(self::SCHEMA, 'orders')); + + self::assertFalse($closure->hasUnmanagedDependents()); + + $order = array_map( + static fn ($name): string => $name->name, + $closure->managedDropOrder, + ); + self::assertSame(['order_totals_rollup', 'order_totals'], $order, 'A dependent matview must be dropped before the one it depends on.'); + } + + public function testRefusesWhenAnUnmanagedDependentIsInTheClosure(): void + { + $this->connection->executeStatement('CREATE TABLE orders (id int PRIMARY KEY, total numeric)'); + $this->createManagedMatview('order_totals', 'SELECT id, total FROM orders'); + $this->connection->executeStatement('CREATE VIEW report_v AS SELECT id FROM order_totals'); + + $conflict = $this->provoke('ALTER TABLE orders DROP COLUMN total'); + + try { + MaterializedViewManager::forConnection($this->connection)->dropConflictClosure($conflict); + self::fail('Expected the unmanaged dependent to block the reactive drop.'); + } catch (UnmanagedDependentFound $exception) { + self::assertStringContainsString('report_v', $exception->getMessage()); + } + + self::assertNotNull($this->relationOid('order_totals'), 'The managed matview must survive a refused drop.'); + self::assertNotNull($this->relationOid('report_v'), 'The unmanaged view must survive a refused drop.'); + } + + public function testParsesAndClearsAnAlterColumnTypeRuleConflict(): void + { + $this->connection->executeStatement('CREATE TABLE orders (id int PRIMARY KEY, total numeric)'); + $this->createManagedMatview('order_totals', 'SELECT id, total FROM orders'); + + $conflict = $this->provoke('ALTER TABLE orders ALTER COLUMN total TYPE text USING total::text'); + + self::assertSame('0A000', $conflict->sqlState()); + self::assertNull($conflict->blockedRelation(), '0A000 names the dependent only, via its rewrite rule.'); + self::assertTrue($conflict->isParsed()); + self::assertSame('order_totals', $conflict->dependents()[0]->name->name); + + $dropped = MaterializedViewManager::forConnection($this->connection)->dropConflictClosure($conflict); + + self::assertCount(1, $dropped); + self::assertNull($this->relationOid('order_totals')); + + $this->connection->executeStatement('ALTER TABLE orders ALTER COLUMN total TYPE text USING total::text'); + self::assertSame('text', $this->columnType('orders', 'total'), 'The previously blocked ALTER TYPE now succeeds.'); + } + + private function createManagedMatview(string $name, string $select): void + { + $this->connection->executeStatement(\sprintf('CREATE MATERIALIZED VIEW %s AS %s', $name, $select)); + + $marker = ManagementMarker::create('testhash-'.$name)->toJson(); + $this->connection->executeStatement(\sprintf( + 'COMMENT ON MATERIALIZED VIEW %s IS %s', + $name, + $this->connection->getDatabasePlatform()->quoteStringLiteral($marker), + )); + } + + private function provoke(string $statement): PostgresDependencyConflict + { + try { + $this->connection->executeStatement($statement); + } catch (DriverException $exception) { + $conflict = PostgresDependencyConflict::fromDriverException($exception); + self::assertNotNull($conflict, 'Expected a gated dependency-conflict SQLSTATE.'); + + return $conflict; + } + + self::fail('Expected the DDL statement to be blocked by a dependent object.'); + } + + private function columnType(string $table, string $column): ?string + { + $type = $this->connection->fetchOne( + 'SELECT data_type FROM information_schema.columns WHERE table_schema = :schema AND table_name = :table AND column_name = :column', + ['schema' => self::SCHEMA, 'table' => $table, 'column' => $column], + ); + + return false === $type ? null : (string) $type; + } + + private function relationOid(string $relation): ?int + { + $oid = $this->connection->fetchOne( + 'SELECT to_regclass(:relation)::oid', + ['relation' => self::SCHEMA.'.'.$relation], + ); + + return (null === $oid || false === $oid) ? null : (int) $oid; + } +} diff --git a/tests/Unit/Dependency/ConflictDropClosureTest.php b/tests/Unit/Dependency/ConflictDropClosureTest.php new file mode 100644 index 0000000..6fa688c --- /dev/null +++ b/tests/Unit/Dependency/ConflictDropClosureTest.php @@ -0,0 +1,55 @@ +isEmpty()); + self::assertFalse($closure->hasUnmanagedDependents()); + self::assertSame([], $closure->managedDropOrder); + self::assertSame([], $closure->unmanagedDependents); + } + + public function testReportsUnmanagedDependents(): void + { + $closure = ConflictDropClosure::of([], ['public.legacy_view']); + + self::assertFalse($closure->isEmpty()); + self::assertTrue($closure->hasUnmanagedDependents()); + } + + public function testMergeConcatenatesManagedOrderDeduplicatingByQualifiedName(): void + { + $a = MaterializedViewName::create('public', 'a'); + $b = MaterializedViewName::create('public', 'b'); + $bAgain = MaterializedViewName::create('public', 'b'); + $c = MaterializedViewName::create('public', 'c'); + + $merged = ConflictDropClosure::of([$a, $b], []) + ->merge(ConflictDropClosure::of([$bAgain, $c], [])); + + $names = array_map(static fn (MaterializedViewName $name): string => $name->qualifiedName(), $merged->managedDropOrder); + + self::assertSame(['public.a', 'public.b', 'public.c'], $names); + } + + public function testMergeUnionsAndSortsUnmanagedDependents(): void + { + $merged = ConflictDropClosure::of([], ['public.z_view', 'public.a_view']) + ->merge(ConflictDropClosure::of([], ['public.a_view', 'public.m_view'])); + + self::assertSame(['public.a_view', 'public.m_view', 'public.z_view'], $merged->unmanagedDependents); + } +} diff --git a/tests/Unit/Dependency/PostgresDependencyConflictTest.php b/tests/Unit/Dependency/PostgresDependencyConflictTest.php new file mode 100644 index 0000000..8108d05 --- /dev/null +++ b/tests/Unit/Dependency/PostgresDependencyConflictTest.php @@ -0,0 +1,149 @@ +sqlState()); + self::assertNotNull($conflict->blockedRelation()); + self::assertSame('orders', $conflict->blockedRelation()->name); + self::assertNull($conflict->blockedRelation()->schema); + self::assertTrue($conflict->isParsed()); + self::assertFalse($conflict->wasTruncated()); + self::assertCount(1, $conflict->dependents()); + self::assertSame(ParsedDependentKind::MaterializedView, $conflict->dependents()[0]->kind); + self::assertSame('order_totals', $conflict->dependents()[0]->name->name); + } + + public function testParsesADropColumnConflictAndIsNotMisledByAColumnNamedLikeTheMarker(): void + { + $conflict = PostgresDependencyConflict::fromRawError('2BP01', <<<'TXT' + ERROR: cannot drop column "x of table y" of table orders because other objects depend on it + DETAIL: materialized view order_totals depends on column "x of table y" of table orders + TXT); + + self::assertNotNull($conflict); + self::assertNotNull($conflict->blockedRelation()); + self::assertSame('orders', $conflict->blockedRelation()->name, 'The blocked relation must be the table, not the decoy column name.'); + } + + public function testParsesASchemaQualifiedDoubleQuotedDependent(): void + { + $conflict = PostgresDependencyConflict::fromRawError('2BP01', <<<'TXT' + ERROR: cannot drop table "Sales"."Orders" because other objects depend on it + DETAIL: materialized view "Reporting"."Order Totals" depends on table "Sales"."Orders" + TXT); + + self::assertNotNull($conflict); + self::assertNotNull($conflict->blockedRelation()); + self::assertSame('Sales', $conflict->blockedRelation()->schema); + self::assertSame('Orders', $conflict->blockedRelation()->name); + self::assertCount(1, $conflict->dependents()); + self::assertSame('Reporting', $conflict->dependents()[0]->name->schema); + self::assertSame('Order Totals', $conflict->dependents()[0]->name->name); + } + + public function testParsesAnAlterColumnTypeRuleConflict(): void + { + $conflict = PostgresDependencyConflict::fromRawError('0A000', <<<'TXT' + ERROR: cannot alter type of a column used by a view or rule + DETAIL: rule _RETURN on materialized view order_totals depends on column "total" + TXT); + + self::assertNotNull($conflict); + self::assertSame('0A000', $conflict->sqlState()); + self::assertNull($conflict->blockedRelation(), '0A000 names neither table nor column on the ERROR line.'); + self::assertCount(1, $conflict->dependents()); + self::assertSame(ParsedDependentKind::MaterializedView, $conflict->dependents()[0]->kind); + self::assertSame('order_totals', $conflict->dependents()[0]->name->name); + } + + public function testFlagsTruncationWhenPostgresCapsThePrintedDependentList(): void + { + $conflict = PostgresDependencyConflict::fromRawError('2BP01', <<<'TXT' + ERROR: cannot drop table base because other objects depend on it + DETAIL: materialized view a depends on table base + materialized view b depends on table base + and 3 other objects (see server log for list) + TXT); + + self::assertNotNull($conflict); + self::assertTrue($conflict->wasTruncated()); + self::assertCount(2, $conflict->dependents()); + } + + public function testDistinguishesAPlainViewDependentFromAMaterializedView(): void + { + $conflict = PostgresDependencyConflict::fromRawError('2BP01', <<<'TXT' + ERROR: cannot drop table orders because other objects depend on it + DETAIL: view orders_v depends on table orders + TXT); + + self::assertNotNull($conflict); + self::assertCount(1, $conflict->dependents()); + self::assertSame(ParsedDependentKind::View, $conflict->dependents()[0]->kind); + } + + public function testIsParsedFalseWhenTheTextIsAbsentOrNonEnglish(): void + { + $conflict = PostgresDependencyConflict::fromRawError('2BP01', <<<'TXT' + FEHLER: Tabelle „orders" kann nicht gelöscht werden, weil andere Objekte davon abhängen + DETAIL: materialisierte Sicht order_totals hängt von Tabelle orders ab + TXT); + + self::assertNotNull($conflict, 'A gated SQLSTATE always yields an object, even when the text is unparsable.'); + self::assertNull($conflict->blockedRelation()); + self::assertFalse($conflict->isParsed()); + self::assertSame([], $conflict->dependents()); + } + + public function testBuildsFromADriverExceptionUsingItsSqlStateAndMessage(): void + { + $message = "ERROR: cannot drop table orders because other objects depend on it\nDETAIL: materialized view order_totals depends on table orders"; + $exception = new DriverException( + new class($message, '2BP01') extends DriverAbstractException {}, + null, + ); + + $conflict = PostgresDependencyConflict::fromDriverException($exception); + + self::assertNotNull($conflict); + self::assertSame('2BP01', $conflict->sqlState()); + self::assertNotNull($conflict->blockedRelation()); + self::assertSame('orders', $conflict->blockedRelation()->name); + } + + public function testReturnsNullFromADriverExceptionWithAnUnrelatedSqlState(): void + { + $exception = new DriverException( + new class('ERROR: deadlock detected', '40P01') extends DriverAbstractException {}, + null, + ); + + self::assertNull(PostgresDependencyConflict::fromDriverException($exception)); + } +} diff --git a/tests/Unit/Sql/DependencyConflictSqlStateTest.php b/tests/Unit/Sql/DependencyConflictSqlStateTest.php new file mode 100644 index 0000000..499cc17 --- /dev/null +++ b/tests/Unit/Sql/DependencyConflictSqlStateTest.php @@ -0,0 +1,87 @@ + + */ + public static function dependencyConflictSqlStateProvider(): iterable + { + yield 'dependent objects still exist 2BP01' => ['2BP01']; + yield 'feature not supported 0A000' => ['0A000']; + } + + public function testInspectsTheSqlStateThroughTheExceptionChainNotTheMessage(): void + { + $exception = new DriverException(self::driverException('2BP01', 'totally unrelated wording'), null); + + self::assertTrue(DependencyConflictSqlState::isDependencyConflict($exception)); + } + + public function testDoesNotMatchOnMessageTextWhenSqlStateIsUnrelated(): void + { + $exception = new DriverException( + self::driverException('42703', 'cannot drop column foo dependent_objects_still_exist 2BP01'), + null, + ); + + self::assertFalse(DependencyConflictSqlState::isDependencyConflict($exception)); + } + + public function testFindsTheSqlStateEvenWhenWrappedDeepInTheChain(): void + { + $driver = self::driverException('0A000'); + $wrapped = new DriverException($driver, null); + $outer = new RuntimeException('migration failed', 0, $wrapped); + + self::assertTrue(DependencyConflictSqlState::isDependencyConflict($outer)); + } + + public function testIgnoresPlainThrowablesWithoutAnySqlState(): void + { + self::assertFalse(DependencyConflictSqlState::isDependencyConflict(new RuntimeException('boom'))); + } + + public function testResolveSqlStateReturnsTheStateOrNull(): void + { + self::assertSame( + '2BP01', + DependencyConflictSqlState::resolveSqlState(new DriverException(self::driverException('2BP01'), null)), + ); + self::assertNull(DependencyConflictSqlState::resolveSqlState(new RuntimeException('no sqlstate here'))); + } + + public function testIsDependencyConflictStateGatesOnTheTwoCodesOnly(): void + { + self::assertTrue(DependencyConflictSqlState::isDependencyConflictState('2BP01')); + self::assertTrue(DependencyConflictSqlState::isDependencyConflictState('0A000')); + self::assertFalse(DependencyConflictSqlState::isDependencyConflictState('42P01')); + self::assertFalse(DependencyConflictSqlState::isDependencyConflictState(null)); + } + + private static function driverException(string $sqlState, string $message = 'driver failure'): DriverAbstractException + { + return new class($message, $sqlState) extends DriverAbstractException {}; + } +} diff --git a/tests/Unit/Sql/ManagementMarkerTest.php b/tests/Unit/Sql/ManagementMarkerTest.php index fd0d38d..50a8353 100644 --- a/tests/Unit/Sql/ManagementMarkerTest.php +++ b/tests/Unit/Sql/ManagementMarkerTest.php @@ -42,4 +42,30 @@ public function testRoundTripsThroughJsonDecode(): void $decoded, ); } + + public function testRecognisesItsOwnCommentAsManaged(): void + { + self::assertTrue(ManagementMarker::isManagedComment(ManagementMarker::create('abc123')->toJson())); + } + + public function testTreatsNullEmptyAndMalformedCommentsAsUnmanaged(): void + { + self::assertFalse(ManagementMarker::isManagedComment(null)); + self::assertFalse(ManagementMarker::isManagedComment('')); + self::assertFalse(ManagementMarker::isManagedComment('{not valid json')); + } + + public function testDoesNotMatchTheMarkerKeyAsASubstringOfFreeText(): void + { + self::assertFalse(ManagementMarker::isManagedComment('a human note mentioning th3mouk_materialized_view in passing')); + self::assertFalse(ManagementMarker::isManagedComment('{"other_key":{"th3mouk_materialized_view":"nested, not top-level"}}')); + } + + public function testReadsTheHashOnlyFromAValidMarker(): void + { + self::assertSame('abc123', ManagementMarker::readHash(ManagementMarker::create('abc123')->toJson())); + self::assertNull(ManagementMarker::readHash('{"th3mouk_materialized_view":{"version":1}}')); + self::assertNull(ManagementMarker::readHash(null)); + self::assertNull(ManagementMarker::readHash('not json')); + } } diff --git a/tests/Unit/Sql/QualifiedNameTest.php b/tests/Unit/Sql/QualifiedNameTest.php new file mode 100644 index 0000000..8736d86 --- /dev/null +++ b/tests/Unit/Sql/QualifiedNameTest.php @@ -0,0 +1,99 @@ +schema); + self::assertSame($expected['name'], $scanned->name); + self::assertSame($expected['offsetAfter'], $offsetAfter); + } + + /** + * @return iterable + */ + public static function scanProvider(): iterable + { + yield 'unqualified, space-terminated' => [ + 'orders because other objects', 0, ['schema' => null, 'name' => 'orders', 'offsetAfter' => 6], + ]; + yield 'schema-qualified unquoted' => [ + 'public.orders ', 0, ['schema' => 'public', 'name' => 'orders', 'offsetAfter' => 13], + ]; + yield 'double-quoted with embedded dot' => [ + '"weird.name" rest', 0, ['schema' => null, 'name' => 'weird.name', 'offsetAfter' => 12], + ]; + yield 'double-quoted MixedCase, schema-qualified' => [ + '"My Schema"."My View" depends', 0, ['schema' => 'My Schema', 'name' => 'My View', 'offsetAfter' => 21], + ]; + yield 'double-quoted with escaped quote' => [ + '"a""b" tail', 0, ['schema' => null, 'name' => 'a"b', 'offsetAfter' => 6], + ]; + yield 'scan from a non-zero offset' => [ + 'view foo depends', 5, ['schema' => null, 'name' => 'foo', 'offsetAfter' => 8], + ]; + } + + #[DataProvider('unscannableProvider')] + public function testScanReturnsNullWhenNoCompleteIdentifierCanBeRead(string $input, int $offset): void + { + $offsetAfter = -1; + + self::assertNull(QualifiedName::scan($input, $offset, $offsetAfter)); + } + + /** + * @return iterable + */ + public static function unscannableProvider(): iterable + { + yield 'end of input' => ['view ', 5]; + yield 'offset past end' => ['abc', 10]; + yield 'unterminated quote' => ['"never closed', 0]; + yield 'empty unquoted token (leading space)' => [' leading', 0]; + } + + #[DataProvider('renderProvider')] + public function testRenderProducesSqlSafeReQuotedForm(?string $schema, string $name, string $expected): void + { + self::assertSame($expected, new QualifiedName($schema, $name)->render()); + } + + /** + * @return iterable + */ + public static function renderProvider(): iterable + { + yield 'simple unqualified' => [null, 'orders', 'orders']; + yield 'simple qualified' => ['public', 'orders', 'public.orders']; + yield 'mixed case needs quoting' => [null, 'MyView', '"MyView"']; + yield 'embedded dot needs quoting' => [null, 'weird.name', '"weird.name"']; + yield 'embedded quote is doubled' => [null, 'a"b', '"a""b"']; + yield 'qualified with quoting on both parts' => ['My Schema', 'My View', '"My Schema"."My View"']; + } + + public function testEqualsIsLogicalAndCaseSensitive(): void + { + self::assertTrue(new QualifiedName('public', 'orders')->equals(new QualifiedName('public', 'orders'))); + self::assertFalse(new QualifiedName('public', 'orders')->equals(new QualifiedName('public', 'Orders'))); + self::assertFalse(new QualifiedName(null, 'orders')->equals(new QualifiedName('public', 'orders'))); + } +}