diff --git a/composer.json b/composer.json index 95d5eba4..185dba35 100644 --- a/composer.json +++ b/composer.json @@ -15,9 +15,9 @@ "require": { "php": ">=8.0", "ext-pdo": "*", - "spiral/core": "^2.8", - "spiral/logger": "^2.8", - "spiral/pagination": "^2.8" + "spiral/core": "^2.9", + "spiral/logger": "^2.9", + "spiral/pagination": "^2.9" }, "autoload": { "files": [ @@ -31,9 +31,8 @@ "vimeo/psalm": "^4.10", "phpunit/phpunit": "^8.5|^9.0", "mockery/mockery": "^1.3", - "spiral/dumper": "^2.8", "spiral/code-style": "^1.0", - "spiral/tokenizer": "^2.8" + "spiral/tokenizer": "^2.9" }, "autoload-dev": { "psr-4": { diff --git a/src/Config/ConnectionConfig.php b/src/Config/ConnectionConfig.php new file mode 100644 index 00000000..72320f13 --- /dev/null +++ b/src/Config/ConnectionConfig.php @@ -0,0 +1,78 @@ + + */ + protected array $nonPrintableOptions = [ + // Postgres and MySQL + 'password', + // IBM, ODBC and DB2 + 'PWD', + ]; + + /** + * @param non-empty-string|null $user + * @param non-empty-string|null $password + */ + public function __construct( + public ?string $user = null, + public ?string $password = null, + ) { + } + + /** + * @return non-empty-string|null + */ + public function getUsername(): ?string + { + return $this->user; + } + + /** + * @return non-empty-string|null + */ + public function getPassword(): ?string + { + return $this->password; + } + + /** + * @param bool $secure + * @return array + */ + protected function toArray(bool $secure = true): array + { + $options = \get_object_vars($this); + + foreach ($options as $key => $value) { + if ($secure && \in_array($key, $this->nonPrintableOptions, true)) { + $value = ''; + } + + $options[$key] = $value; + } + + return $options; + } + + /** + * @return array + */ + public function __debugInfo(): array + { + return $this->toArray(); + } +} diff --git a/src/Config/DatabaseConfig.php b/src/Config/DatabaseConfig.php index a5658697..c8c23fa5 100644 --- a/src/Config/DatabaseConfig.php +++ b/src/Config/DatabaseConfig.php @@ -125,15 +125,16 @@ public function getDriver(string $driver): Autowire } $config = $this->config['connections'][$driver] ?? $this->config['drivers'][$driver]; - if ($config instanceof Autowire) { - return $config; - } - $options = $config; - if (isset($config['options']) && $config['options'] !== []) { - $options = $config['options'] + $config; + if ($config instanceof DriverConfig) { + return new Autowire($config->driver, ['config' => $config]); } - return new Autowire($config['driver'] ?? $config['class'], ['options' => $options]); + throw new \InvalidArgumentException( + \vsprintf('Driver config must be an instance of %s, but %s passed', [ + DriverConfig::class, + \get_debug_type($config) + ]) + ); } } diff --git a/src/Config/DriverConfig.php b/src/Config/DriverConfig.php new file mode 100644 index 00000000..adb29b7f --- /dev/null +++ b/src/Config/DriverConfig.php @@ -0,0 +1,55 @@ + $driver + * @param bool $reconnect Allow reconnects + * @param non-empty-string $timezone All datetime objects will be converted + * relative to this timezone (must match with DB timezone!) + * @param bool $queryCache Enables query caching + * @param bool $readonlySchema Disable schema modifications + * @param bool $readonly Disable write expressions + */ + public function __construct( + public ConnectionConfig $connection, + public string $driver, + public bool $reconnect = true, + public string $timezone = 'UTC', + public bool $queryCache = true, + public bool $readonlySchema = false, + public bool $readonly = false, + ) { + } + + /** + * @return DriverInterface + */ + public function getDriver(): DriverInterface + { + $class = $this->driver; + + return new $class($this); + } +} diff --git a/src/Config/MySQL/ConnectionConfig.php b/src/Config/MySQL/ConnectionConfig.php new file mode 100644 index 00000000..ccefd2e2 --- /dev/null +++ b/src/Config/MySQL/ConnectionConfig.php @@ -0,0 +1,54 @@ + + */ + protected const DEFAULT_PDO_OPTIONS = [ + \PDO::ATTR_CASE => \PDO::CASE_NATURAL, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + // TODO Should be moved into common driver settings. + \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES "UTF8"', + \PDO::ATTR_STRINGIFY_FETCHES => false, + ]; + + /** + * @param non-empty-string|null $user + * @param non-empty-string|null $password + * @param array $options + */ + public function __construct( + ?string $user = null, + ?string $password = null, + array $options = [], + ) { + parent::__construct($user, $password, $options); + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return 'mysql'; + } +} diff --git a/src/Config/MySQL/DsnConnectionConfig.php b/src/Config/MySQL/DsnConnectionConfig.php new file mode 100644 index 00000000..fa96173a --- /dev/null +++ b/src/Config/MySQL/DsnConnectionConfig.php @@ -0,0 +1,64 @@ +dsn = DataSourceName::normalize((string)$dsn, $this->getName()); + } + + /** + * {@inheritDoc} + */ + public function getSourceString(): string + { + /** @psalm-suppress ArgumentTypeCoercion */ + return $this->database ??= DataSourceName::read($this->getDsn(), 'dbname') ?? '*'; + } + + /** + * {@inheritDoc} + */ + public function getDsn(): string + { + return $this->dsn; + } +} diff --git a/src/Config/MySQL/SocketConnectionConfig.php b/src/Config/MySQL/SocketConnectionConfig.php new file mode 100644 index 00000000..4a95fbc9 --- /dev/null +++ b/src/Config/MySQL/SocketConnectionConfig.php @@ -0,0 +1,64 @@ + $options + */ + public function __construct( + public string $database, + public string $socket, + public ?string $charset = null, + ?string $user = null, + ?string $password = null, + array $options = [] + ) { + parent::__construct($user, $password, $options); + } + + /** + * {@inheritDoc} + */ + public function getSourceString(): string + { + return $this->database; + } + + /** + * Returns the MySQL-specific PDO DataSourceName with connection Unix socket, + * that looks like: + * + * mysql:unix_socket=/tmp/mysql.sock;dbname=dbname + * + * + * {@inheritDoc} + */ + public function getDsn(): string + { + $config = [ + 'unix_socket' => $this->socket, + 'dbname' => $this->database, + 'charset' => $this->charset, + ]; + + return \sprintf('%s:%s', $this->getName(), $this->dsn($config)); + } +} diff --git a/src/Config/MySQL/TcpConnectionConfig.php b/src/Config/MySQL/TcpConnectionConfig.php new file mode 100644 index 00000000..afc229f3 --- /dev/null +++ b/src/Config/MySQL/TcpConnectionConfig.php @@ -0,0 +1,67 @@ + $options + */ + public function __construct( + public string $database, + public string $host = 'localhost', + public int $port = 3307, + public ?string $charset = null, + ?string $user = null, + ?string $password = null, + array $options = [], + ) { + parent::__construct($user, $password, $options); + } + + /** + * {@inheritDoc} + */ + public function getSourceString(): string + { + return $this->database; + } + + /** + * Returns the MySQL-specific PDO DataSourceName with connection URI, + * that looks like: + * + * mysql:host=localhost;port=3307;dbname=dbname + * + * + * {@inheritDoc} + */ + public function getDsn(): string + { + $config = [ + 'host' => $this->host, + 'port' => $this->port, + 'dbname' => $this->database, + 'charset' => $this->charset, + ]; + + return \sprintf('%s:%s', $this->getName(), $this->dsn($config)); + } +} diff --git a/src/Config/MySQLDriverConfig.php b/src/Config/MySQLDriverConfig.php new file mode 100644 index 00000000..99f58a7f --- /dev/null +++ b/src/Config/MySQLDriverConfig.php @@ -0,0 +1,48 @@ + + */ +class MySQLDriverConfig extends DriverConfig +{ + /** + * @param ConnectionConfig $connection + * + * {@inheritDoc} + */ + public function __construct( + ConnectionConfig $connection, + string $driver = MySQLDriver::class, + bool $reconnect = true, + string $timezone = 'UTC', + bool $queryCache = true, + bool $readonlySchema = false, + bool $readonly = false, + ) { + /** @psalm-suppress ArgumentTypeCoercion */ + parent::__construct( + connection: $connection, + driver: $driver, + reconnect: $reconnect, + timezone: $timezone, + queryCache: $queryCache, + readonlySchema: $readonlySchema, + readonly: $readonly, + ); + } +} diff --git a/src/Config/PDOConnectionConfig.php b/src/Config/PDOConnectionConfig.php new file mode 100644 index 00000000..67ca1411 --- /dev/null +++ b/src/Config/PDOConnectionConfig.php @@ -0,0 +1,123 @@ + + */ + protected const DEFAULT_PDO_OPTIONS = [ + \PDO::ATTR_CASE => \PDO::CASE_NATURAL, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_EMULATE_PREPARES => false, + ]; + + /** + * @param non-empty-string|null $user + * @param non-empty-string|null $password + * @param array $options + */ + public function __construct( + ?string $user = null, + ?string $password = null, + public array $options = [] + ) { + parent::__construct($user, $password); + + $this->options = \array_replace(static::DEFAULT_PDO_OPTIONS, $this->options); + } + + /** + * @return non-empty-string + */ + abstract public function getName(): string; + + /** + * @return array + */ + public function getOptions(): array + { + return $this->options; + } + + /** + * @param iterable ...$fields + * @return string + */ + protected function dsn(iterable $fields): string + { + $result = []; + + foreach ($fields as $key => $value) { + if ($value === null) { + continue; + } + + $result[] = \is_string($key) + ? \sprintf('%s=%s', $key, $this->dsnValueToString($value)) + : $this->dsnValueToString($value); + } + + return \implode(';', $result); + } + + /** + * @param mixed $value + * @return string + */ + private function dsnValueToString(mixed $value): string + { + return match (true) { + \is_bool($value) => $value ? '1' : '0', + // TODO Think about escaping special chars in strings + \is_scalar($value), $value instanceof \Stringable => (string)$value, + default => throw new \InvalidArgumentException( + \sprintf('Can not convert config value of type "%s" to string', \get_debug_type($value)) + ) + }; + } + + /** + * Returns PDO data source name. + * + * @return string + */ + abstract public function getDsn(): string; +} diff --git a/src/Config/Postgres/ConnectionConfig.php b/src/Config/Postgres/ConnectionConfig.php new file mode 100644 index 00000000..3f0b83eb --- /dev/null +++ b/src/Config/Postgres/ConnectionConfig.php @@ -0,0 +1,38 @@ +dsn = DataSourceName::normalize((string)$dsn, $this->getName()); + } + + /** + * {@inheritDoc} + */ + public function getSourceString(): string + { + /** @psalm-suppress ArgumentTypeCoercion */ + return $this->database ??= DataSourceName::read($this->getDsn(), 'dbname') ?? '*'; + } + + /** + * {@inheritDoc} + */ + public function getDsn(): string + { + return $this->dsn; + } +} diff --git a/src/Config/Postgres/TcpConnectionConfig.php b/src/Config/Postgres/TcpConnectionConfig.php new file mode 100644 index 00000000..a5ac05a1 --- /dev/null +++ b/src/Config/Postgres/TcpConnectionConfig.php @@ -0,0 +1,71 @@ +database; + } + + /** + * Returns the Postgres-specific PDO DataSourceName, that looks like: + * + * pgsql:host=localhost;port=5432;dbname=dbname;user=login;password=pass + * + * + * {@inheritDoc} + */ + public function getDsn(): string + { + $config = [ + 'host' => $this->host, + 'port' => $this->port, + 'dbname' => $this->database, + + // + // Username and Password may be is a part of DataSourceName + // However, they can also be passed as separate + // parameters, so we ignore the case with the DataSourceName: + // + // 'user' => $this->user, + // 'password' => $this->password, + ]; + + return \sprintf('%s:%s', $this->getName(), $this->dsn($config)); + } +} diff --git a/src/Config/PostgresDriverConfig.php b/src/Config/PostgresDriverConfig.php new file mode 100644 index 00000000..dafc81c9 --- /dev/null +++ b/src/Config/PostgresDriverConfig.php @@ -0,0 +1,89 @@ + + */ +class PostgresDriverConfig extends DriverConfig +{ + /** + * Default public schema name for all postgres connections. + * + * @var non-empty-string + */ + public const DEFAULT_SCHEMA = 'public'; + + /** + * @var non-empty-array + * @psalm-readonly-allow-private-mutation + */ + public array $schema; + + /** + * @param ConnectionConfig $connection + * @param iterable|non-empty-string $schema List of available Postgres + * schemas for "search path" (See also {@link https://www.postgresql.org/docs/9.6/ddl-schemas.html}). + * The first parameter's item will be used as default schema. + * + * {@inheritDoc} + */ + public function __construct( + ConnectionConfig $connection, + iterable|string $schema = self::DEFAULT_SCHEMA, + string $driver = PostgresDriver::class, + bool $reconnect = true, + string $timezone = 'UTC', + bool $queryCache = true, + bool $readonlySchema = false, + bool $readonly = false, + ) { + /** @psalm-suppress ArgumentTypeCoercion */ + parent::__construct( + connection: $connection, + driver: $driver, + reconnect: $reconnect, + timezone: $timezone, + queryCache: $queryCache, + readonlySchema: $readonlySchema, + readonly: $readonly, + ); + + $this->schema = $this->bootSchema($schema); + } + + /** + * @param iterable|non-empty-string $schema + * @return array + */ + private function bootSchema(iterable|string $schema): array + { + // Cast any schema config variants to array + $schema = match (true) { + $schema instanceof \Traversable => \iterator_to_array($schema), + \is_string($schema) => [$schema], + default => $schema + }; + + // Fill array by default in case that result array is empty + if ($schema === []) { + $schema = [self::DEFAULT_SCHEMA]; + } + + // Remove schema duplications + return \array_values(\array_unique($schema)); + } +} diff --git a/src/Config/ProvidesSourceString.php b/src/Config/ProvidesSourceString.php new file mode 100644 index 00000000..597093b2 --- /dev/null +++ b/src/Config/ProvidesSourceString.php @@ -0,0 +1,20 @@ + + */ + protected const DEFAULT_PDO_OPTIONS = [ + \PDO::ATTR_CASE => \PDO::CASE_NATURAL, + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_STRINGIFY_FETCHES => false + ]; + + /** + * @param non-empty-string|null $user + * @param non-empty-string|null $password + * @param array $options + */ + public function __construct( + ?string $user = null, + ?string $password = null, + array $options = [] + ) { + parent::__construct($user, $password, $options); + } + + /** + * {@inheritDoc} + */ + public function getName(): string + { + return 'sqlsrv'; + } +} diff --git a/src/Config/SQLServer/DsnConnectionConfig.php b/src/Config/SQLServer/DsnConnectionConfig.php new file mode 100644 index 00000000..a6e50209 --- /dev/null +++ b/src/Config/SQLServer/DsnConnectionConfig.php @@ -0,0 +1,64 @@ +dsn = DataSourceName::normalize((string)$dsn, $this->getName()); + } + + /** + * {@inheritDoc} + */ + public function getSourceString(): string + { + /** @psalm-suppress ArgumentTypeCoercion */ + return $this->database ??= DataSourceName::read($this->getDsn(), 'database') ?? '*'; + } + + /** + * {@inheritDoc} + */ + public function getDsn(): string + { + return $this->dsn; + } +} diff --git a/src/Config/SQLServer/TcpConnectionConfig.php b/src/Config/SQLServer/TcpConnectionConfig.php new file mode 100644 index 00000000..7f705073 --- /dev/null +++ b/src/Config/SQLServer/TcpConnectionConfig.php @@ -0,0 +1,114 @@ +database; + } + + /** + * Returns the SQL Server specific PDO DataSourceName, that looks like: + * + * sqlsrv:Server=localhost,1521;Database=dbname + * + * + * {@inheritDoc} + */ + public function getDsn(): string + { + $config = [ + 'APP' => $this->app, + 'ConnectionPooling' => $this->pooling, + 'Database' => $this->database, + 'Encrypt' => $this->encrypt, + 'Failover_Partner' => $this->failover, + 'LoginTimeout' => $this->timeout, + 'MultipleActiveResultSets' => $this->mars, + 'QuotedId' => $this->quoted, + 'Server' => \implode(',', [$this->host, $this->port]), + 'TraceFile' => $this->traceFile, + 'TraceOn' => $this->trace, + 'TransactionIsolation' => $this->isolation, + 'TrustServerCertificate' => $this->trustServerCertificate, + 'WSID' => $this->wsid, + ]; + + return \sprintf('%s:%s', $this->getName(), $this->dsn($config)); + } +} diff --git a/src/Config/SQLServerDriverConfig.php b/src/Config/SQLServerDriverConfig.php new file mode 100644 index 00000000..e03f67c1 --- /dev/null +++ b/src/Config/SQLServerDriverConfig.php @@ -0,0 +1,48 @@ + + */ +class SQLServerDriverConfig extends DriverConfig +{ + /** + * @param ConnectionConfig $connection + * + * {@inheritDoc} + */ + public function __construct( + ConnectionConfig $connection, + string $driver = SQLServerDriver::class, + bool $reconnect = true, + string $timezone = 'UTC', + bool $queryCache = true, + bool $readonlySchema = false, + bool $readonly = false, + ) { + /** @psalm-suppress ArgumentTypeCoercion */ + parent::__construct( + connection: $connection, + driver: $driver, + reconnect: $reconnect, + timezone: $timezone, + queryCache: $queryCache, + readonlySchema: $readonlySchema, + readonly: $readonly, + ); + } +} diff --git a/src/Config/SQLite/ConnectionConfig.php b/src/Config/SQLite/ConnectionConfig.php new file mode 100644 index 00000000..f354eb31 --- /dev/null +++ b/src/Config/SQLite/ConnectionConfig.php @@ -0,0 +1,34 @@ +dsn = DataSourceName::normalize((string)$dsn, $this->getName()); + } + + /** + * {@inheritDoc} + */ + public function getSourceString(): string + { + /** @psalm-suppress ArgumentTypeCoercion */ + return $this->database ??= \substr($this->getDsn(), \strlen($this->getName()) + 1); + } + + /** + * {@inheritDoc} + */ + public function getDsn(): string + { + return $this->dsn; + } +} diff --git a/src/Config/SQLite/FileConnectionConfig.php b/src/Config/SQLite/FileConnectionConfig.php new file mode 100644 index 00000000..fdcbe9bd --- /dev/null +++ b/src/Config/SQLite/FileConnectionConfig.php @@ -0,0 +1,51 @@ + + * sqlite:/path/to/database.db + * + * + * {@inheritDoc} + */ + public function getDsn(): string + { + return \sprintf('%s:%s', $this->getName(), $this->database); + } + + /** + * {@inheritDoc} + */ + public function getSourceString(): string + { + return $this->database; + } +} diff --git a/src/Config/SQLite/MemoryConnectionConfig.php b/src/Config/SQLite/MemoryConnectionConfig.php new file mode 100644 index 00000000..e28b8a66 --- /dev/null +++ b/src/Config/SQLite/MemoryConnectionConfig.php @@ -0,0 +1,28 @@ + + */ +class SQLiteDriverConfig extends DriverConfig +{ + /** + * @param ConnectionConfig|null $connection + * + * {@inheritDoc} + */ + public function __construct( + ?ConnectionConfig $connection = null, + string $driver = SQLiteDriver::class, + bool $reconnect = true, + string $timezone = 'UTC', + bool $queryCache = true, + bool $readonlySchema = false, + bool $readonly = false, + ) { + /** @psalm-suppress ArgumentTypeCoercion */ + parent::__construct( + connection: $connection ?? new MemoryConnectionConfig(), + driver: $driver, + reconnect: $reconnect, + timezone: $timezone, + queryCache: $queryCache, + readonlySchema: $readonlySchema, + readonly: $readonly, + ); + } +} diff --git a/src/Config/Support/DataSourceName.php b/src/Config/Support/DataSourceName.php new file mode 100644 index 00000000..00efba82 --- /dev/null +++ b/src/Config/Support/DataSourceName.php @@ -0,0 +1,50 @@ +|non-empty-string $needle + * @return non-empty-string|null + */ + public static function read(string $haystack, array|string $needle): ?string + { + $needle = \array_map(static fn(string $item): string => \preg_quote($item), (array)$needle); + $pattern = \sprintf('/\b(?:%s)=([^;]+)/i', \implode('|', $needle)); + + if (\preg_match($pattern, $haystack, $matches)) { + return $matches[1]; + } + + return null; + } +} diff --git a/src/Database.php b/src/Database.php index 79d472a8..820a2637 100644 --- a/src/Database.php +++ b/src/Database.php @@ -33,18 +33,6 @@ final class Database implements DatabaseInterface, InjectableInterface public const ISOLATION_READ_COMMITTED = DriverInterface::ISOLATION_READ_COMMITTED; public const ISOLATION_READ_UNCOMMITTED = DriverInterface::ISOLATION_READ_UNCOMMITTED; - /** @var string */ - private $name; - - /** @var string */ - private $prefix; - - /** @var DriverInterface */ - private $driver; - - /** @var DriverInterface|null */ - private $readDriver; - /** * @param string $name Internal database name/id. * @param string $prefix Default database table prefix, will be used for all @@ -53,15 +41,11 @@ final class Database implements DatabaseInterface, InjectableInterface * @param DriverInterface|null $readDriver Read-only driver connection. */ public function __construct( - string $name, - string $prefix, - DriverInterface $driver, - DriverInterface $readDriver = null + private string $name, + private string $prefix, + private DriverInterface $driver, + private ?DriverInterface $readDriver = null ) { - $this->name = $name; - $this->prefix = $prefix; - $this->driver = $driver; - $this->readDriver = $readDriver; } /** diff --git a/src/DatabaseManager.php b/src/DatabaseManager.php index 4de92221..463c808d 100644 --- a/src/DatabaseManager.php +++ b/src/DatabaseManager.php @@ -263,7 +263,7 @@ public function driver(string $driver): DriverInterface return $this->drivers[$driver]; } catch (ContainerExceptionInterface $e) { - throw new DBALException($e->getMessage(), $e->getCode(), $e); + throw new DBALException($e->getMessage(), (int)$e->getCode(), $e); } } diff --git a/src/Driver/Driver.php b/src/Driver/Driver.php index d360ee08..7f16d204 100644 --- a/src/Driver/Driver.php +++ b/src/Driver/Driver.php @@ -11,14 +11,9 @@ namespace Cycle\Database\Driver; -use DateTimeImmutable; -use DateTimeInterface; -use DateTimeZone; -use PDO; -use PDOStatement; -use Psr\Log\LoggerAwareInterface; -use Psr\Log\LoggerAwareTrait; -use Cycle\Database\Exception\ConfigException; +use Cycle\Database\Config\DriverConfig; +use Cycle\Database\Config\PDOConnectionConfig; +use Cycle\Database\Config\ProvidesSourceString; use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\ReadonlyConnectionException; use Cycle\Database\Exception\StatementException; @@ -26,6 +21,13 @@ use Cycle\Database\Query\BuilderInterface; use Cycle\Database\Query\Interpolator; use Cycle\Database\StatementInterface; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use PDO; +use PDOStatement; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; use Throwable; /** @@ -35,50 +37,15 @@ abstract class Driver implements DriverInterface, LoggerAwareInterface { use LoggerAwareTrait; - // DateTime format to be used to perform automatic conversion of DateTime objects. - protected const DATETIME = 'Y-m-d H:i:s'; - - // Driver specific PDO options - protected const DEFAULT_PDO_OPTIONS = [ - PDO::ATTR_CASE => PDO::CASE_NATURAL, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_EMULATE_PREPARES => false, - ]; - /** - * Connection configuration described in DBAL config file. Any driver can be used as data source - * for multiple databases as table prefix and quotation defined on Database instance level. + * DateTime format to be used to perform automatic conversion of DateTime objects. * - * @var array + * @var non-empty-string (Typehint required for overriding behaviour) */ - protected $options = [ - // allow reconnects - 'reconnect' => true, - - // all datetime objects will be converted relative to - // this timezone (must match with DB timezone!) - 'timezone' => 'UTC', - - // DSN - 'connection' => '', - 'username' => '', - 'password' => '', - - // pdo options - 'options' => [], - - // enables query caching - 'queryCache' => true, - - // disable schema modifications - 'readonlySchema' => false, - - // disable write expressions - 'readonly' => false, - ]; + protected const DATETIME = 'Y-m-d H:i:s'; /** @var PDO|null */ - protected $pdo; + protected ?\PDO $pdo = null; /** @var int */ protected $transactionLevel = 0; @@ -93,13 +60,13 @@ abstract class Driver implements DriverInterface, LoggerAwareInterface protected $queryCache = []; /** - * @param array $options - * @param HandlerInterface $schemaHandler + * @param DriverConfig $config + * @param HandlerInterface $schemaHandler * @param CompilerInterface $queryCompiler - * @param BuilderInterface $queryBuilder + * @param BuilderInterface $queryBuilder */ public function __construct( - array $options, + protected DriverConfig $config, HandlerInterface $schemaHandler, protected CompilerInterface $queryCompiler, BuilderInterface $queryBuilder @@ -107,50 +74,13 @@ public function __construct( $this->schemaHandler = $schemaHandler->withDriver($this); $this->queryBuilder = $queryBuilder->withDriver($this); - $options['options'] = array_replace( - static::DEFAULT_PDO_OPTIONS, - $options['options'] ?? [] - ); - - $this->options = array_replace( - $this->options, - $options - ); - - if ($this->options['queryCache'] && $queryCompiler instanceof CachingCompilerInterface) { + if ($this->config->queryCache && $queryCompiler instanceof CachingCompilerInterface) { $this->queryCompiler = new CompilerCache($queryCompiler); } - if ($this->options['readonlySchema']) { + if ($this->config->readonlySchema) { $this->schemaHandler = new ReadonlyHandler($this->schemaHandler); } - - // Actualize DSN - $this->updateDSN(); - } - - /** - * Updates an internal options - * - * @return void - */ - private function updateDSN(): void - { - [$connection, $this->options['username'], $this->options['password']] = $this->parseDSN(); - - // Update connection. The DSN field can be located in one of the - // following keys of the configuration array. - switch (true) { - case \array_key_exists('dsn', $this->options): - $this->options['dsn'] = $connection; - break; - case \array_key_exists('addr', $this->options): - $this->options['addr'] = $connection; - break; - default: - $this->options['connection'] = $connection; - break; - } } /** @@ -158,7 +88,7 @@ private function updateDSN(): void */ public function isReadonly(): bool { - return (bool)($this->options['readonly'] ?? false); + return $this->config->readonly; } /** @@ -172,13 +102,13 @@ public function __destruct() /** * @return array */ - public function __debugInfo() + public function __debugInfo(): array { return [ - 'addr' => $this->getDSN(), - 'source' => $this->getSource(), + 'connection' => $this->config->connection, + 'source' => $this->getSource(), 'connected' => $this->isConnected(), - 'options' => $this->options['options'], + 'options' => $this->config, ]; } @@ -186,7 +116,7 @@ public function __debugInfo() * Compatibility with deprecated methods. * * @param string $name - * @param array $arguments + * @param array $arguments * @return mixed * * @deprecated this method will be removed in a future releases. @@ -221,14 +151,14 @@ public function __call(string $name, array $arguments): mixed * Get driver source database or file name. * * @return string - * * @throws DriverException */ public function getSource(): string { - // TODO should be optimized in future releases - if (preg_match('/(?:dbname|database)=([^;]+)/i', $this->getDSN(), $matches)) { - return $matches[1]; + $config = $this->config->connection; + + if ($config instanceof ProvidesSourceString) { + return $config->getSourceString(); } return '*'; @@ -239,7 +169,7 @@ public function getSource(): string */ public function getTimezone(): DateTimeZone { - return new DateTimeZone($this->options['timezone']); + return new DateTimeZone($this->config->timezone); } /** @@ -321,7 +251,7 @@ public function quote($value, int $type = PDO::PARAM_STR): string * Execute query and return query statement. * * @param string $statement - * @param array $parameters + * @param array $parameters * @return StatementInterface * * @throws StatementException @@ -335,7 +265,7 @@ public function query(string $statement, array $parameters = []): StatementInter * Execute query and return number of affected rows. * * @param string $query - * @param array $parameters + * @param array $parameters * @return int * * @throws StatementException @@ -393,7 +323,7 @@ public function beginTransaction(string $isolationLevel = null): bool if ( $e instanceof StatementException\ConnectionException - && $this->options['reconnect'] + && $this->config->reconnect ) { $this->disconnect(); @@ -509,26 +439,23 @@ public function identifier(string $identifier): string * Create instance of PDOStatement using provided SQL query and set of parameters and execute * it. Will attempt singular reconnect. * - * @param string $query - * @param iterable $parameters + * @param string $query + * @param iterable $parameters * @param bool|null $retry * @return StatementInterface * * @throws StatementException */ - protected function statement( - string $query, - iterable $parameters = [], - bool $retry = true - ): StatementInterface { - $queryStart = microtime(true); + protected function statement(string $query, iterable $parameters = [], bool $retry = true): StatementInterface + { + $queryStart = \microtime(true); try { $statement = $this->bindParameters($this->prepare($query), $parameters); $statement->execute(); return new Statement($statement); - } catch (Throwable $e) { + } catch (Throwable $e) { $e = $this->mapException($e, Interpolator::interpolate($query, $parameters)); if ( @@ -563,12 +490,12 @@ protected function statement( */ protected function prepare(string $query): PDOStatement { - if ($this->options['queryCache'] && isset($this->queryCache[$query])) { + if ($this->config->queryCache && isset($this->queryCache[$query])) { return $this->queryCache[$query]; } $statement = $this->getPDO()->prepare($query); - if ($this->options['queryCache']) { + if ($this->config->queryCache) { $this->queryCache[$query] = $statement; } @@ -579,7 +506,7 @@ protected function prepare(string $query): PDOStatement * Bind parameters into statement. * * @param PDOStatement $statement - * @param iterable $parameters + * @param iterable $parameters * @return PDOStatement */ protected function bindParameters(PDOStatement $statement, iterable $parameters): PDOStatement @@ -624,7 +551,7 @@ protected function formatDatetime(DateTimeInterface $value): string try { $datetime = new DateTimeImmutable('now', $this->getTimezone()); } catch (Throwable $e) { - throw new DriverException($e->getMessage(), $e->getCode(), $e); + throw new DriverException($e->getMessage(), (int)$e->getCode(), $e); } return $datetime->setTimestamp($value->getTimestamp())->format(static::DATETIME); @@ -634,7 +561,7 @@ protected function formatDatetime(DateTimeInterface $value): string * Convert PDO exception into query or integrity exception. * * @param Throwable $exception - * @param string $query + * @param string $query * @return StatementException */ abstract protected function mapException( @@ -696,42 +623,6 @@ protected function rollbackSavepoint(int $level): void $this->execute('ROLLBACK TO SAVEPOINT ' . $this->identifier("SVP{$level}")); } - /** - * @return array{string, string, string} - */ - private function parseDSN(): array - { - $dsn = $this->getDSN(); - - $user = (string)($this->options['username'] ?? ''); - $pass = (string)($this->options['password'] ?? ''); - - if (\strpos($dsn, '://') > 0) { - $parts = \parse_url($dsn); - - if (!isset($parts['scheme'])) { - throw new ConfigException('Configuration database scheme must be defined'); - } - - // Update username and password from DSN if not defined. - $user = $user ?: $parts['user'] ?? ''; - $pass = $pass ?: $parts['pass'] ?? ''; - - // Build new DSN - $dsn = \sprintf('%s:host=%s', $parts['scheme'], $parts['host'] ?? 'localhost'); - - if (isset($parts['port'])) { - $dsn .= ';port=' . $parts['port']; - } - - if (isset($parts['path']) && \trim($parts['path'], '/')) { - $dsn .= ';dbname=' . \trim($parts['path'], '/'); - } - } - - return [$dsn, $user, $pass]; - } - /** * Create instance of configured PDO class. * @@ -739,9 +630,20 @@ private function parseDSN(): array */ protected function createPDO(): PDO { - [$dsn, $user, $pass] = $this->parseDSN(); + $connection = $this->config->connection; + + if (! $connection instanceof PDOConnectionConfig) { + throw new \InvalidArgumentException( + 'Could not establish PDO connection using non-PDO configuration' + ); + } - return new PDO($dsn, $user, $pass, $this->options['options']); + return new PDO( + dsn: $connection->getDsn(), + username: $connection->getUsername(), + password: $connection->getPassword(), + options: $connection->getOptions(), + ); } /** @@ -760,21 +662,11 @@ protected function getPDO(): PDO return $this->pdo; } - /** - * Connection DSN. - * - * @return string - */ - protected function getDSN(): string - { - return $this->options['connection'] ?? $this->options['dsn'] ?? $this->options['addr']; - } - /** * Creating a context for logging * - * @param float $queryStart Query start time - * @param PDOStatement|null $statement Statement + * @param float $queryStart Query start time + * @param PDOStatement|null $statement Statement * * @return array */ diff --git a/src/Driver/DriverInterface.php b/src/Driver/DriverInterface.php index 759e1f83..c97fd669 100644 --- a/src/Driver/DriverInterface.php +++ b/src/Driver/DriverInterface.php @@ -11,7 +11,6 @@ namespace Cycle\Database\Driver; -use DateTimeZone; use PDO; use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\ReadonlyConnectionException; @@ -105,9 +104,9 @@ public function getType(): string; /** * Connection specific timezone, at this moment locked to UTC. * - * @return DateTimeZone + * @return \DateTimeZone */ - public function getTimezone(): DateTimeZone; + public function getTimezone(): \DateTimeZone; /** * @return HandlerInterface @@ -154,10 +153,7 @@ public function disconnect(); * @param int $type Parameter type. * @return string */ - public function quote( - $value, - int $type = PDO::PARAM_STR - ): string; + public function quote($value, int $type = PDO::PARAM_STR): string; /** * Wraps PDO query method with custom representation class. diff --git a/src/Driver/MySQL/MySQLDriver.php b/src/Driver/MySQL/MySQLDriver.php index 4327b89b..33d4dbed 100644 --- a/src/Driver/MySQL/MySQLDriver.php +++ b/src/Driver/MySQL/MySQLDriver.php @@ -11,7 +11,7 @@ namespace Cycle\Database\Driver\MySQL; -use PDO; +use Cycle\Database\Config\MySQLDriverConfig; use Cycle\Database\Driver\Driver; use Cycle\Database\Exception\StatementException; use Cycle\Database\Query\QueryBuilder; @@ -21,21 +21,14 @@ */ class MySQLDriver extends Driver { - protected const DEFAULT_PDO_OPTIONS = [ - PDO::ATTR_CASE => PDO::CASE_NATURAL, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES "UTF8"', - PDO::ATTR_STRINGIFY_FETCHES => false, - ]; - /** - * @param array $options + * @param MySQLDriverConfig $config */ - public function __construct(array $options) + public function __construct(MySQLDriverConfig $config) { // default query builder parent::__construct( - $options, + $config, new MySQLHandler(), new MySQLCompiler('``'), QueryBuilder::defaultBuilder() @@ -64,9 +57,9 @@ protected function mapException(\Throwable $exception, string $query): Statement $message = strtolower($exception->getMessage()); if ( - strpos($message, 'server has gone away') !== false - || strpos($message, 'broken pipe') !== false - || strpos($message, 'connection') !== false + str_contains($message, 'server has gone away') + || str_contains($message, 'broken pipe') + || str_contains($message, 'connection') || ((int)$exception->getCode() > 2000 && (int)$exception->getCode() < 2100) ) { return new StatementException\ConnectionException($exception, $query); diff --git a/src/Driver/Postgres/PostgresDriver.php b/src/Driver/Postgres/PostgresDriver.php index 8b056e4b..7699f0fc 100644 --- a/src/Driver/Postgres/PostgresDriver.php +++ b/src/Driver/Postgres/PostgresDriver.php @@ -11,6 +11,7 @@ namespace Cycle\Database\Driver\Postgres; +use Cycle\Database\Config\PostgresDriverConfig; use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\Postgres\Query\PostgresInsertQuery; use Cycle\Database\Driver\Postgres\Query\PostgresSelectQuery; @@ -26,27 +27,6 @@ */ class PostgresDriver extends Driver { - /** - * Option key for default postgres schema name. - * - * @var non-empty-string - */ - private const OPT_DEFAULT_SCHEMA = 'default_schema'; - - /** - * Option key for all available postgres schema names. - * - * @var non-empty-string - */ - private const OPT_AVAILABLE_SCHEMAS = 'schema'; - - /** - * Default public schema name for all postgres connections. - * - * @var non-empty-string - */ - public const PUBLIC_SCHEMA = 'public'; - /** * Cached list of primary keys associated with their table names. Used by InsertBuilder to * emulate last insert id. @@ -67,18 +47,18 @@ class PostgresDriver extends Driver * Schemas to search tables in * * @var string[] - * @psalm-var non-empty-array + * @psalm-var non-empty-array */ private array $searchSchemas = []; /** - * @param array $options + * @param PostgresDriverConfig $config */ - public function __construct(array $options) + public function __construct(PostgresDriverConfig $config) { // default query builder parent::__construct( - $options, + $config, new PostgresHandler(), new PostgresCompiler('""'), new QueryBuilder( @@ -89,7 +69,7 @@ public function __construct(array $options) ) ); - $this->defineSchemas($this->options); + $this->defineSchemas(); } /** @@ -117,6 +97,8 @@ public function getSearchSchemas(): array */ public function shouldUseDefinedSchemas(): bool { + // TODO May be redundant? + // Search schemas list can not be empty. return $this->searchSchemas !== []; } @@ -189,12 +171,12 @@ public function beginTransaction(string $isolationLevel = null): bool } return $ok; - } catch (Throwable $e) { + } catch (Throwable $e) { $e = $this->mapException($e, 'BEGIN TRANSACTION'); if ( $e instanceof StatementException\ConnectionException - && $this->options['reconnect'] + && $this->config->reconnect ) { $this->disconnect(); @@ -231,7 +213,7 @@ public function parseSchemaAndTable(string $name): array [$schema, $table] = explode('.', $name, 2); if ($schema === '$user') { - $schema = $this->options['username']; + $schema = $this->config->connection->getUsername(); } } @@ -245,8 +227,11 @@ protected function createPDO(): \PDO { // Cycle is purely UTF-8 $pdo = parent::createPDO(); + // TODO Should be moved into driver settings. $pdo->exec("SET NAMES 'UTF-8'"); + // TODO May be redundant? + // Search schemas list can not be empty. if ($this->searchPath !== []) { $schema = '"' . implode('", "', $this->searchPath) . '"'; $pdo->exec("SET search_path TO {$schema}"); @@ -264,16 +249,16 @@ protected function mapException(Throwable $exception, string $query): StatementE $message = strtolower($exception->getMessage()); if ( - strpos($message, 'eof detected') !== false - || strpos($message, 'broken pipe') !== false - || strpos($message, '0800') !== false - || strpos($message, '080P') !== false - || strpos($message, 'connection') !== false + str_contains($message, 'eof detected') + || str_contains($message, 'broken pipe') + || str_contains($message, '0800') + || str_contains($message, '080P') + || str_contains($message, 'connection') ) { return new StatementException\ConnectionException($exception, $query); } - if ((int) $exception->getCode() >= 23000 && (int) $exception->getCode() < 24000) { + if ((int)$exception->getCode() >= 23000 && (int)$exception->getCode() < 24000) { return new StatementException\ConstrainException($exception, $query); } @@ -283,20 +268,16 @@ protected function mapException(Throwable $exception, string $query): StatementE /** * Define schemas from config */ - private function defineSchemas(array $options): void + private function defineSchemas(): void { - $options[self::OPT_AVAILABLE_SCHEMAS] = (array)($options[self::OPT_AVAILABLE_SCHEMAS] ?? []); - - $defaultSchema = $options[self::OPT_DEFAULT_SCHEMA] - ?? $options[self::OPT_AVAILABLE_SCHEMAS][0] - ?? static::PUBLIC_SCHEMA; + /** @var PostgresDriverConfig $config */ + $config = $this->config; - $this->searchSchemas = $this->searchPath = array_values(array_unique( - [$defaultSchema, ...$options[self::OPT_AVAILABLE_SCHEMAS]] - )); + $this->searchSchemas = $this->searchPath = \array_values($config->schema); - if (($pos = array_search('$user', $this->searchSchemas, true)) !== false) { - $this->searchSchemas[$pos] = $options['username']; + $position = \array_search('$user', $this->searchSchemas, true); + if ($position !== false) { + $this->searchSchemas[$position] = (string)$config->connection->getUsername(); } } } diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index c082d678..e7be6a87 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -11,9 +11,7 @@ namespace Cycle\Database\Driver\SQLServer; -use DateTimeInterface; -use PDO; -use PDOStatement; +use Cycle\Database\Config\SQLServerDriverConfig; use Cycle\Database\Driver\Driver; use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\StatementException; @@ -22,22 +20,20 @@ class SQLServerDriver extends Driver { - protected const DATETIME = 'Y-m-d\TH:i:s.000'; - protected const DEFAULT_PDO_OPTIONS = [ - PDO::ATTR_CASE => PDO::CASE_NATURAL, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_STRINGIFY_FETCHES => false - ]; + /** + * @var non-empty-string + */ + protected const DATETIME = 'Y-m-d\TH:i:s.000'; /** * {@inheritdoc} * * @throws DriverException */ - public function __construct(array $options) + public function __construct(SQLServerDriverConfig $config) { parent::__construct( - $options, + $config, new SQLServerHandler(), new SQLServerCompiler('[]'), QueryBuilder::defaultBuilder() @@ -59,15 +55,14 @@ public function getType(): string /** * Bind parameters into statement. SQLServer need encoding to be specified for binary parameters. * - * @param PDOStatement $statement + * @param \PDOStatement $statement * @param array $parameters - * @return PDOStatement + * @return \PDOStatement */ - protected function bindParameters( - PDOStatement $statement, - iterable $parameters - ): PDOStatement { + protected function bindParameters(\PDOStatement $statement, iterable $parameters): \PDOStatement + { $index = 0; + foreach ($parameters as $name => $parameter) { if (is_string($name)) { $index = $name; @@ -75,25 +70,25 @@ protected function bindParameters( $index++; } - $type = PDO::PARAM_STR; + $type = \PDO::PARAM_STR; if ($parameter instanceof ParameterInterface) { $type = $parameter->getType(); $parameter = $parameter->getValue(); } - if ($parameter instanceof DateTimeInterface) { + if ($parameter instanceof \DateTimeInterface) { $parameter = $this->formatDatetime($parameter); } - if ($type === PDO::PARAM_LOB) { + if ($type === \PDO::PARAM_LOB) { /** @psalm-suppress UndefinedConstant */ $statement->bindParam( $index, $parameter, $type, 0, - PDO::SQLSRV_ENCODING_BINARY + \PDO::SQLSRV_ENCODING_BINARY ); unset($parameter); @@ -118,9 +113,7 @@ protected function bindParameters( */ protected function createSavepoint(int $level): void { - if ($this->logger !== null) { - $this->logger->info("Transaction: new savepoint 'SVP{$level}'"); - } + $this->logger?->info("Transaction: new savepoint 'SVP{$level}'"); $this->execute('SAVE TRANSACTION ' . $this->identifier("SVP{$level}")); } @@ -134,9 +127,8 @@ protected function createSavepoint(int $level): void */ protected function releaseSavepoint(int $level): void { - if ($this->logger !== null) { - $this->logger->info("Transaction: release savepoint 'SVP{$level}'"); - } + $this->logger?->info("Transaction: release savepoint 'SVP{$level}'"); + // SQLServer automatically commits nested transactions with parent transaction } @@ -149,9 +141,7 @@ protected function releaseSavepoint(int $level): void */ protected function rollbackSavepoint(int $level): void { - if ($this->logger !== null) { - $this->logger->info("Transaction: rollback savepoint 'SVP{$level}'"); - } + $this->logger?->info("Transaction: rollback savepoint 'SVP{$level}'"); $this->execute('ROLLBACK TRANSACTION ' . $this->identifier("SVP{$level}")); } @@ -163,10 +153,11 @@ protected function mapException(\Throwable $exception, string $query): Statement { $message = strtolower($exception->getMessage()); + if ( - strpos($message, '0800') !== false - || strpos($message, '080P') !== false - || strpos($message, 'connection') !== false + \str_contains($message, '0800') + || \str_contains($message, '080P') + || \str_contains($message, 'connection') ) { return new StatementException\ConnectionException($exception, $query); } diff --git a/src/Driver/SQLite/SQLiteDriver.php b/src/Driver/SQLite/SQLiteDriver.php index 7aed1969..d31755a7 100644 --- a/src/Driver/SQLite/SQLiteDriver.php +++ b/src/Driver/SQLite/SQLiteDriver.php @@ -11,20 +11,20 @@ namespace Cycle\Database\Driver\SQLite; +use Cycle\Database\Config\SQLiteDriverConfig; use Cycle\Database\Driver\Driver; use Cycle\Database\Exception\StatementException; use Cycle\Database\Query\QueryBuilder; -use Throwable; class SQLiteDriver extends Driver { /** - * @param array $options + * @param SQLiteDriverConfig $config */ - public function __construct(array $options) + public function __construct(SQLiteDriverConfig $config) { parent::__construct( - $options, + $config, new SQLiteHandler(), new SQLiteCompiler('""'), QueryBuilder::defaultBuilder() @@ -42,16 +42,7 @@ public function getType(): string /** * @inheritDoc */ - public function getSource(): string - { - // remove "sqlite:" - return substr($this->getDSN(), 7); - } - - /** - * @inheritDoc - */ - protected function mapException(Throwable $exception, string $query): StatementException + protected function mapException(\Throwable $exception, string $query): StatementException { if ((int)$exception->getCode() === 23000) { return new StatementException\ConstrainException($exception, $query); @@ -65,10 +56,6 @@ protected function mapException(Throwable $exception, string $query): StatementE */ protected function setIsolationLevel(string $level): void { - if ($this->logger !== null) { - $this->logger->alert( - "Transaction isolation level is not fully supported by SQLite ({$level})" - ); - } + $this->logger?->alert("Transaction isolation level is not fully supported by SQLite ({$level})"); } } diff --git a/src/Exception/HandlerException.php b/src/Exception/HandlerException.php index 37a35d6f..4d9ebc8b 100644 --- a/src/Exception/HandlerException.php +++ b/src/Exception/HandlerException.php @@ -21,7 +21,7 @@ class HandlerException extends DriverException implements StatementExceptionInte */ public function __construct(StatementException $e) { - parent::__construct($e->getMessage(), $e->getCode(), $e); + parent::__construct($e->getMessage(), (int)$e->getCode(), $e); } /** diff --git a/tests/Database/AlterColumnTest.php b/tests/Database/AlterColumnTest.php index ec2a80c6..677856f1 100644 --- a/tests/Database/AlterColumnTest.php +++ b/tests/Database/AlterColumnTest.php @@ -10,28 +10,12 @@ namespace Cycle\Database\Tests; -use Cycle\Database\Database; use Cycle\Database\Driver\Handler; use Cycle\Database\Schema\AbstractColumn; use Cycle\Database\Schema\AbstractTable; abstract class AlterColumnTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/Database/BaseTest.php b/tests/Database/BaseTest.php index 0d03fbfc..0293f32d 100644 --- a/tests/Database/BaseTest.php +++ b/tests/Database/BaseTest.php @@ -11,11 +11,12 @@ namespace Cycle\Database\Tests; +use Cycle\Database\Config\DriverConfig; +use Cycle\Database\Driver\DriverInterface; use Cycle\Database\Tests\Traits\Loggable; use Cycle\Database\Tests\Traits\TableAssertions; use PHPUnit\Framework\TestCase; use Cycle\Database\Database; -use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\Handler; use Cycle\Database\Injection\FragmentInterface; use Cycle\Database\Injection\ParameterInterface; @@ -29,75 +30,76 @@ abstract class BaseTest extends TestCase use TableAssertions; use Loggable; + /** + * @var string|null + */ public const DRIVER = null; - /** @var array */ - public static $config; - - /** @var array */ - public static $driverCache = []; + /** + * @var array + */ + public static array $config; - /** @var Driver */ - protected $driver; + /** + * @var Database + */ + protected Database $database; - /** @var Database */ - protected $database; + /** + * @var array + */ + private static array $memoizedDrivers = []; public function setUp(): void { + if (self::$config['debug'] ?? false) { + $this->enableProfiling(); + } + $this->database = $this->db(); } + public function tearDown(): void + { + $this->dropDatabase($this->database); + } + /** - * @param array $options - * @return Driver + * @param array{readonly: bool} $options + * @return DriverInterface */ - public function getDriver(array $options = []): Driver + private function getDriver(array $options = []): DriverInterface { - $config = self::$config[static::DRIVER]; + $hash = \hash('crc32', static::DRIVER . ':' . \json_encode($options)); - if (!isset($this->driver)) { - $class = $config['driver']; + if (! isset(self::$memoizedDrivers[$hash])) { + /** @var DriverConfig $config */ + $config = clone self::$config[static::DRIVER]; - $options = \array_merge($options, [ - 'connection' => $config['conn'], - 'username' => $config['user'] ?? '', - 'password' => $config['pass'] ?? '', - 'options' => [], - 'queryCache' => true - ]); - - if (isset($config['schema'])) { - $options['schema'] = $config['schema']; + // Add readonly options support + if (isset($options['readonly']) && $options['readonly'] === true) { + $config->readonly = true; } - $this->driver = new $class($options); - } + $driver = $config->getDriver(); - $this->setUpLogger($this->driver); + $this->setUpLogger($driver); - if (self::$config['debug']) { - $this->enableProfiling(); + self::$memoizedDrivers[$hash] = $driver; } - return $this->driver; + return self::$memoizedDrivers[$hash]; } /** * @param string $name * @param string $prefix - * @param array $config - * @return Database|null When non empty null will be given, for safety, for science. + * @param array{readonly: bool} $config + * @return Database */ - protected function db(string $name = 'default', string $prefix = '', array $config = []): ?Database + protected function db(string $name = 'default', string $prefix = '', array $config = []): Database { - if (isset(static::$driverCache[static::DRIVER])) { - $driver = static::$driverCache[static::DRIVER]; - } else { - static::$driverCache[static::DRIVER] = $driver = $this->getDriver($config); - } - - return new Database($name, $prefix, $driver); + return new Database($name, $prefix, $this->getDriver($config)); } /** @@ -130,7 +132,7 @@ protected function assertSameQuery(string $query, $fragment): void */ protected function dropDatabase(Database $database = null): void { - if ($database == null) { + if ($database === null) { return; } diff --git a/tests/Database/ConsistencyTest.php b/tests/Database/ConsistencyTest.php index bea932c4..3ef3065c 100644 --- a/tests/Database/ConsistencyTest.php +++ b/tests/Database/ConsistencyTest.php @@ -15,20 +15,6 @@ abstract class ConsistencyTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } public function schema(string $table): AbstractTable { diff --git a/tests/Database/CreateTableTest.php b/tests/Database/CreateTableTest.php index d3b021f1..ae62ada1 100644 --- a/tests/Database/CreateTableTest.php +++ b/tests/Database/CreateTableTest.php @@ -10,26 +10,11 @@ namespace Cycle\Database\Tests; -use Cycle\Database\Database; use Cycle\Database\Exception\SchemaException; use Cycle\Database\Schema\AbstractTable; abstract class CreateTableTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } public function schema(string $table): AbstractTable { diff --git a/tests/Database/DatabaseTest.php b/tests/Database/DatabaseTest.php index 19b74847..911a5d39 100644 --- a/tests/Database/DatabaseTest.php +++ b/tests/Database/DatabaseTest.php @@ -42,7 +42,7 @@ public function testGetType(): void { $db = $this->db(); $this->assertSame( - $this->getDriver()->getType(), + $db->getDriver()->getType(), $db->getType() ); diff --git a/tests/Database/DatetimeColumnTest.php b/tests/Database/DatetimeColumnTest.php index 3cbcd9a2..e8d5826e 100644 --- a/tests/Database/DatetimeColumnTest.php +++ b/tests/Database/DatetimeColumnTest.php @@ -10,7 +10,6 @@ namespace Cycle\Database\Tests; -use Cycle\Database\Database; use Cycle\Database\Driver\Handler; use Cycle\Database\Schema\AbstractColumn; use Cycle\Database\Schema\AbstractTable; @@ -18,21 +17,6 @@ //See MySQL Driver! abstract class DatetimeColumnTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/Database/DefaultValueTest.php b/tests/Database/DefaultValueTest.php index 5a1a5621..17994cd1 100644 --- a/tests/Database/DefaultValueTest.php +++ b/tests/Database/DefaultValueTest.php @@ -10,7 +10,6 @@ namespace Cycle\Database\Tests; -use Cycle\Database\Database; use Cycle\Database\Schema\AbstractTable; /** @@ -23,21 +22,6 @@ */ abstract class DefaultValueTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/Database/Driver/Postgres/AlterColumnTest.php b/tests/Database/Driver/Postgres/AlterColumnTest.php index cf70cebb..b023b6c3 100644 --- a/tests/Database/Driver/Postgres/AlterColumnTest.php +++ b/tests/Database/Driver/Postgres/AlterColumnTest.php @@ -23,7 +23,7 @@ class AlterColumnTest extends \Cycle\Database\Tests\AlterColumnTest public function testNativeEnums(): void { - $driver = $this->getDriver(); + $driver = $this->database->getDriver(); try { $driver->execute("CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy');"); } catch (StatementException $e) { diff --git a/tests/Database/Driver/Postgres/ConsistencyTest.php b/tests/Database/Driver/Postgres/ConsistencyTest.php index 6d9c506b..838c7b08 100644 --- a/tests/Database/Driver/Postgres/ConsistencyTest.php +++ b/tests/Database/Driver/Postgres/ConsistencyTest.php @@ -27,7 +27,7 @@ public function testPrimary(): void /** * @var PostgresDriver $d */ - $d = $this->getDriver(); + $d = $this->database->getDriver(); $schema = $d->getSchema('table'); $this->assertFalse($schema->exists()); @@ -60,7 +60,7 @@ public function testPrimary(): void public function testPrimaryException(): void { /** @var PostgresDriver $d */ - $d = $this->getDriver(); + $d = $this->database->getDriver(); $this->expectException(\Cycle\Database\Exception\DriverException::class); diff --git a/tests/Database/Driver/Postgres/DriverTest.php b/tests/Database/Driver/Postgres/DriverTest.php index 2c49829c..e0d2335e 100644 --- a/tests/Database/Driver/Postgres/DriverTest.php +++ b/tests/Database/Driver/Postgres/DriverTest.php @@ -4,19 +4,37 @@ namespace Cycle\Database\Tests\Driver\Postgres; +use Cycle\Database\Config\Postgres\TcpConnectionConfig; +use Cycle\Database\Config\PostgresDriverConfig; use Cycle\Database\Driver\Postgres\PostgresDriver; use PHPUnit\Framework\TestCase; class DriverTest extends TestCase { + /** + * TODO Should be moved in common config + * + * @return TcpConnectionConfig + */ + protected function getConnection(): TcpConnectionConfig + { + return new TcpConnectionConfig( + database: 'spiral', + host: '127.0.0.1', + port: 15432, + user: 'postgres', + password: 'postgres' + ); + } + public function testIfSchemaOptionsDoesNotPresentUsePublicSchema(): void { - $driver = new PostgresDriver([ - 'connection' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'username' => 'postgres', - 'password' => 'postgres', - 'schema' => ['$user', 'public'] - ]); + $driver = new PostgresDriver( + new PostgresDriverConfig( + connection: $this->getConnection(), + schema: ['$user', 'public'] + ) + ); $driver->connect(); @@ -26,12 +44,12 @@ public function testIfSchemaOptionsDoesNotPresentUsePublicSchema(): void public function testDefaultSchemaCanBeDefined(): void { - $driver = new PostgresDriver([ - 'connection' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'username' => 'postgres', - 'password' => 'postgres', - 'default_schema' => 'private' - ]); + $driver = new PostgresDriver( + new PostgresDriverConfig( + connection: $this->getConnection(), + schema: 'private', + ) + ); $driver->connect(); @@ -41,12 +59,12 @@ public function testDefaultSchemaCanBeDefined(): void public function testDefaultSchemaCanBeDefinedFromAvailableSchemas(): void { - $driver = new PostgresDriver([ - 'connection' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'username' => 'postgres', - 'password' => 'postgres', - 'schema' => 'private' - ]); + $driver = new PostgresDriver( + new PostgresDriverConfig( + connection: $this->getConnection(), + schema: 'private', + ) + ); $driver->connect(); @@ -54,31 +72,14 @@ public function testDefaultSchemaCanBeDefinedFromAvailableSchemas(): void $this->assertSame('private', $driver->query('SHOW search_path')->fetch()['search_path']); } - public function testDefaultSchemaCanNotBeRedefinedFromAvailableSchemas(): void - { - $driver = new PostgresDriver([ - 'connection' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'username' => 'postgres', - 'password' => 'postgres', - 'default_schema' => 'private', - 'schema' => ['test', 'private'] - ]); - - $driver->connect(); - - $this->assertSame(['private', 'test'], $driver->getSearchSchemas()); - $this->assertSame('private, test', $driver->query('SHOW search_path')->fetch()['search_path']); - } - public function testDefaultSchemaForCurrentUser(): void { - $driver = new PostgresDriver([ - 'connection' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'username' => 'postgres', - 'password' => 'postgres', - 'default_schema' => '$user', - 'schema' => ['test', 'private'] - ]); + $driver = new PostgresDriver( + new PostgresDriverConfig( + connection: $this->getConnection(), + schema: ['$user', 'test', 'private'], + ) + ); $driver->connect(); @@ -91,12 +92,12 @@ public function testDefaultSchemaForCurrentUser(): void */ public function testIfSchemaOptionsPresentsUseIt($schema, $available, $result): void { - $driver = new PostgresDriver([ - 'connection' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'username' => 'postgres', - 'password' => 'postgres', - 'schema' => $schema - ]); + $driver = new PostgresDriver( + new PostgresDriverConfig( + connection: $this->getConnection(), + schema: $schema, + ) + ); $this->assertSame($available, $driver->getSearchSchemas()); $driver->connect(); diff --git a/tests/Database/Driver/Postgres/Helpers.php b/tests/Database/Driver/Postgres/Helpers.php index 2b320ebc..17e23dbf 100644 --- a/tests/Database/Driver/Postgres/Helpers.php +++ b/tests/Database/Driver/Postgres/Helpers.php @@ -11,6 +11,8 @@ namespace Cycle\Database\Tests\Driver\Postgres; +use Cycle\Database\Config\Postgres\TcpConnectionConfig; +use Cycle\Database\Config\PostgresDriverConfig; use Cycle\Database\Database; use Cycle\Database\Driver\DriverInterface; use Cycle\Database\Driver\Postgres\PostgresDriver; @@ -74,19 +76,16 @@ private function createTable(DriverInterface $driver, string $name): PostgresTab private function getDriver($schema = null, string $defaultSchema = null): DriverInterface { - $options = [ - 'connection' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'username' => 'postgres', - 'password' => 'postgres' - ]; - - if ($schema) { - $options['schema'] = $schema; - } - - if ($defaultSchema) { - $options['default_schema'] = $defaultSchema; - } + $options = new PostgresDriverConfig( + connection: new TcpConnectionConfig( + database: 'spiral', + host: '127.0.0.1', + port: 15432, + user: 'postgres', + password: 'postgres' + ), + schema: \array_filter([$defaultSchema, ...\array_values((array)$schema)]), + ); $driver = new PostgresDriver($options); $driver->connect(); diff --git a/tests/Database/ExceptionsTest.php b/tests/Database/ExceptionsTest.php index af73e70f..8896c83c 100644 --- a/tests/Database/ExceptionsTest.php +++ b/tests/Database/ExceptionsTest.php @@ -11,7 +11,6 @@ namespace Cycle\Database\Tests; -use Cycle\Database\Database; use Cycle\Database\Exception\HandlerException; use Cycle\Database\Exception\StatementException; @@ -20,16 +19,6 @@ */ abstract class ExceptionsTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - public function testSelectionException(): void { $select = $this->database->select()->from('udnefinedTable'); @@ -64,15 +53,21 @@ public function testHandlerException(): void public function testInsertNotNullable(): void { - $schema = $this->getDriver()->getSchema('test'); + $schema = $this->database->getDriver()->getSchema('test'); $schema->primary('id'); $schema->string('value')->nullable(false)->defaultValue(null); $schema->save(); - $this->getDriver()->insertQuery('', 'test')->values(['value' => 'value'])->run(); + $this->database->getDriver() + ->insertQuery('', 'test') + ->values(['value' => 'value']) + ->run(); try { - $this->getDriver()->insertQuery('', 'test')->values(['value' => null])->run(); + $this->database->getDriver() + ->insertQuery('', 'test') + ->values(['value' => null]) + ->run(); } catch (StatementException\ConstrainException $e) { $this->assertInstanceOf(StatementException\ConstrainException::class, $e); } diff --git a/tests/Database/ForeignKeysTest.php b/tests/Database/ForeignKeysTest.php index f073e06d..c0d073dd 100644 --- a/tests/Database/ForeignKeysTest.php +++ b/tests/Database/ForeignKeysTest.php @@ -18,21 +18,6 @@ abstract class ForeignKeysTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/Database/IndexesTest.php b/tests/Database/IndexesTest.php index 70485d5a..ea0bf63c 100644 --- a/tests/Database/IndexesTest.php +++ b/tests/Database/IndexesTest.php @@ -17,21 +17,6 @@ abstract class IndexesTest extends BaseTest { - /** - * @var Database - */ - protected $database; - - public function setUp(): void - { - $this->database = $this->db(); - } - - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/Database/IsolationTest.php b/tests/Database/IsolationTest.php index 11523208..c51bd47c 100644 --- a/tests/Database/IsolationTest.php +++ b/tests/Database/IsolationTest.php @@ -15,14 +15,11 @@ abstract class IsolationTest extends BaseTest { - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $prefix, string $table): AbstractTable { - return $this->db('default', $prefix)->table($table)->getSchema(); + return $this->db('default', $prefix) + ->table($table) + ->getSchema(); } public function testGetPrefix(): void diff --git a/tests/Database/ReadonlyTest.php b/tests/Database/ReadonlyTest.php index 66295eb7..f0cf38fe 100644 --- a/tests/Database/ReadonlyTest.php +++ b/tests/Database/ReadonlyTest.php @@ -4,7 +4,6 @@ namespace Cycle\Database\Tests; -use Cycle\Database\Database; use Cycle\Database\Driver\Driver; use Cycle\Database\Exception\ReadonlyConnectionException; use Cycle\Database\Table; @@ -18,7 +17,7 @@ abstract class ReadonlyTest extends BaseTest public function setUp(): void { - $this->database = new Database('default', '', $this->getDriver(['readonly' => true])); + $this->database = $this->db('default', '', ['readonly' => true]); $this->allowWrite(function () { $table = $this->database->table($this->table); @@ -35,11 +34,11 @@ private function allowWrite(\Closure $then): void $driver = $this->database->getDriver(); (function (\Closure $then): void { - $this->options['readonly'] = false; + $this->config->readonly = false; try { $then(); } finally { - $this->options['readonly'] = true; + $this->config->readonly = true; } })->call($driver, $then); } diff --git a/tests/Database/ReflectorTest.php b/tests/Database/ReflectorTest.php index 9e4f8f00..aaf815cc 100644 --- a/tests/Database/ReflectorTest.php +++ b/tests/Database/ReflectorTest.php @@ -15,11 +15,6 @@ abstract class ReflectorTest extends BaseTest { - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table, string $prefix = ''): AbstractTable { return $this->db('default', $prefix)->table($table)->getSchema(); diff --git a/tests/Database/StatementTest.php b/tests/Database/StatementTest.php index 982a39c5..0fbf64d4 100644 --- a/tests/Database/StatementTest.php +++ b/tests/Database/StatementTest.php @@ -18,14 +18,9 @@ abstract class StatementTest extends BaseTest { - /** - * @var Database - */ - protected $database; - public function setUp(): void { - $this->database = $this->db(); + parent::setUp(); $schema = $this->database->table('sample_table')->getSchema(); $schema->primary('id'); @@ -34,11 +29,6 @@ public function setUp(): void $schema->save(); } - public function tearDown(): void - { - $this->dropDatabase($this->database); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/Database/TableTest.php b/tests/Database/TableTest.php index fc2ca66b..7ced8547 100644 --- a/tests/Database/TableTest.php +++ b/tests/Database/TableTest.php @@ -17,14 +17,10 @@ abstract class TableTest extends BaseTest { - /** - * @var Database - */ - protected $database; public function setUp(): void { - $this->database = $this->db(); + parent::setUp(); $schema = $this->database->table('table')->getSchema(); $schema->primary('id'); @@ -34,11 +30,6 @@ public function setUp(): void $schema->save(); } - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/Database/TransactionsTest.php b/tests/Database/TransactionsTest.php index 61ae4268..24c3489b 100644 --- a/tests/Database/TransactionsTest.php +++ b/tests/Database/TransactionsTest.php @@ -15,14 +15,9 @@ abstract class TransactionsTest extends BaseTest { - /** - * @var Database - */ - protected $database; - public function setUp(): void { - $this->database = $this->db(); + parent::setUp(); $schema = $this->database->table('table')->getSchema(); $schema->primary('id'); @@ -31,11 +26,6 @@ public function setUp(): void $schema->save(); } - public function tearDown(): void - { - $this->dropDatabase($this->db()); - } - public function schema(string $table): AbstractTable { return $this->database->table($table)->getSchema(); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ec5fd432..427b4e6a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,9 +1,10 @@ [ - 'driver' => Database\Driver\SQLite\SQLiteDriver::class, - 'conn' => 'sqlite::memory:', - 'user' => 'sqlite', - 'pass' => '', - 'queryCache' => 100 - ], - 'mysql' => [ - 'driver' => Database\Driver\MySQL\MySQLDriver::class, - 'conn' => 'mysql://root:root@127.0.0.1:13306/spiral', - 'queryCache' => 100 - ], - 'postgres' => [ - 'driver' => Database\Driver\Postgres\PostgresDriver::class, - 'conn' => 'pgsql:host=127.0.0.1;port=15432;dbname=spiral', - 'user' => 'postgres', - 'pass' => 'postgres', - 'queryCache' => 100 - ], - 'sqlserver' => [ - 'driver' => Database\Driver\SQLServer\SQLServerDriver::class, - 'conn' => 'sqlsrv:Server=127.0.0.1,11433;Database=tempdb', - 'user' => 'SA', - 'pass' => 'SSpaSS__1', - 'queryCache' => 100 - ], + 'sqlite' => new Database\Config\SQLiteDriverConfig( + queryCache: true, + ), + 'mysql' => new Database\Config\MySQLDriverConfig( + connection: new Database\Config\MySQL\TcpConnectionConfig( + database: 'spiral', + host: '127.0.0.1', + port: 13306, + user: 'root', + password: 'root', + ), + queryCache: true + ), + 'postgres' => new Database\Config\PostgresDriverConfig( + connection: new Database\Config\Postgres\TcpConnectionConfig( + database: 'spiral', + host: '127.0.0.1', + port: 15432, + user: 'postgres', + password: 'postgres', + ), + schema: 'public', + queryCache: true, + ), + 'sqlserver' => new Database\Config\SQLServerDriverConfig( + connection: new Database\Config\SQLServer\TcpConnectionConfig( + database: 'tempdb', + host: '127.0.0.1', + port: 11433, + user: 'SA', + password: 'SSpaSS__1' + ), + queryCache: true + ), ]; $db = getenv('DB') ?: null;