diff --git a/.docker/ci/Dockerfile b/.docker/ci/Dockerfile new file mode 100644 index 0000000..d5d1f08 --- /dev/null +++ b/.docker/ci/Dockerfile @@ -0,0 +1,15 @@ +FROM devitools/php-hyperf:8.3-dev@sha256:285435f969f1309e78e24a9b432f92f44dd6753621b10b5faa94affd66a1b925 + +RUN apk add --no-cache --virtual .mongodb-build-deps autoconf g++ make openssl-dev php83-dev \ + && curl --fail --location --retry 3 https://pecl.php.net/get/mongodb-1.21.12.tgz -o /tmp/mongodb.tgz \ + && echo 'd4d2a8ad034685eacd7ea3ed96c0c9aed8c0165ea6e79d106bdf95214d5eaa35 /tmp/mongodb.tgz' | sha256sum -c - \ + && tar -xzf /tmp/mongodb.tgz -C /tmp \ + && cd /tmp/mongodb-1.21.12 \ + && phpize83 \ + && ./configure --with-php-config=/usr/bin/php-config83 --with-mongodb-ssl=openssl \ + && make -j2 \ + && make install \ + && cd / \ + && rm -rf /tmp/mongodb* \ + && apk del .mongodb-build-deps \ + && php -r 'exit(PHP_VERSION_ID >= 80336 && phpversion("mongodb") === "1.21.12" ? 0 : 1);' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 255fc0e..ff0e331 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,16 +15,16 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: - - name: Cache Docker images - uses: ScribeMD/docker-cache@0.5.0 - with: - key: docker-${{ runner.os }}-${{ hashFiles('compose.yaml') }} - - name: Checkout current code uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Cache Docker images + uses: ScribeMD/docker-cache@0.5.0 + with: + key: docker-${{ runner.os }}-${{ hashFiles('compose.yml', '.docker/ci/Dockerfile') }} + - name: Configure Git permissions run: git config --global --add safe.directory /__w/serendipity/serendipity diff --git a/compose.yml b/compose.yml index d3adc01..7166cd4 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,8 @@ services: app: container_name: serendipity-app - image: devitools/hyperf:8.3-dev + build: + context: .docker/ci volumes: - ./:/opt/www environment: @@ -53,7 +54,7 @@ services: volumes: - serendipity-mongo-data:/data/db healthcheck: - test: test $$(echo "rs.initiate().ok || rs.status().ok" | mongo -u user -p password --quiet) -eq 1 + test: [ "CMD", "mongosh", "--quiet", "--eval", "quit(db.hello().isWritablePrimary ? 0 : 1)" ] interval: 10s start_period: 30s networks: diff --git a/deptrac.yaml b/deptrac.yaml index db93c61..41402a6 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -67,6 +67,7 @@ deptrac: ^Visus\\Cuid2\\.*|\ ^Sentry\\.*|\ ^Swow\\.*|\ + ^Swoole\\.*|\ ^MongoDB\\.*|\ ^Symfony\\.*|\ ^Hyperf\\(?!Contract|Validation|Context\\Context).*|\ diff --git a/docs/postgresql-session-recovery.md b/docs/postgresql-session-recovery.md new file mode 100644 index 0000000..66051bb --- /dev/null +++ b/docs/postgresql-session-recovery.md @@ -0,0 +1,37 @@ +# PostgreSQL session recovery + +Session recovery is an explicit opt-in for the `hyperf/db` PostgreSQL driver. +It keeps the existing Swoole PostgreSQL backend and the Serendipity relational +connection interfaces. It does not switch to PDO or enable read/write routing. + +Add its bindings to the application's `config/autoload/dependencies.php`: + +```php +use Serendipity\Hyperf\Database\Relational\PostgreSQL\PostgreSQLSessionRecovery; + +return array_replace($existingDependencies, PostgreSQLSessionRecovery::dependencies()); +``` + +The application must provide its usual `Psr\Log\LoggerInterface` binding and a +runtime with `Swoole\Coroutine\PostgreSQL` support. The binding applies to +PostgreSQL pools created by `Hyperf\DB\Pool\PoolFactory`; other drivers and +applications that do not register the binding retain their existing behavior. + +The failed operation throws its original exception and is never automatically +replayed. A transport or protocol failure invalidates the session. Release +discards the physical connection while returning the managed object to the +native pool; a later acquisition establishes a fresh session. An invalid active +transaction cannot reconnect or continue. Its rollback only resets local state +and discards the session. Structured SQL failures keep healthy sessions usable +after the normal transaction rollback. + +The adapter preserves diagnostics when native error messages are missing. +Invalidation warnings contain the pool, stage, category, SQLSTATE, driver code +and result status when available. They do not include SQL, bindings or credentials. +Cleanup and logging failures do not replace the operation's original exception. + +A connection failure during `COMMIT` leaves the transaction outcome uncertain; +the caller must not assume rollback or retry the write automatically. Application +transaction helpers must also preserve the initial exception if cleanup fails. +This module does not introduce a transaction callback runner, partner request +retries, tracing changes, infrastructure changes or deployment behavior. diff --git a/makefile b/makefile index e6813dc..99b0130 100644 --- a/makefile +++ b/makefile @@ -35,7 +35,7 @@ watch: ## Start the project in watch mode ##@ Composer install: ## Composer install dependencies - @$(COMPOSE_RUNNER) --profile mongo --profile postgres run --rm --entrypoint composer app install + @$(COMPOSE_RUNNER) --profile mongo --profile postgres run --rm --build --entrypoint composer app install dump: ## Run the composer dump @$(COMPOSE_RUNNER) run --rm --entrypoint composer app dump-autoload diff --git a/rector.php b/rector.php index 022ef9c..0fb6fd2 100644 --- a/rector.php +++ b/rector.php @@ -4,7 +4,6 @@ declare(strict_types=1); -use Rector\Caching\ValueObject\Storage\FileCacheStorage; use Rector\CodeQuality\Rector\Class_\InlineConstructorDefaultToPropertyRector; use Rector\Config\RectorConfig; use Rector\Php80\Rector\Class_\ClassPropertyAssignToConstructorPromotionRector; @@ -33,6 +32,5 @@ LevelSetList::UP_TO_PHP_83, ]); - $config->cacheClass(FileCacheStorage::class); $config->cacheDirectory('/tmp/rector'); }; diff --git a/src/Domain/Exception/RepositoryException.php b/src/Domain/Exception/RepositoryException.php index 5b513d4..73b96cb 100644 --- a/src/Domain/Exception/RepositoryException.php +++ b/src/Domain/Exception/RepositoryException.php @@ -7,6 +7,8 @@ use Exception; use Throwable; +use function is_int; + class RepositoryException extends Exception { public readonly ThrowableType $type; @@ -18,9 +20,10 @@ public function __construct( ?string $message = null, ?int $code = null, ) { + $previousCode = $previous?->getCode(); parent::__construct( $message ?? $previous?->getMessage() ?? '', - $code ?? $previous?->getCode() ?? 0, + $code ?? (is_int($previousCode) ? $previousCode : 0), $previous ); diff --git a/src/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLConnection.php b/src/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLConnection.php new file mode 100644 index 0000000..cf47f8e --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLConnection.php @@ -0,0 +1,97 @@ +session->isValid() && isset($this->connection) && ($this->transactions > 0 || parent::check()); + } + + public function getConnection(): static + { + if ($this->transactions > 0 && ! $this->check()) { + return $this; + } + $this->getActiveConnection(); + return $this; + } + + public function reconnect(): bool + { + if ($this->transactions > 0) { + throw new QueryException('Cannot reconnect an active PostgreSQL transaction.'); + } + $this->session->discard(fn () => parent::close()); + $this->connection = $this->connector->connect($this->config, $this->session); + $this->session->activate(); + $this->lastUseTime = microtime(true); + return true; + } + + public function execute(string $query, array $bindings = []): int + { + return $this->statements->execute($this->session, $this->connection ?? null, $query, $bindings); + } + + public function query(string $query, array $bindings = []): array + { + return $this->statements->query($this->session, $this->connection ?? null, $query, $bindings); + } + + public function call(string $method, array $argument = []): mixed + { + $callback = fn () => parent::call($method, $argument); + return $this->commands->call($this->session, $this->connection ?? null, $method, $callback); + } + + public function run(Closure $closure): mixed + { + return $this->commands->run($this->session, $this->connection ?? null, fn () => parent::run($closure)); + } + + public function rollBack(?int $toLevel = null): void + { + if (! $this->session->isValid() || ! isset($this->connection)) { + $this->transactions = 0; + $this->session->discard(fn () => parent::close()); + return; + } + parent::rollBack($toLevel); + } + + public function release(): void + { + $snapshot = fn () => PgSQLFailure::from($this->connection ?? null); + $this->commands->release($this->session, $this, $snapshot, fn () => parent::release()); + } + + protected function causedByLostConnection(Throwable $exception): bool + { + return false; + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLPool.php b/src/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLPool.php new file mode 100644 index 0000000..f5c6f71 --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLPool.php @@ -0,0 +1,23 @@ + $this->container, + 'pool' => $this, + 'config' => $this->config, + 'session' => make(PgSQLSession::class, ['poolName' => $this->getName()]), + ]); + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PgSQLCommands.php b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLCommands.php new file mode 100644 index 0000000..1a4ea07 --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLCommands.php @@ -0,0 +1,69 @@ +usable($driver); + $result = $session->call($method, $driver, $driver, $callback); + if ($result === false) { + $session->failure($method, $driver, $driver); + } + return $result; + } + + public function run(PgSQLSession $session, ?PostgreSQL $driver, Closure $callback): mixed + { + $driver = $session->usable($driver); + try { + return $callback(); + } catch (Throwable $exception) { + if ($exception instanceof ErrorException && str_contains($exception->getMessage(), PostgreSQL::class)) { + $session->inspect('run', $driver, $driver, $exception); + } + throw $exception; + } finally { + $session->inspect('run', $driver, $driver); + } + } + + /** @param Closure(): PgSQLFailure $snapshot */ + public function release( + PgSQLSession $session, + AbstractConnection $connection, + Closure $snapshot, + Closure $release, + ): void { + $this->rollback($session, $connection, $snapshot); + if (! $session->isValid()) { + $session->discard(fn () => $connection->close()); + } + $session->clean('release', $release); + } + + /** @param Closure(): PgSQLFailure $snapshot */ + private function rollback(PgSQLSession $session, AbstractConnection $connection, Closure $snapshot): void + { + try { + if ($connection->transactionLevel() > 0) { + $connection->rollBack(0); + } + } catch (Throwable $exception) { + $session->invalidate('release', $snapshot(), 'rollback_failed'); + $session->clean('rollback', static fn () => throw $exception); + $connection->rollBack(0); + } + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PgSQLConnector.php b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLConnector.php new file mode 100644 index 0000000..68c2bc1 --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLConnector.php @@ -0,0 +1,59 @@ +create(); + try { + $this->initialize($driver, $config, $session); + $this->schema($driver, $config, $session); + return $driver; + } catch (Throwable $exception) { + $session->clean('connect', static function () use (&$driver): void { + $driver = null; + }); + throw $exception; + } + } + + public function create(): PostgreSQL + { + return new PostgreSQL(); + } + + private function initialize(PostgreSQL $driver, array $config, PgSQLSession $session): void + { + $dsn = sprintf( + 'host=%s port=%s dbname=%s user=%s password=%s', + stringify($config['host']), + stringify($config['port']), + stringify($config['database']), + stringify($config['username']), + stringify($config['password']), + ); + if ($session->call('connect', $driver, $driver, fn () => $driver->connect($dsn)) === false) { + $session->failure('connect', $driver, $driver); + } + } + + private function schema(PostgreSQL $driver, array $config, PgSQLSession $session): void + { + if (! empty($config['schema'])) { + $query = sprintf('set search_path to %s', stringify($config['schema'])); + if ($session->call('schema', $driver, $driver, fn () => $driver->query($query)) === false) { + $session->failure('schema', $driver, $driver); + } + } + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PgSQLDiagnostics.php b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLDiagnostics.php new file mode 100644 index 0000000..5fe5118 --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLDiagnostics.php @@ -0,0 +1,49 @@ +log('[postgres] session invalidated', [ + 'stage' => $stage, + 'reason' => $reason, + 'sqlstate' => $failure->sqlstate, + 'driver_code' => $failure->code, + 'result_status' => $failure->status, + 'pool' => $pool, + ]); + } + + public function clean(string $stage, Closure $callback, string $pool): void + { + try { + $callback(); + } catch (Throwable $exception) { + $this->log('[postgres] session cleanup failed', [ + 'stage' => $stage, + 'exception' => $exception::class, + 'pool' => $pool, + ]); + } + } + + private function log(string $message, array $context): void + { + try { + $this->logger->warning($message, $context); + } catch (Throwable) { + } + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PgSQLFailure.php b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLFailure.php new file mode 100644 index 0000000..06a954a --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLFailure.php @@ -0,0 +1,60 @@ + $diagnostics */ + $diagnostics = arrayify($driver->resultDiag ?? $fallback->resultDiag ?? []); + $failure = new self( + stringify($driver->error ?? ''), + stringify($diagnostics['sqlstate'] ?? ''), + stringify($diagnostics['message_primary'] ?? ''), + integerify($driver->errCode ?? 0), + integerify($driver->resultStatus ?? 0), + ); + return $failure->isEmpty() && $fallback !== null ? self::from($fallback) : $failure; + } + + public function isEmpty(): bool + { + return array_filter([$this->error, $this->sqlstate, $this->detail, $this->code, $this->status]) === []; + } + + public function message(string $stage): string + { + if ($this->error !== '') { + return $this->error; + } + return sprintf( + 'PostgreSQL %s failed (SQLSTATE %s, error code %d, result status %d): %s', + $stage, + $this->sqlstate !== '' ? $this->sqlstate : 'unknown', + $this->code, + $this->status, + $this->detail !== '' ? $this->detail : 'The driver returned no error message', + ); + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureClassifier.php b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureClassifier.php new file mode 100644 index 0000000..468fe54 --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureClassifier.php @@ -0,0 +1,111 @@ +sqlstate !== '') { + return $this->sqlstateReason($failure->sqlstate) ?? $this->nativeReason($failure, $exception); + } + $message = $failure->error . ' ' . $failure->detail . ' ' . ($exception?->getMessage() ?? ''); + $reason = $this->messageReason(strtolower($message)); + if ($reason !== null) { + return $reason; + } + return $failure->code !== 0 ? 'driver_error' : null; + } + + public function failureReason(PgSQLFailure $failure): ?string + { + return $this->reason($failure) ?? ($failure->sqlstate === '' ? 'unknown_native_failure' : null); + } + + private function sqlstateReason(string $sqlstate): ?string + { + if (str_starts_with($sqlstate, '08')) { + return 'connection_lost'; + } + return in_array($sqlstate, ['57P01', '57P02', '57P03'], true) ? 'server_shutdown' : null; + } + + private function nativeReason(PgSQLFailure $failure, ?Throwable $exception): ?string + { + $reason = $this->nativeMessageReason(strtolower($failure->error)); + if ($reason !== null) { + return $reason; + } + if ($failure->code !== 0) { + return 'driver_error'; + } + return $this->nativeWarningReason($exception); + } + + private function nativeWarningReason(?Throwable $exception): ?string + { + if (! $exception instanceof ErrorException) { + return null; + } + $pattern = '/^' . preg_quote(PostgreSQL::class, '/') . '(?:Statement)?::\w+\(\): (.*)$/s'; + if (preg_match($pattern, $exception->getMessage(), $matches) !== 1) { + return null; + } + return $this->nativeMessageReason(strtolower($matches[1])); + } + + private function nativeMessageReason(string $message): ?string + { + foreach ($this->patterns() as $pattern => $reason) { + if (str_starts_with($message, $pattern)) { + return $reason; + } + } + return null; + } + + private function messageReason(string $message): ?string + { + foreach ($this->patterns() as $pattern => $reason) { + if (str_contains($message, $pattern)) { + return $reason; + } + } + return null; + } + + /** @return array */ + private function patterns(): array + { + return [ + 'coroutine timedout' => 'client_timeout', + 'coroutine canceled' => 'client_cancelled', + 'coroutine cancelled' => 'client_cancelled', + 'swoole_event_add failed' => 'reactor_failure', + 'swoole_event_del failed' => 'reactor_failure', + 'bad file descriptor' => 'reactor_failure', + 'another command is already in progress' => 'protocol_busy', + 'server closed the connection unexpectedly' => 'connection_lost', + 'no connection to the server' => 'connection_lost', + 'ssl connection has been closed unexpectedly' => 'connection_lost', + 'could not receive data from server' => 'connection_lost', + 'could not send data to server' => 'connection_lost', + 'reset by peer' => 'connection_lost', + 'broken pipe' => 'connection_lost', + 'onerror' => 'connection_lost', + ]; + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PgSQLSession.php b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLSession.php new file mode 100644 index 0000000..b0511b3 --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLSession.php @@ -0,0 +1,84 @@ +invalid; + } + + public function activate(): void + { + $this->invalid = false; + } + + public function usable(?Client $driver): Client + { + if ($this->invalid || $driver === null) { + throw new QueryException('PostgreSQL session is unusable; the current operation was not executed.'); + } + return $driver; + } + + public function call(string $stage, Client|Statement $driver, Client $fallback, Closure $callback): mixed + { + try { + return $callback(); + } catch (Throwable $exception) { + $this->inspect($stage, $driver, $fallback, $exception); + throw $exception; + } + } + + public function failure(string $stage, Client|Statement $driver, Client $fallback): never + { + $failure = PgSQLFailure::from($driver, $fallback); + $this->invalidate($stage, $failure, $this->classifier->failureReason($failure)); + throw new QueryException($failure->message($stage)); + } + + public function inspect(string $stage, Client|Statement $driver, Client $fallback, ?Throwable $error = null): void + { + $failure = PgSQLFailure::from($driver, $fallback); + $this->invalidate($stage, $failure, $this->classifier->reason($failure, $error)); + } + + public function invalidate(string $stage, PgSQLFailure $failure, ?string $reason): void + { + if ($reason === null || $this->invalid) { + return; + } + $this->invalid = true; + $this->diagnostics->invalidated($stage, $failure, $reason, $this->poolName); + } + + public function discard(Closure $close): void + { + $this->invalid = true; + $this->clean('close', $close); + } + + public function clean(string $stage, Closure $callback): void + { + $this->diagnostics->clean($stage, $callback, $this->poolName); + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PgSQLStatements.php b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLStatements.php new file mode 100644 index 0000000..978ad0d --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PgSQLStatements.php @@ -0,0 +1,89 @@ +result($session, $session->usable($driver), $query, $bindings, 'affected_rows')); + } + + public function query(PgSQLSession $session, ?Client $driver, string $query, array $bindings): array + { + return arrayify($this->result($session, $session->usable($driver), $query, $bindings, 'fetch')); + } + + private function result(PgSQLSession $session, Client $driver, string $query, array $bindings, string $stage): mixed + { + $statement = $this->prepare($session, $driver, $query); + $failure = null; + try { + $this->executePrepared($session, $driver, $statement, $bindings); + return $this->read($session, $driver, $statement, $stage); + } catch (Throwable $exception) { + $failure = $exception; + throw $exception; + } finally { + $this->finish($session, $driver, static function () use (&$statement): void { + $statement = null; + }, $failure); + } + } + + private function prepare(PgSQLSession $session, Client $driver, string $query): Statement + { + $num = 1; + while (($position = strpos($query, '?')) !== false) { + $query = substr_replace($query, '$' . $num++, $position, 1); + } + $statement = $session->call('prepare', $driver, $driver, fn () => $driver->prepare($query)); + if (! $statement instanceof Statement) { + $session->failure('prepare', $driver, $driver); + } + return $statement; + } + + private function executePrepared(PgSQLSession $session, Client $driver, Statement $statement, array $bindings): void + { + $result = $session->call('execute', $statement, $driver, fn () => $statement->execute($bindings)); + if ($result === false || ! empty($statement->error)) { + $session->failure('execute', $statement, $driver); + } + } + + private function read(PgSQLSession $session, Client $driver, Statement $statement, string $stage): mixed + { + $reader = fn () => $stage === 'fetch' ? $statement->fetchAll() : $statement->affectedRows(); + $result = $session->call($stage, $statement, $driver, $reader); + if ($result === false) { + $session->failure($stage, $statement, $driver); + } + return $result; + } + + private function finish(PgSQLSession $session, Client $driver, Closure $dispose, ?Throwable $original): void + { + try { + $session->call('statement_cleanup', $driver, $driver, $dispose); + } catch (Throwable $exception) { + $session->invalidate('statement_cleanup', PgSQLFailure::from($driver), 'statement_cleanup_failed'); + if ($original === null) { + throw $exception; + } + $session->clean('statement', static fn () => throw $exception); + } + } +} diff --git a/src/Hyperf/Database/Relational/PostgreSQL/PostgreSQLSessionRecovery.php b/src/Hyperf/Database/Relational/PostgreSQL/PostgreSQLSessionRecovery.php new file mode 100644 index 0000000..bd4bcae --- /dev/null +++ b/src/Hyperf/Database/Relational/PostgreSQL/PostgreSQLSessionRecovery.php @@ -0,0 +1,17 @@ + DiagnosticPgSQLPool::class, + ]; + } +} diff --git a/src/Hyperf/Request/HyperfFormRequest.php b/src/Hyperf/Request/HyperfFormRequest.php index 1168142..bb3cda7 100644 --- a/src/Hyperf/Request/HyperfFormRequest.php +++ b/src/Hyperf/Request/HyperfFormRequest.php @@ -105,11 +105,16 @@ protected function retrieve(Set $data, string $key, mixed $default = null): mixe return data_get($data->toArray(), $key, $default); } + /** + * @param array> $headers + * @return array + */ private function normalizeHeaders(array $headers): array { - $callback = fn (mixed $value) => is_array($value) - ? implode('; ', $value) - : $value; - return array_map($callback, $headers); + $normalized = []; + foreach ($headers as $name => $values) { + $normalized[$name] = implode('; ', $values); + } + return $normalized; } } diff --git a/src/Hyperf/Testing/Extension/InputExtension.php b/src/Hyperf/Testing/Extension/InputExtension.php index 0d5f6cc..f8668d4 100644 --- a/src/Hyperf/Testing/Extension/InputExtension.php +++ b/src/Hyperf/Testing/Extension/InputExtension.php @@ -16,6 +16,7 @@ use Serendipity\Presentation\Input; use function array_keys; +use function is_object; use function spl_object_hash; use function sprintf; use function str_contains; @@ -52,8 +53,7 @@ protected function tearDownInput(bool $isRequestSetUp): void if ($context instanceof ArrayObject) { $keys = array_keys($context->getArrayCopy()); foreach ($keys as $key) { - if (str_contains($key, ValidatorInterface::class) - || str_ends_with($key, ':scene')) { + if ($this->isValidationContextKey($key)) { Context::destroy($key); } } @@ -61,6 +61,7 @@ protected function tearDownInput(bool $isRequestSetUp): void } /** + * @SuppressWarnings(StaticAccess) * @template T of mixed * @param class-string $class * @param array|string> $headers @@ -79,9 +80,11 @@ final protected function input( $this->setUpRequestContext($parsedBody, $queryParams, $params, $headers); $input = $this->make($class, $args); - $hash = spl_object_hash($input); - Context::destroy(sprintf('%s:%s', $hash, ValidatorInterface::class)); - Context::destroy(sprintf('%s:%s', $hash, 'scene')); + if (is_object($input)) { + $hash = spl_object_hash($input); + Context::destroy(sprintf('%s:%s', $hash, ValidatorInterface::class)); + Context::destroy(sprintf('%s:%s', $hash, 'scene')); + } return $input; } @@ -113,6 +116,11 @@ final protected function setUpRequestContext( Context::set(ServerRequestInterface::class, $value); } + private function isValidationContextKey(string $key): bool + { + return str_contains($key, ValidatorInterface::class) || str_ends_with($key, ':scene'); + } + protected function invoke(callable $action, Input $input, array $values = [], int $columns = 80): mixed { try { diff --git a/tests/Domain/Exception/RepositoryExceptionTest.php b/tests/Domain/Exception/RepositoryExceptionTest.php new file mode 100644 index 0000000..9af1cf7 --- /dev/null +++ b/tests/Domain/Exception/RepositoryExceptionTest.php @@ -0,0 +1,72 @@ +assertSame('quotes', $exception->context); + $this->assertSame('', $exception->getMessage()); + $this->assertSame(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertSame(ThrowableType::UNTREATED, $exception->type); + } + + public function testInheritsIntegerCodeAndMessageFromPreviousException(): void + { + $previous = new RuntimeException('database unavailable', 42); + + $exception = new RepositoryException('quotes', $previous); + + $this->assertSame('database unavailable', $exception->getMessage()); + $this->assertSame(42, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } + + public function testSqlstateStringRemainsAvailableThroughPreviousException(): void + { + $previous = new class extends PDOException { + public function __construct() + { + parent::__construct('connection failure'); + $this->code = '08P01'; + } + }; + + $exception = new RepositoryException('quotes', $previous); + + $this->assertSame(0, $exception->getCode()); + $this->assertSame('connection failure', $exception->getMessage()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame('08P01', $exception->getPrevious()->getCode()); + } + + public function testExplicitOverridesTakePrecedenceIncludingZeroCode(): void + { + $previous = new RuntimeException('original', 42); + + $exception = new RepositoryException( + 'quotes', + $previous, + ThrowableType::UNRECOVERABLE, + 'custom message', + 0, + ); + + $this->assertSame('custom message', $exception->getMessage()); + $this->assertSame(0, $exception->getCode()); + $this->assertSame(ThrowableType::UNRECOVERABLE, $exception->type); + $this->assertSame($previous, $exception->getPrevious()); + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLConnectionTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLConnectionTest.php new file mode 100644 index 0000000..fb3b7b3 --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLConnectionTest.php @@ -0,0 +1,231 @@ +expectException(QueryException::class); + $this->expectExceptionMessage('syntax error'); + + $database->query('SELECT FROM'); + } + + public function testInheritedRetryNeverReconnectsOrRepeatsLostConnectionOperations(): void + { + foreach (['server closed the connection unexpectedly', 'no connection to the server', + 'Resource deadlock avoided', 'Name or service not known'] as $message) { + $reconnections = 0; + $driver = $this->driver(); + $driver->expects($this->never())->method('prepare'); + $connection = $this->connection($driver, static function () use (&$reconnections, $driver): PostgreSQL { + ++$reconnections; + return $driver; + }); + $failure = new QueryException($message); + try { + $connection->retry($failure, 'execute', ['INSERT INTO example VALUES (1)']); + $this->fail('Expected original failure without replay'); + } catch (QueryException $exception) { + $this->assertSame($failure, $exception); + } + $this->assertSame(0, $reconnections); + } + } + + public function testNativeCloseIsIdempotentAndClosedTransactionOnlyRollsBackLocally(): void + { + $driver = $this->driver(); + $driver->expects($this->once())->method('query')->willReturn($this->statement()); + $connection = $this->connection($driver); + $connection->beginTransaction(); + $connection->close(); + $connection->close(); + $this->assertFalse($connection->check()); + $this->assertSame($connection, $connection->getConnection()); + try { + $connection->query('SELECT 1'); + $this->fail('Expected closed session guard'); + } catch (QueryException $exception) { + $this->assertStringContainsString('not executed', $exception->getMessage()); + } + $connection->rollBack(); + $this->assertSame(0, $connection->transactionLevel()); + } + + public function testBeginAndCommitAreNotRetriedAndInvalidRollbackIsLocal(): void + { + foreach (['BEGIN', 'COMMIT'] as $failedCommand) { + $driver = $this->driver(); + $success = $this->statement(); + $commands = []; + $driver->method('query')->willReturnCallback(function (string $query) use (&$commands, $driver, $success, $failedCommand) { + $commands[] = $query; + if ($query === $failedCommand) { + $driver->error = 'server closed the connection unexpectedly'; + return false; + } + return $success; + }); + $connection = $this->connection($driver); + try { + $connection->beginTransaction(); + $connection->commit(); + $this->fail('Expected transaction failure'); + } catch (QueryException) { + $connection->rollBack(); + $this->assertSame(0, $connection->transactionLevel()); + } + $this->assertSame($failedCommand === 'BEGIN' ? ['BEGIN'] : ['BEGIN', 'COMMIT'], $commands); + } + } + + public function testReconnectFailureAndSchemaFailureRemainUnusableUntilSuccessfulAcquisition(): void + { + foreach (['connect', 'schema'] as $failedStage) { + $old = $this->driver(); + $old->method('query')->willReturn($this->statement()); + $bad = $this->driver(); + $bad->expects($this->once())->method('connect')->willReturn($failedStage !== 'connect'); + $bad->method('query')->willReturn(false); + $good = $this->driver(); + $good->expects($this->once())->method('connect')->willReturn(true); + $good->method('query')->willReturn($this->statement()); + $drivers = [$bad, $good]; + $connection = $this->connection($old, static function () use (&$drivers): PostgreSQL { + return array_shift($drivers); + }, ['schema' => 'public']); + $connection->close(); + $connection->close(); + try { + $connection->getConnection(); + $this->fail('Expected failed reconnection'); + } catch (QueryException $exception) { + $this->assertStringContainsString('PostgreSQL ' . $failedStage . ' failed', $exception->getMessage()); + } + $this->assertFalse($connection->check()); + $this->assertSame($connection, $connection->getConnection()); + $this->assertTrue($connection->check()); + } + } + + public function testHealthyActiveTransactionKeepsItsSessionDespiteIdleExpiration(): void + { + $driver = $this->driver(); + $driver->method('query')->willReturn($this->statement()); + $connection = $this->connection($driver, fn () => throw new RuntimeException('Unexpected reconnection'), ['pool' => ['max_idle_time' => 0.0]]); + $connection->beginTransaction(); + $this->assertSame($connection, $connection->getConnection()); + $connection->rollBack(); + } + + public function testReleaseRollsBackHealthyTransactionAndRetiresFailedRollback(): void + { + foreach ([true, false] as $rollbackSucceeds) { + $driver = $this->driver(); + $success = $this->statement(); + $queries = []; + $driver->method('query')->willReturnCallback(static function (string $query) use (&$queries, $rollbackSucceeds, $success) { + $queries[] = $query; + return $query === 'ROLLBACK' && ! $rollbackSucceeds ? false : $success; + }); + $connection = $this->connection($driver); + $connection->beginTransaction(); + $connection->release(); + $this->assertSame(['BEGIN', 'ROLLBACK'], $queries); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertSame($rollbackSucceeds, $connection->check()); + } + } + + public function testNativeCallbackIsBoundToAdapterAndReceivesItsClient(): void + { + $driver = $this->driver(); + $connection = $this->connection($driver); + + $this->assertSame($driver, $connection->run(fn ($client) => $client)); + $this->assertTrue($connection->run(fn () => $this instanceof DiagnosticPgSQLConnection)); + } + + private function driver(?string $error = '', string $sqlstate = ''): PostgreSQL + { + $driver = $this->createMock(PostgreSQL::class); + $driver->error = $error; + $driver->errCode = 0; + $driver->resultStatus = 0; + $driver->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $driver; + } + + private function statement(string $error = '', string $sqlstate = ''): PostgreSQLStatement + { + $config = ApplicationContext::getContainer()->get(ConfigInterface::class); + $settings = $config->get('db.postgres'); + $native = new PostgreSQL(); + $native->connect(sprintf( + 'host=%s port=%s dbname=%s user=%s password=%s', + $settings['host'], + $settings['port'], + $settings['database'], + $settings['username'], + $settings['password'] + )); + $statement = $native->prepare('SELECT 1'); + $statement->error = $error; + $statement->errCode = 0; + $statement->resultStatus = 0; + $statement->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $statement; + } + + private function connection(PostgreSQL $driver, ?Closure $factory = null, array $config = []): DiagnosticPgSQLConnection + { + $driver->method('connect')->willReturn(true); + $connector = $this->getMockBuilder(PgSQLConnector::class)->onlyMethods(['create'])->getMock(); + $initial = true; + $connector->method('create')->willReturnCallback(static function () use ($driver, $factory, &$initial): PostgreSQL { + if ($initial) { + $initial = false; + return $driver; + } + return $factory !== null ? $factory() : $driver; + }); + $container = $this->createMock(ContainerInterface::class); + $container->method('has')->willReturn(false); + $option = $this->createMock(PoolOptionInterface::class); + $option->method('getMaxIdleTime')->willReturn($config['pool']['max_idle_time'] ?? 60.0); + $pool = $this->createMock(Pool::class); + $pool->method('getOption')->willReturn($option); + $session = new PgSQLSession(new PgSQLFailureClassifier(), new PgSQLDiagnostics(new NullLogger()), 'test'); + return new DiagnosticPgSQLConnection($container, $pool, $config, $session, $connector, new PgSQLStatements(), new PgSQLCommands()); + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLPoolTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLPoolTest.php new file mode 100644 index 0000000..b480b92 --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/DiagnosticPgSQLPoolTest.php @@ -0,0 +1,425 @@ +originalContainer = ApplicationContext::getContainer(); + $container = new Container(new DefinitionSource(PostgreSQLSessionRecovery::dependencies())); + $container->set(ConfigInterface::class, $this->originalContainer->get(ConfigInterface::class)); + $container->set(LoggerInterface::class, new NullLogger()); + $container->set(StdoutLoggerInterface::class, $this->originalContainer->get(StdoutLoggerInterface::class)); + $container->set(SwitchManager::class, $this->originalContainer->get(SwitchManager::class)); + ApplicationContext::setContainer($container); + } + + protected function tearDown(): void + { + try { + foreach ($this->pools as $pool) { + while ($pool->getConnectionsInChannel() > 0) { + $pool->flushOne(true); + } + } + } finally { + $this->pools = []; + ApplicationContext::setContainer($this->originalContainer); + parent::tearDown(); + } + } + + public function testPoolFactoryCreatesDiagnosticConnectionsAndPreservesBindings(): void + { + [$database, $pool] = $this->database(); + + $this->assertInstanceOf(DiagnosticPgSQLPool::class, $pool); + $this->assertTrue($database->run(fn () => $this instanceof DiagnosticPgSQLConnection)); + $this->assertSame([['value' => 'example']], $database->query('SELECT ?::text AS value', ['example'])); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getConnectionsInChannel()); + } + + public function testTimeoutRecoversOnNextAcquisitionWithAndWithoutTracing(): void + { + $switch = ApplicationContext::getContainer()->get(SwitchManager::class); + $enabled = $switch->isEnable('db'); + $tracer = Context::get(TracerContext::TRACER); + $root = TracerContext::getRoot(); + TracerContext::setTracer(new NoopTracer()); + TracerContext::setRoot((new NoopTracer())->startSpan('session-recovery')); + try { + foreach ([false, true] as $tracing) { + $switch->apply(['db' => $tracing]); + [$database, $pool] = $this->database(); + $pid = $this->pid($database); + for ($cycle = 0; $cycle < 3; ++$cycle) { + $this->expectTimeout(fn () => $database->query('SELECT pg_sleep(0.15)')); + $nextPid = $this->pid($database); + $this->assertNotSame($pid, $nextPid); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getConnectionsInChannel()); + $pid = $nextPid; + } + } + } finally { + $switch->apply(['db' => $enabled]); + Context::set(TracerContext::TRACER, $tracer); + Context::set(TracerContext::ROOT, $root); + } + } + + public function testSyntaxConstraintAndServerCancellationPreserveHealthySession(): void + { + [$database] = $this->database(); + $pid = $this->pid($database); + $database->execute('CREATE TEMP TABLE session_recovery_unique (id integer PRIMARY KEY)'); + $database->execute('INSERT INTO session_recovery_unique VALUES (1)'); + foreach (['SELECT FROM', 'INSERT INTO session_recovery_unique VALUES (1)'] as $query) { + try { + $database->query($query); + $this->fail('Expected SQL error'); + } catch (QueryException) { + $this->assertSame($pid, $this->pid($database)); + } + } + $database->execute("SET statement_timeout = '20ms'"); + try { + $database->query('SELECT pg_sleep(0.15)'); + $this->fail('Expected server cancellation'); + } catch (QueryException $exception) { + $this->assertStringContainsString('statement timeout', $exception->getMessage()); + } finally { + $database->execute('RESET statement_timeout'); + } + $this->assertSame($pid, $this->pid($database)); + } + + public function testSqlMessagesContainingTransportPatternsPreserveSavepointRecovery(): void + { + [$database, $pool] = $this->database(); + $pid = $this->pid($database); + $connection = $pool->get()->getConnection(); + try { + $connection->execute('CREATE TEMP TABLE recovery_preserved_writes (value integer)'); + $connection->beginTransaction(); + $connection->execute('INSERT INTO recovery_preserved_writes VALUES (1)'); + foreach (['missing_onerror_review_probe', 'missing_broken pipe_review_probe'] as $table) { + $connection->beginTransaction(); + try { + $connection->query('SELECT * FROM "' . $table . '"'); + $this->fail('Expected a missing relation error'); + } catch (QueryException $exception) { + $this->assertStringContainsString($table, $exception->getMessage()); + $this->assertTrue($connection->check()); + $this->assertSame(2, $connection->transactionLevel()); + } + $connection->rollBack(); + $this->assertSame(1, $connection->transactionLevel()); + $this->assertSame($pid, (int) $connection->query('SELECT pg_backend_pid() AS pid')[0]['pid']); + } + $connection->execute('INSERT INTO recovery_preserved_writes VALUES (2)'); + $connection->commit(); + } finally { + $connection->release(); + } + $this->assertSame([['value' => 1], ['value' => 2]], $database->query('SELECT value FROM recovery_preserved_writes ORDER BY value')); + $this->assertSame($pid, $this->pid($database)); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getConnectionsInChannel()); + } + + public function testNativeTimeoutOverridesStaleSqlDiagnosticsWithoutReplayingCallback(): void + { + [$database, $pool] = $this->database(); + $pid = $this->pid($database); + $attempts = 0; + $diagnostics = []; + + $callback = function ($driver) use (&$attempts, &$diagnostics): void { + ++$attempts; + $driver->resultDiag = ['sqlstate' => '42P01', 'message_primary' => 'old SQL error']; + if ($driver->query('SELECT pg_sleep(0.15)') === false) { + $diagnostics = [ + 'sqlstate' => $driver->resultDiag['sqlstate'], + 'code' => $driver->errCode, + 'error' => $driver->error, + ]; + throw new QueryException('Native callback timed out'); + } + }; + $this->expectTimeout(fn () => $database->run($callback)); + + $this->assertSame(1, $attempts); + $this->assertSame(['sqlstate' => '42P01', 'code' => 0, 'error' => 'Coroutine timedout'], $diagnostics); + $this->assertNotSame($pid, $this->pid($database)); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getConnectionsInChannel()); + } + + public function testHealthyTransactionCanRollbackAfterSqlError(): void + { + [$database, $pool] = $this->database(); + $pid = $this->pid($database); + $connection = $pool->get()->getConnection(); + try { + $connection->beginTransaction(); + try { + $connection->query('SELECT FROM'); + $this->fail('Expected SQL syntax error'); + } catch (QueryException) { + $this->assertTrue($connection->check()); + } + $connection->rollBack(); + $this->assertSame(0, $connection->transactionLevel()); + } finally { + $connection->release(); + } + $this->assertSame($pid, $this->pid($database)); + } + + public function testInvalidTransactionCannotReconnectOrContinueBeforeRollback(): void + { + [$database, $pool] = $this->database(); + $pid = $this->pid($database); + $connection = $pool->get()->getConnection(); + try { + $connection->beginTransaction(); + $this->expectTimeout(fn () => $connection->query('SELECT pg_sleep(0.15)')); + foreach ([fn () => $connection->getActiveConnection(), fn () => $connection->reconnect(), fn () => $connection->commit()] as $attempt) { + try { + $attempt(); + $this->fail('Invalid transaction must not reconnect or continue'); + } catch (QueryException) { + $this->assertSame(1, $connection->transactionLevel()); + } + } + $connection->rollBack(); + $this->assertSame(0, $connection->transactionLevel()); + } finally { + $connection->release(); + } + $this->assertNotSame($pid, $this->pid($database)); + } + + public function testRunCallbackFailureDoesNotRetireHealthySession(): void + { + [$database] = $this->database(); + $pid = $this->pid($database); + foreach (['callback business failure', 'server closed the connection unexpectedly'] as $message) { + $failure = new RuntimeException($message); + $calls = 0; + try { + $database->run(function () use ($failure, &$calls): never { + ++$calls; + throw $failure; + }); + $this->fail('Expected callback failure'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + $this->assertSame(1, $calls); + $this->assertSame($pid, $this->pid($database)); + } + $this->assertFalse($database->run(fn () => false)); + $this->assertSame($pid, $this->pid($database)); + $this->expectTimeout(fn () => $database->run(function ($driver): void { + if ($driver->query('SELECT pg_sleep(0.15)') === false) { + throw new QueryException('native callback timed out'); + } + })); + $this->assertNotSame($pid, $this->pid($database)); + } + + public function testWaitingCoroutineAcquiresReplacementWithoutPoolLeak(): void + { + [$database, $pool] = $this->database(); + $oldPid = $this->pid($database); + $connection = $pool->get()->getConnection(); + $native = $connection->run(fn ($driver) => WeakReference::create($driver)); + $result = new Channel(1); + Coroutine::create(function () use ($pool, $result, $native): void { + $borrowed = $pool->get(); + try { + $closedBeforeAcquisition = $native->get() === null; + $row = $borrowed->getConnection()->query('SELECT pg_backend_pid() AS pid')[0]; + $acquisition = ['closed' => $closedBeforeAcquisition, 'pid' => (int) $row['pid']]; + } catch (Throwable $exception) { + $acquisition = $exception; + } finally { + $borrowed->release(); + } + $result->push($acquisition); + }); + $this->expectTimeout(fn () => $connection->query('SELECT pg_sleep(0.15)')); + $connection->release(); + $acquisition = $result->pop(2); + $this->assertIsArray($acquisition); + $this->assertTrue($acquisition['closed']); + $this->assertIsInt($acquisition['pid']); + $this->assertNotSame($oldPid, $acquisition['pid']); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getConnectionsInChannel()); + } + + public function testTimeoutDoesNotReplayAnOperationWithDatabaseSideEffects(): void + { + [$database] = $this->database(); + $sequence = 'recovery_attempts_' . bin2hex(random_bytes(6)); + $database->execute('CREATE SEQUENCE ' . $sequence); + try { + $this->expectTimeout(fn () => $database->query("SELECT nextval('" . $sequence . "'), pg_sleep(0.15)")); + $row = $database->query('SELECT CASE WHEN is_called THEN last_value ELSE 0 END AS attempts FROM ' . $sequence)[0]; + $this->assertSame(1, (int) $row['attempts']); + } finally { + $database->execute('DROP SEQUENCE ' . $sequence); + } + } + + public function testTerminatedSessionAbortsTransactionAndNextAcquisitionRecovers(): void + { + [$database, $pool] = $this->database(); + $pid = $this->pid($database); + $connection = $pool->get()->getConnection(); + try { + $connection->beginTransaction(); + $control = DB::connection('postgres'); + $control->query('SELECT pg_terminate_backend(?)', [$pid]); + try { + $connection->query('SELECT 1'); + $this->fail('Expected disconnected session'); + } catch (Exception) { + $this->assertFalse($connection->check()); + $this->assertSame(1, $connection->transactionLevel()); + } + $connection->rollBack(); + } finally { + $connection->release(); + } + $this->assertNotSame($pid, $this->pid($database)); + } + + public function testDatabaseFacadeCanLocallyAbortInvalidTransactionWithoutReconnectOrLeak(): void + { + [$database, $pool] = $this->database(); + $oldPid = $this->pid($database); + $result = new Channel(1); + $coroutine = Coroutine::create(function () use ($database, $result): void { + try { + $database->beginTransaction(); + $this->expectTimeout(fn () => $database->query('SELECT pg_sleep(0.15)')); + $database->rollBack(); + $result->push($this->pid($database)); + } catch (Throwable $exception) { + $result->push($exception); + } + }); + $this->assertTrue(Coroutine::join([$coroutine], 3)); + $newPid = $result->pop(0.1); + $this->assertIsInt($newPid); + $this->assertNotSame($oldPid, $newPid); + $this->assertSame(1, $pool->getCurrentConnections()); + $this->assertSame(1, $pool->getConnectionsInChannel()); + } + + public function testConcurrentBorrowersHaveIndependentInvalidationState(): void + { + [$database, $pool] = $this->database(2); + $damaged = $pool->get()->getConnection(); + $healthy = $pool->get()->getConnection(); + $healthyPid = (int) $healthy->query('SELECT pg_backend_pid() AS pid')[0]['pid']; + $damagedPid = (int) $damaged->query('SELECT pg_backend_pid() AS pid')[0]['pid']; + $result = new Channel(1); + Coroutine::create(function () use ($damaged, $result): void { + try { + $this->expectTimeout(fn () => $damaged->query('SELECT pg_sleep(0.15)')); + $result->push(true); + } catch (Throwable $exception) { + $result->push($exception); + } + }); + try { + $this->assertSame($healthyPid, (int) $healthy->query('SELECT pg_backend_pid() AS pid')[0]['pid']); + $this->assertTrue($result->pop(2)); + $this->assertFalse($damaged->check()); + $this->assertTrue($healthy->check()); + $this->assertSame($healthyPid, (int) $healthy->query('SELECT pg_backend_pid() AS pid')[0]['pid']); + } finally { + $damaged->release(); + $healthy->release(); + } + $this->assertNotSame($damagedPid, $this->pid($database)); + $this->assertSame(2, $pool->getCurrentConnections()); + $this->assertSame(2, $pool->getConnectionsInChannel()); + } + + private function database(int $capacity = 1): array + { + $container = ApplicationContext::getContainer(); + $config = $container->get(ConfigInterface::class); + $name = 'recovery_' . bin2hex(random_bytes(6)); + $settings = $config->get('db.postgres'); + $settings['pool']['min_connections'] = 1; + $settings['pool']['max_connections'] = $capacity; + $config->set('db.' . $name, $settings); + $pool = $container->get(PoolFactory::class)->getPool($name); + $this->pools[] = $pool; + return [DB::connection($name), $pool]; + } + + private function pid(DB $database): int + { + return (int) $database->query('SELECT pg_backend_pid() AS pid')[0]['pid']; + } + + private function expectTimeout(callable $operation): void + { + $timeout = Coroutine::getOptions()['socket_read_timeout'] ?? 60.0; + Coroutine::set(['socket_read_timeout' => 0.02]); + try { + $operation(); + $this->fail('Expected an injected timeout'); + } catch (QueryException) { + $this->addToAssertionCount(1); + } finally { + Coroutine::set(['socket_read_timeout' => $timeout]); + } + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLCommandsTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLCommandsTest.php new file mode 100644 index 0000000..373f9fd --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLCommandsTest.php @@ -0,0 +1,207 @@ +session(); + $driver = $this->driver('server closed the connection unexpectedly'); + $driver->expects($this->once())->method('query')->with('COMMIT')->willReturn(false); + try { + $commands->call($session, $driver, 'commit', fn () => $driver->query('COMMIT')); + $this->fail('Expected command failure'); + } catch (QueryException $exception) { + $this->assertSame('server closed the connection unexpectedly', $exception->getMessage()); + } + $this->assertFalse($session->isValid()); + } + + public function testSuccessfulCommandPreservesResult(): void + { + $commands = new PgSQLCommands(); + $session = $this->session(); + $driver = $this->driver(); + $calls = 0; + + $result = $commands->call($session, $driver, 'custom', static function () use (&$calls): int { + ++$calls; + return 42; + }); + + $this->assertSame(42, $result); + $this->assertSame(1, $calls); + $this->assertTrue($session->isValid()); + } + + public function testApplicationCallbackFailureIsPreservedWithoutInvalidatingOrReplaying(): void + { + $commands = new PgSQLCommands(); + foreach ([new RuntimeException('server closed the connection unexpectedly'), new ErrorException('application error')] as $failure) { + $session = $this->session(); + $calls = 0; + try { + $commands->run($session, $this->driver(), static function () use ($failure, &$calls): never { + ++$calls; + throw $failure; + }); + $this->fail('Expected application failure'); + } catch (ErrorException|RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + $this->assertSame(1, $calls); + $this->assertTrue($session->isValid()); + } + } + + public function testNativeCallbackWarningPreservesExceptionAndRetiresSession(): void + { + $commands = new PgSQLCommands(); + $session = $this->session(); + $failure = new ErrorException('Swoole\Coroutine\PostgreSQL::query(): swoole_event_add failed'); + try { + $commands->run($session, $this->driver(), static fn () => throw $failure); + $this->fail('Expected native warning'); + } catch (ErrorException $exception) { + $this->assertSame($failure, $exception); + } + $this->assertFalse($session->isValid()); + } + + public function testFalseCallbackResultIsPreservedAndNativeDiagnosticsDetermineValidity(): void + { + $commands = new PgSQLCommands(); + foreach (['' => true, 'Coroutine canceled' => false] as $error => $valid) { + $session = $this->session(); + + $this->assertFalse($commands->run($session, $this->driver($error), static fn () => false)); + $this->assertSame($valid, $session->isValid()); + } + } + + public function testHealthyReleaseRollsBackBeforeReturningWrapperWithoutClosingClient(): void + { + $commands = new PgSQLCommands(); + $session = $this->session(); + $order = []; + $connection = $this->createMock(AbstractConnection::class); + $connection->method('transactionLevel')->willReturn(1); + $connection->expects($this->once())->method('rollBack')->with(0)->willReturnCallback(static function () use (&$order): void { + $order[] = 'rollback'; + }); + $connection->expects($this->never())->method('close'); + + $commands->release($session, $connection, static fn () => throw new RuntimeException('Unexpected snapshot'), static function () use (&$order): void { + $order[] = 'release'; + }); + + $this->assertSame(['rollback', 'release'], $order); + $this->assertTrue($session->isValid()); + } + + public function testRollbackFailureInvalidatesBeforeLocalCleanupAndRelease(): void + { + $commands = new PgSQLCommands(); + $session = $this->session(); + $connection = $this->createMock(AbstractConnection::class); + $connection->method('transactionLevel')->willReturn(1); + $order = []; + $rollbacks = 0; + $connection->expects($this->exactly(2))->method('rollBack')->with(0)->willReturnCallback(function () use ($session, &$rollbacks, &$order): void { + if (++$rollbacks === 1) { + $order[] = 'failed rollback'; + throw new RuntimeException('SQL rollback failed'); + } + $this->assertFalse($session->isValid()); + $order[] = 'local cleanup'; + }); + $connection->expects($this->once())->method('close')->willReturnCallback(static function () use (&$order): bool { + $order[] = 'close'; + return true; + }); + + $commands->release($session, $connection, static fn () => new PgSQLFailure('SQL rollback failed', 'XX000', '', 0, 7), static function () use (&$order): void { + $order[] = 'release'; + }); + + $this->assertSame(['failed rollback', 'local cleanup', 'close', 'release'], $order); + $this->assertFalse($session->isValid()); + } + + public function testInvalidSessionClosesBeforeReturningWrapper(): void + { + $commands = new PgSQLCommands(); + $session = $this->session(); + $session->invalidate('execute', new PgSQLFailure('', '', '', 0, 0), 'unknown_native_failure'); + $connection = $this->createMock(AbstractConnection::class); + $connection->method('transactionLevel')->willReturn(0); + $closed = false; + $connection->expects($this->once())->method('close')->willReturnCallback(static function () use (&$closed): bool { + $closed = true; + return true; + }); + $released = 0; + + $commands->release($session, $connection, static fn () => throw new RuntimeException('Unexpected snapshot'), function () use (&$closed, &$released): void { + $this->assertTrue($closed); + ++$released; + }); + + $this->assertSame(1, $released); + } + + public function testReleaseAndLoggerFailuresCannotEscapeOrCauseDoubleRelease(): void + { + $commands = new PgSQLCommands(); + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning')->willThrowException(new RuntimeException('logger failed')); + $session = $this->session($logger); + $connection = $this->createMock(AbstractConnection::class); + $connection->method('transactionLevel')->willReturn(0); + $connection->expects($this->never())->method('close'); + $releases = 0; + + $commands->release($session, $connection, static fn () => throw new RuntimeException('Unexpected snapshot'), static function () use (&$releases): void { + ++$releases; + throw new RuntimeException('pool release failed'); + }); + + $this->assertSame(1, $releases); + $this->assertTrue($session->isValid()); + } + + private function session(?LoggerInterface $logger = null): PgSQLSession + { + $session = new PgSQLSession(new PgSQLFailureClassifier(), new PgSQLDiagnostics($logger ?? new NullLogger()), 'test'); + $session->activate(); + return $session; + } + + private function driver(?string $error = '', string $sqlstate = ''): PostgreSQL + { + $driver = $this->createMock(PostgreSQL::class); + $driver->error = $error; + $driver->errCode = 0; + $driver->resultStatus = 0; + $driver->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $driver; + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLConnectorTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLConnectorTest.php new file mode 100644 index 0000000..1fdcade --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLConnectorTest.php @@ -0,0 +1,112 @@ +get(ConfigInterface::class)->get('db.postgres'); + $config['schema'] = 'public'; + $session = $this->session(); + $connector = new PgSQLConnector(); + + $driver = $connector->connect($config, $session); + $rows = $driver->query("SELECT current_setting('search_path') AS schema")->fetchAll(); + + $this->assertSame([['schema' => 'public']], $rows); + $this->assertFalse($session->isValid()); + } + + public function testFailedConnectReturnsDiagnosticFailureWithoutRetry(): void + { + $driver = $this->driver(null); + $driver->expects($this->once())->method('connect')->with( + 'host=db port=5432 dbname=app user=user password=secret', + )->willReturn(false); + $driver->expects($this->never())->method('query'); + $connector = $this->connector($driver); + $session = $this->session(); + try { + $connector->connect($this->config(), $session); + $this->fail('Expected connection failure'); + } catch (QueryException $exception) { + $this->assertStringContainsString('PostgreSQL connect failed', $exception->getMessage()); + $this->assertStringContainsString('The driver returned no error message', $exception->getMessage()); + } + $this->assertFalse($session->isValid()); + } + + public function testFailedSchemaDoesNotPublishUsableSessionOrRetry(): void + { + $driver = $this->driver(); + $driver->expects($this->once())->method('connect')->willReturn(true); + $driver->expects($this->once())->method('query')->with('set search_path to public')->willReturn(false); + $connector = $this->connector($driver); + $session = $this->session(); + try { + $connector->connect($this->config(), $session); + $this->fail('Expected schema failure'); + } catch (QueryException $exception) { + $this->assertStringContainsString('PostgreSQL schema failed', $exception->getMessage()); + } + $this->assertFalse($session->isValid()); + } + + public function testNativeConnectionExceptionIsPreservedWithoutAnotherAttempt(): void + { + $failure = new RuntimeException('server closed the connection unexpectedly'); + $driver = $this->driver(); + $driver->expects($this->once())->method('connect')->willThrowException($failure); + $connector = $this->connector($driver); + $session = $this->session(); + try { + $connector->connect($this->config(), $session); + $this->fail('Expected original native exception'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + $this->assertFalse($session->isValid()); + } + + private function connector(PostgreSQL $driver): PgSQLConnector + { + $connector = $this->getMockBuilder(PgSQLConnector::class)->onlyMethods(['create'])->getMock(); + $connector->expects($this->once())->method('create')->willReturn($driver); + return $connector; + } + + private function session(): PgSQLSession + { + return new PgSQLSession(new PgSQLFailureClassifier(), new PgSQLDiagnostics(new NullLogger()), 'test'); + } + + private function config(): array + { + return ['host' => 'db', 'port' => 5432, 'database' => 'app', 'username' => 'user', 'password' => 'secret', 'schema' => 'public']; + } + + private function driver(?string $error = '', string $sqlstate = ''): PostgreSQL + { + $driver = $this->createMock(PostgreSQL::class); + $driver->error = $error; + $driver->errCode = 0; + $driver->resultStatus = 0; + $driver->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $driver; + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLDiagnosticsTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLDiagnosticsTest.php new file mode 100644 index 0000000..a6fbd38 --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLDiagnosticsTest.php @@ -0,0 +1,70 @@ +createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning')->with( + '[postgres] session invalidated', + ['stage' => 'execute', 'reason' => 'connection_lost', 'sqlstate' => '08006', + 'driver_code' => 104, 'result_status' => 7, 'pool' => 'manager'], + ); + $diagnostics = new PgSQLDiagnostics($logger); + $failure = new PgSQLFailure('secret SQL and bindings', '08006', 'private credentials', 104, 7); + + $diagnostics->invalidated('execute', $failure, 'connection_lost', 'manager'); + } + + public function testSuccessfulCleanupRunsOnceWithoutLogging(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + $diagnostics = new PgSQLDiagnostics($logger); + $calls = 0; + + $diagnostics->clean('close', static function () use (&$calls): void { + ++$calls; + }, 'manager'); + + $this->assertSame(1, $calls); + } + + public function testFailedCleanupIsLoggedWithoutRetryingOrEscaping(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('warning')->with( + '[postgres] session cleanup failed', + ['stage' => 'close', 'exception' => RuntimeException::class, 'pool' => 'manager'], + ); + $diagnostics = new PgSQLDiagnostics($logger); + $calls = 0; + + $diagnostics->clean('close', static function () use (&$calls): void { + ++$calls; + throw new RuntimeException('private native error'); + }, 'manager'); + + $this->assertSame(1, $calls); + } + + public function testLoggerFailureCannotEscapeInvalidationOrCleanup(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('warning')->willThrowException(new RuntimeException('logger failed')); + $diagnostics = new PgSQLDiagnostics($logger); + + $diagnostics->invalidated('prepare', new PgSQLFailure('', '', '', 0, 0), 'unknown_native_failure', 'manager'); + $diagnostics->clean('close', static fn () => throw new RuntimeException('close failed'), 'manager'); + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureClassifierTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureClassifierTest.php new file mode 100644 index 0000000..79a1f97 --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureClassifierTest.php @@ -0,0 +1,100 @@ + 'connection_lost', '57P01' => 'server_shutdown', '57P02' => 'server_shutdown', '57P03' => 'server_shutdown'] as $state => $reason) { + $failure = new PgSQLFailure('', $state, '', 0, 7); + $this->assertSame($reason, $classifier->reason($failure)); + } + foreach (['42601', '23505', '25P02', '57014'] as $state) { + $failure = new PgSQLFailure('server SQL error', $state, '', 0, 7); + $this->assertNull($classifier->failureReason($failure)); + } + foreach ([ + 'Coroutine timedout' => 'client_timeout', + 'Coroutine canceled' => 'client_cancelled', + 'Coroutine cancelled' => 'client_cancelled', + 'swoole_event_add failed' => 'reactor_failure', + 'swoole_event_del failed' => 'reactor_failure', + 'Bad file descriptor' => 'reactor_failure', + 'another command is already in progress' => 'protocol_busy', + 'server closed the connection unexpectedly' => 'connection_lost', + 'no connection to the server' => 'connection_lost', + 'SSL connection has been closed unexpectedly' => 'connection_lost', + 'could not receive data from server' => 'connection_lost', + 'could not send data to server' => 'connection_lost', + 'reset by peer' => 'connection_lost', + 'Broken pipe' => 'connection_lost', + 'onerror' => 'connection_lost', + ] as $message => $reason) { + $this->assertSame($reason, $classifier->reason(new PgSQLFailure($message, '', '', 0, 0))); + } + $this->assertSame('driver_error', $classifier->reason(new PgSQLFailure('', '', '', 104, 0))); + $this->assertSame('unknown_native_failure', $classifier->failureReason(new PgSQLFailure('', '', '', 0, 0))); + } + + public function testServerSqlDiagnosticsTakePrecedenceOverTransportWords(): void + { + $classifier = new PgSQLFailureClassifier(); + foreach (['42P01', '23505', 'P0001', '57014'] as $state) { + foreach (['onerror', 'broken pipe', 'Coroutine timedout', 'swoole_event_add failed'] as $message) { + $failure = new PgSQLFailure('ERROR: ' . $message, $state, $message, 0, 7); + + $this->assertNull($classifier->failureReason($failure)); + $this->assertNull($classifier->reason($failure, new QueryException($message))); + $this->assertNull($classifier->reason($failure, new ErrorException($message))); + $this->assertNull($classifier->reason($failure, new QueryException('Swoole\Coroutine\PostgreSQL::query(): ' . $message))); + $this->assertNull($classifier->reason($failure, new ErrorException('Swoole\Coroutine\PostgreSQL::query(): ERROR: ' . $message))); + } + } + } + + public function testNativeFailuresOverrideStaleServerSqlDiagnostics(): void + { + $classifier = new PgSQLFailureClassifier(); + foreach ([ + 'Coroutine timedout' => 'client_timeout', + 'Coroutine canceled' => 'client_cancelled', + 'Coroutine cancelled' => 'client_cancelled', + 'onerror' => 'connection_lost', + 'swoole_event_add failed' => 'reactor_failure', + 'swoole_event_del failed' => 'reactor_failure', + 'Bad file descriptor' => 'reactor_failure', + 'another command is already in progress' => 'protocol_busy', + 'server closed the connection unexpectedly' . "\n" . 'The server terminated abnormally' => 'connection_lost', + 'no connection to the server' => 'connection_lost', + 'SSL connection has been closed unexpectedly' => 'connection_lost', + 'could not receive data from server: Broken pipe' => 'connection_lost', + 'could not send data to server: Broken pipe' => 'connection_lost', + 'reset by peer' => 'connection_lost', + 'Broken pipe' => 'connection_lost', + ] as $message => $reason) { + $failure = new PgSQLFailure($message, '42P01', 'relation does not exist', 0, 7); + + $this->assertSame($reason, $classifier->failureReason($failure)); + } + $this->assertSame('driver_error', $classifier->reason(new PgSQLFailure('', '42P01', '', 104, 7))); + $this->assertSame('connection_lost', $classifier->reason(new PgSQLFailure('Broken pipe', '42P01', '', 32, 7))); + foreach ([PostgreSQL::class, PostgreSQLStatement::class] as $driver) { + $failure = new PgSQLFailure('ERROR: relation does not exist', '42P01', 'relation does not exist', 0, 7); + $warning = new ErrorException($driver . '::query(): swoole_event_add failed'); + + $this->assertSame('reactor_failure', $classifier->reason($failure, $warning)); + } + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureTest.php new file mode 100644 index 0000000..a0d4ca8 --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLFailureTest.php @@ -0,0 +1,116 @@ +assertTrue($failure->isEmpty()); + $this->assertSame( + 'PostgreSQL prepare failed (SQLSTATE unknown, error code 0, result status 0): The driver returned no error message', + $failure->message('prepare'), + ); + } + + public function testNativeErrorMessageIsPreserved(): void + { + $driver = $this->driver('original native error', '42601'); + $driver->errCode = 10; + $driver->resultStatus = 7; + $failure = PgSQLFailure::from($driver); + + $this->assertSame('original native error', $failure->message('execute')); + $this->assertSame('42601', $failure->sqlstate); + $this->assertSame(10, $failure->code); + $this->assertSame(7, $failure->status); + } + + public function testMissingMessageUsesStructuredDiagnostics(): void + { + $driver = $this->driver(null, '08006'); + $driver->resultDiag['message_primary'] = 'server closed the connection unexpectedly'; + $driver->resultStatus = 7; + $failure = PgSQLFailure::from($driver); + + $this->assertSame( + 'PostgreSQL prepare failed (SQLSTATE 08006, error code 0, result status 7): server closed the connection unexpectedly', + $failure->message('prepare'), + ); + } + + public function testStatementDiagnosticsTakePrecedenceOverConnectionFields(): void + { + $statement = $this->statement('duplicate key', '23505'); + $driver = $this->driver('old connection error', '08006'); + $failure = PgSQLFailure::from($statement, $driver); + + $this->assertSame('duplicate key', $failure->error); + $this->assertSame('23505', $failure->sqlstate); + } + + public function testMissingStatementDiagnosticsUseConnectionSqlstate(): void + { + $statement = $this->statement('duplicate key'); + $statement->resultDiag = null; + $driver = $this->driver('duplicate key', '23505'); + $failure = PgSQLFailure::from($statement, $driver); + + $this->assertSame('duplicate key', $failure->error); + $this->assertSame('23505', $failure->sqlstate); + } + + public function testEmptyStatementUsesConnectionFailure(): void + { + $statement = $this->statement(); + $driver = $this->driver('Coroutine timedout'); + $failure = PgSQLFailure::from($statement, $driver); + + $this->assertSame('Coroutine timedout', $failure->error); + $this->assertFalse($failure->isEmpty()); + } + + private function driver(?string $error = '', string $sqlstate = ''): PostgreSQL + { + $driver = $this->createMock(PostgreSQL::class); + $driver->error = $error; + $driver->errCode = 0; + $driver->resultStatus = 0; + $driver->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $driver; + } + + private function statement(string $error = '', string $sqlstate = ''): PostgreSQLStatement + { + $config = ApplicationContext::getContainer()->get(ConfigInterface::class); + $settings = $config->get('db.postgres'); + $native = new PostgreSQL(); + $native->connect(sprintf( + 'host=%s port=%s dbname=%s user=%s password=%s', + $settings['host'], + $settings['port'], + $settings['database'], + $settings['username'], + $settings['password'] + )); + $statement = $native->prepare('SELECT 1'); + $statement->error = $error; + $statement->errCode = 0; + $statement->resultStatus = 0; + $statement->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $statement; + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLSessionTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLSessionTest.php new file mode 100644 index 0000000..ee3c73a --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLSessionTest.php @@ -0,0 +1,150 @@ +session(); + $this->assertFalse($session->isValid()); + $this->expectException(QueryException::class); + + $session->usable($this->driver()); + } + + public function testActiveSessionReturnsItsClientAndRejectsMissingClient(): void + { + $session = $this->session(); + $driver = $this->driver(); + $session->activate(); + $this->assertSame($driver, $session->usable($driver)); + $this->expectException(QueryException::class); + + $session->usable(null); + } + + public function testNativeCallRunsOnceAndPreservesTheOriginalFailure(): void + { + $session = $this->session(); + $session->activate(); + $driver = $this->driver(); + $failure = new ErrorException('swoole_event_add failed'); + $driver->expects($this->once())->method('prepare')->willThrowException($failure); + try { + $session->call('prepare', $driver, $driver, fn () => $driver->prepare('SELECT 1')); + $this->fail('Expected native failure'); + } catch (ErrorException $actual) { + $this->assertSame($failure, $actual); + } + $this->assertFalse($session->isValid()); + } + + public function testNativeCallPreservesSuccessfulResult(): void + { + $session = $this->session(); + $session->activate(); + $driver = $this->driver(); + $calls = 0; + + $result = $session->call('execute', $driver, $driver, static function () use (&$calls): int { + ++$calls; + return 42; + }); + + $this->assertSame(42, $result); + $this->assertSame(1, $calls); + $this->assertTrue($session->isValid()); + } + + public function testStructuredSqlFailureDoesNotInvalidateSession(): void + { + $session = $this->session(); + $session->activate(); + $driver = $this->driver('syntax error', '42601'); + try { + $session->failure('execute', $driver, $driver); + $this->fail('Expected SQL failure'); + } catch (QueryException $actual) { + $this->assertSame('syntax error', $actual->getMessage()); + } + $this->assertTrue($session->isValid()); + } + + public function testUnknownNativeFailureInvalidatesAndProvidesFallbackMessage(): void + { + $session = $this->session(); + $session->activate(); + $driver = $this->driver(null); + try { + $session->failure('prepare', $driver, $driver); + $this->fail('Expected unknown native failure'); + } catch (QueryException $actual) { + $this->assertStringContainsString('The driver returned no error message', $actual->getMessage()); + } + $this->assertFalse($session->isValid()); + } + + public function testInvalidationLogsOnceAndCanBeResetForANewClient(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(2))->method('warning'); + $session = $this->session($logger); + $failure = new PgSQLFailure('', '08006', '', 0, 7); + $session->activate(); + + $session->invalidate('execute', $failure, null); + $this->assertTrue($session->isValid()); + $session->invalidate('execute', $failure, 'connection_lost'); + $session->invalidate('fetch', $failure, 'connection_lost'); + $this->assertFalse($session->isValid()); + $session->activate(); + $session->invalidate('execute', $failure, 'connection_lost'); + $this->assertFalse($session->isValid()); + } + + public function testDiscardInvalidatesBeforeCleanupAndRemainsInvalidIfCleanupFails(): void + { + $session = $this->session(); + $session->activate(); + $calls = 0; + + $session->discard(function () use ($session, &$calls): void { + $this->assertFalse($session->isValid()); + ++$calls; + throw new RuntimeException('close failed'); + }); + + $this->assertSame(1, $calls); + $this->assertFalse($session->isValid()); + } + + private function session(?LoggerInterface $logger = null): PgSQLSession + { + return new PgSQLSession(new PgSQLFailureClassifier(), new PgSQLDiagnostics($logger ?? new NullLogger()), 'test'); + } + + private function driver(?string $error = '', string $sqlstate = ''): PostgreSQL + { + $driver = $this->createMock(PostgreSQL::class); + $driver->error = $error; + $driver->errCode = 0; + $driver->resultStatus = 0; + $driver->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $driver; + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLStatementsTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLStatementsTest.php new file mode 100644 index 0000000..4d3f138 --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PgSQLStatementsTest.php @@ -0,0 +1,228 @@ +session(); + $config = ApplicationContext::getContainer()->get(ConfigInterface::class)->get('db.postgres'); + $driver = (new PgSQLConnector())->connect($config, $session); + $statements->execute($session, $driver, 'CREATE TEMP TABLE statement_results (id integer)', []); + + $this->assertSame(1, $statements->execute($session, $driver, 'INSERT INTO statement_results VALUES (?)', [42])); + $this->assertSame([['id' => 42]], $statements->query($session, $driver, 'SELECT id FROM statement_results WHERE id = ?', [42])); + $this->assertTrue($session->isValid()); + } + + public function testMissingPrepareMessageRetiresSessionAndDoesNotRepeatPreparation(): void + { + foreach ([1, 7] as $status) { + $statements = new PgSQLStatements(); + $session = $this->session(); + $driver = $this->driver(null); + $driver->resultStatus = $status; + $driver->resultDiag = null; + $driver->expects($this->once())->method('prepare')->with('SELECT $1::text')->willReturn(false); + try { + $statements->query($session, $driver, 'SELECT ?::text', ['private binding']); + $this->fail('Expected failed preparation'); + } catch (QueryException $exception) { + $this->assertStringContainsString('SQLSTATE unknown, error code 0, result status ' . $status, $exception->getMessage()); + $this->assertStringContainsString('The driver returned no error message', $exception->getMessage()); + } + $this->assertFalse($session->isValid()); + try { + $statements->query($session, $driver, 'SELECT 1', []); + $this->fail('Expected unusable session guard'); + } catch (QueryException $exception) { + $this->assertStringContainsString('not executed', $exception->getMessage()); + } + } + } + + public function testStructuredPrepareFailurePreservesOriginalMessageAndHealthySession(): void + { + $statements = new PgSQLStatements(); + $session = $this->session(); + $driver = $this->driver('syntax error', '42601'); + $driver->expects($this->once())->method('prepare')->willReturn(false); + try { + $statements->query($session, $driver, 'SELECT FROM', []); + $this->fail('Expected SQL failure'); + } catch (QueryException $exception) { + $this->assertSame('syntax error', $exception->getMessage()); + } + $this->assertTrue($session->isValid()); + } + + public function testPreparationWarningPreservesExceptionAndInvalidatesSession(): void + { + $statements = new PgSQLStatements(); + $session = $this->session(); + $driver = $this->driver(); + $failure = new ErrorException('Swoole\Coroutine\PostgreSQL::prepare(): swoole_event_add failed'); + $driver->expects($this->once())->method('prepare')->willThrowException($failure); + try { + $statements->query($session, $driver, 'SELECT 1', []); + $this->fail('Expected original native warning'); + } catch (ErrorException $exception) { + $this->assertSame($failure, $exception); + } + $this->assertFalse($session->isValid()); + } + + public function testExecutionUsesStatementDiagnosticInsteadOfStaleClientError(): void + { + $statements = new PgSQLStatements(); + $session = $this->session(static fn (string $stage, Closure $next) => $stage === 'execute' ? false : $next()); + $driver = $this->driver('old connection error', '08006'); + $driver->expects($this->once())->method('prepare')->willReturn($this->statement('duplicate key', '23505')); + try { + $statements->execute($session, $driver, 'INSERT INTO example VALUES (?)', [1]); + $this->fail('Expected constraint failure'); + } catch (QueryException $exception) { + $this->assertSame('duplicate key', $exception->getMessage()); + } + $this->assertTrue($session->isValid()); + } + + public function testFalseExecuteFetchAndAffectedRowsRetireSession(): void + { + foreach (['execute', 'fetch', 'affected_rows'] as $failedStage) { + $statements = new PgSQLStatements(); + $driver = $this->driver('Coroutine timedout'); + $driver->expects($this->once())->method('prepare')->willReturn($this->statement()); + $session = $this->session(static function (string $stage, Closure $next) use ($failedStage): mixed { + if ($stage === $failedStage) { + return false; + } + return $stage === 'execute' ? true : $next(); + }); + try { + $failedStage === 'fetch' + ? $statements->query($session, $driver, 'SELECT 1', []) + : $statements->execute($session, $driver, 'UPDATE example SET id = 1', []); + $this->fail('Expected native failure'); + } catch (QueryException) { + $this->assertFalse($session->isValid()); + } + } + } + + public function testCleanupFailureCannotReplaceOriginalExecutionException(): void + { + $statements = new PgSQLStatements(); + $driver = $this->driver(); + $driver->expects($this->once())->method('prepare')->willReturn($this->statement()); + $primary = new ErrorException('swoole_event_add failed'); + $executions = 0; + $session = $this->session(static function (string $stage, Closure $next) use ($primary, &$executions): mixed { + if ($stage === 'execute') { + ++$executions; + throw $primary; + } + $result = $next(); + if ($stage === 'statement_cleanup') { + throw new RuntimeException('cleanup failed'); + } + return $result; + }); + try { + $statements->execute($session, $driver, 'INSERT INTO example VALUES (1)', []); + $this->fail('Expected original execution failure'); + } catch (ErrorException $exception) { + $this->assertSame($primary, $exception); + } + $this->assertSame(1, $executions); + $this->assertFalse($session->isValid()); + } + + public function testCleanupFailureAfterSuccessPropagatesAndRetiresSession(): void + { + $statements = new PgSQLStatements(); + $driver = $this->driver(); + $driver->expects($this->once())->method('prepare')->willReturn($this->statement()); + $failure = new ErrorException('swoole_event_del failed'); + $session = $this->session(static function (string $stage, Closure $next) use ($failure): mixed { + $result = $next(); + if ($stage === 'statement_cleanup') { + throw $failure; + } + return $result; + }); + try { + $statements->query($session, $driver, 'SELECT 1', []); + $this->fail('Expected cleanup warning'); + } catch (ErrorException $exception) { + $this->assertSame($failure, $exception); + } + $this->assertFalse($session->isValid()); + } + + private function session(?Closure $interceptor = null): PgSQLSession + { + $arguments = [new PgSQLFailureClassifier(), new PgSQLDiagnostics(new NullLogger()), 'test']; + if ($interceptor === null) { + $session = new PgSQLSession(...$arguments); + } else { + $session = $this->getMockBuilder(PgSQLSession::class)->setConstructorArgs($arguments)->onlyMethods(['call'])->getMock(); + $session->method('call')->willReturnCallback(static fn ($stage, $driver, $fallback, $next) => $interceptor($stage, $next)); + } + $session->activate(); + return $session; + } + + private function driver(?string $error = '', string $sqlstate = ''): PostgreSQL + { + $driver = $this->createMock(PostgreSQL::class); + $driver->error = $error; + $driver->errCode = 0; + $driver->resultStatus = 0; + $driver->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $driver; + } + + private function statement(string $error = '', string $sqlstate = ''): PostgreSQLStatement + { + $config = ApplicationContext::getContainer()->get(ConfigInterface::class); + $settings = $config->get('db.postgres'); + $native = new PostgreSQL(); + $native->connect(sprintf( + 'host=%s port=%s dbname=%s user=%s password=%s', + $settings['host'], + $settings['port'], + $settings['database'], + $settings['username'], + $settings['password'] + )); + $statement = $native->prepare('SELECT 1'); + $statement->error = $error; + $statement->errCode = 0; + $statement->resultStatus = 0; + $statement->resultDiag = $sqlstate !== '' ? ['sqlstate' => $sqlstate] : []; + return $statement; + } +} diff --git a/tests/Hyperf/Database/Relational/PostgreSQL/PostgreSQLSessionRecoveryTest.php b/tests/Hyperf/Database/Relational/PostgreSQL/PostgreSQLSessionRecoveryTest.php new file mode 100644 index 0000000..fc42e4d --- /dev/null +++ b/tests/Hyperf/Database/Relational/PostgreSQL/PostgreSQLSessionRecoveryTest.php @@ -0,0 +1,59 @@ +assertArrayNotHasKey(PgSQLPool::class, $configuration['dependencies']); + $this->assertSame([PgSQLPool::class => DiagnosticPgSQLPool::class], PostgreSQLSessionRecovery::dependencies()); + } + + public function testPoolFactoryKeepsNativePostgresWhenModuleIsNotEnabled(): void + { + $original = ApplicationContext::getContainer(); + $container = new Container(new DefinitionSource([])); + $container->set(ConfigInterface::class, new Config(['db' => ['postgres' => ['driver' => 'pgsql']]])); + ApplicationContext::setContainer($container); + try { + $pool = $container->get(PoolFactory::class)->getPool('postgres'); + + $this->assertSame(PgSQLPool::class, $pool::class); + } finally { + ApplicationContext::setContainer($original); + } + } + + public function testEnablingRecoveryLeavesMysqlPoolUnchanged(): void + { + $original = ApplicationContext::getContainer(); + $container = new Container(new DefinitionSource(PostgreSQLSessionRecovery::dependencies())); + $container->set(ConfigInterface::class, new Config(['db' => ['mysql' => ['driver' => 'mysql']]])); + ApplicationContext::setContainer($container); + try { + $pool = $container->get(PoolFactory::class)->getPool('mysql'); + + $this->assertSame(MySQLPool::class, $pool::class); + } finally { + ApplicationContext::setContainer($original); + } + } +} diff --git a/tests/Hyperf/Listener/SentryHttpListenerTest.php b/tests/Hyperf/Listener/SentryHttpListenerTest.php index da5afbd..80282cd 100644 --- a/tests/Hyperf/Listener/SentryHttpListenerTest.php +++ b/tests/Hyperf/Listener/SentryHttpListenerTest.php @@ -177,7 +177,10 @@ public function testProcessWithHttpHandleStartedFail(): void { // Arrange $options = [ - 'dsn' => 'https://example.com/sentry', + 'dsn' => null, + 'integrations' => static function (array $integrations): array { + throw new Exception('SDK initialization failure'); + }, ]; $this->config->expects($this->exactly(2)) diff --git a/tests/Hyperf/Request/HyperfFormRequestTest.php b/tests/Hyperf/Request/HyperfFormRequestTest.php index a08d38d..c3bab4d 100644 --- a/tests/Hyperf/Request/HyperfFormRequestTest.php +++ b/tests/Hyperf/Request/HyperfFormRequestTest.php @@ -8,6 +8,7 @@ use Hyperf\Context\RequestContext; use Hyperf\Context\ResponseContext; use Hyperf\Contract\ValidatorInterface; +use Hyperf\HttpMessage\Server\Request; use Hyperf\Support\MessageBag; use Hyperf\Validation\Contract\ValidatorFactoryInterface; use Hyperf\Validation\ValidationException; @@ -89,4 +90,25 @@ protected function authorize(): bool $formRequest->validateResolved(); } + + public function testPropertiesNormalizePsrHeaderValuesAndPreserveBaseProperties(): void + { + $this->container->method('get') + ->with(EventDispatcherInterface::class) + ->willReturn($this->eventDispatcher); + $request = (new Request('GET', '/')) + ->withHeader('X-Regions', ['north', 'south']) + ->withHeader('X-Single', ['one']) + ->withHeader('X-Empty', []); + RequestContext::set($request); + $formRequest = new class ($this->container, Set::createFrom(['source' => 'base'])) extends HyperfFormRequest { + }; + + $properties = $formRequest->properties()->toArray(); + + $this->assertSame('north; south', $properties['X-Regions']); + $this->assertSame('one', $properties['X-Single']); + $this->assertSame('', $properties['X-Empty']); + $this->assertSame('base', $properties['source']); + } } diff --git a/tests/Hyperf/Testing/Extension/InputExtensionTest.php b/tests/Hyperf/Testing/Extension/InputExtensionTest.php index 4b70b23..54ecabc 100644 --- a/tests/Hyperf/Testing/Extension/InputExtensionTest.php +++ b/tests/Hyperf/Testing/Extension/InputExtensionTest.php @@ -26,11 +26,73 @@ protected function setUp(): void protected function tearDown(): void { - Context::destroy(ServerRequestInterface::class); - Context::destroy('http.request.parsedData'); + $this->mock->exposeTearDownInput(); + Context::destroy('unrelated-context'); parent::tearDown(); } + public function testReusedInputDiscardsItsCachedValidatorAndScene(): void + { + $input = $this->createPartialMock(Input::class, []); + $this->mock = new InputExtensionMock($input); + $this->mock->exposeSetUpInput(); + $first = $this->mock->exposeInput(Input::class, ['value' => 'first']); + $validatorKey = sprintf('%s:%s', spl_object_hash($input), ValidatorInterface::class); + $validator = $this->createMock(ValidatorInterface::class); + Context::set($validatorKey, $validator); + $input->scene('previous-request'); + Context::set('unrelated-context', 'preserved'); + $this->assertSame($validator, Context::get($validatorKey)); + $this->assertSame('previous-request', $input->getScene()); + + $second = $this->mock->exposeInput(Input::class, ['value' => 'second']); + + $this->assertSame($first, $second); + $this->assertNull(Context::get($validatorKey)); + $this->assertNull($input->getScene()); + $this->assertSame(['value' => 'second'], Context::get(ServerRequestInterface::class)->getParsedBody()); + $this->assertSame('preserved', Context::get('unrelated-context')); + } + + public function testScalarFactoryResultRemainsSupported(): void + { + $this->mock = new InputExtensionMock(0); + $this->mock->exposeSetUpInput(); + + $result = $this->mock->exposeInput('TestClass', ['value' => 'scalar']); + + $this->assertSame(0, $result); + $this->assertSame(['value' => 'scalar'], Context::get(ServerRequestInterface::class)->getParsedBody()); + } + + public function testRegisteredTearDownClearsAllValidationContextsAndPreservesOtherState(): void + { + $this->mock->exposeSetUpInput(); + $first = $this->createPartialMock(Input::class, []); + $second = $this->createPartialMock(Input::class, []); + $firstKey = sprintf('%s:%s', spl_object_hash($first), ValidatorInterface::class); + $secondKey = sprintf('%s:%s', spl_object_hash($second), ValidatorInterface::class); + Context::set($firstKey, $this->createMock(ValidatorInterface::class)); + Context::set($secondKey, $this->createMock(ValidatorInterface::class)); + $first->scene('first'); + $second->scene('second'); + Context::set('unrelated-context', 'preserved'); + Context::set(ServerRequestInterface::class, 'request'); + Context::set('http.request.parsedData', ['stale' => true]); + $cleanup = $this->mock->getRegisteredTearDowns()[0]; + + $cleanup(); + + $this->assertFalse($this->mock->getIsRequestSetUp()); + $this->assertNull(Context::get($firstKey)); + $this->assertNull(Context::get($secondKey)); + $this->assertNull($first->getScene()); + $this->assertNull($second->getScene()); + $this->assertNull(Context::get(ServerRequestInterface::class)); + $this->assertNull(Context::get('http.request.parsedData')); + $this->assertSame('preserved', Context::get('unrelated-context')); + } + public function testSetUpInputShouldRegisterTearDown(): void { // Act