diff --git a/analysis-baseline.toml b/analysis-baseline.toml index 384d919..b2fc9a1 100644 --- a/analysis-baseline.toml +++ b/analysis-baseline.toml @@ -296,9 +296,45 @@ count = 1 [[issues]] file = "src/Metadata/Source.php" -code = "mixed-array-assignment" -message = "Unsafe array assignment on type `mixed`." -count = 9 +code = "impossible-condition" +message = "This condition (type `false`) will always evaluate to false." +count = 1 + +[[issues]] +file = "src/Metadata/Source.php" +code = "impossible-nonnull-entry-check" +message = "Impossible `isset` check on key `'constraint_names'` accessed on `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}`." +count = 1 + +[[issues]] +file = "src/Metadata/Source.php" +code = "invalid-property-assignment-value" +message = "Invalid type for property `$data`: expected `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}`, but got `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_names': non-empty-list>>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}`." +count = 1 + +[[issues]] +file = "src/Metadata/Source.php" +code = "invalid-property-assignment-value" +message = "Invalid type for property `$data`: expected `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}`, but got `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints': non-empty-array, 'constraint_name'?: string, 'constraint_type'?: string, 'delete_rule'?: null|string, 'match_option'?: null|string, 'referenced_columns'?: list, 'referenced_table_name'?: null|string, 'referenced_table_schema'?: null|string, 'table_name'?: string, 'update_rule'?: null|string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}`." +count = 1 + +[[issues]] +file = "src/Metadata/Source.php" +code = "invalid-property-assignment-value" +message = "Invalid type for property `$data`: expected `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}`, but got `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers': non-empty-array>}`." +count = 1 + +[[issues]] +file = "src/Metadata/Source.php" +code = "mixed-property-type-coercion" +message = "A value with a less specific type `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys': non-empty-array|array{'column_name': mixed, 'constraint_name': mixed, 'ordinal_position': mixed, 'table_name': mixed}>>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}` is being assigned to property `$data` (array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>})." +count = 1 + +[[issues]] +file = "src/Metadata/Source.php" +code = "mixed-property-type-coercion" +message = "A value with a less specific type `array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references': non-empty-array|array{'constraint_name': mixed, 'delete_rule': mixed, 'referenced_column_name': mixed, 'referenced_table_name': mixed, 'update_rule': mixed}>>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>}` is being assigned to property `$data` (array{'columns'?: array, 'is_nullable': bool|null, 'numeric_precision': int|null|string, 'numeric_scale': int|null|string, 'numeric_unsigned': bool|null, 'ordinal_position': int|null|string}>>>, 'constraint_keys'?: array>, 'constraint_references'?: array>, 'constraints'?: array, 'constraint_type'?: string, 'delete_rule'?: string, 'match_option'?: string, 'referenced_columns'?: list, 'referenced_table_name'?: string, 'referenced_table_schema'?: string, 'update_rule'?: string}>>>, 'schemas'?: list, 'table_names'?: array>, 'triggers'?: array>})." +count = 1 [[issues]] file = "src/Metadata/Source.php" @@ -356,20 +392,26 @@ count = 2 [[issues]] file = "src/Metadata/Source.php" -code = "reference-constraint-violation" -message = "Invalid assignment to by-reference parameter `$c`." -count = 7 +code = "redundant-comparison" +message = "Redundant `!==` comparison: left-hand side is never identical to (always false for !==) right-hand side." +count = 1 [[issues]] file = "src/Metadata/Source.php" -code = "too-many-arguments" -message = 'Too many arguments provided for method `PhpDb\Metadata\Source\AbstractSource::prepareDataHierarchy`.' -count = 6 +code = "redundant-null-coalesce" +message = "Redundant null coalesce: left-hand side is always `null`." +count = 1 + +[[issues]] +file = "src/Metadata/Source.php" +code = "reference-constraint-violation" +message = "Invalid assignment to by-reference parameter `$c`." +count = 7 [[issues]] file = "src/Metadata/Source.php" code = "unused-method" -message = "Method `loadconstraintdatanames()` is never used." +message = "Method `loadConstraintDataNames()` is never used." count = 1 [[issues]] @@ -426,202 +468,16 @@ code = "missing-constructor" message = 'Class `PhpDb\Mysql\Result` has typed properties without default values but no constructor to initialize them.' count = 1 -[[issues]] -file = "src/Result.php" -code = "possibly-null-array-index" -message = "Possibly using `null` as an array index to access element." -count = 1 - -[[issues]] -file = "src/Result.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array index accessed on `list`." -count = 1 - -[[issues]] -file = "src/Result.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array index accessed on `list`." -count = 1 - -[[issues]] -file = "src/Result.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `non-negative-int` accessed on `array`." -count = 1 - [[issues]] file = "src/Result.php" code = "unused-property" message = "Property `$numberOfRows` is never used." count = 1 -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "invalid-return-statement" -message = 'Invalid return type for function `PhpDb\Mysql\Sql\Ddl\AlterTableDecorator::processChangeColumns`: expected `array>`, but found `list{array{}|non-empty-list}`.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "less-specific-argument" -message = 'Argument type mismatch for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifier`: expected `string`, but provided type `array-key` is less specific.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "mixed-argument" -message = 'Invalid argument type for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteIdentifier`: expected `string`, but found `truthy-mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "mixed-argument" -message = 'Invalid argument type for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteValue`: expected `string`, but found `truthy-mixed`.' -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "mixed-argument" -message = 'Invalid argument type for argument #1 of `PhpDb\Sql\AbstractSql::processExpression`: expected `PhpDb\Sql\ExpressionInterface`, but found `mixed`.' -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "mixed-argument" -message = "Invalid argument type for argument #1 of `strtoupper`: expected `string`, but found `truthy-mixed`." -count = 4 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "mixed-argument" -message = "Invalid argument type for argument #1 of `uksort`: expected `array`, but found `mixed`." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 6 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "mixed-method-access" -message = "Attempting to access a method on a non-object type (`mixed`)." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "possibly-null-argument" -message = "Argument #3 of function `substr_replace` is possibly `null`, but parameter type `array|int` does not accept it." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "possibly-null-operand" -message = "Left operand in arithmetic operation might be `null` (type `int|null`)." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `int(0)|int(1)|int(2)` accessed on `array{0: int, 1: int, 2: int, 3: int, ...}`." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `int(0)|int(1)|int(2)` accessed on `array{0: int, 1: int, 2: int, 3: int}`." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `non-negative-int` accessed on `array{0: int, 1: int, 2: int, 3: int, ...}`." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/AlterTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `non-negative-int` accessed on `array{0: int, 1: int, 2: int, 3: int}`." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "mixed-argument" -message = 'Invalid argument type for argument #1 of `PhpDb\Adapter\Platform\PlatformInterface::quoteValue`: expected `string`, but found `truthy-mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "mixed-argument" -message = 'Invalid argument type for argument #1 of `PhpDb\Sql\AbstractSql::processExpression`: expected `PhpDb\Sql\ExpressionInterface`, but found `mixed`.' -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "mixed-argument" -message = "Invalid argument type for argument #1 of `strtoupper`: expected `string`, but found `truthy-mixed`." -count = 2 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "mixed-argument" -message = "Invalid argument type for argument #1 of `uksort`: expected `array`, but found `mixed`." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "mixed-assignment" -message = "Assigning `mixed` type to a variable may lead to unexpected behavior." -count = 3 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "mixed-method-access" -message = "Attempting to access a method on a non-object type (`mixed`)." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "possibly-null-argument" -message = "Argument #3 of function `substr_replace` is possibly `null`, but parameter type `array|int` does not accept it." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "possibly-null-operand" -message = "Left operand in arithmetic operation might be `null` (type `int|null`)." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `int(0)|int(1)|int(2)` accessed on `array{0: int, 1: int, 2: int, 3: int, ...}`." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `int(0)|int(1)|int(2)` accessed on `array{0: int, 1: int, 2: int, 3: int}`." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `non-negative-int` accessed on `array{0: int, 1: int, 2: int, 3: int, ...}`." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/CreateTableDecorator.php" -code = "possibly-undefined-int-array-index" -message = "Possibly undefined array key `non-negative-int` accessed on `array{0: int, 1: int, 2: int, 3: int}`." -count = 1 - [[issues]] file = "src/Sql/SelectDecorator.php" code = "invalid-property-assignment-value" -message = "Invalid type for property `$specifications`: expected `array>|array`, but got `array{'limit': string('LIMIT 18446744073709551615'), ...|string>}`." +message = "Invalid type for property `$specifications`: expected `array>|array`, but got `array{'limit': string('LIMIT 18446744073709551615'), ...>}|array{'limit': string('LIMIT 18446744073709551615'), ...}`." count = 1 [[issues]] diff --git a/composer.json b/composer.json index 55831d1..929684f 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ }, "require": { "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "php-db/phpdb": "^0.6.0" + "php-db/phpdb": "^0.6.x-dev" }, "require-dev": { "ext-mysqli": "*", diff --git a/composer.lock b/composer.lock index e95afe9..0453b9d 100644 --- a/composer.lock +++ b/composer.lock @@ -262,30 +262,29 @@ "source": { "type": "git", "url": "https://github.com/php-db/phpdb.git", - "reference": "e037464a435005e04a3fde3c113324c26406ce7e" + "reference": "b57f549b411d0b4fe46de8a469983816395be850" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-db/phpdb/zipball/e037464a435005e04a3fde3c113324c26406ce7e", - "reference": "e037464a435005e04a3fde3c113324c26406ce7e", + "url": "https://api.github.com/repos/php-db/phpdb/zipball/b57f549b411d0b4fe46de8a469983816395be850", + "reference": "b57f549b411d0b4fe46de8a469983816395be850", "shasum": "" }, "require": { "laminas/laminas-servicemanager": "^3.0.0 || ^4.0.0", "laminas/laminas-stdlib": "^3.20.0", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": "~8.3.0 || ~8.4.0 || ~8.5.0" }, "conflict": { "laminas/laminas-db": "*", "zendframework/zend-db": "*" }, "require-dev": { - "laminas/laminas-coding-standard": "^3.0.1", + "infection/infection": "^0.34.1", "laminas/laminas-eventmanager": "^3.14.0", "laminas/laminas-hydrator": "^4.6.0", + "php-db/phpdb-qa-tools": "0.1.x-dev", "phpbench/phpbench": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^11.5.42", "rector/rector": "^2.0" }, @@ -323,7 +322,7 @@ "issues": "https://github.com/php-db/phpdb/issues", "source": "https://github.com/php-db/phpdb" }, - "time": "2026-07-08T05:31:35+00:00" + "time": "2026-08-24T01:49:39+00:00" }, { "name": "psr/container", @@ -4500,5 +4499,5 @@ "platform-overrides": { "php": "8.3.99" }, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Connection.php b/src/Connection.php index 4ab3800..1069a9e 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -186,14 +186,6 @@ public function connect(): ConnectionInterface ); } - if ($this->resource->connect_error) { - throw new Exception\RuntimeException( - 'Connection error', - $this->resource->connect_errno, - new Exception\ErrorException($this->resource->connect_error, $this->resource->connect_errno), - ); - } - if ('' !== ($p['charset'] ?? '')) { $this->resource->set_charset($p['charset']); } @@ -264,9 +256,11 @@ public function getCurrentSchema(): string|false } $r = $result->fetch_row(); + // @codeCoverageIgnoreStart if (false === $r) { throw new Exception\RuntimeException($this->resource->error); } + // @codeCoverageIgnoreEnd /** @var array{0: string|null}|null $r */ if (null === $r || null === $r[0]) { diff --git a/src/Driver.php b/src/Driver.php index cbe8117..2b0828d 100644 --- a/src/Driver.php +++ b/src/Driver.php @@ -50,11 +50,13 @@ public function __construct( #[Override] public function checkEnvironment(): bool { + // @codeCoverageIgnoreStart if (! extension_loaded('mysqli')) { throw new Exception\RuntimeException( 'The Mysqli extension is required for this adapter but the extension is not loaded', ); } + // @codeCoverageIgnoreEnd return true; } diff --git a/src/Pdo/Connection.php b/src/Pdo/Connection.php index 49fe7dc..6bef316 100644 --- a/src/Pdo/Connection.php +++ b/src/Pdo/Connection.php @@ -17,10 +17,11 @@ use function is_array; use function is_int; use function is_string; +use function preg_match; +use function sprintf; use function strtolower; // @mago-expect lint:cyclomatic-complexity -// @mago-expect lint:kan-defect final class Connection extends AbstractPdoConnection { // @mago-expect analysis:write-only-property - read by the parent's final AbstractPdoConnection::getDsn() @@ -104,22 +105,22 @@ public function connect(): ConnectionInterface if (null === $dsn) { $dsn = []; if (null !== $database) { - $dsn[] = "dbname={$database}"; + $dsn[] = "dbname={$this->getDsnParameter('dbname', $database)}"; } if (null !== $hostname) { - $dsn[] = "host={$hostname}"; + $dsn[] = "host={$this->getDsnParameter('host', $hostname)}"; } if (null !== $port) { $dsn[] = "port={$port}"; } if (null !== $charset) { - $dsn[] = "charset={$charset}"; + $dsn[] = "charset={$this->getDsnParameter('charset', $charset)}"; } if (null !== $unixSocket) { - $dsn[] = "unix_socket={$unixSocket}"; + $dsn[] = "unix_socket={$this->getDsnParameter('unix_socket', $unixSocket)}"; } if (null !== $version) { - $dsn[] = "version={$version}"; + $dsn[] = "version={$this->getDsnParameter('version', $version)}"; } $dsn = 'mysql:' . implode(';', $dsn); } @@ -132,9 +133,7 @@ public function connect(): ConnectionInterface $this->driverName = strtolower((string) $this->resource->getAttribute(PDO::ATTR_DRIVER_NAME)); } catch (PDOException $e) { $code = $e->getCode(); - if (! is_int($code)) { - $code = 0; - } + $code = is_int($code) ? $code : 0; throw new Exception\RuntimeException("Connect Error: {$e->getMessage()}", $code, $e); } @@ -154,13 +153,10 @@ public function getCurrentSchema(): string|false $this->connect(); } - if (null === $this->resource) { - throw new Exception\RuntimeException( - 'Cannot query current schema without a connected resource; call connect() first.', - ); - } + /** @var PDO $resource */ + $resource = $this->resource; - $result = $this->resource->query('SELECT DATABASE()'); + $result = $resource->query('SELECT DATABASE()'); if (! $result instanceof PDOStatement) { return false; } @@ -186,4 +182,25 @@ public function getLastGeneratedValue(?string $name = null): string|int|false|nu return false; } + + /** + * Return a value that is safe to interpolate into a generated DSN. + * + * @todo Promote to AbstractPdoConnection in php-db/phpdb as a protected method once a second + * PDO driver package needs it — the validation is generic to all semicolon-delimited + * PDO DSN formats and has no MySQL-specific dependencies. + * + * @throws Exception\InvalidConnectionParametersException If the value contains DSN control characters. + */ + private function getDsnParameter(string $name, string $value): string + { + if (preg_match('/[;\x00-\x1f]/', $value) === 1) { + throw new Exception\InvalidConnectionParametersException( + sprintf('The "%s" connection parameter contains invalid characters', $name), + $this->connectionParameters, + ); + } + + return $value; + } } diff --git a/src/Pdo/Driver.php b/src/Pdo/Driver.php index fa412d1..ec123df 100644 --- a/src/Pdo/Driver.php +++ b/src/Pdo/Driver.php @@ -7,7 +7,6 @@ use Override; use PDO; use PDOStatement; -use PhpDb\Adapter\Driver\Feature\DriverFeatureProviderInterface; use PhpDb\Adapter\Driver\Pdo\AbstractPdo; use PhpDb\Adapter\Driver\Pdo\Result; use PhpDb\Adapter\Driver\Pdo\Statement; @@ -24,6 +23,7 @@ final class Driver extends AbstractPdo /** * @param array $features */ + // @mago-expect analysis:unused-parameter public function __construct( (PdoConnectionInterface&PdoDriverAwareInterface)|PDO $connection, StatementInterface&PdoDriverAwareInterface $statementPrototype = new Statement(), @@ -39,11 +39,6 @@ public function __construct( } $this->statementPrototype->setDriver($this); - - // $features is not constructor promoted because $this->features is defined in the trait - if ([] !== $features && $this instanceof DriverFeatureProviderInterface) { - $this->addFeatures($features); - } } /** diff --git a/src/Result.php b/src/Result.php index 6c8d4bf..4b40b06 100644 --- a/src/Result.php +++ b/src/Result.php @@ -11,9 +11,12 @@ use Override; use PhpDb\Adapter\Driver\ResultInterface; use PhpDb\Adapter\Exception; +use PhpDb\ResultSet\ResultSet; +use PhpDb\ResultSet\ResultSetInterface; // phpcs:ignore SlevomatCodingStandard.Namespaces.UnusedUses.UnusedUse use ReturnTypeWillChange; +use function array_combine; use function array_fill; use function call_user_func_array; use function count; @@ -140,6 +143,29 @@ public function getGeneratedValue(): string|int|false|null return $this->generatedValue; } + /** + * {@inheritDoc} + * + * @throws Exception\RuntimeException When isQueryResult() is false. + * @throws \Exception If the seeded result set rejects this result as its data source. + */ + #[Override] + public function getQueryResult(?ResultSetInterface $resultPrototype = null): ResultSetInterface + { + if (! $this->isQueryResult()) { + throw new Exception\RuntimeException( + 'Cannot produce a query result set from a result that is not a query result;' + . ' check isQueryResult() first', + ); + } + + $resultPrototype ??= new ResultSet(); + $resultSet = clone $resultPrototype; + $resultSet->initialize($this); + + return $resultSet; + } + /** * {@inheritDoc} */ @@ -282,14 +308,13 @@ public function valid() */ protected function loadDataFromMysqliStatement(): bool { - if (! $this->resource instanceof mysqli_stmt) { - throw new Exception\RuntimeException('Expected resource to be an instance of mysqli_stmt'); - } + /** @var mysqli_stmt $statement */ + $statement = $this->resource; // build the default reference based bind structure, if it does not already exist if (null === $this->statementBindValues['keys']) { $this->statementBindValues['keys'] = []; - $resultResource = $this->resource->result_metadata(); + $resultResource = $statement->result_metadata(); if (false === $resultResource) { return $resultResource; } @@ -307,24 +332,23 @@ protected function loadDataFromMysqliStatement(): bool foreach ($this->statementBindValues['values'] as $i => &$f) { $refs[$i] = &$f; } - call_user_func_array([$this->resource, 'bind_result'], $this->statementBindValues['values']); + call_user_func_array([$statement, 'bind_result'], $this->statementBindValues['values']); } - if (($r = $this->resource->fetch()) === null) { + if (($r = $statement->fetch()) === null) { if (! $this->isBuffered) { - $this->resource->close(); + $statement->close(); } return false; } if (! $r) { - throw new Exception\RuntimeException($this->resource->error); + throw new Exception\RuntimeException($statement->error); } - // dereference - for ($i = 0, $count = count($this->statementBindValues['keys']); $i < $count; $i++) { - $this->currentData[$this->statementBindValues['keys'][$i]] = $this->statementBindValues['values'][$i]; - } + // dereference: values was filled to the same length as keys when the bindings were built + $this->currentData = array_combine($this->statementBindValues['keys'], $this->statementBindValues['values']); + $this->currentComplete = true; $this->nextComplete = true; $this->position++; diff --git a/src/Sql/ColumnFormatEnum.php b/src/Sql/ColumnFormatEnum.php new file mode 100644 index 0000000..093353e --- /dev/null +++ b/src/Sql/ColumnFormatEnum.php @@ -0,0 +1,17 @@ +subject)), since AlterTable extends AbstractSql protected SqlInterface|PreparableSqlInterface|null $subject = null; - /** @var array{ - * unsigned: int, - * zerofill: int, - * charset: int, - * collate: int, - * identity: int, - * serial: int, - * autoincrement: int, - * comment: int, - * columnformat: int, - * format: int, - * storage: int, - * after: int - * } $columnOptionSortOrder - */ - protected array $columnOptionSortOrder = [ - 'unsigned' => 0, - 'zerofill' => 1, - 'charset' => 2, - 'collate' => 3, - 'identity' => 4, - 'serial' => 4, - 'autoincrement' => 4, - 'comment' => 5, - 'columnformat' => 6, - 'format' => 6, - 'storage' => 7, - 'after' => 8, - ]; - #[Override] public function setSubject( SqlInterface|PreparableSqlInterface|null $subject, @@ -69,221 +29,72 @@ public function setSubject( return $this; } - /** - * @return array{0: int, 1: int, 2: int, 3: int} - */ - protected function getSqlInsertOffsets(string $sql): array - { - $sqlLength = strlen($sql); - $insertStart = []; - - foreach (['NOT NULL', 'NULL', 'DEFAULT', 'UNIQUE', 'PRIMARY', 'REFERENCES'] as $needle) { - $insertPos = strpos($sql, " {$needle}"); - - if (false !== $insertPos) { - switch ($needle) { - case 'REFERENCES': - $insertStart[2] ??= $insertPos; - // no break - case 'PRIMARY': - case 'UNIQUE': - $insertStart[1] ??= $insertPos; - // no break - default: - $insertStart[0] ??= $insertPos; - } - } - } - - foreach (range( - start: 0, - end: 3, - ) as $i) { - $insertStart[$i] ??= $sqlLength; - } - - /** @var array{0: int, 1: int, 2: int, 3: int} $insertStart */ - return $insertStart; - } - /** * @return array> - * - * @throws Exception\RuntimeException */ #[Override] protected function processAddColumns(?PlatformInterface $adapterPlatform = null): array { - if (null === $adapterPlatform) { - throw new Exception\RuntimeException('Cannot build column SQL without a platform.'); - } + /** @var PlatformInterface $platform */ + $platform = $adapterPlatform; $sqls = []; - foreach ($this->addColumns as $i => $column) { - $sql = $this->processExpression($column, $adapterPlatform); - $insertStart = $this->getSqlInsertOffsets($sql); - $columnOptions = $column->getOptions(); - - uksort($columnOptions, [$this, 'compareColumnOptions']); - - foreach ($columnOptions as $coName => $coValue) { - $insert = ''; - - if (! $coValue) { - continue; - } + /** @var array $addColumns */ + $addColumns = $this->addColumns; - switch ($this->normalizeColumnOption($coName)) { - case 'unsigned': - $insert = ' UNSIGNED'; - $j = 0; - break; - case 'zerofill': - $insert = ' ZEROFILL'; - $j = 0; - break; - case 'charset': - $insert = " CHARACTER SET {$coValue}"; - $j = 0; - break; - case 'collate': - $insert = " COLLATE {$coValue}"; - $j = 0; - break; - case 'identity': - case 'serial': - case 'autoincrement': - $insert = ' AUTO_INCREMENT'; - $j = 1; - break; - case 'comment': - $insert = " COMMENT {$adapterPlatform->quoteValue($coValue)}"; - $j = 2; - break; - case 'columnformat': - case 'format': - $insert = ' COLUMN_FORMAT ' . strtoupper($coValue); - $j = 2; - break; - case 'storage': - $insert = ' STORAGE ' . strtoupper($coValue); - $j = 2; - break; - case 'after': - $insert = " AFTER {$adapterPlatform->quoteIdentifier($coValue)}"; - $j = 2; - } + foreach ($addColumns as $i => $column) { + /** @var array $options */ + $options = $column->getOptions(); - if ($insert) { - $sql = substr_replace($sql, $insert, $insertStart[$j], length: 0); - $insertStartCount = count($insertStart); - for (; $j < $insertStartCount; ++$j) { - $insertStart[$j] += strlen($insert); - } - } - } - $sqls[$i] = $sql; + $sqls[$i] = $this->processColumnOptions( + $this->processExpression($column, $platform), + $options, + $platform, + $this->resolveAfterOption(...), + ); } + return [$sqls]; } /** - * @return array> - * - * @throws Exception\RuntimeException + * @return array{0: list} */ #[Override] protected function processChangeColumns(?PlatformInterface $adapterPlatform = null): array { - if (null === $adapterPlatform) { - throw new Exception\RuntimeException('Cannot build column SQL without a platform.'); - } + /** @var PlatformInterface $platform */ + $platform = $adapterPlatform; $sqls = []; - foreach ($this->changeColumns as $name => $column) { - $sql = $this->processExpression($column, $adapterPlatform); - $insertStart = $this->getSqlInsertOffsets($sql); - $columnOptions = $column->getOptions(); - uksort($columnOptions, [$this, 'compareColumnOptions']); + /** @var array $changeColumns */ + $changeColumns = $this->changeColumns; - foreach ($columnOptions as $coName => $coValue) { - $insert = ''; + foreach ($changeColumns as $name => $column) { + /** @var array $options */ + $options = $column->getOptions(); - if (! $coValue) { - continue; - } - - switch ($this->normalizeColumnOption($coName)) { - case 'unsigned': - $insert = ' UNSIGNED'; - $j = 0; - break; - case 'zerofill': - $insert = ' ZEROFILL'; - $j = 0; - break; - case 'charset': - $insert = " CHARACTER SET {$coValue}"; - $j = 0; - break; - case 'collate': - $insert = " COLLATE {$coValue}"; - $j = 0; - break; - case 'identity': - case 'serial': - case 'autoincrement': - $insert = ' AUTO_INCREMENT'; - $j = 1; - break; - case 'comment': - $insert = " COMMENT {$adapterPlatform->quoteValue($coValue)}"; - $j = 2; - break; - case 'columnformat': - case 'format': - $insert = ' COLUMN_FORMAT ' . strtoupper($coValue); - $j = 2; - break; - case 'storage': - $insert = ' STORAGE ' . strtoupper($coValue); - $j = 2; - break; - } - - if ($insert) { - $sql = substr_replace($sql, $insert, $insertStart[$j], length: 0); - $insertStartCount = count($insertStart); - for (; $j < $insertStartCount; ++$j) { - $insertStart[$j] += strlen($insert); - } - } - } $sqls[] = [ - $adapterPlatform->quoteIdentifier($name), - $sql, + $platform->quoteIdentifier($name), + $this->processColumnOptions( + $this->processExpression($column, $platform), + $options, + $platform, + ), ]; } return [$sqls]; } - // phpcs:ignore SlevomatCodingStandard.Classes.UnusedPrivateElements.UnusedMethod - private function compareColumnOptions(string $columnA, string $columnB): int + private function resolveAfterOption(string $option, mixed $value, PlatformInterface $platform): ?string { - $columnA = $this->normalizeColumnOption($columnA); - $columnA = $this->columnOptionSortOrder[$columnA] ?? count($this->columnOptionSortOrder); - - $columnB = $this->normalizeColumnOption($columnB); - $columnB = $this->columnOptionSortOrder[$columnB] ?? count($this->columnOptionSortOrder); - - return $columnA - $columnB; - } + if ('after' !== $option) { + return null; + } - private function normalizeColumnOption(string $name): string - { - return strtolower(str_replace(['-', '_', ' '], replace: '', subject: $name)); + return $platform->quoteIdentifier((string) $value); } } diff --git a/src/Sql/Ddl/ColumnOptionTrait.php b/src/Sql/Ddl/ColumnOptionTrait.php new file mode 100644 index 0000000..770dfd5 --- /dev/null +++ b/src/Sql/Ddl/ColumnOptionTrait.php @@ -0,0 +1,246 @@ +, string}> + */ + private const array COLUMN_OPTIONS = [ + 'unsigned' => [0, ' UNSIGNED'], + 'zerofill' => [0, ' ZEROFILL'], + 'charset' => [0, ' CHARACTER SET %s'], + 'collate' => [0, ' COLLATE %s'], + 'identity' => [1, ' AUTO_INCREMENT'], + 'serial' => [1, ' AUTO_INCREMENT'], + 'autoincrement' => [1, ' AUTO_INCREMENT'], + 'comment' => [2, ' COMMENT %s'], + 'columnformat' => [2, ' COLUMN_FORMAT %s'], + 'format' => [2, ' COLUMN_FORMAT %s'], + 'storage' => [2, ' STORAGE %s'], + 'after' => [2, ' AFTER %s'], + ]; + + /** + * Offsets keyed by how late in the definition an option may be inserted. + * + * @return array{0: int, 1: int, 2: int, 3: int} + */ + protected function getSqlInsertOffsets(string $sql): array + { + $sqlLength = strlen($sql); + $insertStart = []; + + foreach (['NOT NULL', 'NULL', 'DEFAULT', 'UNIQUE', 'PRIMARY', 'REFERENCES'] as $needle) { + $insertPos = strpos($sql, " {$needle}"); + + if (false !== $insertPos) { + switch ($needle) { + case 'REFERENCES': + $insertStart[2] ??= $insertPos; + // no break + case 'PRIMARY': + case 'UNIQUE': + $insertStart[1] ??= $insertPos; + // no break + default: + $insertStart[0] ??= $insertPos; + } + } + } + + foreach (range( + start: 0, + end: 3, + ) as $i) { + $insertStart[$i] ??= $sqlLength; + } + + /** @var array{0: int, 1: int, 2: int, 3: int} $insertStart */ + return $insertStart; + } + + /** + * Appends each option to $sql at the offset its keyword belongs to. + * + * @param array $options + * @param (callable(string, mixed, PlatformInterface): ?string)|null $resolveExtra + * Value resolver for options only valid in the calling statement, tried before the common one. + * @throws InvalidArgumentException If an option value would not be safe to emit unquoted. + * @throws ValueError If a COLUMN_FORMAT or STORAGE value is not a keyword its enum declares. + */ + protected function processColumnOptions( + string $sql, + array $options, + PlatformInterface $platform, + ?callable $resolveExtra = null, + ): string { + $insertStart = $this->getSqlInsertOffsets($sql); + + uksort($options, $this->compareColumnOptions(...)); + + // @mago-expect analysis:mixed-assignment + foreach ($options as $name => $value) { + if (! $value) { + continue; + } + + $option = $this->normalizeColumnOption($name); + + if (! array_key_exists($option, self::COLUMN_OPTIONS)) { + continue; + } + + $resolved = null === $resolveExtra ? null : $resolveExtra($option, $value, $platform); + $resolved ??= $this->resolveColumnOptionValue($option, $value, $platform); + + if (null === $resolved) { + continue; + } + + [$j, $template] = self::COLUMN_OPTIONS[$option]; + + $insert = sprintf($template, $resolved); + $length = strlen($insert); + + foreach ($insertStart as $slot => $offset) { + if ($slot < $j) { + continue; + } + + if ($slot === $j) { + $sql = substr_replace($sql, $insert, $offset, length: 0); + } + + $insertStart[$slot] = $offset + $length; + } + } + + return $sql; + } + + private function compareColumnOptions(string $columnA, string $columnB): int + { + $sortOrder = array_flip(array_keys(self::COLUMN_OPTIONS)); + $unknown = count($sortOrder); + + $columnA = $sortOrder[$this->normalizeColumnOption($columnA)] ?? $unknown; + $columnB = $sortOrder[$this->normalizeColumnOption($columnB)] ?? $unknown; + + return $columnA - $columnB; + } + + /** + * Backed enums match case-sensitively, so the value is upper-cased before it is handed to the enum. + * + * @param Closure(string): (ColumnFormatEnum|StorageEnum) $from The enum's from() method, which validates the keyword. + * @return string The keyword to emit, as declared by the matching enum case. + * @throws InvalidArgumentException If the value is not a string. + * @throws ValueError If the value is not one of the declared keywords. + */ + private function getColumnOptionKeyword(string $option, Closure $from, mixed $value): string + { + if (! is_string($value)) { + throw new InvalidArgumentException(sprintf( + 'Invalid value for the "%s" column option; expected a keyword string, received "%s"', + $option, + get_debug_type($value), + )); + } + + return $from(strtoupper(trim($value)))->value; + } + + /** + * @return string The validated name, unchanged. + * @throws InvalidArgumentException If the value is not a bare character set or collation name. + */ + private function getColumnOptionName(string $option, mixed $value): string + { + if (! is_string($value) || preg_match(self::NAME_PATTERN, $value) !== 1) { + throw new InvalidArgumentException(sprintf( + 'Invalid value for the "%s" column option; expected an unquoted name matching %s, received "%s"', + $option, + self::NAME_PATTERN, + is_string($value) ? $value : get_debug_type($value), + )); + } + + return $value; + } + + private function normalizeColumnOption(string $name): string + { + return strtolower(str_replace(['-', '_', ' '], replace: '', subject: $name)); + } + + /** + * @return string|null The value to substitute into the option's SQL template, empty for flags, + * or null when the option is only valid in a statement with its own resolver. + * @throws InvalidArgumentException If the value would not be safe to emit unquoted. + * @throws ValueError If a COLUMN_FORMAT or STORAGE value is not a keyword its enum declares. + */ + private function resolveColumnOptionValue(string $option, mixed $value, PlatformInterface $platform): ?string + { + return match ($option) { + 'after' => null, + 'charset', 'collate' => $this->getColumnOptionName($option, $value), + 'comment' => $platform->quoteValue((string) $value), + 'columnformat', 'format' => $this->getColumnOptionKeyword($option, ColumnFormatEnum::from(...), $value), + 'storage' => $this->getColumnOptionKeyword($option, StorageEnum::from(...), $value), + default => '', + }; + } +} diff --git a/src/Sql/Ddl/CreateTableDecorator.php b/src/Sql/Ddl/CreateTableDecorator.php index 14f7991..8668233 100644 --- a/src/Sql/Ddl/CreateTableDecorator.php +++ b/src/Sql/Ddl/CreateTableDecorator.php @@ -6,44 +6,20 @@ use Override; use PhpDb\Adapter\Platform\PlatformInterface; +use PhpDb\Sql\Ddl\Column\ColumnInterface; use PhpDb\Sql\Ddl\CreateTable; -use PhpDb\Sql\Exception; use PhpDb\Sql\Platform\PlatformDecoratorInterface; use PhpDb\Sql\PreparableSqlInterface; use PhpDb\Sql\SqlInterface; -use function count; -use function range; -use function str_replace; -use function strlen; -use function strpos; -use function strtolower; -use function strtoupper; -use function substr_replace; -use function uksort; - -// @mago-expect lint:kan-defect final class CreateTableDecorator extends CreateTable implements PlatformDecoratorInterface { + use ColumnOptionTrait; + // @mago-expect analysis:write-only-property - read by the inherited AbstractSql::$subject handling // (get_object_vars($this->subject)), since CreateTable extends AbstractSql protected SqlInterface|PreparableSqlInterface|null $subject = null; - /** @var array */ - protected array $columnOptionSortOrder = [ - 'unsigned' => 0, - 'zerofill' => 1, - 'charset' => 2, - 'collate' => 3, - 'identity' => 4, - 'serial' => 4, - 'autoincrement' => 4, - 'comment' => 5, - 'columnformat' => 6, - 'format' => 6, - 'storage' => 7, - ]; - #[Override] public function setSubject( PreparableSqlInterface|SqlInterface|null $subject, @@ -53,47 +29,8 @@ public function setSubject( return $this; } - /** - * @return array{0: int, 1: int, 2: int, 3: int} - */ - protected function getSqlInsertOffsets(string $sql): array - { - $sqlLength = strlen($sql); - $insertStart = []; - - foreach (['NOT NULL', 'NULL', 'DEFAULT', 'UNIQUE', 'PRIMARY', 'REFERENCES'] as $needle) { - $insertPos = strpos($sql, " {$needle}"); - - if (false !== $insertPos) { - switch ($needle) { - case 'REFERENCES': - $insertStart[2] ??= $insertPos; - // no break - case 'PRIMARY': - case 'UNIQUE': - $insertStart[1] ??= $insertPos; - // no break - default: - $insertStart[0] ??= $insertPos; - } - } - } - - foreach (range( - start: 0, - end: 3, - ) as $i) { - $insertStart[$i] ??= $sqlLength; - } - - /** @var array{0: int, 1: int, 2: int, 3: int} $insertStart */ - return $insertStart; - } - /** * {@inheritDoc} - * - * @throws Exception\RuntimeException */ #[Override] protected function processColumns(?PlatformInterface $adapterPlatform = null): ?array @@ -102,93 +39,25 @@ protected function processColumns(?PlatformInterface $adapterPlatform = null): ? return null; } - if (null === $adapterPlatform) { - throw new Exception\RuntimeException('Cannot build column SQL without a platform.'); - } + /** @var PlatformInterface $platform */ + $platform = $adapterPlatform; $sqls = []; - foreach ($this->columns as $i => $column) { - $sql = $this->processExpression($column, $adapterPlatform); - $insertStart = $this->getSqlInsertOffsets($sql); - $columnOptions = $column->getOptions(); - - uksort($columnOptions, [$this, 'compareColumnOptions']); - - foreach ($columnOptions as $coName => $coValue) { - $insert = ''; - - if (! $coValue) { - continue; - } + /** @var array $columns */ + $columns = $this->columns; - switch ($this->normalizeColumnOption($coName)) { - case 'unsigned': - $insert = ' UNSIGNED'; - $j = 0; - break; - case 'zerofill': - $insert = ' ZEROFILL'; - $j = 0; - break; - case 'charset': - $insert = " CHARACTER SET {$coValue}"; - $j = 0; - break; - case 'collate': - $insert = " COLLATE {$coValue}"; - $j = 0; - break; - case 'identity': - case 'serial': - case 'autoincrement': - $insert = ' AUTO_INCREMENT'; - $j = 1; - break; - case 'comment': - $insert = " COMMENT {$adapterPlatform->quoteValue($coValue)}"; - $j = 2; - break; - case 'columnformat': - case 'format': - $insert = ' COLUMN_FORMAT ' . strtoupper($coValue); - $j = 2; - break; - case 'storage': - $insert = ' STORAGE ' . strtoupper($coValue); - $j = 2; - break; - } + foreach ($columns as $i => $column) { + /** @var array $options */ + $options = $column->getOptions(); - if ($insert) { - $sql = substr_replace($sql, $insert, $insertStart[$j], length: 0); - $insertStartCount = count($insertStart); - for (; $j < $insertStartCount; ++$j) { - $insertStart[$j] += strlen($insert); - } - } - } - - $sqls[$i] = $sql; + $sqls[$i] = $this->processColumnOptions( + $this->processExpression($column, $platform), + $options, + $platform, + ); } return [$sqls]; } - - // phpcs:ignore SlevomatCodingStandard.Classes.UnusedPrivateElements.UnusedMethod - private function compareColumnOptions(string $columnA, string $columnB): int - { - $columnA = $this->normalizeColumnOption($columnA); - $columnA = $this->columnOptionSortOrder[$columnA] ?? count($this->columnOptionSortOrder); - - $columnB = $this->normalizeColumnOption($columnB); - $columnB = $this->columnOptionSortOrder[$columnB] ?? count($this->columnOptionSortOrder); - - return $columnA - $columnB; - } - - private function normalizeColumnOption(string $name): string - { - return strtolower(str_replace(['-', '_', ' '], replace: '', subject: $name)); - } } diff --git a/src/Sql/StorageEnum.php b/src/Sql/StorageEnum.php new file mode 100644 index 0000000..97059d0 --- /dev/null +++ b/src/Sql/StorageEnum.php @@ -0,0 +1,16 @@ +testSuite()->name() !== 'integration test' - || [] === $this->fixtureLoaders + || [] === $this->fixtureLoaders ) { return; } diff --git a/test/integration/Mysqli/ConnectionTest.php b/test/integration/Mysqli/ConnectionTest.php index e4c748a..efe3f99 100644 --- a/test/integration/Mysqli/ConnectionTest.php +++ b/test/integration/Mysqli/ConnectionTest.php @@ -5,6 +5,7 @@ namespace PhpDbIntegrationTest\Mysql\Mysqli; use mysqli; +use PhpDb\Adapter\Exception\InvalidQueryException; use PhpDb\Adapter\Exception\RuntimeException; use PhpDb\Mysql\Connection; use PhpDb\Mysql\Driver; @@ -16,6 +17,13 @@ use PHPUnit\Framework\TestCase; use function getenv; +use function mysqli_report; +use function usleep; + +use const MYSQLI_OPT_LOCAL_INFILE; +use const MYSQLI_REPORT_ERROR; +use const MYSQLI_REPORT_OFF; +use const MYSQLI_REPORT_STRICT; #[Group('integration')] #[Group('integration-mysqli')] @@ -81,6 +89,17 @@ public function connectAndDisconnect(): void static::assertFalse($connection->isConnected()); } + #[Test] + public function connectTwiceReturnsSameInstance(): void + { + $connection = new Connection($this->connectionParameters()); + new Driver($connection, new Statement(), new Result()); + + $connection->connect(); + + static::assertSame($connection, $connection->connect()); + } + #[Test] public function constructWithMysqliResource(): void { @@ -89,6 +108,24 @@ public function constructWithMysqliResource(): void static::assertTrue($connection->isConnected()); } + #[Test] + public function driverOptionsAreAppliedOnConnect(): void + { + $parameters = $this->connectionParameters(); + $parameters['driver_options'] = [ + 'MYSQLI_OPT_CONNECT_TIMEOUT' => 10, + 'NOT_A_MYSQLI_CONSTANT' => 1, + MYSQLI_OPT_LOCAL_INFILE => 0, + ]; + + $connection = new Connection($parameters); + new Driver($connection, new Statement(), new Result()); + + $connection->connect(); + + static::assertTrue($connection->isConnected()); + } + #[Test] public function executeAutoConnects(): void { @@ -111,6 +148,21 @@ public function executeInsertReturnsGeneratedValue(): void $connection->execute('DELETE FROM test WHERE name = \'generated\''); } + #[Test] + public function executeInvalidSqlThrowsInvalidQueryException(): void + { + $connection = $this->createConnection(); + $connection->connect(); + + mysqli_report(MYSQLI_REPORT_OFF); + try { + $this->expectException(InvalidQueryException::class); + $connection->execute('SELECT FROM WHERE'); + } finally { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + } + } + #[Test] public function executeSelect(): void { @@ -149,6 +201,46 @@ public function getCurrentSchemaAutoConnects(): void static::assertTrue($connection->isConnected()); } + #[Test] + public function getCurrentSchemaOnKilledConnectionThrows(): void + { + $victim = $this->createMysqli(); + $killer = $this->createMysqli(); + + $connection = new Connection($victim); + new Driver($connection, new Statement(), new Result()); + + $killer->query("KILL {$victim->thread_id}"); + usleep(200_000); + + mysqli_report(MYSQLI_REPORT_OFF); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to query current schema'); + $connection->getCurrentSchema(); + } finally { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + } + } + + #[Test] + public function getCurrentSchemaReturnsFalseWithoutSelectedDatabase(): void + { + $parameters = $this->connectionParameters(); + $mysqli = new mysqli( + $parameters['hostname'], + $parameters['username'], + $parameters['password'], + '', + $parameters['port'], + ); + + $connection = new Connection($mysqli); + new Driver($connection, new Statement(), new Result()); + + static::assertFalse($connection->getCurrentSchema()); + } + #[Test] public function rollbackWithoutTransactionThrows(): void { diff --git a/test/integration/Mysqli/StatementResultTest.php b/test/integration/Mysqli/StatementResultTest.php index 6bd92bb..8447c2c 100644 --- a/test/integration/Mysqli/StatementResultTest.php +++ b/test/integration/Mysqli/StatementResultTest.php @@ -8,12 +8,14 @@ use mysqli_result; use mysqli_stmt; use PhpDb\Adapter\Driver\ResultInterface; +use PhpDb\Adapter\Exception\InvalidQueryException; use PhpDb\Adapter\Exception\RuntimeException; use PhpDb\Adapter\ParameterContainer; use PhpDb\Mysql\Connection; use PhpDb\Mysql\Driver; use PhpDb\Mysql\Result; use PhpDb\Mysql\Statement; +use PhpDb\ResultSet\ResultSet; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; @@ -22,6 +24,12 @@ use function getenv; use function is_int; use function iterator_to_array; +use function mysqli_report; +use function usleep; + +use const MYSQLI_REPORT_ERROR; +use const MYSQLI_REPORT_OFF; +use const MYSQLI_REPORT_STRICT; #[Group('integration')] #[Group('integration-mysqli')] @@ -130,6 +138,17 @@ public function countOnNonQueryResultThrows(): void $this->executeNonQuery()->count(); } + #[Test] + public function createStatementConnectsTheConnection(): void + { + $connection = new Connection($this->connectionParameters()); + $driver = new Driver($connection, new Statement(), new Result()); + + $driver->createStatement('SELECT 1'); + + static::assertTrue($connection->isConnected()); + } + #[Test] public function createStatementFromMysqliStmtResource(): void { @@ -154,6 +173,22 @@ public function currentOnNonQueryResultThrows(): void $this->executeNonQuery()->current(); } + #[Test] + public function executeFailingPreparedStatementThrows(): void + { + $statement = $this->createDriver(false) + ->createStatement('INSERT INTO test (id, name, value) VALUES (?, ?, ?)'); + + mysqli_report(MYSQLI_REPORT_OFF); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Duplicate entry'); + $statement->execute($this->createParameterContainer([1, 'dup', 'dup'])); + } finally { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + } + } + #[Test] public function executeWithEmptyArray(): void { @@ -165,6 +200,97 @@ public function executeWithEmptyArray(): void static::assertTrue($result->isQueryResult()); } + #[Test] + public function fetchFailureMidIterationThrows(): void + { + $victim = $this->createMysqli(); + $killer = $this->createMysqli(); + + $driver = new Driver(new Connection($victim), new Statement(bufferResults: false), new Result()); + $result = $driver->createStatement( + "SELECT REPEAT('x', 65536) AS filler FROM test t1 JOIN test t2 JOIN test t3 JOIN test t4", + ) + ->execute([]); + + static::assertNotNull($result); + static::assertNotNull($result->current()); + + $killer->query("KILL {$victim->thread_id}"); + usleep(200_000); + + mysqli_report(MYSQLI_REPORT_OFF); + try { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/gone away|Lost connection/i'); + while ($result->valid()) { + $result->next(); + $result->current(); + } + } finally { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + } + } + + #[Test] + public function getQueryResultClonesTheGivenPrototype(): void + { + $result = $this->createDriver(true) + ->createStatement('SELECT * FROM test WHERE id = ?') + ->execute($this->createParameterContainer([1])); + + static::assertNotNull($result); + static::assertInstanceOf(Result::class, $result); + + $prototype = new ResultSet(); + $resultSet = $result->getQueryResult($prototype); + + static::assertNotSame($prototype, $resultSet); + static::assertInstanceOf(ResultSet::class, $resultSet); + } + + #[Test] + public function getQueryResultOnNonQueryResultThrows(): void + { + $result = $this->executeNonQuery(); + static::assertInstanceOf(Result::class, $result); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Cannot produce a query result set from a result that is not a query result', + ); + + $result->getQueryResult(); + } + + #[Test] + public function getQueryResultSeedsResultSetFromQueryResult(): void + { + $result = $this->createDriver(true) + ->createStatement('SELECT * FROM test WHERE id = ?') + ->execute($this->createParameterContainer([1])); + + static::assertNotNull($result); + static::assertInstanceOf(Result::class, $result); + + $resultSet = $result->getQueryResult(); + + static::assertSame(1, $resultSet->count()); + } + + #[Test] + public function initializeWithStatementDefaultsBufferedStateToUnknown(): void + { + $mysqli = $this->createMysqli(); + $stmt = $mysqli->prepare('SELECT * FROM test'); + static::assertInstanceOf(mysqli_stmt::class, $stmt); + $stmt->execute(); + + $result = new Result(); + $result->initialize($stmt, null); + + static::assertNull($result->isBuffered()); + } + #[Test] public function insertReturnsGeneratedValueAndAffectedRows(): void { @@ -182,6 +308,46 @@ public function insertReturnsGeneratedValueAndAffectedRows(): void ->execute($this->createParameterContainer(['new'])); } + #[Test] + public function nextBeforeAnyFetchAdvancesPosition(): void + { + $result = $this->createDriver(false) + ->createStatement('SELECT * FROM test WHERE id = ?') + ->execute($this->createParameterContainer([1])); + + static::assertNotNull($result); + + $result->next(); + + static::assertSame(1, $result->key()); + } + + #[Test] + public function prepareInvalidSqlThrowsInvalidQueryException(): void + { + $statement = $this->createDriver(false)->createStatement('SELECT FROM WHERE'); + + mysqli_report(MYSQLI_REPORT_OFF); + try { + $this->expectException(InvalidQueryException::class); + $this->expectExceptionMessage("Statement couldn't be produced"); + $statement->prepare(); + } finally { + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + } + } + + #[Test] + public function prepareTwiceThrows(): void + { + $statement = $this->createDriver(false)->createStatement('SELECT 1'); + $statement->prepare(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('This statement has already been prepared'); + $statement->prepare(); + } + #[Test] public function rewindOnNonQueryResultThrows(): void { @@ -222,6 +388,28 @@ public function statementContainerAccessors(): void static::assertSame('SELECT 2', $statement->getSql()); } + #[Test] + public function statementResultWithoutMetadataYieldsNoRows(): void + { + $result = $this->createDriver(false) + ->createStatement('UPDATE test SET value = value WHERE id = ?') + ->execute($this->createParameterContainer([1])); + + static::assertNotNull($result); + static::assertNull($result->current()); + } + + #[Test] + public function unbufferedResultClosesStatementAfterFullIteration(): void + { + $result = $this->createDriver(false) + ->createStatement('SELECT * FROM test WHERE value = ?') + ->execute($this->createParameterContainer(['bar'])); + + static::assertNotNull($result); + static::assertCount(3, iterator_to_array($result, preserve_keys: false)); + } + #[Test] public function unbufferedResultCountThrows(): void { @@ -275,6 +463,28 @@ public function validReturnsTrueAfterCurrent(): void static::assertTrue($result->valid()); } + /** + * @return array{hostname: string, username: string, password: string, database: string, port: int} + */ + private function connectionParameters(): array + { + $host = (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_HOSTNAME'); + if ('' === $host) { + $host = 'localhost'; + } + + $port = (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_PORT'); + $port = '' === $port ? 3306 : (int) $port; + + return [ + 'hostname' => $host, + 'username' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_USERNAME'), + 'password' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_PASSWORD'), + 'database' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_DATABASE'), + 'port' => $port, + ]; + } + private function createDriver(bool $bufferResults = false): Driver { return new Driver( @@ -286,20 +496,14 @@ private function createDriver(bool $bufferResults = false): Driver private function createMysqli(): mysqli { - $host = (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_HOSTNAME'); - if ('' === $host) { - $host = 'localhost'; - } - - $port = (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_PORT'); - $port = '' === $port ? 3306 : (int) $port; + $parameters = $this->connectionParameters(); return new mysqli( - $host, - (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_USERNAME'), - (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_PASSWORD'), - (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_DATABASE'), - $port, + $parameters['hostname'], + $parameters['username'], + $parameters['password'], + $parameters['database'], + $parameters['port'], ); } diff --git a/test/integration/Pdo/ConnectionTest.php b/test/integration/Pdo/ConnectionTest.php index d959a00..7351543 100644 --- a/test/integration/Pdo/ConnectionTest.php +++ b/test/integration/Pdo/ConnectionTest.php @@ -157,6 +157,23 @@ public function getCurrentSchema(): void $connection->disconnect(); } + #[Test] + public function getCurrentSchemaAutoConnects(): void + { + $connection = new Connection([ + 'hostname' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_HOSTNAME'), + 'username' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_USERNAME'), + 'password' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_PASSWORD'), + 'dbname' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_DATABASE'), + 'port' => (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_PORT'), + ]); + + static::assertSame( + (string) getenv('TESTS_PHPDB_ADAPTER_MYSQL_DATABASE'), + $connection->getCurrentSchema(), + ); + } + #[Test] public function getLastGeneratedValue(): void { diff --git a/test/unit/ConnectionTest.php b/test/unit/ConnectionTest.php index def32ff..6d1897c 100644 --- a/test/unit/ConnectionTest.php +++ b/test/unit/ConnectionTest.php @@ -7,6 +7,7 @@ use Exception; use mysqli; use Override; +use PhpDb\Adapter\Exception\RuntimeException; use PhpDb\Mysql\Connection; use PhpDb\Mysql\Driver; use PhpDb\Mysql\Result; @@ -24,6 +25,8 @@ #[RequiresPhpExtension('mysqli')] #[CoversMethod(Connection::class, 'setDriver')] #[CoversMethod(Connection::class, 'connect')] +#[CoversMethod(Connection::class, 'execute')] +#[CoversMethod(Connection::class, 'rollback')] final class ConnectionTest extends TestCase { // fake test-only credential, not a real secret @@ -49,6 +52,19 @@ public function connectionFails(): void $connection->connect(); } + #[Test] + public function executeWithoutDriverThrows(): void + { + $mysqli = $this->createStub(mysqli::class); + $mysqli->method('query')->willReturn(true); + + $connection = new Connection($mysqli); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot execute without a driver; call setDriver() first.'); + $connection->execute('SELECT 1'); + } + #[Test] public function getConnectionParameters(): void { @@ -75,6 +91,14 @@ public function nonSecureConnection(): void $connection->connect(); } + #[Test] + public function rollbackWithoutConnectionThrows(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Must be connected before you can rollback.'); + $this->connection->rollback(); + } + #[Test] public function setConnectionParameters(): void { diff --git a/test/unit/Pdo/ConnectionTest.php b/test/unit/Pdo/ConnectionTest.php index 2ff0830..29591e6 100644 --- a/test/unit/Pdo/ConnectionTest.php +++ b/test/unit/Pdo/ConnectionTest.php @@ -5,19 +5,41 @@ namespace PhpDbTest\Mysql\Pdo; use Override; +use PDO; +use PDOException; use PhpDb\Adapter\Exception\InvalidConnectionParametersException; use PhpDb\Adapter\Exception\RuntimeException; use PhpDb\Mysql\Pdo\Connection; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use function sprintf; + +#[CoversMethod(Connection::class, '__construct')] #[CoversMethod(Connection::class, 'connect')] +#[CoversMethod(Connection::class, 'getDsnParameter')] +#[CoversMethod(Connection::class, 'getCurrentSchema')] +#[CoversMethod(Connection::class, 'getLastGeneratedValue')] final class ConnectionTest extends TestCase { protected Connection $connection; + /** @return array */ + public static function unsafeDsnParameterProvider(): array + { + return [ + 'dbname appends parameter' => ['dbname', 'foo;host=attacker.example.com', 'dbname'], + 'host appends parameter' => ['host', '127.0.0.1;dbname=other', 'host'], + 'charset appends parameter' => ['charset', 'utf8;dbname=other', 'charset'], + 'unix_socket appends parameter' => ['unix_socket', '/tmp/mysql.sock;dbname=other', 'unix_socket'], + 'version appends parameter' => ['version', '5.7;dbname=other', 'version'], + 'newline in host' => ['host', "127.0.0.1\nhost=attacker.example.com", 'host'], + ]; + } + #[Test] #[Group('2622')] public function arrayOfConnectionParametersCreatesCorrectDsn(): void @@ -28,6 +50,7 @@ public function arrayOfConnectionParametersCreatesCorrectDsn(): void 'dbname' => 'foo', 'port' => '3306', 'unix_socket' => '/var/run/mysqld/mysqld.sock', + 'version' => '5.7', ]); try { $connection->connect(); @@ -42,6 +65,31 @@ public function arrayOfConnectionParametersCreatesCorrectDsn(): void static::assertStringContainsString('dbname=foo', $responseString); static::assertStringContainsString('port=3306', $responseString); static::assertStringContainsString('unix_socket=/var/run/mysqld/mysqld.sock', $responseString); + static::assertStringContainsString('version=5.7', $responseString); + } + + #[Test] + public function connectReturnsSelfWhenConstructedWithPdoInstance(): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('getAttribute')->willReturn('mysql'); + + $connection = new Connection($pdo); + + static::assertSame($connection, $connection->connect()); + static::assertSame($pdo, $connection->getResource()); + } + + #[Test] + public function getCurrentSchemaReturnsFalseWhenQueryProducesNoStatement(): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('getAttribute')->willReturn('mysql'); + $pdo->method('query')->willReturn(false); + + $connection = new Connection($pdo); + + static::assertFalse($connection->getCurrentSchema()); } /** @@ -63,6 +111,24 @@ public function getDsn(): void static::assertEquals($dsn, $responseString); } + #[Test] + public function getLastGeneratedValueReturnsFalseWhenDriverThrows(): void + { + $pdo = $this->createStub(PDO::class); + $pdo->method('getAttribute')->willReturn('mysql'); + $pdo->method('lastInsertId')->willThrowException(new PDOException('driver does not support lastInsertId')); + + $connection = new Connection($pdo); + + static::assertFalse($connection->getLastGeneratedValue()); + } + + #[Test] + public function getLastGeneratedValueReturnsFalseWithoutResource(): void + { + static::assertFalse($this->connection->getLastGeneratedValue()); + } + #[Test] public function hostnameAndUnixSocketThrowsInvalidConnectionParametersException(): void { @@ -81,6 +147,25 @@ public function hostnameAndUnixSocketThrowsInvalidConnectionParametersException( $connection->connect(); } + #[Test] + #[DataProvider('unsafeDsnParameterProvider')] + public function rejectsConnectionParameterContainingDsnControlCharacters( + string $parameter, + string $value, + string $reportedParameter, + ): void { + $this->expectException(InvalidConnectionParametersException::class); + $this->expectExceptionMessage( + sprintf('The "%s" connection parameter contains invalid characters', $reportedParameter), + ); + + $connection = new Connection([ + 'driver' => 'pdo_mysql', + $parameter => $value, + ]); + $connection->connect(); + } + /** * Test getResource method tries to connect to the database, it should never return null */ diff --git a/test/unit/Sql/Ddl/AlterTableDecoratorTest.php b/test/unit/Sql/Ddl/AlterTableDecoratorTest.php index 5b2f052..4c0931a 100644 --- a/test/unit/Sql/Ddl/AlterTableDecoratorTest.php +++ b/test/unit/Sql/Ddl/AlterTableDecoratorTest.php @@ -4,28 +4,105 @@ namespace PhpDbTest\Mysql\Sql\Ddl; +use BackedEnum; use PhpDb\Adapter\Driver\Pdo\AbstractPdoConnection; use PhpDb\Adapter\Driver\Pdo\Result; use PhpDb\Adapter\Driver\Pdo\Statement; use PhpDb\Mysql\AdapterPlatform; use PhpDb\Mysql\Pdo\Driver; +use PhpDb\Mysql\Sql\ColumnFormatEnum; use PhpDb\Mysql\Sql\Ddl\AlterTableDecorator; +use PhpDb\Mysql\Sql\Ddl\ColumnOptionTrait; +use PhpDb\Mysql\Sql\StorageEnum; use PhpDb\Sql\Ddl\AlterTable; use PhpDb\Sql\Ddl\Column; +use PhpDb\Sql\Exception\InvalidArgumentException; +use PhpDbTest\Mysql\Sql\Ddl\TestAsset\ColumnOptionMatrix; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use ValueError; + +use function sprintf; +use function strtoupper; #[CoversMethod(AlterTableDecorator::class, 'setSubject')] #[CoversMethod(AlterTableDecorator::class, 'processAddColumns')] #[CoversMethod(AlterTableDecorator::class, 'processChangeColumns')] -#[CoversMethod(AlterTableDecorator::class, 'getSqlInsertOffsets')] -#[CoversMethod(AlterTableDecorator::class, 'compareColumnOptions')] -#[CoversMethod(AlterTableDecorator::class, 'normalizeColumnOption')] +#[CoversMethod(AlterTableDecorator::class, 'resolveAfterOption')] +#[CoversTrait(ColumnOptionTrait::class)] final class AlterTableDecoratorTest extends TestCase { protected AdapterPlatform $platform; + /** @return array, string}> */ + public static function addColumnMatrixProvider(): array + { + return ColumnOptionMatrix::pairedWith([ + 'all options' => "ALTER TABLE `test`\n ADD COLUMN `name` VARCHAR(255) UNSIGNED ZEROFILL CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL AUTO_INCREMENT COMMENT 'here' COLUMN_FORMAT DYNAMIC STORAGE MEMORY AFTER `id`", + 'charset collate' => "ALTER TABLE `test`\n ADD COLUMN `name` VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_unicode_ci NOT NULL", + 'format storage' => "ALTER TABLE `test`\n ADD COLUMN `name` VARCHAR(255) NOT NULL COLUMN_FORMAT FIXED STORAGE DISK", + 'reverse declared' => "ALTER TABLE `test`\n ADD COLUMN `name` VARCHAR(255) UNSIGNED CHARACTER SET latin1 NOT NULL COMMENT 'c' STORAGE DISK", + 'unknown option' => "ALTER TABLE `test`\n ADD COLUMN `name` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL", + 'after only' => "ALTER TABLE `test`\n ADD COLUMN `name` VARCHAR(255) NOT NULL AFTER `other_col`", + 'falsy skipped' => "ALTER TABLE `test`\n ADD COLUMN `name` VARCHAR(255) COLLATE utf8mb4_bin NOT NULL", + ]); + } + + /** @return array, string}> */ + public static function changeColumnMatrixProvider(): array + { + return ColumnOptionMatrix::pairedWith([ + 'all options' => "ALTER TABLE `test`\n CHANGE COLUMN `name` `name` VARCHAR(255) UNSIGNED ZEROFILL CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL AUTO_INCREMENT COMMENT 'here' COLUMN_FORMAT DYNAMIC STORAGE MEMORY", + 'charset collate' => "ALTER TABLE `test`\n CHANGE COLUMN `name` `name` VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_unicode_ci NOT NULL", + 'format storage' => "ALTER TABLE `test`\n CHANGE COLUMN `name` `name` VARCHAR(255) NOT NULL COLUMN_FORMAT FIXED STORAGE DISK", + 'reverse declared' => "ALTER TABLE `test`\n CHANGE COLUMN `name` `name` VARCHAR(255) UNSIGNED CHARACTER SET latin1 NOT NULL COMMENT 'c' STORAGE DISK", + 'unknown option' => "ALTER TABLE `test`\n CHANGE COLUMN `name` `name` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL", + 'after only' => "ALTER TABLE `test`\n CHANGE COLUMN `name` `name` VARCHAR(255) NOT NULL", + 'falsy skipped' => "ALTER TABLE `test`\n CHANGE COLUMN `name` `name` VARCHAR(255) COLLATE utf8mb4_bin NOT NULL", + ]); + } + + /** @return array */ + public static function keywordOptionProvider(): array + { + return [ + 'columnformat' => ['columnformat'], + 'storage' => ['storage'], + ]; + } + + /** @return array */ + public static function unsafeColumnOptionProvider(): array + { + return [ + 'charset statement terminator' => ['charset', 'utf8mb3; DROP TABLE users; --', 'charset'], + 'charset quoted value' => ['charset', "'utf8mb3'", 'charset'], + 'collate statement terminator' => [ + 'collate', + 'utf8mb3_unicode_ci; DROP TABLE users; --', + 'collate', + ], + ]; + } + + /** @return array}> */ + public static function unsafeKeywordOptionProvider(): array + { + return [ + 'columnformat statement terminator' => [ + 'column_format', + 'FIXED; DROP TABLE users; --', + ColumnFormatEnum::class, + ], + 'columnformat unknown keyword' => ['column_format', 'COMPRESSED', ColumnFormatEnum::class], + 'storage statement terminator' => ['storage', 'DISK; DROP TABLE users; --', StorageEnum::class], + 'storage unknown keyword' => ['storage', 'TAPE', StorageEnum::class], + ]; + } + #[Test] public function addColumnAfter(): void { @@ -130,6 +207,62 @@ public function addColumnFormat(): void static::assertStringContainsString('COLUMN_FORMAT FIXED', $this->buildSql($alter)); } + #[Test] + #[DataProvider('unsafeKeywordOptionProvider')] + public function addColumnRejectsKeywordOptionValueThatWouldInjectSql( + string $option, + string $value, + string $enum, + ): void { + $alter = new AlterTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, $value); + $alter->addColumn($col); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage(sprintf( + '"%s" is not a valid backing value for enum %s', + strtoupper($value), + $enum, + )); + + $this->buildSql($alter); + } + + #[Test] + #[DataProvider('keywordOptionProvider')] + public function addColumnRejectsNonStringKeywordOptionValue(string $option): void + { + $alter = new AlterTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, true); + $alter->addColumn($col); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Invalid value for the "%s" column option', $option)); + $this->expectExceptionMessage('received "bool"'); + + $this->buildSql($alter); + } + + #[Test] + #[DataProvider('unsafeColumnOptionProvider')] + public function addColumnRejectsOptionValueThatWouldInjectSql( + string $option, + string $value, + string $reportedOption, + ): void { + $alter = new AlterTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, $value); + $alter->addColumn($col); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Invalid value for the "%s" column option', $reportedOption)); + + $this->buildSql($alter); + } + #[Test] public function addColumnStorage(): void { @@ -255,6 +388,62 @@ public function changeColumnIdentity(): void static::assertStringContainsString('AUTO_INCREMENT', $this->buildSql($alter)); } + #[Test] + #[DataProvider('unsafeKeywordOptionProvider')] + public function changeColumnRejectsKeywordOptionValueThatWouldInjectSql( + string $option, + string $value, + string $enum, + ): void { + $alter = new AlterTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, $value); + $alter->changeColumn('name', $col); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage(sprintf( + '"%s" is not a valid backing value for enum %s', + strtoupper($value), + $enum, + )); + + $this->buildSql($alter); + } + + #[Test] + #[DataProvider('keywordOptionProvider')] + public function changeColumnRejectsNonStringKeywordOptionValue(string $option): void + { + $alter = new AlterTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, true); + $alter->changeColumn('name', $col); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Invalid value for the "%s" column option', $option)); + $this->expectExceptionMessage('received "bool"'); + + $this->buildSql($alter); + } + + #[Test] + #[DataProvider('unsafeColumnOptionProvider')] + public function changeColumnRejectsOptionValueThatWouldInjectSql( + string $option, + string $value, + string $reportedOption, + ): void { + $alter = new AlterTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, $value); + $alter->changeColumn('name', $col); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Invalid value for the "%s" column option', $reportedOption)); + + $this->buildSql($alter); + } + #[Test] public function changeColumnStorage(): void { @@ -288,6 +477,36 @@ public function changeColumnZerofill(): void static::assertStringContainsString('ZEROFILL', $this->buildSql($alter)); } + /** + * Pins the exact DDL produced for a matrix of column options on an added column. + * + * @param array $options + */ + #[Test] + #[DataProvider('addColumnMatrixProvider')] + public function generatesExpectedSqlForAddedColumnOptions(array $options, string $expected): void + { + $alter = new AlterTable('test'); + $alter->addColumn($this->makeColumn($options)); + + static::assertSame($expected, $this->buildSql($alter)); + } + + /** + * Pins the exact DDL produced for a matrix of column options on a changed column. + * + * @param array $options + */ + #[Test] + #[DataProvider('changeColumnMatrixProvider')] + public function generatesExpectedSqlForChangedColumnOptions(array $options, string $expected): void + { + $alter = new AlterTable('test'); + $alter->changeColumn('name', $this->makeColumn($options)); + + static::assertSame($expected, $this->buildSql($alter)); + } + protected function setUp(): void { $driver = new Driver( @@ -305,4 +524,17 @@ private function buildSql(AlterTable $table): string return $decorator->getSqlString($this->platform); } + + /** @param array $options */ + private function makeColumn(array $options): Column\Varchar + { + $col = new Column\Varchar('name', 255); + $col->setNullable(false); + + foreach ($options as $name => $value) { + $col->setOption($name, $value); + } + + return $col; + } } diff --git a/test/unit/Sql/Ddl/CreateTableDecoratorTest.php b/test/unit/Sql/Ddl/CreateTableDecoratorTest.php index c048910..17553e2 100644 --- a/test/unit/Sql/Ddl/CreateTableDecoratorTest.php +++ b/test/unit/Sql/Ddl/CreateTableDecoratorTest.php @@ -4,28 +4,92 @@ namespace PhpDbTest\Mysql\Sql\Ddl; +use BackedEnum; use PhpDb\Adapter\Driver\Pdo\AbstractPdoConnection; use PhpDb\Adapter\Driver\Pdo\Result; use PhpDb\Adapter\Driver\Pdo\Statement; use PhpDb\Mysql\AdapterPlatform; use PhpDb\Mysql\Pdo\Driver; +use PhpDb\Mysql\Sql\ColumnFormatEnum; +use PhpDb\Mysql\Sql\Ddl\ColumnOptionTrait; use PhpDb\Mysql\Sql\Ddl\CreateTableDecorator; +use PhpDb\Mysql\Sql\StorageEnum; use PhpDb\Sql\Ddl\Column; use PhpDb\Sql\Ddl\Constraint; use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Exception\InvalidArgumentException; +use PhpDbTest\Mysql\Sql\Ddl\TestAsset\ColumnOptionMatrix; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\CoversTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use ValueError; + +use function sprintf; +use function strtoupper; #[CoversMethod(CreateTableDecorator::class, 'setSubject')] #[CoversMethod(CreateTableDecorator::class, 'processColumns')] -#[CoversMethod(CreateTableDecorator::class, 'getSqlInsertOffsets')] -#[CoversMethod(CreateTableDecorator::class, 'compareColumnOptions')] -#[CoversMethod(CreateTableDecorator::class, 'normalizeColumnOption')] +#[CoversTrait(ColumnOptionTrait::class)] final class CreateTableDecoratorTest extends TestCase { protected AdapterPlatform $platform; + /** @return array, string}> */ + public static function columnOptionMatrixProvider(): array + { + return ColumnOptionMatrix::pairedWith([ + 'all options' => "CREATE TABLE `test` ( \n `name` VARCHAR(255) UNSIGNED ZEROFILL CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL AUTO_INCREMENT COMMENT 'here' COLUMN_FORMAT DYNAMIC STORAGE MEMORY \n)", + 'charset collate' => "CREATE TABLE `test` ( \n `name` VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_unicode_ci NOT NULL \n)", + 'format storage' => "CREATE TABLE `test` ( \n `name` VARCHAR(255) NOT NULL COLUMN_FORMAT FIXED STORAGE DISK \n)", + 'reverse declared' => "CREATE TABLE `test` ( \n `name` VARCHAR(255) UNSIGNED CHARACTER SET latin1 NOT NULL COMMENT 'c' STORAGE DISK \n)", + 'unknown option' => "CREATE TABLE `test` ( \n `name` VARCHAR(255) CHARACTER SET utf8mb4 NOT NULL \n)", + 'after only' => "CREATE TABLE `test` ( \n `name` VARCHAR(255) NOT NULL \n)", + 'falsy skipped' => "CREATE TABLE `test` ( \n `name` VARCHAR(255) COLLATE utf8mb4_bin NOT NULL \n)", + ]); + } + + /** @return array */ + public static function keywordOptionProvider(): array + { + return [ + 'columnformat' => ['columnformat'], + 'storage' => ['storage'], + ]; + } + + /** @return array */ + public static function unsafeColumnOptionProvider(): array + { + return [ + 'charset statement terminator' => ['charset', 'utf8mb3; DROP TABLE users; --', 'charset'], + 'charset quoted value' => ['charset', "'utf8mb3'", 'charset'], + 'charset backtick' => ['charset', 'utf8mb3` DEFAULT `', 'charset'], + 'collate statement terminator' => [ + 'collate', + 'utf8mb3_unicode_ci; DROP TABLE users; --', + 'collate', + ], + 'collate trailing clause' => ['collate', 'utf8mb3_unicode_ci COMMENT "x"', 'collate'], + ]; + } + + /** @return array}> */ + public static function unsafeKeywordOptionProvider(): array + { + return [ + 'columnformat statement terminator' => [ + 'column_format', + 'FIXED; DROP TABLE users; --', + ColumnFormatEnum::class, + ], + 'columnformat unknown keyword' => ['column_format', 'COMPRESSED', ColumnFormatEnum::class], + 'storage statement terminator' => ['storage', 'DISK; DROP TABLE users; --', StorageEnum::class], + 'storage unknown keyword' => ['storage', 'TAPE', StorageEnum::class], + ]; + } + #[Test] public function charsetAppearsAfterUnsigned(): void { @@ -109,6 +173,19 @@ public function columnFormatOption(): void static::assertStringContainsString('COLUMN_FORMAT FIXED', $this->buildSql($table)); } + #[Test] + public function columnFormatOptionWithUnderscoreAlias(): void + { + $table = new CreateTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption('column_format', 'dynamic'); + $table->addColumn($col); + + $sql = $this->buildSql($table); + + static::assertStringContainsString('COLUMN_FORMAT DYNAMIC', $sql); + } + #[Test] public function commentOption(): void { @@ -158,6 +235,109 @@ public function fullColumnDefinition(): void static::assertStringContainsString('CHARACTER SET utf8mb3 COLLATE utf8mb3_unicode_ci NOT NULL', $sql); } + /** + * Pins the exact DDL produced for a matrix of column options. + * + * @param array $options + */ + #[Test] + #[DataProvider('columnOptionMatrixProvider')] + public function generatesExpectedSqlForColumnOptions(array $options, string $expected): void + { + $table = new CreateTable('test'); + $col = new Column\Varchar('name', 255); + $col->setNullable(false); + + foreach ($options as $name => $value) { + $col->setOption($name, $value); + } + + $table->addColumn($col); + + static::assertSame($expected, $this->buildSql($table)); + } + + #[Test] + public function optionInsertsBeforeInlinePrimaryKey(): void + { + $table = new CreateTable('test'); + $col = new Column\Integer('id'); + $col->addConstraint(new Constraint\PrimaryKey()); + $col->setOption('autoincrement', true); + $table->addColumn($col); + + $sql = $this->buildSql($table); + + static::assertStringContainsString('AUTO_INCREMENT PRIMARY KEY', $sql); + } + + #[Test] + public function optionInsertsBeforeInlineReferences(): void + { + $table = new CreateTable('test'); + $col = new Column\Integer('other_id'); + $col->addConstraint(new Constraint\ForeignKey('fk_other', 'other_id', 'other', 'id')); + $col->setOption('comment', 'linked'); + $table->addColumn($col); + + $sql = $this->buildSql($table); + + static::assertMatchesRegularExpression("/COMMENT 'linked'.*REFERENCES/s", $sql); + } + + #[Test] + #[DataProvider('unsafeColumnOptionProvider')] + public function rejectsColumnOptionValueThatWouldInjectSql( + string $option, + string $value, + string $reportedOption, + ): void { + $table = new CreateTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, $value); + $table->addColumn($col); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Invalid value for the "%s" column option', $reportedOption)); + + $this->buildSql($table); + } + + #[Test] + #[DataProvider('unsafeKeywordOptionProvider')] + public function rejectsKeywordOptionValueThatWouldInjectSql(string $option, string $value, string $enum): void + { + $table = new CreateTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, $value); + $table->addColumn($col); + + $this->expectException(ValueError::class); + $this->expectExceptionMessage(sprintf( + '"%s" is not a valid backing value for enum %s', + strtoupper($value), + $enum, + )); + + $this->buildSql($table); + } + + #[Test] + #[DataProvider('keywordOptionProvider')] + public function rejectsNonStringKeywordOptionValue(string $option): void + { + $table = new CreateTable('test'); + $col = new Column\Varchar('name', 255); + $col->setOption($option, true); + $table->addColumn($col); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Invalid value for the "%s" column option', $option)); + $this->expectExceptionMessage('received "bool"'); + + $this->buildSql($table); + } + #[Test] public function storageOption(): void { @@ -169,6 +349,14 @@ public function storageOption(): void static::assertStringContainsString('STORAGE DISK', $this->buildSql($table)); } + #[Test] + public function tableWithoutColumnsRendersNoColumnDefinitions(): void + { + $sql = $this->buildSql(new CreateTable('test')); + + static::assertStringContainsString('CREATE TABLE `test`', $sql); + } + #[Test] public function unsignedOption(): void { diff --git a/test/unit/Sql/Ddl/TestAsset/ColumnOptionMatrix.php b/test/unit/Sql/Ddl/TestAsset/ColumnOptionMatrix.php new file mode 100644 index 0000000..4498db6 --- /dev/null +++ b/test/unit/Sql/Ddl/TestAsset/ColumnOptionMatrix.php @@ -0,0 +1,58 @@ +> */ + public static function all(): array + { + return [ + 'all options' => [ + 'unsigned' => true, + 'zerofill' => true, + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_bin', + 'auto_increment' => true, + 'comment' => 'here', + 'column_format' => 'dynamic', + 'storage' => 'memory', + 'after' => 'id', + ], + 'charset collate' => ['charset' => 'utf8mb3', 'collate' => 'utf8mb3_unicode_ci'], + 'format storage' => ['column_format' => 'fixed', 'storage' => 'disk'], + 'reverse declared' => [ + 'storage' => 'DISK', + 'comment' => 'c', + 'unsigned' => true, + 'charset' => 'latin1', + ], + 'unknown option' => ['charset' => 'utf8mb4', 'nonsense' => 'ignored'], + 'after only' => ['after' => 'other_col'], + 'falsy skipped' => ['charset' => '', 'unsigned' => false, 'collate' => 'utf8mb4_bin'], + ]; + } + + /** + * @param array $expected Keyed by the option set name. + * @return array, string}> + */ + public static function pairedWith(array $expected): array + { + $cases = []; + foreach (self::all() as $name => $options) { + $cases[$name] = [$options, $expected[$name]]; + } + + return $cases; + } +}