diff --git a/.gitattributes b/.gitattributes index ec6cd3f..5d358e0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,4 @@ /.github/ export-ignore /test/ export-ignore /phpunit.xml export-ignore -/psalm.xml export-ignore +/phpstan.neon export-ignore diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 4ed43c5..f993045 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -7,7 +7,7 @@ jobs: strategy: matrix: - php: [ '8.1', '8.2', '8.3' ] + php: [ '8.1', '8.2', '8.3', '8.4' ] services: mysql: @@ -38,18 +38,19 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - tools: psalm - extensions: sqlsrv-5.10.1 - name: Setup problem matchers for PHPUnit run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + - name: Validate composer.json and composer.lock + run: composer validate --strict + - name: Install Composer dependencies run: composer install --no-progress - - name: Run Psalm - run: psalm --output-format=github - if: ${{ matrix.php == '8.3' }} + - name: Perform static analysis + run: composer analyze -- --error-format=github + if: ${{ matrix.php == '8.4' }} - name: Run PHPUnit run: composer test-without-mssql diff --git a/.gitignore b/.gitignore index 47bcc6d..735e57d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ /vendor/ /composer.lock -/test/src/LocalConfig.php +/test/config.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4ae950..9bf2ada 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,8 +11,8 @@ From a console in the working directory, execute `composer test` to run all unit > [!NOTE] > By default, database tests will attempt to run on a database named `PeachySQL`. -> To override connection settings, create a `LocalConfig.php` class in the `test/src` -> directory which extends `Config` and overrides the desired methods. +> To override connection settings, create a `test/config.php` file which returns +> an instance of `Config` with the desired property values. ## Formatting and static analysis diff --git a/composer.json b/composer.json index 18333a9..f052a57 100644 --- a/composer.json +++ b/composer.json @@ -21,10 +21,9 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.64", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^10.5", - "psalm/plugin-phpunit": "^0.19", - "ramsey/uuid": "^4.2.3", - "vimeo/psalm": "^5.26" + "ramsey/uuid": "^4.2.3" }, "autoload": { "psr-4": { @@ -40,7 +39,7 @@ "sort-packages": true }, "scripts": { - "analyze": "psalm", + "analyze": "phpstan analyze", "cs-fix": "php-cs-fixer fix -v", "test": "phpunit", "test-mssql": "phpunit --exclude-group mysql,pgsql", diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..6042026 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 10 + paths: + - src + - test diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 59a5961..0000000 --- a/psalm.xml +++ /dev/null @@ -1,22 +0,0 @@ -<?xml version="1.0"?> -<psalm - errorLevel="1" - resolveFromConfigFile="true" - findUnusedBaselineEntry="true" - findUnusedCode="true" - findUnusedPsalmSuppress="false" - xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" - xmlns="https://getpsalm.org/schema/config" - xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" -> - <projectFiles> - <directory name="src"/> - <directory name="test"/> - <ignoreFiles> - <directory name="vendor"/> - </ignoreFiles> - </projectFiles> - <plugins> - <pluginClass class="Psalm\PhpUnitPlugin\Plugin"/> - </plugins> -</psalm> diff --git a/src/Options.php b/src/Options.php index 7ad05a7..08055f4 100644 --- a/src/Options.php +++ b/src/Options.php @@ -50,7 +50,10 @@ public function __construct( } elseif ($this->driver === 'pgsql') { $this->binarySelectedAsStream = true; $this->nativeBoolColumns = true; - $this->floatSelectedAsString = true; + + if (PHP_VERSION_ID < 80_400) { + $this->floatSelectedAsString = true; + } } } } diff --git a/src/PeachySql.php b/src/PeachySql.php index 587fad2..cff208f 100644 --- a/src/PeachySql.php +++ b/src/PeachySql.php @@ -36,6 +36,7 @@ public function __construct(PDO $connection, ?Options $options = null) public function begin(): void { if (!$this->conn->beginTransaction()) { + /** @phpstan-ignore argument.type */ throw $this->getError('Failed to begin transaction', $this->conn->errorInfo()); } } @@ -47,6 +48,7 @@ public function begin(): void public function commit(): void { if (!$this->conn->commit()) { + /** @phpstan-ignore argument.type */ throw $this->getError('Failed to commit transaction', $this->conn->errorInfo()); } } @@ -58,6 +60,7 @@ public function commit(): void public function rollback(): void { if (!$this->conn->rollback()) { + /** @phpstan-ignore argument.type */ throw $this->getError('Failed to roll back transaction', $this->conn->errorInfo()); } } @@ -72,10 +75,12 @@ final public function makeBinaryParam(?string $binaryStr): array return [$binaryStr, PDO::PARAM_LOB, 0, $driverOptions]; } - /** @internal */ + /** + * @param array{0: string, 1: int|null, 2: string|null} $error + * @internal + */ public static function getError(string $message, array $error): SqlException { - /** @var array{0: string, 1: int|null, 2: string|null} $error */ $code = $error[1] ?? 0; $details = $error[2] ?? ''; $sqlState = $error[0]; @@ -84,18 +89,19 @@ public static function getError(string $message, array $error): SqlException } /** - * Returns a prepared statement which can be executed multiple times + * Returns a prepared statement which can be executed multiple times. + * @param list<mixed> $params * @throws SqlException if an error occurs */ public function prepare(string $sql, array $params = []): Statement { try { if (!$stmt = $this->conn->prepare($sql)) { + /** @phpstan-ignore argument.type */ throw $this->getError('Failed to prepare statement', $this->conn->errorInfo()); } $i = 0; - /** @psalm-suppress MixedAssignment */ foreach ($params as &$param) { $i++; @@ -111,6 +117,7 @@ public function prepare(string $sql, array $params = []): Statement } } } catch (\PDOException $e) { + /** @phpstan-ignore argument.type */ throw $this->getError('Failed to prepare statement', $this->conn->errorInfo()); } @@ -118,7 +125,8 @@ public function prepare(string $sql, array $params = []): Statement } /** - * Prepares and executes a single query with bound parameters + * Prepares and executes a single query with bound parameters. + * @param list<mixed> $params */ public function query(string $sql, array $params = []): Statement { diff --git a/src/QueryBuilder/Insert.php b/src/QueryBuilder/Insert.php index 57ded63..4b12259 100644 --- a/src/QueryBuilder/Insert.php +++ b/src/QueryBuilder/Insert.php @@ -29,6 +29,7 @@ public static function batchRows(array $colVals, int $maxBoundParams, int $maxRo $maxRowsPerQuery = $maxRows; } + /** @phpstan-ignore argument.type */ return array_chunk($colVals, $maxRowsPerQuery); } @@ -38,25 +39,18 @@ public static function batchRows(array $colVals, int $maxBoundParams, int $maxRo */ public function buildQuery(string $table, array $colVals): SqlParams { - self::validateColValsStructure($colVals); + if (!$colVals || empty($colVals[0])) { + throw new \Exception('A valid array of columns/values to insert must be specified'); + } $columns = $this->escapeColumns(array_keys($colVals[0])); $insert = "INSERT INTO {$table} (" . implode(', ', $columns) . ')'; $valSetStr = ' (' . str_repeat('?,', count($columns) - 1) . '?),'; $valStr = ' VALUES' . substr_replace(str_repeat($valSetStr, count($colVals)), '', -1); // remove trailing comma - $params = array_merge(...array_map('array_values', $colVals)); + /** @phpstan-ignore argument.type */ + $params = array_merge(...array_map(array_values(...), $colVals)); return new SqlParams($insert . $valStr, $params); } - - /** - * @throws \Exception if the column/values array does not have a valid structure - */ - private static function validateColValsStructure(array $colVals): void - { - if (empty($colVals[0]) || !is_array($colVals[0])) { - throw new \Exception('A valid array of columns/values to insert must be specified'); - } - } } diff --git a/src/QueryBuilder/Select.php b/src/QueryBuilder/Select.php index 2dbe383..99c0e5d 100644 --- a/src/QueryBuilder/Select.php +++ b/src/QueryBuilder/Select.php @@ -8,6 +8,7 @@ class Select extends Query { /** + * @param string[] $orderBy * @throws \Exception if there is an invalid sort direction */ public function buildOrderByClause(array $orderBy): string @@ -19,12 +20,10 @@ public function buildOrderByClause(array $orderBy): string $sql = ' ORDER BY '; // [column1, column2, ...] - if (isset($orderBy[0])) { - /** @var array<int, string> $orderBy */ + if (array_is_list($orderBy)) { return $sql . implode(', ', $this->escapeColumns($orderBy)); } - /** @var array<string, string> $orderBy */ // [column1 => direction, column2 => direction, ...] foreach ($orderBy as $column => $direction) { $column = $this->escapeIdentifier($column); diff --git a/src/QueryBuilder/Selector.php b/src/QueryBuilder/Selector.php index 353d04e..c3d323f 100644 --- a/src/QueryBuilder/Selector.php +++ b/src/QueryBuilder/Selector.php @@ -11,6 +11,9 @@ class Selector { /** @var WhereClause */ private array $where = []; + /** + * @var string[] + */ private array $orderBy = []; private ?int $limit = null; private ?int $offset = null; @@ -35,6 +38,7 @@ public function where(array $filter): static } /** + * @param mixed[] $sort * @throws \Exception if called more than once */ public function orderBy(array $sort): static @@ -43,6 +47,13 @@ public function orderBy(array $sort): static throw new \Exception('orderBy method can only be called once'); } + foreach ($sort as $val) { + if (!is_string($val)) { + throw new \Exception('Invalid type for sort value: ' . get_debug_type($val)); + } + } + + /** @var string[] $sort */ $this->orderBy = $sort; return $this; } diff --git a/src/QueryBuilder/Update.php b/src/QueryBuilder/Update.php index a8b6258..deb4772 100644 --- a/src/QueryBuilder/Update.php +++ b/src/QueryBuilder/Update.php @@ -24,7 +24,6 @@ public function buildQuery(string $table, array $set, array $where): SqlParams $params = []; $sql = "UPDATE {$table} SET "; - /** @psalm-suppress MixedAssignment */ foreach ($set as $column => $value) { $sql .= $this->escapeIdentifier($column) . ' = ?, '; $params[] = $value; diff --git a/src/Statement.php b/src/Statement.php index 8849eaf..c86c649 100644 --- a/src/Statement.php +++ b/src/Statement.php @@ -36,9 +36,11 @@ public function execute(): void try { if (!$this->stmt->execute()) { + /** @phpstan-ignore argument.type */ throw PeachySql::getError('Failed to execute prepared statement', $this->stmt->errorInfo()); } } catch (PDOException $e) { + /** @phpstan-ignore argument.type */ throw PeachySql::getError('Failed to execute prepared statement', $this->stmt->errorInfo()); } @@ -61,15 +63,13 @@ public function execute(): void /** * Returns an iterator which can be used to loop through each row in the result - * @return \Generator<int, array> + * @return \Generator<int, mixed[]> */ public function getIterator(): \Generator { if ($this->stmt !== null) { - while ( - /** @var array|false $row */ - $row = $this->stmt->fetch(PDO::FETCH_ASSOC) - ) { + while ($row = $this->stmt->fetch(PDO::FETCH_ASSOC)) { + /** @phpstan-ignore generator.valueType */ yield $row; } @@ -94,7 +94,8 @@ public function close(): void } /** - * Returns all rows selected by the query + * Returns all rows selected by the query. + * @return mixed[] */ public function getAll(): array { @@ -102,12 +103,14 @@ public function getAll(): array } /** - * Returns the first selected row, or null if zero rows were returned + * Returns the first selected row, or null if zero rows were returned. + * @return mixed[]|null */ public function getFirst(): ?array { $row = $this->getIterator()->current(); + /** @phpstan-ignore notIdentical.alwaysTrue */ if ($row !== null) { $this->close(); // don't leave the SQL statement open } diff --git a/test/DbTestCase.php b/test/DbTestCase.php index 427d476..59b0f42 100644 --- a/test/DbTestCase.php +++ b/test/DbTestCase.php @@ -109,6 +109,10 @@ public function testBlob(): void $db = static::dbProvider(); $img = file_get_contents('test/DevTheorem.png'); + if ($img === false) { + throw new \Exception('Failed to read image'); + } + $id = $db->insertRow($this->table, [ 'name' => 'DevTheorem', 'dob' => '2024-10-24', @@ -122,7 +126,7 @@ public function testBlob(): void ->where(['user_id' => $id])->query()->getFirst(); if ($db->options->binarySelectedAsStream) { - /** @psalm-suppress PossiblyInvalidArgument */ + /** @phpstan-ignore argument.type */ $row['photo'] = stream_get_contents($row['photo']); } @@ -153,6 +157,11 @@ public function testIteratorQuery(): void $this->assertInstanceOf(\Generator::class, $iterator); $colValsCompare = []; + /** @var array{ + * user_id: int, name: string, dob: string, weight: string|float, + * is_disabled: int|bool, uuid: string|null|resource + * } $row + */ foreach ($iterator as $row) { unset($row['user_id']); @@ -163,7 +172,7 @@ public function testIteratorQuery(): void $row['is_disabled'] = (bool) $row['is_disabled']; } if ($options->binarySelectedAsStream && $row['uuid'] !== null) { - /** @psalm-suppress MixedArgument */ + /** @phpstan-ignore argument.type */ $row['uuid'] = stream_get_contents($row['uuid']); } @@ -186,7 +195,6 @@ public function testIteratorQuery(): void foreach ($realNames as $_row) { $_id = $_row['user_id']; $_name = $_row['name']; - /** @psalm-suppress MixedArrayAssignment */ $_uuid[0] = $_row['uuid']; $stmt->execute(); } @@ -250,14 +258,14 @@ public function testInsertBulk(): void if ($options->binarySelectedAsStream || $options->nativeBoolColumns || $options->floatSelectedAsString) { /** @var array{weight: float|string, is_disabled: int|bool, uuid: string|resource} $row */ foreach ($rows as &$row) { - if (!is_float($row['weight'])) { + if ($options->floatSelectedAsString) { $row['weight'] = (float) $row['weight']; } - if (!is_int($row['is_disabled'])) { + if ($options->nativeBoolColumns) { $row['is_disabled'] = (int) $row['is_disabled']; } - if (!is_string($row['uuid'])) { - /** @psalm-suppress InvalidArgument */ + if ($options->binarySelectedAsStream) { + /** @phpstan-ignore argument.type */ $row['uuid'] = stream_get_contents($row['uuid']); } } @@ -274,12 +282,13 @@ public function testInsertBulk(): void $userId = $ids[0]; $set = ['uuid' => $peachySql->makeBinaryParam($newUuid)]; $peachySql->updateRows($this->table, $set, ['user_id' => $userId]); - /** @var array{uuid: string|resource} $updatedRow */ $updatedRow = $peachySql->selectFrom("SELECT uuid FROM {$this->table}") ->where(['user_id' => $userId])->query()->getFirst(); - if (!is_string($updatedRow['uuid'])) { - $updatedRow['uuid'] = stream_get_contents($updatedRow['uuid']); // needed for PostgreSQL + if ($updatedRow === null) { + throw new \Exception('Failed to select updated UUID'); + } elseif ($options->binarySelectedAsStream && is_resource($updatedRow['uuid'])) { + $updatedRow['uuid'] = stream_get_contents($updatedRow['uuid']); } $this->assertSame($newUuid, $updatedRow['uuid']); diff --git a/test/MssqlDbTest.php b/test/MssqlDbTest.php index 920ff63..d695bf2 100644 --- a/test/MssqlDbTest.php +++ b/test/MssqlDbTest.php @@ -27,10 +27,10 @@ public static function dbProvider(): PeachySql { if (!self::$db) { $c = App::$config; - $server = $c->getMssqlServer(); + $server = $c->mssqlServer; $connStr = getenv('MSSQL_CONNECTION_STRING'); - $username = $c->getMssqlUsername(); - $password = $c->getMssqlPassword(); + $username = $c->mssqlUsername; + $password = $c->mssqlPassword; if ($connStr !== false) { // running tests with GitHub Actions diff --git a/test/MysqlDbTest.php b/test/MysqlDbTest.php index 7c71f5c..9828180 100644 --- a/test/MysqlDbTest.php +++ b/test/MysqlDbTest.php @@ -28,7 +28,7 @@ public static function dbProvider(): PeachySql if (!self::$db) { $c = App::$config; - $pdo = new PDO($c->getMysqlDsn(), $c->getMysqlUser(), $c->getMysqlPassword(), [ + $pdo = new PDO($c->mysqlDsn, $c->mysqlUser, $c->mysqlPassword, [ PDO::ATTR_EMULATE_PREPARES => false, ]); diff --git a/test/PgsqlDbTest.php b/test/PgsqlDbTest.php index 4e03d66..397b554 100644 --- a/test/PgsqlDbTest.php +++ b/test/PgsqlDbTest.php @@ -34,7 +34,7 @@ public static function dbProvider(): PeachySql $c = App::$config; $dbName = getenv('POSTGRES_HOST') !== false ? 'postgres' : 'PeachySQL'; - $pdo = new PDO($c->getPgsqlDsn($dbName), $c->getPgsqlUser(), $c->getPgsqlPassword(), [ + $pdo = new PDO($c->getPgsqlDsn($dbName), $c->pgsqlUser, $c->pgsqlPassword, [ PDO::ATTR_EMULATE_PREPARES => false, ]); diff --git a/test/QueryBuilder/InsertTest.php b/test/QueryBuilder/InsertTest.php index 196debd..571721d 100644 --- a/test/QueryBuilder/InsertTest.php +++ b/test/QueryBuilder/InsertTest.php @@ -55,6 +55,7 @@ public static function batchRowsTestCases(): array /** * @dataProvider batchRowsTestCases * @param list<array<string, string>> $colVals + * @param list<list<array<string, string>>> $expected */ public function testBatchRows(array $colVals, int $maxParams, int $maxRows, array $expected): void { diff --git a/test/bootstrap.php b/test/bootstrap.php index 3a18502..5071992 100644 --- a/test/bootstrap.php +++ b/test/bootstrap.php @@ -1,13 +1,19 @@ <?php -use DevTheorem\PeachySQL\Test\src\{App, Config, LocalConfig}; +use DevTheorem\PeachySQL\Test\src\{App, Config}; require 'vendor/autoload.php'; -if (class_exists(LocalConfig::class)) { - // suppress error when LocalConfig doesn't exist - /** @psalm-suppress MixedAssignment */ - App::$config = new LocalConfig(); +$configFile = __DIR__ . '/config.php'; + +if (file_exists($configFile)) { + $config = require $configFile; + + if (!$config instanceof Config) { + throw new Exception('Expected config file to return Config instance'); + } + + App::$config = $config; } else { App::$config = new Config(); } diff --git a/test/src/Config.php b/test/src/Config.php index e408455..010f74d 100644 --- a/test/src/Config.php +++ b/test/src/Config.php @@ -2,53 +2,21 @@ namespace DevTheorem\PeachySQL\Test\src; -/** - * Default test config. Values can be overridden with a LocalConfig child class. - */ class Config { - public function getMysqlDsn(): string - { - return "mysql:host=127.0.0.1;port=3306;dbname=PeachySQL"; - } + public string $mssqlServer = '(local)\SQLEXPRESS'; + public string $mssqlUsername = ''; + public string $mssqlPassword = ''; - public function getMysqlUser(): string - { - return 'root'; - } + public string $mysqlDsn = 'mysql:host=127.0.0.1;port=3306;dbname=PeachySQL'; + public string $mysqlUser = 'root'; + public string $mysqlPassword = ''; - public function getMysqlPassword(): string - { - return ''; - } + public string $pgsqlUser = 'postgres'; + public string $pgsqlPassword = 'postgres'; public function getPgsqlDsn(string $database): string { return "pgsql:host=localhost;dbname=$database"; } - - public function getPgsqlUser(): string - { - return 'postgres'; - } - - public function getPgsqlPassword(): string - { - return 'postgres'; - } - - public function getMssqlServer(): string - { - return '(local)\SQLEXPRESS'; - } - - public function getMssqlUsername(): string - { - return ''; - } - - public function getMssqlPassword(): string - { - return ''; - } }