diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..f02a64bf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_style = space +indent_size = 2 + +[*.json] +indent_style = space +indent_size = 4 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ae75b7a0..04179573 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -73,7 +73,6 @@ jobs: file: ./coverage.xml sqlite: - needs: lint name: SQLite PHP ${{ matrix.php-versions }} runs-on: ubuntu-latest strategy: diff --git a/.gitignore b/.gitignore index d5e6a473..d89a76d9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ build/ clover.xml clover.json .php_cs.cache -/.phpunit.result.cache +.phpunit.result.cache diff --git a/LICENSE b/LICENSE index be7d3b94..586cdb02 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020 Spiral Scout +Copyright (c) 2021 Spiral Scout Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/composer.json b/composer.json index a7a73ebd..95d5eba4 100644 --- a/composer.json +++ b/composer.json @@ -1,36 +1,60 @@ { - "name": "cycle/database", - "type": "library", - "description": "DBAL, schema introspection, migration and pagination", - "license": "MIT", - "authors": [ - { - "name": "Anton Titov / Wolfy-J", - "email": "wolfy.jd@gmail.com" - } - ], - "require": { - "php": ">=8.0", - "ext-pdo": "*", - "spiral/core": "^2.7", - "spiral/logger": "^2.7", - "spiral/pagination": "^2.7" - }, - "require-dev": { - "phpunit/phpunit": "~8.0", - "mockery/mockery": "^1.1", - "spiral/dumper": "^2.7", - "spiral/code-style": "^1.0", - "spiral/tokenizer": "^2.7" - }, - "autoload": { - "psr-4": { - "Cycle\\Database\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Cycle\\Database\\Tests\\": "tests/Database/" - } - } + "name": "cycle/database", + "type": "library", + "description": "DBAL, schema introspection, migration and pagination", + "license": "MIT", + "authors": [ + { + "name": "Anton Titov / Wolfy-J", + "email": "wolfy.jd@gmail.com" + } + ], + "replace": { + "spiral/database": "^2.0" + }, + "require": { + "php": ">=8.0", + "ext-pdo": "*", + "spiral/core": "^2.8", + "spiral/logger": "^2.8", + "spiral/pagination": "^2.8" + }, + "autoload": { + "files": [ + "src/polyfill.php" + ], + "psr-4": { + "Cycle\\Database\\": "src" + } + }, + "require-dev": { + "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" + }, + "autoload-dev": { + "psr-4": { + "Cycle\\Database\\Tests\\": "tests/Database" + } + }, + "scripts": { + "test": [ + "phpcs --standard=phpcs.xml", + "psalm --no-cache", + "phpunit" + ] + }, + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 00000000..a81e1731 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ./src + diff --git a/phpunit.xml b/phpunit.xml index 1d822c27..c7cd12cf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,4 +22,4 @@ src/ - \ No newline at end of file + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 00000000..b82c72f3 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + diff --git a/resources/.phpstorm.meta.php b/resources/.phpstorm.meta.php new file mode 100644 index 00000000..55f0c22e --- /dev/null +++ b/resources/.phpstorm.meta.php @@ -0,0 +1,754 @@ +select(), stupid bug. - $columns = $columns[0]; + $arguments = $arguments[0]; } return $this->getDriver(self::READ) ->getQueryBuilder() - ->selectQuery($this->prefix, [], $columns); + ->selectQuery($this->prefix, [], $arguments); } /** diff --git a/src/DatabaseInterface.php b/src/DatabaseInterface.php index 2789d768..42fcd9d6 100644 --- a/src/DatabaseInterface.php +++ b/src/DatabaseInterface.php @@ -1,10 +1,10 @@ config->getDefaultDatabase(); } - //Spiral support ability to link multiple virtual databases together using aliases + // Cycle support ability to link multiple virtual databases together + // using aliases. $database = $this->config->resolveAlias($database); if (isset($this->databases[$database])) { diff --git a/src/DatabaseProviderInterface.php b/src/DatabaseProviderInterface.php index 2492d243..ada66cbc 100644 --- a/src/DatabaseProviderInterface.php +++ b/src/DatabaseProviderInterface.php @@ -1,10 +1,10 @@ true, // disable schema modifications - 'readonlySchema' => false + 'readonlySchema' => false, + + // disable write expressions + 'readonly' => false, ]; /** @var PDO|null */ @@ -119,6 +124,41 @@ public function __construct( if ($this->options['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; + } + } + + /** + * {@inheritDoc} + */ + public function isReadonly(): bool + { + return (bool)($this->options['readonly'] ?? false); } /** @@ -299,9 +339,14 @@ public function query(string $statement, array $parameters = []): StatementInter * @return int * * @throws StatementException + * @throws ReadonlyConnectionException */ public function execute(string $query, array $parameters = []): int { + if ($this->isReadonly()) { + throw ReadonlyConnectionException::onWriteStatementExecution(); + } + return $this->statement($query, $parameters)->rowCount(); } @@ -651,6 +696,42 @@ 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. * @@ -658,12 +739,9 @@ protected function rollbackSavepoint(int $level): void */ protected function createPDO(): PDO { - return new PDO( - $this->getDSN(), - $this->options['username'], - $this->options['password'], - $this->options['options'] - ); + [$dsn, $user, $pass] = $this->parseDSN(); + + return new PDO($dsn, $user, $pass, $this->options['options']); } /** diff --git a/src/Driver/DriverInterface.php b/src/Driver/DriverInterface.php index a6bb9493..759e1f83 100644 --- a/src/Driver/DriverInterface.php +++ b/src/Driver/DriverInterface.php @@ -1,10 +1,10 @@ driver->query( $query, - [$this->driver->getSource(), $name] + [$this->driver->getSource(), $table] )->fetchColumn(); } diff --git a/src/Driver/MySQL/Schema/MySQLColumn.php b/src/Driver/MySQL/Schema/MySQLColumn.php index a8f20884..596f38b8 100644 --- a/src/Driver/MySQL/Schema/MySQLColumn.php +++ b/src/Driver/MySQL/Schema/MySQLColumn.php @@ -1,10 +1,10 @@ exec("SET NAMES 'UTF-8'"); diff --git a/src/Driver/Postgres/PostgresHandler.php b/src/Driver/Postgres/PostgresHandler.php index 58c93a59..b97548b8 100644 --- a/src/Driver/Postgres/PostgresHandler.php +++ b/src/Driver/Postgres/PostgresHandler.php @@ -1,10 +1,10 @@ sqlStatement($params); + if ($this->driver->isReadonly()) { + throw ReadonlyConnectionException::onWriteStatementExecution(); + } + $result = $this->driver->query($queryString, $params->getParameters()); try { diff --git a/src/Driver/Postgres/Query/PostgresSelectQuery.php b/src/Driver/Postgres/Query/PostgresSelectQuery.php index 1227fac4..3cc4c7b7 100644 --- a/src/Driver/Postgres/Query/PostgresSelectQuery.php +++ b/src/Driver/Postgres/Query/PostgresSelectQuery.php @@ -1,10 +1,10 @@ type = $schema['typname']; /** - * Attention, this is not default spiral enum type emulated via CHECK. This is real - * Postgres enum type. + * Attention, this is not default enum type emulated via CHECK. + * This is real Postgres enum type. */ self::resolveEnum($driver, $column); } diff --git a/src/Driver/Postgres/Schema/PostgresForeignKey.php b/src/Driver/Postgres/Schema/PostgresForeignKey.php index 6ff46bab..9fd24f3a 100644 --- a/src/Driver/Postgres/Schema/PostgresForeignKey.php +++ b/src/Driver/Postgres/Schema/PostgresForeignKey.php @@ -1,10 +1,10 @@ bindParam( $index, $parameter, diff --git a/src/Driver/SQLServer/SQLServerHandler.php b/src/Driver/SQLServer/SQLServerHandler.php index e72820f3..ba7bed89 100644 --- a/src/Driver/SQLServer/SQLServerHandler.php +++ b/src/Driver/SQLServer/SQLServerHandler.php @@ -1,10 +1,10 @@ driver->query($query, [$name])->fetchColumn(); + return (bool)$this->driver->query($query, [$table])->fetchColumn(); } /** diff --git a/src/Driver/SQLServer/Schema/SQLServerColumn.php b/src/Driver/SQLServer/Schema/SQLServerColumn.php index 08112510..ecf86e0f 100644 --- a/src/Driver/SQLServer/Schema/SQLServerColumn.php +++ b/src/Driver/SQLServer/Schema/SQLServerColumn.php @@ -1,10 +1,10 @@ $schema) { + foreach ($indexes as $_ => $schema) { //Once all columns are aggregated we can finally create an index $result[] = SQLServerIndex::createInstance($this->getFullName(), $schema); } diff --git a/src/Driver/SQLite/SQLiteCompiler.php b/src/Driver/SQLite/SQLiteCompiler.php index cf344969..882d3b80 100644 --- a/src/Driver/SQLite/SQLiteCompiler.php +++ b/src/Driver/SQLite/SQLiteCompiler.php @@ -1,10 +1,10 @@ fetchColumn(); /* - * There is not really many ways to get extra information about column in SQLite, let's parse - * table schema. As mention, spiral SQLite schema reader will support fully only tables created - * by spiral as we expecting every column definition be on new line. - */ + * There is not really many ways to get extra information about column + * in SQLite, let's parse table schema. As mention, Cycle SQLite + * schema reader will support fully only tables created by Cycle as we + * expecting every column definition be on new line. + */ $definition = explode("\n", $definition); $result = []; diff --git a/src/Driver/Statement.php b/src/Driver/Statement.php index a19c1acf..62e7159e 100644 --- a/src/Driver/Statement.php +++ b/src/Driver/Statement.php @@ -1,10 +1,10 @@ current->getIndexes() as $name => $index) { + foreach ($this->current->getIndexes() as $_ => $index) { if (!$this->initial->hasIndex($index->getColumnsWithSort())) { $difference[] = $index; } @@ -155,7 +155,7 @@ public function addedIndexes(): array public function droppedIndexes(): array { $difference = []; - foreach ($this->initial->getIndexes() as $name => $index) { + foreach ($this->initial->getIndexes() as $_ => $index) { if (!$this->current->hasIndex($index->getColumnsWithSort())) { $difference[] = $index; } @@ -173,7 +173,7 @@ public function alteredIndexes(): array { $difference = []; - foreach ($this->current->getIndexes() as $name => $index) { + foreach ($this->current->getIndexes() as $_ => $index) { if (!$this->initial->hasIndex($index->getColumnsWithSort())) { //Added into schema continue; @@ -194,7 +194,7 @@ public function alteredIndexes(): array public function addedForeignKeys(): array { $difference = []; - foreach ($this->current->getForeignKeys() as $name => $foreignKey) { + foreach ($this->current->getForeignKeys() as $_ => $foreignKey) { if (!$this->initial->hasForeignKey($foreignKey->getColumns())) { $difference[] = $foreignKey; } @@ -209,7 +209,7 @@ public function addedForeignKeys(): array public function droppedForeignKeys(): array { $difference = []; - foreach ($this->initial->getForeignKeys() as $name => $foreignKey) { + foreach ($this->initial->getForeignKeys() as $_ => $foreignKey) { if (!$this->current->hasForeignKey($foreignKey->getColumns())) { $difference[] = $foreignKey; } @@ -227,7 +227,7 @@ public function alteredForeignKeys(): array { $difference = []; - foreach ($this->current->getForeignKeys() as $name => $foreignKey) { + foreach ($this->current->getForeignKeys() as $_ => $foreignKey) { if (!$this->initial->hasForeignKey($foreignKey->getColumns())) { //Added into schema continue; diff --git a/src/Schema/ComparatorInterface.php b/src/Schema/ComparatorInterface.php index e3be69ba..09e92f24 100644 --- a/src/Schema/ComparatorInterface.php +++ b/src/Schema/ComparatorInterface.php @@ -1,10 +1,10 @@ beginTransaction(null, false); } else { - /** @var DriverInterface $driver */ $driver->beginTransaction(null); } } @@ -192,7 +191,6 @@ protected function beginTransaction(): void protected function commitTransaction(): void { foreach ($this->drivers as $driver) { - /** @var DriverInterface $driver */ $driver->commitTransaction(); } } @@ -203,7 +201,6 @@ protected function commitTransaction(): void protected function rollbackTransaction(): void { foreach (array_reverse($this->drivers) as $driver) { - /** @var DriverInterface $driver */ $driver->rollbackTransaction(); } } diff --git a/src/Schema/State.php b/src/Schema/State.php index 1f760588..38067df3 100644 --- a/src/Schema/State.php +++ b/src/Schema/State.php @@ -1,10 +1,10 @@ driver)) { $class = $config['driver']; - $options = [ + $options = \array_merge($options, [ 'connection' => $config['conn'], - 'username' => $config['user'], - 'password' => $config['pass'], + 'username' => $config['user'] ?? '', + 'password' => $config['pass'] ?? '', 'options' => [], 'queryCache' => true - ]; + ]); if (isset($config['schema'])) { $options['schema'] = $config['schema']; @@ -84,15 +86,15 @@ public function getDriver(): Driver /** * @param string $name * @param string $prefix - * + * @param array $config * @return Database|null When non empty null will be given, for safety, for science. */ - protected function db(string $name = 'default', string $prefix = '') + 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(); + static::$driverCache[static::DRIVER] = $driver = $this->getDriver($config); } return new Database($name, $prefix, $driver); diff --git a/tests/Database/Driver/MySQL/ReadonlyTest.php b/tests/Database/Driver/MySQL/ReadonlyTest.php new file mode 100644 index 00000000..faaa7528 --- /dev/null +++ b/tests/Database/Driver/MySQL/ReadonlyTest.php @@ -0,0 +1,21 @@ +database = new Database('default', '', $this->getDriver(['readonly' => true])); + + $this->allowWrite(function () { + $table = $this->database->table($this->table); + $schema = $table->getSchema(); + $schema->primary('id'); + $schema->string('value')->nullable(); + $schema->save(); + }); + } + + private function allowWrite(\Closure $then): void + { + /** @var Driver $driver */ + $driver = $this->database->getDriver(); + + (function (\Closure $then): void { + $this->options['readonly'] = false; + try { + $then(); + } finally { + $this->options['readonly'] = true; + } + })->call($driver, $then); + } + + public function tearDown(): void + { + $this->allowWrite(function () { + $schema = $this->database->table($this->table) + ->getSchema(); + + $schema->declareDropped(); + $schema->save(); + }); + } + + protected function table(): Table + { + return $this->database->table($this->table); + } + + public function testTableAllowSelection(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->select() + ->run() + ; + } + + public function testTableAllowCount(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->count() + ; + } + + public function testTableAllowExists(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->exists() + ; + } + + public function testTableAllowGetPrimaryKeys(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->getPrimaryKeys() + ; + } + + public function testTableAllowHasColumn(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->hasColumn('column') + ; + } + + public function testTableAllowGetColumns(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->getColumns() + ; + } + + public function testTableAllowHasIndex(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->hasIndex(['column']) + ; + } + + public function testTableAllowGetIndexes(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->getIndexes() + ; + } + + public function testTableAllowHasForeignKey(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->hasForeignKey(['column']) + ; + } + + public function testTableAllowGetForeignKeys(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->getForeignKeys() + ; + } + + public function testTableAllowGetDependencies(): void + { + $this->expectNotToPerformAssertions(); + + $this->table() + ->getDependencies() + ; + } + + public function testTableRejectInsertOne(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->table() + ->insertOne(['value' => 'example']) + ; + } + + public function testTableRejectInsertMultiple(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->table() + ->insertMultiple(['value'], ['example']) + ; + } + + public function testTableRejectInsert(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->table() + ->insert() + ->columns('value') + ->values('example') + ->run(); + } + + public function testTableRejectUpdate(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->table() + ->update(['value' => 'updated']) + ->run() + ; + } + + public function testTableRejectDelete(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->table() + ->delete() + ->run() + ; + } + + public function testTableRejectEraseData(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->table() + ->eraseData() + ; + } + + public function testSchemaRejectSaving(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $table = $this->database + ->table('not_allowed_to_creation'); + + $schema = $table->getSchema(); + $schema->primary('id'); + $schema->string('value')->nullable(); + $schema->save(); + } + + public function testDatabaseAllowSelection(): void + { + $this->expectNotToPerformAssertions(); + + $this->database->select() + ->from($this->table) + ->run() + ; + } + + public function testDatabaseRejectUpdate(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->database->update($this->table, ['value' => 'example']) + ->run() + ; + } + + public function testDatabaseRejectInsert(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->database->insert($this->table) + ->columns('value') + ->values('example') + ->run() + ; + } + + public function testDatabaseRejectDelete(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->database->delete($this->table) + ->run() + ; + } + + public function testDatabaseAllowRawQuery(): void + { + $this->expectNotToPerformAssertions(); + + $this->database->query('SELECT 1'); + } + + public function testDatabaseRejectRawExecution(): void + { + $this->expectException(ReadonlyConnectionException::class); + + $this->database->execute("DROP TABLE {$this->table}"); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0c96ea3e..ec5fd432 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -30,9 +30,7 @@ ], 'mysql' => [ 'driver' => Database\Driver\MySQL\MySQLDriver::class, - 'conn' => 'mysql:host=127.0.0.1:13306;dbname=spiral', - 'user' => 'root', - 'pass' => 'root', + 'conn' => 'mysql://root:root@127.0.0.1:13306/spiral', 'queryCache' => 100 ], 'postgres' => [ @@ -53,9 +51,8 @@ $db = getenv('DB') ?: null; Database\Tests\BaseTest::$config = [ - 'debug' => false, - ] + ( - $db === null + 'debug' => getenv('DB_DEBUG') ?: false, + ] + ($db === null ? $drivers : array_intersect_key($drivers, array_flip((array)$db)) );