diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index abd5e193..a97f03dd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -59,6 +59,11 @@ jobs: sleep 2 done + - name: Wait for MinIO bucket initialization + run: | + echo "Waiting for MinIO bucket initialization..." + docker wait bowphp_minio_init || true + - name: Cache Composer packages id: composer-cache uses: actions/cache@v4 diff --git a/docker-compose.yml b/docker-compose.yml index baffd928..c7113c82 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,6 +162,21 @@ services: interval: 10s timeout: 5s retries: 5 + minio-init: + container_name: bowphp_minio_init + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minioadmin minioadmin; + mc mb --ignore-existing local/tests; + mc anonymous set public local/tests; + exit 0; + " + networks: + - bowphp_network zookeeper: container_name: bowphp_zookeeper image: confluentinc/cp-zookeeper:7.5.0 diff --git a/src/Application/Application.php b/src/Application/Application.php index 153f76e8..a221efa6 100644 --- a/src/Application/Application.php +++ b/src/Application/Application.php @@ -178,7 +178,7 @@ public function run(): bool // Error management if ($resolved) { - $this->send($response); + $this->sendResponse($response); return true; } @@ -192,6 +192,20 @@ public function run(): bool return false; } + /** + * Launch the application and send the answer to the customer. + * + * Public alias of {@see Application::run()}. + * + * @return bool + * @throws ReflectionException + * @throws RouterException + */ + public function send(): bool + { + return $this->run(); + } + /** * Send the answer to the customer * @@ -199,7 +213,7 @@ public function run(): bool * @param int $code * @return void */ - private function send(mixed $response, int $code = 200): void + private function sendResponse(mixed $response, int $code = 200): void { if ($response instanceof ResponseInterface) { $response->sendContent(); diff --git a/src/Database/Connection/AbstractConnection.php b/src/Database/Connection/AbstractConnection.php index 5b3757aa..4f5984d5 100644 --- a/src/Database/Connection/AbstractConnection.php +++ b/src/Database/Connection/AbstractConnection.php @@ -31,37 +31,157 @@ abstract class AbstractConnection protected int $fetch = PDO::FETCH_OBJ; /** - * The PDO instance + * The write (primary) PDO instance * - * @var PDO + * @var ?PDO */ - protected PDO $pdo; + protected ?PDO $write_pdo = null; /** - * Create an instance of the PDO + * The read (replica) PDO instance + * + * @var ?PDO + */ + protected ?PDO $read_pdo = null; + + /** + * The configuration used to build the write connection + * + * @var array + */ + protected array $write_config = []; + + /** + * The configuration used to build the read connection, + * or null when the connection is not split (reads use write). + * + * @var ?array + */ + protected ?array $read_config = null; + + /** + * AbstractConnection constructor. + * + * Splits the connection configuration into a write (primary) + * configuration and an optional read (replica) configuration. + * + * @param array $config + */ + public function __construct(array $config) + { + $this->config = $config; + + $this->write_config = $config; + unset($this->write_config['read']); + + if (isset($config['read']) && is_array($config['read'])) { + $this->read_config = array_merge($this->write_config, $config['read']); + } else { + $this->read_config = null; + } + + // Validate eagerly so misconfiguration fails fast, while the + // connection itself is still established lazily on first use. + $this->validateConfig($this->write_config); + + if ($this->read_config !== null) { + $this->validateConfig($this->read_config); + } + } + + /** + * Validate the connection configuration. + * + * @param array $config + * @return void + */ + abstract protected function validateConfig(array $config): void; + + /** + * Build a PDO instance from the given configuration. + * + * @param array $config + * @return PDO + */ + abstract protected function makePdo(array $config): PDO; + + /** + * Build (eagerly) the write connection. + * + * Kept for backward compatibility with callers that expect to + * (re)establish the connection explicitly. * * @return void */ - abstract public function connection(): void; + public function connection(): void + { + $this->write_pdo = $this->makePdo($this->write_config); + } /** - * Retrieves the connection + * Retrieves the connection (the write/primary connection) * * @return PDO */ public function getConnection(): PDO { - return $this->pdo; + return $this->getWriteConnection(); + } + + /** + * Retrieves the write (primary) connection, building it lazily + * + * @return PDO + */ + public function getWriteConnection(): PDO + { + if ($this->write_pdo === null) { + $this->write_pdo = $this->makePdo($this->write_config); + } + + return $this->write_pdo; } /** - * Set the connection + * Retrieves the read (replica) connection, building it lazily. + * + * Falls back to the write connection when the connection is not split. + * + * @return PDO + */ + public function getReadConnection(): PDO + { + if ($this->read_config === null) { + return $this->getWriteConnection(); + } + + if ($this->read_pdo === null) { + $this->read_pdo = $this->makePdo($this->read_config); + } + + return $this->read_pdo; + } + + /** + * Whether the write connection has already been established. + * + * Used to inspect transaction state without forcing a connection open. + * + * @return bool + */ + public function hasWriteConnection(): bool + { + return $this->write_pdo instanceof PDO; + } + + /** + * Set the connection (the write/primary connection) * * @param PDO $pdo */ public function setConnection(PDO $pdo): void { - $this->pdo = $pdo; + $this->write_pdo = $pdo; } /** @@ -84,10 +204,14 @@ public function setFetchMode(int $fetch): void { $this->fetch = $fetch; - $this->pdo->setAttribute( - PDO::ATTR_DEFAULT_FETCH_MODE, - $fetch - ); + foreach ([$this->write_pdo, $this->read_pdo] as $pdo) { + if ($pdo instanceof PDO) { + $pdo->setAttribute( + PDO::ATTR_DEFAULT_FETCH_MODE, + $fetch + ); + } + } } /** @@ -137,7 +261,7 @@ public function getCollation(): string */ public function getPdoDriver(): string { - return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + return $this->getConnection()->getAttribute(PDO::ATTR_DRIVER_NAME); } /** diff --git a/src/Database/Connection/Adapters/MysqlAdapter.php b/src/Database/Connection/Adapters/MysqlAdapter.php index 2fb2cb10..b8bb4910 100644 --- a/src/Database/Connection/Adapters/MysqlAdapter.php +++ b/src/Database/Connection/Adapters/MysqlAdapter.php @@ -25,53 +25,51 @@ class MysqlAdapter extends AbstractConnection protected ?string $name = 'mysql'; /** - * MysqlAdapter constructor. + * Validate the connection configuration. * - * @param array $config + * @param array $config + * @return void */ - public function __construct(array $config) + protected function validateConfig(array $config): void { - $this->config = $config; - - $this->connection(); + // Check the existence of database definition + if (!isset($config['database'])) { + throw new InvalidArgumentException("The database is not defined"); + } } /** - * Make connexion + * Build a PDO instance from the given configuration. * - * @return void + * @param array $config + * @return PDO */ - public function connection(): void + protected function makePdo(array $config): PDO { // Build of the mysql dsn - if (isset($this->config['socket']) && !empty($this->config['socket'])) { - $hostname = $this->config['socket']; + if (isset($config['socket']) && !empty($config['socket'])) { + $hostname = $config['socket']; $port = ''; } else { - $hostname = $this->config['hostname'] ?? null; - $port = (string)($this->config['port'] ?? self::PORT); - } - - // Check the existence of database definition - if (!isset($this->config['database'])) { - throw new InvalidArgumentException("The database is not defined"); + $hostname = $config['hostname'] ?? null; + $port = (string)($config['port'] ?? self::PORT); } // Formatting connection parameters - $dsn = sprintf("mysql:host=%s;port=%s;dbname=%s", $hostname, $port, $this->config['database']); + $dsn = sprintf("mysql:host=%s;port=%s;dbname=%s", $hostname, $port, $config['database']); - $username = $this->config["username"]; - $password = $this->config["password"]; + $username = $config["username"]; + $password = $config["password"]; // Configuration the PDO attributes that we want to set $options = [ - PDO::ATTR_DEFAULT_FETCH_MODE => $this->config['fetch'] ?? $this->fetch, + PDO::ATTR_DEFAULT_FETCH_MODE => $config['fetch'] ?? $this->fetch, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . Str::upper($this->config["charset"]), + PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . Str::upper($config["charset"]), PDO::ATTR_ORACLE_NULLS => PDO::NULL_EMPTY_STRING ]; // Build the PDO connection - $this->pdo = new PDO($dsn, $username, $password, $options); + return new PDO($dsn, $username, $password, $options); } } diff --git a/src/Database/Connection/Adapters/PostgreSQLAdapter.php b/src/Database/Connection/Adapters/PostgreSQLAdapter.php index fe5ec75a..a6535c1a 100644 --- a/src/Database/Connection/Adapters/PostgreSQLAdapter.php +++ b/src/Database/Connection/Adapters/PostgreSQLAdapter.php @@ -24,79 +24,79 @@ class PostgreSQLAdapter extends AbstractConnection protected ?string $name = 'pgsql'; /** - * MysqlAdapter constructor. + * Validate the connection configuration. * - * @param array $config + * @param array $config + * @return void */ - public function __construct(array $config) + protected function validateConfig(array $config): void { - $this->config = $config; - - $this->connection(); + // Check the existence of database definition + if (!isset($config['database'])) { + throw new InvalidArgumentException("The database is not defined"); + } } /** - * Make connexion + * Build a PDO instance from the given configuration. * - * @return void + * @param array $config + * @return PDO */ - public function connection(): void + protected function makePdo(array $config): PDO { - // Build of the mysql dsn - if (isset($this->config['socket']) && !is_null($this->config['socket']) && !empty($this->config['socket'])) { - $hostname = $this->config['socket']; + // Build of the pgsql dsn + if (isset($config['socket']) && !is_null($config['socket']) && !empty($config['socket'])) { + $hostname = $config['socket']; $port = ''; } else { - $hostname = $this->config['hostname'] ?? null; - $port = (string)($this->config['port'] ?? self::PORT); - } - - // Check the existence of database definition - if (!isset($this->config['database'])) { - throw new InvalidArgumentException("The database is not defined"); + $hostname = $config['hostname'] ?? null; + $port = (string)($config['port'] ?? self::PORT); } // Formatting connection parameters - $dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $hostname, $port, $this->config['database']); + $dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $hostname, $port, $config['database']); - if (isset($this->config['sslmode'])) { - $dsn .= ';sslmode=' . $this->config['sslmode']; + if (isset($config['sslmode'])) { + $dsn .= ';sslmode=' . $config['sslmode']; } - if (isset($this->config['sslrootcert'])) { - $dsn .= ';sslrootcert=' . $this->config['sslrootcert']; + if (isset($config['sslrootcert'])) { + $dsn .= ';sslrootcert=' . $config['sslrootcert']; } - if (isset($this->config['sslcert'])) { - $dsn .= ';sslcert=' . $this->config['sslcert']; + if (isset($config['sslcert'])) { + $dsn .= ';sslcert=' . $config['sslcert']; } - if (isset($this->config['sslkey'])) { - $dsn .= ';sslkey=' . $this->config['sslkey']; + if (isset($config['sslkey'])) { + $dsn .= ';sslkey=' . $config['sslkey']; } - if (isset($this->config['sslcrl'])) { - $dsn .= ';sslcrl=' . $this->config['sslcrl']; + if (isset($config['sslcrl'])) { + $dsn .= ';sslcrl=' . $config['sslcrl']; } - if (isset($this->config['application_name'])) { - $dsn .= ';application_name=' . $this->config['application_name']; + if (isset($config['application_name'])) { + $dsn .= ';application_name=' . $config['application_name']; } - $username = $this->config["username"]; - $password = $this->config["password"]; + $username = $config["username"]; + $password = $config["password"]; // Configuration the PDO attributes that we want to set $options = [ - PDO::ATTR_DEFAULT_FETCH_MODE => $this->config['fetch'] ?? $this->fetch, + PDO::ATTR_DEFAULT_FETCH_MODE => $config['fetch'] ?? $this->fetch, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, ]; // Build the PDO connection - $this->pdo = new PDO($dsn, $username, $password, $options); + $pdo = new PDO($dsn, $username, $password, $options); - if ($this->config["charset"]) { - $this->pdo->query('SET NAMES \'' . $this->config["charset"] . '\''); + if ($config["charset"]) { + $pdo->query('SET NAMES \'' . $config["charset"] . '\''); } + + return $pdo; } } diff --git a/src/Database/Connection/Adapters/SqliteAdapter.php b/src/Database/Connection/Adapters/SqliteAdapter.php index b3997d70..3dbb5f47 100644 --- a/src/Database/Connection/Adapters/SqliteAdapter.php +++ b/src/Database/Connection/Adapters/SqliteAdapter.php @@ -18,38 +18,40 @@ class SqliteAdapter extends AbstractConnection protected ?string $name = 'sqlite'; /** - * SqliteAdapter constructor. + * Validate the connection configuration. * - * @param array $config + * @param array $config + * @return void */ - public function __construct(array $config) + protected function validateConfig(array $config): void { - $this->config = $config; - - $this->connection(); - } - - /** - * @inheritDoc - */ - public function connection(): void - { - if (!isset($this->config['driver'])) { + if (!isset($config['driver'])) { throw new InvalidArgumentException("Please select the right sqlite driver"); } - if (!isset($this->config['database'])) { + if (!isset($config['database'])) { throw new InvalidArgumentException('The database is not defined'); } + } + /** + * Build a PDO instance from the given configuration. + * + * @param array $config + * @return PDO + */ + protected function makePdo(array $config): PDO + { // Build the PDO connection - $this->pdo = new PDO('sqlite:' . $this->config['database']); + $pdo = new PDO('sqlite:' . $config['database']); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->pdo->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING); - $this->pdo->setAttribute( + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING); + $pdo->setAttribute( PDO::ATTR_DEFAULT_FETCH_MODE, - $this->config['fetch'] ?? $this->fetch + $config['fetch'] ?? $this->fetch ); + + return $pdo; } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 0bcdc1f7..d68d9c1f 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -102,11 +102,41 @@ public static function connection(?string $name = null): ?Database static::$adapter->setFetchMode(static::$config['fetch']); } - if (static::$adapter->getConnection() instanceof PDO && $name == static::$name) { - return static::getInstance(); + return static::getInstance(); + } + + /** + * Resolve the write (primary) connection. + * + * @return PDO + */ + private static function writeConnection(): PDO + { + static::ensureDatabaseConnection(); + + return static::$adapter->getWriteConnection(); + } + + /** + * Resolve the read (replica) connection. + * + * While a transaction is open on the primary, reads are routed to the + * primary so they observe their own uncommitted changes. + * + * @return PDO + */ + private static function readConnection(): PDO + { + static::ensureDatabaseConnection(); + + if ( + static::$adapter->hasWriteConnection() + && static::$adapter->getWriteConnection()->inTransaction() + ) { + return static::$adapter->getWriteConnection(); } - return static::getInstance(); + return static::$adapter->getReadConnection(); } /** @@ -182,8 +212,7 @@ public static function update(string $sql_statement, array $data = []): int */ private static function executePrepareQuery(string $sql_statement, array $data = []): int { - $pdo_statement = static::$adapter - ->getConnection() + $pdo_statement = static::writeConnection() ->prepare($sql_statement); static::$adapter->bind( @@ -218,8 +247,7 @@ public static function select(string $sql_statement, array $data = []): mixed ); } - $pdo_statement = static::$adapter - ->getConnection() + $pdo_statement = static::readConnection() ->prepare($sql_statement); static::$adapter->bind( @@ -251,8 +279,7 @@ public static function selectOne(string $sql_statement, array $data = []): mixed } // Prepare query - $pdo_statement = static::$adapter - ->getConnection() + $pdo_statement = static::readConnection() ->prepare($sql_statement); // Bind data @@ -283,7 +310,7 @@ public static function insert(string $sql_statement, array $data = []): int } if (empty($data)) { - $pdo_statement = static::$adapter->getConnection()->prepare($sql_statement); + $pdo_statement = static::writeConnection()->prepare($sql_statement); $pdo_statement->execute(); @@ -320,8 +347,7 @@ public static function statement(string $sql_statement): bool $sql_statement = trim($sql_statement); - return static::$adapter - ->getConnection() + return static::writeConnection() ->exec($sql_statement) === 0; } @@ -360,7 +386,7 @@ public static function table(string $table): QueryBuilder return new QueryBuilder( $table, - static::$adapter->getConnection() + static::$adapter ); } @@ -396,8 +422,8 @@ public static function startTransaction(): void { static::ensureDatabaseConnection(); - if (!static::$adapter->getConnection()->inTransaction()) { - static::$adapter->getConnection()->beginTransaction(); + if (!static::writeConnection()->inTransaction()) { + static::writeConnection()->beginTransaction(); } } @@ -410,7 +436,7 @@ public static function inTransaction(): bool { static::ensureDatabaseConnection(); - return static::$adapter->getConnection()->inTransaction(); + return static::writeConnection()->inTransaction(); } /** @@ -427,7 +453,7 @@ public static function commit(): void public static function commitTransaction(): void { if (static::inTransaction()) { - static::$adapter->getConnection()->commit(); + static::writeConnection()->commit(); } } @@ -445,7 +471,7 @@ public static function rollback(): void public static function rollbackTransaction(): void { if (static::inTransaction()) { - static::$adapter->getConnection()->rollBack(); + static::writeConnection()->rollBack(); } } @@ -460,10 +486,10 @@ public static function lastInsertId(?string $name = null): int|string|PDO static::ensureDatabaseConnection(); if ($name === null) { - return static::$adapter->getConnection(); + return static::writeConnection(); } - return static::$adapter->getConnection()->lastInsertId($name); + return static::writeConnection()->lastInsertId($name); } /** @@ -473,9 +499,7 @@ public static function lastInsertId(?string $name = null): int|string|PDO */ public static function getPdo(): PDO { - static::ensureDatabaseConnection(); - - return static::$adapter->getConnection(); + return static::writeConnection(); } /** diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 24ee5a54..347e685d 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -85,12 +85,23 @@ class QueryBuilder implements JsonSerializable protected ?string $as = null; /** - * The PDO instance + * The PDO instance. + * + * Only set when the builder is constructed from a raw PDO (no read/write + * splitting). When built from an adapter, the connection is resolved + * lazily through {@see QueryBuilder::$connection_adapter}. * * @var ?PDO */ protected ?PDO $connection = null; + /** + * The connection adapter, when read/write splitting is available. + * + * @var ?AbstractConnection + */ + protected ?AbstractConnection $connection_adapter = null; + /** * Define whether to retrieve information from the list * @@ -142,16 +153,55 @@ class QueryBuilder implements JsonSerializable public function __construct(string $table, AbstractConnection|PDO $connection) { if ($connection instanceof AbstractConnection) { + $this->connection_adapter = $connection; $this->adapter = $connection->getName(); - $connection = $connection->getConnection(); } else { + $this->connection = $connection; $this->adapter = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); } - $this->connection = $connection; $this->table = $table; } + /** + * Resolve the write (primary) connection. + * + * @return PDO + */ + private function writeConnection(): PDO + { + if ($this->connection_adapter !== null) { + return $this->connection_adapter->getWriteConnection(); + } + + return $this->connection; + } + + /** + * Resolve the read (replica) connection. + * + * While a transaction is open on the primary, reads are routed to the + * primary so they observe their own uncommitted changes. Falls back to + * the single connection when read/write splitting is unavailable. + * + * @return PDO + */ + private function readConnection(): PDO + { + if ($this->connection_adapter === null) { + return $this->connection; + } + + if ( + $this->connection_adapter->hasWriteConnection() + && $this->connection_adapter->getWriteConnection()->inTransaction() + ) { + return $this->connection_adapter->getWriteConnection(); + } + + return $this->connection_adapter->getReadConnection(); + } + /** * Get the connection adapter name * @@ -169,7 +219,7 @@ public function getAdapterName(): string */ public function getPdo(): PDO { - return $this->connection; + return $this->writeConnection(); } /** @@ -930,7 +980,7 @@ private function aggregate($aggregate, $column): mixed } } - $statement = $this->execute($sql, $this->where_data_binding); + $statement = $this->execute($sql, $this->where_data_binding, false); $this->where_data_binding = []; @@ -1174,7 +1224,7 @@ public function get(array $columns = []): array|object|null // Execution of request. $sql = $this->toSql(); - $statement = $this->execute($sql, $this->where_data_binding); + $statement = $this->execute($sql, $this->where_data_binding, false); $data = $statement->fetchAll(); @@ -1406,9 +1456,11 @@ public function distinct(string $column) */ public function truncate(): bool { - if ($this->connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') { + $connection = $this->writeConnection(); + + if ($connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') { $sql = 'delete from ' . $this->table . ';'; - if (!$this->connection->inTransaction()) { + if (!$connection->inTransaction()) { $sql .= ' VACUUM;'; } } else { @@ -1418,7 +1470,7 @@ public function truncate(): bool $this->last_query = $sql; $start_at = microtime(true); - $result = (bool) $this->connection->exec($sql); + $result = (bool) $connection->exec($sql); $ended_at = microtime(true); $this->triggerQueryEvent($sql, $ended_at - $start_at); @@ -1438,7 +1490,7 @@ public function insertAndGetLastId(array $values): string|int|bool { $this->insert($values); - $result = $this->connection->lastInsertId(); + $result = $this->writeConnection()->lastInsertId(); return is_numeric($result) ? (int)$result : $result; } @@ -1514,13 +1566,16 @@ private function insertOne(array $values): int * * @param string $sql * @param array $bindings + * @param bool $write Whether the statement mutates data (routes to the primary). * @return PDOStatement */ - private function execute(string $sql, array $bindings = []): PDOStatement + private function execute(string $sql, array $bindings = [], bool $write = true): PDOStatement { $this->last_query = $sql; - $statement = $this->connection->prepare($sql); + $connection = $write ? $this->writeConnection() : $this->readConnection(); + + $statement = $connection->prepare($sql); $this->bind($statement, $bindings); @@ -1553,7 +1608,7 @@ public function drop(): bool $this->last_query = $sql; $start_at = microtime(true); - $result = (bool) $this->connection->exec($sql); + $result = (bool) $this->writeConnection()->exec($sql); $ended_at = microtime(true); $this->triggerQueryEvent($sql, $ended_at - $start_at); @@ -1645,7 +1700,7 @@ public function exists(?string $column = null, mixed $value = null): bool */ public function getLastInsertId(?string $name = null) { - return $this->connection->lastInsertId($name); + return $this->writeConnection()->lastInsertId($name); } /** diff --git a/src/Router/Router.php b/src/Router/Router.php index 0610cfd5..2d6edc2b 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -350,7 +350,6 @@ public function middleware(array|string $middlewares): Router * @param string $path * @param callable|string|array $cb * @return Route - * @throws */ public function any(string $path, callable|string|array $cb): Route { diff --git a/tests/Config/stubs/config/storage.php b/tests/Config/stubs/config/storage.php index 8108745a..e82d3836 100644 --- a/tests/Config/stubs/config/storage.php +++ b/tests/Config/stubs/config/storage.php @@ -39,14 +39,17 @@ 's3' => [ "driver" => "s3", 'credentials' => [ - 'key' => getenv('AWS_KEY'), - 'secret' => getenv('AWS_SECRET'), + // `?:` so an unset env (which CI exposes as an empty string) + // falls back to the local docker-compose MinIO defaults. + 'key' => app_env('AWS_KEY') ?: 'minioadmin', + 'secret' => app_env('AWS_SECRET') ?: 'minioadmin', ], - 'bucket' => getenv('AWS_S3_BUCKET', 'tests'), - 'region' => getenv('AWS_REGION', 'us-east-1'), + 'bucket' => app_env('AWS_S3_BUCKET') ?: 'tests', + 'region' => app_env('AWS_REGION') ?: 'us-east-1', 'version' => 'latest', - // MinIO configuration (optional) - 'endpoint' => getenv('AWS_ENDPOINT', false), // e.g., 'http://localhost:9000' for MinIO + // MinIO configuration. Defaults target the local docker-compose + // MinIO service; override via env for a real AWS S3 endpoint. + 'endpoint' => app_env('AWS_ENDPOINT') ?: 'http://127.0.0.1:9000', 'use_path_style_endpoint' => true, // Set to true for MinIO ] ], diff --git a/tests/Database/Query/ReadWriteConnectionTest.php b/tests/Database/Query/ReadWriteConnectionTest.php new file mode 100644 index 00000000..7f828279 --- /dev/null +++ b/tests/Database/Query/ReadWriteConnectionTest.php @@ -0,0 +1,168 @@ + 'sqlite', + 'database' => self::$write_db, + 'read' => [ + 'database' => self::$read_db, + ], + ]); + + $this->seed($adapter->getWriteConnection(), 'primary'); + $this->seed($adapter->getReadConnection(), 'replica'); + + return $adapter; + } + + private function seed(PDO $pdo, string $marker): void + { + $pdo->exec('DROP TABLE IF EXISTS pets'); + $pdo->exec('CREATE TABLE pets (id INTEGER PRIMARY KEY, name VARCHAR(255))'); + $pdo->exec("INSERT INTO pets (id, name) VALUES (1, '" . $marker . "')"); + } + + public function test_split_config_builds_distinct_connections(): void + { + $adapter = $this->makeSplitAdapter(); + + $this->assertNotSame( + $adapter->getWriteConnection(), + $adapter->getReadConnection(), + 'A split connection must expose two distinct PDO instances.' + ); + } + + public function test_connections_are_opened_lazily(): void + { + $adapter = new SqliteAdapter([ + 'driver' => 'sqlite', + 'database' => self::$write_db, + 'read' => ['database' => self::$read_db], + ]); + + $this->assertFalse( + $adapter->hasWriteConnection(), + 'No PDO should be opened until the connection is first used.' + ); + + // Touching only the read side must not open the write connection. + $adapter->getReadConnection(); + $this->assertFalse( + $adapter->hasWriteConnection(), + 'A read-only access must not open the primary connection.' + ); + + $adapter->getWriteConnection(); + $this->assertTrue($adapter->hasWriteConnection()); + } + + public function test_read_falls_back_to_write_without_read_config(): void + { + $adapter = new SqliteAdapter([ + 'driver' => 'sqlite', + 'database' => self::$write_db, + ]); + + $this->assertSame( + $adapter->getWriteConnection(), + $adapter->getReadConnection(), + 'Without a read block, reads must reuse the write connection.' + ); + } + + public function test_select_routes_to_read_replica(): void + { + $adapter = $this->makeSplitAdapter(); + $builder = new QueryBuilder('pets', $adapter); + + $row = $builder->where('id', 1)->first(); + + $this->assertSame('replica', $row->name); + } + + public function test_write_routes_to_primary(): void + { + $adapter = $this->makeSplitAdapter(); + + (new QueryBuilder('pets', $adapter)) + ->where('id', 1) + ->update(['name' => 'updated']); + + // The write landed on the primary file... + $primary = $adapter->getWriteConnection() + ->query('SELECT name FROM pets WHERE id = 1') + ->fetchColumn(); + $this->assertSame('updated', $primary); + + // ...and the replica file is untouched. + $replica = $adapter->getReadConnection() + ->query('SELECT name FROM pets WHERE id = 1') + ->fetchColumn(); + $this->assertSame('replica', $replica); + } + + public function test_reads_route_to_primary_during_transaction(): void + { + $adapter = $this->makeSplitAdapter(); + + // Open a transaction on the primary; reads must now stick to it. + $adapter->getWriteConnection()->beginTransaction(); + + try { + $row = (new QueryBuilder('pets', $adapter))->where('id', 1)->first(); + + $this->assertSame( + 'primary', + $row->name, + 'While a transaction is open, reads must hit the primary.' + ); + } finally { + $adapter->getWriteConnection()->rollBack(); + } + + // After the transaction closes, reads resume on the replica. + $row = (new QueryBuilder('pets', $adapter))->where('id', 1)->first(); + $this->assertSame('replica', $row->name); + } +} diff --git a/tests/Routing/AttributeRouteIntegrationTest.php b/tests/Routing/AttributeRouteIntegrationTest.php index 62f07453..c81ce178 100644 --- a/tests/Routing/AttributeRouteIntegrationTest.php +++ b/tests/Routing/AttributeRouteIntegrationTest.php @@ -41,8 +41,14 @@ protected function setUp(): void { parent::setUp(); - // Reset the router for each test $this->router = Router::configure(); + + // The route collection is a global (static) registry that intentionally + // gathers routes across every Router instance. Clear it before each test + // so assertions are not polluted by routes registered by earlier tests. + $routes = new \ReflectionProperty(Router::class, 'routes'); + $routes->setAccessible(true); + $routes->setValue(null, []); } public function test_registrar_registers_routes_from_controller(): void