diff --git a/.gitattributes b/.gitattributes index 516c22d..cde4f1f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,8 +3,5 @@ tests/ export-ignore .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore -.scrutinizer.yml export-ignore -.travis.yml export-ignore CONTRIBUTING.md export-ignore -phpspec.yml.dist export-ignore phpunit.xml.dist export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f7b8104 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: composer + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + versioning-strategy: widen diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e34a488 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,61 @@ +name: Test + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + php-versions: [ '8.2', '8.3', '8.4', '8.5' ] + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + coverage: none + tools: composer:v2 + extensions: pdo_sqlite + + - name: Validate Composer metadata + run: | + if [ -f composer.lock ]; then + composer validate --strict --no-interaction + else + composer validate --strict --no-check-lock --no-interaction + fi + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.json', '**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php-versions }}- + + - name: Install dependencies + run: | + if [ -f composer.lock ]; then + composer install --prefer-dist --no-progress --no-interaction + else + composer update --prefer-dist --no-progress --no-interaction + fi + + - name: Run test suite + run: vendor/bin/phpunit diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index acbda73..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,7 +0,0 @@ -checks: - php: - code_rating: true - duplication: true - -tools: - external_code_coverage: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 27005b6..0000000 --- a/.travis.yml +++ /dev/null @@ -1,27 +0,0 @@ -language: php - -php: - - 5.6 - - 7.0 - - 7.1 - - hhvm - -matrix: - allow_failures: - - php: hhvm - include: - - php: 5.6 - env: - - COMPOSER_FLAGS="--prefer-lowest --prefer-stable" - - COVERAGE=true - - PHPUNIT_FLAGS="--coverage-clover=coverage.clover" - -install: - - travis_retry composer update ${COMPOSER_FLAGS} --prefer-source --no-interaction - -script: - - vendor/bin/phpunit ${PHPUNIT_FLAGS} - -after_script: - - if [[ "$COVERAGE" = true ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi - - if [[ "$COVERAGE" = true ]]; then php ocular.phar code-coverage:upload --format=php-clover coverage.clover; fi diff --git a/README.md b/README.md index a3833ba..6f4cc37 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,27 @@ # Port DBAL adapter [![Latest Version](https://img.shields.io/github/release/portphp/dbal.svg?style=flat-square)](https://github.com/portphp/dbal/releases) -[![Build Status](https://travis-ci.org/portphp/dbal.svg)](https://travis-ci.org/portphp/dbal) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/portphp/dbal/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/portphp/dbal/?branch=master) -[![Code Coverage](https://scrutinizer-ci.com/g/portphp/dbal/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/portphp/dbal/?branch=master) +[![CI](https://github.com/portphp/dbal/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/portphp/dbal/actions/workflows/test.yml) +[![PHP Version](https://img.shields.io/packagist/php-v/portphp/dbal.svg?style=flat-square)](https://packagist.org/packages/portphp/dbal) + +**Requirements:** PHP ^8.2 (tested on 8.2–8.5). Requires `portphp/portphp` ^2.0 and `doctrine/dbal` ^3 or ^4. Doctrine DBAL adapter for [Port](https://github.com/portphp). ## Installation -Open a command console, enter your project directory and execute the -following command to download the latest stable version of this package: - ```bash $ composer require portphp/dbal ``` -This command requires you to have Composer installed globally, as explained -in the [installation chapter](https://getcomposer.org/doc/00-intro.md) -of the Composer documentation. - ## Documentation -Documentation is available at https://portphp.readthedocs.io/. +Documentation is available at https://portphp.readthedocs.io. ## Issues and feature requests Please report issues and request features at https://github.com/portphp/portphp/issues. -## Contributing - -Contributions are very welcome. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for -details. Thanks to [everyone who has contributed](https://github.com/portphp/dbal/graphs/contributors) -already. - -## Security - -If you discover any security-related issues, please contact us at -[security@portphp.org](mailto:security@portphp.org). - ## License This package is licensed under the [MIT license](LICENSE). diff --git a/UPGRADE-2.0.md b/UPGRADE-2.0.md new file mode 100644 index 0000000..638553e --- /dev/null +++ b/UPGRADE-2.0.md @@ -0,0 +1,8 @@ +# Upgrade from 1.x to 2.0 + +- Minimum PHP is **8.2** (`^8.2`). +- Requires `portphp/portphp` **^2.0**. +- Requires `doctrine/dbal` **^3.0 || ^4.0** (DBAL 2.x is no longer supported). +- `DbalReader` uses the DBAL 3/4 Result API (`executeQuery` / `fetchAssociative`). +- `DbalReaderFactory` no longer implements the file-based `ReaderFactory` interface (it is a SQL factory). +- CI is GitHub Actions (Travis/Scrutinizer removed). diff --git a/composer.json b/composer.json index 07e7ee5..80855ac 100644 --- a/composer.json +++ b/composer.json @@ -14,17 +14,18 @@ }, { "name": "Community contributors", - "homepage": "https://github.com/portphp/dbal-adapter/graphs/contributors" + "homepage": "https://github.com/portphp/dbal/graphs/contributors" } ], "support": { "issues": "https://github.com/portphp/portphp/issues", - "source": "https://github.com/portphp/dbal-adapter", - "docs": "https://docs.portphp.org" + "source": "https://github.com/portphp/dbal", + "docs": "https://portphp.readthedocs.io" }, "require": { - "portphp/portphp": "^1.0.0", - "doctrine/dbal": "^2.4" + "php": "^8.2", + "portphp/portphp": "^2.0", + "doctrine/dbal": "^3.0 || ^4.0" }, "autoload": { "psr-4": { @@ -32,9 +33,8 @@ } }, "require-dev": { - "ext-sqlite3": "*", - "phpunit/phpunit": "^4.0", - "phpspec/phpspec": "^2.1" + "ext-pdo_sqlite": "*", + "phpunit/phpunit": "^9.6" }, "autoload-dev": { "psr-4": { @@ -43,7 +43,7 @@ }, "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "2.0.x-dev" } } } diff --git a/phpspec.yml.dist b/phpspec.yml.dist deleted file mode 100644 index e1fe828..0000000 --- a/phpspec.yml.dist +++ /dev/null @@ -1,5 +0,0 @@ -suites: - library_suite: - namespace: Port\Dbal - psr4_prefix: Port\Dbal -formatter.name: pretty diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f2c9ad5..8ca7a88 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,20 +1,12 @@ - - + beStrictAboutOutputDuringTests="true"> - - ./tests/ + + tests diff --git a/spec/DbalReaderFactorySpec.php b/spec/DbalReaderFactorySpec.php deleted file mode 100644 index 01b163d..0000000 --- a/spec/DbalReaderFactorySpec.php +++ /dev/null @@ -1,24 +0,0 @@ -beConstructedWith($dbal); - } - - function it_is_initializable() - { - $this->shouldHaveType('Port\Dbal\DbalReaderFactory'); - } - - function it_creates_a_reader() - { - $this->getReader('SQL', [])->shouldHaveType('Port\Dbal\DbalReader'); - } -} diff --git a/src/DbalReader.php b/src/DbalReader.php index a113f0b..f6bbbb0 100644 --- a/src/DbalReader.php +++ b/src/DbalReader.php @@ -3,7 +3,7 @@ namespace Port\Dbal; use Doctrine\DBAL\Connection; -use Doctrine\DBAL\Statement; +use Doctrine\DBAL\Result; use Port\Reader\CountableReader; /** @@ -11,202 +11,108 @@ */ class DbalReader implements CountableReader { - /** - * @var Connection - */ - private $connection; - - /** - * @var array - */ - private $data; - - /** - * @var Statement - */ - private $stmt; - - /** - * @var string - */ - private $sql; - - /** - * @var array - */ - private $params; - - /** - * @var integer - */ - private $rowCount; - - /** - * @var boolean - */ - private $rowCountCalculated = true; - - /** - * @var string - */ - private $key; - - /** - * @param Connection $connection - * @param string $sql - * @param array $params - */ - public function __construct(Connection $connection, $sql, array $params = []) + private Connection $connection; + + private array|false|null $data = null; + + private ?Result $result = null; + + private string $sql = ''; + + private array $params = []; + + private ?int $rowCount = null; + + private bool $rowCountCalculated = true; + + private int $key = 0; + + public function __construct(Connection $connection, string $sql, array $params = []) { $this->connection = $connection; - $this->setSql($sql, $params); } - /** - * Do calculate row count? - * - * @param boolean $calculate - */ - public function setRowCountCalculated($calculate = true) + public function setRowCountCalculated(bool $calculate = true): void { - $this->rowCountCalculated = (bool) $calculate; + $this->rowCountCalculated = $calculate; } - /** - * Is row count calculated? - * - * @return boolean - */ - public function isRowCountCalculated() + public function isRowCountCalculated(): bool { return $this->rowCountCalculated; } - /** - * Set Query string with Parameters - * - * @param string $sql - * @param array $params - */ - public function setSql($sql, array $params = []) + public function setSql(string $sql, array $params = []): void { - $this->sql = (string) $sql; - + $this->sql = $sql; $this->setSqlParameters($params); } - /** - * Set SQL parameters - * - * @param array $params - */ - public function setSqlParameters(array $params) + public function setSqlParameters(array $params): void { $this->params = $params; - - $this->stmt = null; + $this->result = null; $this->rowCount = null; + $this->data = null; + $this->key = 0; } - /** - * {@inheritdoc} - */ - public function current() + public function current(): mixed { - if (null === $this->data) { + if (null === $this->data && null === $this->result) { $this->rewind(); } return $this->data; } - /** - * {@inheritdoc} - */ - public function next() + public function next(): void { $this->key++; - $this->data = $this->stmt->fetch(\PDO::FETCH_ASSOC); + $this->data = $this->result?->fetchAssociative() ?: false; } - /** - * {@inheritdoc} - */ - public function key() + public function key(): mixed { return $this->key; } - /** - * {@inheritdoc} - */ - public function valid() + public function valid(): bool { - if (null === $this->data) { + if (null === $this->data && null === $this->result) { $this->rewind(); } - return (false !== $this->data); + return false !== $this->data; } - /** - * {@inheritdoc} - */ - public function rewind() + public function rewind(): void { - if (null === $this->stmt) { - $this->stmt = $this->prepare($this->sql, $this->params); - } - if (0 !== $this->key) { - $this->stmt->execute(); - $this->data = $this->stmt->fetch(\PDO::FETCH_ASSOC); - $this->key = 0; - } + $this->result = $this->connection->executeQuery($this->sql, $this->params); + $this->data = $this->result->fetchAssociative(); + $this->key = 0; } - /** - * {@inheritdoc} - */ - public function count() + public function count(): int { if (null === $this->rowCount) { if ($this->rowCountCalculated) { $this->doCalcRowCount(); } else { - if (null === $this->stmt) { + if (null === $this->result) { $this->rewind(); } - $this->rowCount = $this->stmt->rowCount(); + $this->rowCount = $this->result?->rowCount() ?? 0; } } return $this->rowCount; } - private function doCalcRowCount() + private function doCalcRowCount(): void { - $statement = $this->prepare(sprintf('SELECT COUNT(*) FROM (%s) AS port_cnt', $this->sql), $this->params); - $statement->execute(); - - $this->rowCount = (int) $statement->fetchColumn(0); - } - - /** - * Prepare given statement - * - * @param string $sql - * @param array $params - * - * @return Statement - */ - private function prepare($sql, array $params) - { - $statement = $this->connection->prepare($sql); - foreach ($params as $key => $value) { - $statement->bindValue($key, $value); - } - - return $statement; + $countSql = sprintf('SELECT COUNT(*) FROM (%s) AS port_cnt', $this->sql); + $this->rowCount = (int) $this->connection->fetchOne($countSql, $this->params); } } diff --git a/src/DbalReaderFactory.php b/src/DbalReaderFactory.php index b69e906..76c1726 100644 --- a/src/DbalReaderFactory.php +++ b/src/DbalReaderFactory.php @@ -3,35 +3,18 @@ namespace Port\Dbal; use Doctrine\DBAL\Connection; -use Port\Reader\ReaderFactory; /** * Factory that creates DbalReaders - * - * @author David de Boer */ -class DbalReaderFactory implements ReaderFactory +class DbalReaderFactory { - /** - * @var Connection - */ - protected $connection; - - /** - * @param Connection $connection - */ - public function __construct(Connection $connection) - { - $this->connection = $connection; + public function __construct( + private Connection $connection + ) { } - /** - * @param string $sql - * @param array $params - * - * @return DbalReader - */ - public function getReader($sql, array $params = []) + public function getReader(string $sql, array $params = []): DbalReader { return new DbalReader($this->connection, $sql, $params); } diff --git a/tests/DbalReaderTest.php b/tests/DbalReaderTest.php new file mode 100644 index 0000000..902c2be --- /dev/null +++ b/tests/DbalReaderTest.php @@ -0,0 +1,117 @@ +assertSame(10, $this->getReader()->count()); + } + + public function testCountInhibited(): void + { + $reader = $this->getReader(); + $reader->setRowCountCalculated(false); + + // SQLite SELECT rowCount is often 0; ensure method is callable + $this->assertIsInt($reader->count()); + } + + public function testIterate(): void + { + $i = 31; + foreach ($this->getReader() as $key => $row) { + $this->assertIsArray($row); + $this->assertSame('user-'.$i, $row['username']); + $this->assertSame($i - 31, $key); + $i++; + } + + $this->assertSame(41, $i); + } + + public function testReaderRewindWorksCorrectly(): void + { + $reader = $this->getReader(); + foreach ($reader as $row) { + if (($row['username'] ?? null) === 'user-35') { + break; + } + } + + $reader->rewind(); + + $this->assertSame([ + 'id' => 31, + 'username' => 'user-31', + 'name' => 'name 4', + ], $reader->current()); + } + + public function testCallingCurrentTwiceShouldNotAdvance(): void + { + $reader = $this->getReader(); + $first = $reader->current(); + $second = $reader->current(); + $this->assertSame($first, $second); + } + + private function getConnection() + { + $connection = DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]); + + $schema = new Schema(); + $table = $schema->createTable('groups'); + $table->addColumn('id', 'integer', ['autoincrement' => true]); + $table->addColumn('name', 'string', ['length' => 45]); + $table->setPrimaryKey(['id']); + + $user = $schema->createTable('user'); + $user->addColumn('id', 'integer', ['autoincrement' => true]); + $user->addColumn('username', 'string', ['length' => 32]); + $user->addColumn('group_id', 'integer'); + $user->setPrimaryKey(['id']); + + $platform = $connection->getDatabasePlatform(); + foreach ($schema->toSql($platform) as $query) { + $connection->executeStatement($query); + } + + $counter = 1; + for ($i = 1; $i <= 10; $i++) { + $connection->insert('groups', ['name' => "name {$i}"]); + $id = $connection->lastInsertId(); + for ($j = 1; $j <= 10; $j++) { + $connection->insert('user', [ + 'username' => "user-{$counter}", + 'group_id' => $id, + ]); + $counter++; + } + } + + return $connection; + } + + private function getReader(): DbalReader + { + $connection = $this->getConnection(); + + return new DbalReader($connection, implode(' ', [ + 'SELECT u.id, u.username, g.name', + 'FROM user u INNER JOIN groups g ON u.group_id = g.id', + 'WHERE g.name LIKE :name', + ]), [ + 'name' => 'name 4', + ]); + } +} diff --git a/tests/Reader/DbalReaderTest.php b/tests/Reader/DbalReaderTest.php deleted file mode 100644 index 280d5dd..0000000 --- a/tests/Reader/DbalReaderTest.php +++ /dev/null @@ -1,193 +0,0 @@ -getReader(); - - $reader->setRowCountCalculated(); - $this->assertTrue($reader->isRowCountCalculated()); - - $reader->setRowCountCalculated(false); - $this->assertFalse($reader->isRowCountCalculated()); - - $reader->setRowCountCalculated(true); - $this->assertTrue($reader->isRowCountCalculated()); - } - - public function testCount() - { - $this->assertEquals(10, $this->getReader()->count()); - } - - public function testCountInhibited() - { - $reader = $this->getReader(); - $reader->setRowCountCalculated(false); - - $this->assertEquals(null, $reader->count()); - } - - public function testSqlAndParamsAreMutable() - { - $reader = $this->getReader(); - - $reader->setSql('SELECT * FROM groups WHERE id = :id', array('id' => 2)); - $this->assertAttributeEquals('SELECT * FROM groups WHERE id = :id', 'sql', $reader); - $this->assertAttributeEquals(array('id' => 2), 'params', $reader); - } - - public function testChangeSqlOrParamsClearsNumRowsAndStatement() - { - $reader = $this->getReader(); - $reader->count(); - $reader->current(); - - $this->assertAttributeNotEmpty('rowCount', $reader); - $this->assertAttributeNotEmpty('stmt', $reader); - - $reader->setSql('SELECT * FROM `user` WHERE id IN (:id)', array('id' => array())); - - $this->assertAttributeEmpty('rowCount', $reader); - $this->assertAttributeEmpty('stmt', $reader); - } - - public function testIterate() - { - $i=31; - foreach ($this->getReader() as $key => $row) { - $this->assertInternalType('array', $row); - $this->assertEquals('user-'.$i, $row['username']); - $this->assertEquals($i - 31, $key); - $i++; - } - - $this->assertEquals(41, $i); - } - - public function testReaderRewindWorksCorrectly() - { - $reader = $this->getReader(); - foreach ($reader as $row) { - if (!isset($row['username'])) { - $this->fail('There should be a username'); - } - if ($row['username'] == 'user-35') { - break; - } - } - - $reader->rewind(); - - $this->assertEquals(array( - 'id' => 31, - 'username' => 'user-31', - 'name' => 'name 4', - ), $reader->current()); - } - - public function testCallingCurrentTwiceShouldNotAdvance() - { - $reader = $this->getReader(); - - $expected = array( - 'id' => 31, - 'username' => 'user-31', - 'name' => 'name 4', - ); - $this->assertEquals($expected, $reader->current()); - $this->assertEquals($expected, $reader->current()); - } - - public function testEmptyResultDoesNotThrowException() - { - $reader = $this->getReader(); - - $reader->setSql(null, array('name' => 'unknown group')); - $this->assertFalse($reader->current()); - } - - public function testCallValidRewindsIfNeeded() - { - $reader = $this->getReader(); - - $this->assertTrue($reader->valid()); - $this->assertAttributeInternalType('array', 'data', $reader); - } - - public function getConnection() - { - $params = array( - 'driver' => 'pdo_sqlite', - 'memory' => true, - ); - - $connection = DriverManager::getConnection($params, new Configuration()); - - $schema = new Schema(); - - $table = $schema->createTable('groups'); - $table->addColumn('id', 'integer', ['autoincrement' => true]); - $table->addColumn('name', 'string', array('length' => 45)); - $table->setPrimaryKey(array('id')); - - $myTable = $schema->createTable('user'); - $myTable->addColumn('id', 'integer', array('unsigned' => true, 'autoincrement' => true)); - $myTable->addColumn('username', 'string', array('length' => 32)); - $myTable->addColumn('group_id', 'integer'); - $myTable->setPrimaryKey(array('id')); - $myTable->addUniqueIndex(array('username')); - $myTable->addForeignKeyConstraint($table, array('group_id'), array('id')); - - foreach ($schema->toSql(new SqlitePlatform()) as $query) { - $connection->query($query); - }; - - return $connection; - } - - protected function getReader() - { - $connection = $this->getConnection(); - $this->loadFixtures($connection); - - return new DbalReader($connection, implode(' ', array( - 'SELECT u.id, u.username, g.name', - 'FROM `user` u INNER JOIN groups g ON u.group_id = g.id', - 'WHERE g.name LIKE :name', - )), array( - 'name' => 'name 4', - )); - } - - /** - * @param Connection $connection - */ - protected function loadFixtures($connection) - { - $counter = 1; - for ($i = 1; $i <= 10; $i++) { - $connection->insert('groups', array('name' => "name {$i}")); - $id = $connection->lastInsertId(); - - for ($j = 1; $j <= 10; $j++) { - $connection->insert('user', array( - 'username' => "user-{$counter}", - 'group_id' => $id, - )); - - $counter++; - } - } - } -}