diff --git a/analyzer-baseline.toml b/analyzer-baseline.toml index d7150b8a..93a74a29 100644 --- a/analyzer-baseline.toml +++ b/analyzer-baseline.toml @@ -3006,18 +3006,6 @@ code = "class-must-be-final" message = 'Class `PhpDb\Sql\Ddl\Constraint\Check` should be declared `final`.' count = 1 -[[issues]] -file = "src/Sql/Ddl/Constraint/Check.php" -code = "missing-parameter-type" -message = "Parameter `$expression` is missing a type hint." -count = 1 - -[[issues]] -file = "src/Sql/Ddl/Constraint/Check.php" -code = "possibly-invalid-argument" -message = 'Possible argument type mismatch for argument #1 of `PhpDb\Sql\Argument\Literal::__construct`: expected `string`, but possibly received `PhpDb\Sql\ExpressionInterface|non-empty-string`.' -count = 1 - [[issues]] file = "src/Sql/Ddl/Constraint/ConstraintInterface.php" code = "imprecise-type" diff --git a/docs/book/sql-ddl/constraints.md b/docs/book/sql-ddl/constraints.md index 887b1daa..e003eeba 100644 --- a/docs/book/sql-ddl/constraints.md +++ b/docs/book/sql-ddl/constraints.md @@ -217,26 +217,43 @@ $check = new Check( ### Using Expressions in Check Constraints -Check constraints can accept either string expressions or `Expression` objects. +Check constraints accept either a string or any `ExpressionInterface` implementation such as +`PhpDb\Sql\Expression`. The name is optional; without one the constraint renders as a bare +`CHECK (...)` clause. #### String Expressions (Simple) -For simple constraints, use strings: +A string is an expression slot by design: it is rendered verbatim as a literal SQL fragment, so +identifiers and values inside it are not quoted or escaped. Only pass trusted input. An empty +string throws `PhpDb\Sql\Exception\InvalidArgumentException` at construction. ```php use PhpDb\Sql\Ddl\Constraint\Check; -// Simple string expression +// Named $check = new Check('age >= 18', 'check_adult'); $check = new Check('price > 0', 'check_positive_price'); $check = new Check("status IN ('active', 'pending', 'completed')", 'check_valid_status'); + +// Unnamed +$check = new Check('price > 0'); +``` + +**Generated SQL:** + +```sql +CONSTRAINT "check_adult" CHECK (age >= 18) +CHECK (price > 0) ``` #### Expression Objects (Advanced) -For complex or parameterized constraints, use `Expression` objects: +An `Expression` is rendered through the platform, so its identifier and value arguments are +quoted. Values are always inlined into the DDL statement: DDL is never prepared, and MySQL rejects +parameter markers inside a `CHECK` clause. ```php +use PhpDb\Sql\Argument\Identifier; use PhpDb\Sql\Expression; use PhpDb\Sql\Ddl\Constraint\Check; @@ -247,12 +264,19 @@ $expr = new Expression( ); $check = new Check($expr, 'check_valid_age_range'); -// Complex expression +// Expression with quoted identifiers $expr = new Expression( - 'discount_percent BETWEEN ? AND ?', - [0, 100] + '? > ?', + [new Identifier('end_date'), new Identifier('start_date')] ); -$check = new Check($expr, 'check_discount_range'); +$check = new Check($expr, 'check_date_range'); +``` + +**Generated SQL:** + +```sql +CONSTRAINT "check_valid_age_range" CHECK (age >= '18' AND age <= '120') +CONSTRAINT "check_date_range" CHECK ("end_date" > "start_date") ``` ## Indexes diff --git a/src/Sql/Ddl/Constraint/Check.php b/src/Sql/Ddl/Constraint/Check.php index a6608f9b..1490d197 100644 --- a/src/Sql/Ddl/Constraint/Check.php +++ b/src/Sql/Ddl/Constraint/Check.php @@ -7,9 +7,11 @@ use Override; use PhpDb\Sql\Argument\Identifier; use PhpDb\Sql\Argument\Literal; +use PhpDb\Sql\Exception\InvalidArgumentException; use PhpDb\Sql\ExpressionInterface; use function implode; +use function sprintf; class Check extends AbstractConstraint { @@ -18,10 +20,17 @@ class Check extends AbstractConstraint protected string $specification = 'CHECK (%s)'; /** - * @param string|ExpressionInterface $expression + * A string expression is rendered verbatim as a literal SQL fragment. An ExpressionInterface + * expression is rendered through the platform, so its identifiers and values are quoted. + * + * @throws InvalidArgumentException When the expression is an empty string. */ - public function __construct($expression, ?string $name) + public function __construct(string|ExpressionInterface $expression, ?string $name = null) { + if ('' === $expression) { + throw new InvalidArgumentException('Check constraint expression must not be an empty string.'); + } + parent::__construct(null, $name); $this->expression = $expression; @@ -31,6 +40,10 @@ public function __construct($expression, ?string $name) #[Override] public function getExpressionData(): array { + $checkData = $this->expression instanceof ExpressionInterface + ? $this->expression->getExpressionData() + : ['spec' => '%s', 'values' => [new Literal($this->expression)]]; + $specParts = []; $values = []; @@ -39,9 +52,9 @@ public function getExpressionData(): array $values[] = new Identifier($this->name); } - if ('' !== $this->expression) { - $specParts[] = $this->specification; - $values[] = new Literal($this->expression); + $specParts[] = sprintf($this->specification, $checkData['spec']); + foreach ($checkData['values'] as $value) { + $values[] = $value; } return [ diff --git a/test/unit/Sql/Ddl/Constraint/CheckTest.php b/test/unit/Sql/Ddl/Constraint/CheckTest.php index 921b8da0..93fff318 100644 --- a/test/unit/Sql/Ddl/Constraint/CheckTest.php +++ b/test/unit/Sql/Ddl/Constraint/CheckTest.php @@ -5,14 +5,108 @@ namespace PhpDbTest\Sql\Ddl\Constraint; use PhpDb\Sql\Argument; +use PhpDb\Sql\Argument\Identifier; +use PhpDb\Sql\Ddl\Column\Integer; use PhpDb\Sql\Ddl\Constraint\Check; +use PhpDb\Sql\Ddl\Constraint\ConstraintInterface; +use PhpDb\Sql\Ddl\CreateTable; +use PhpDb\Sql\Exception\InvalidArgumentException; +use PhpDb\Sql\Expression; +use PhpDbTest\TestAsset\TrustingSql92Platform; use PHPUnit\Framework\Attributes\CoversMethod; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; #[CoversMethod(Check::class, '__construct')] #[CoversMethod(Check::class, 'getExpressionData')] +#[Group('unit')] final class CheckTest extends TestCase { + /** + * Asserts what a table-level constraint renders to inside CREATE TABLE, with values quoted. + */ + private static function assertConstraintRenders(string $expected, ConstraintInterface $constraint): void + { + $createTable = new CreateTable('t'); + $createTable->addConstraint($constraint); + + static::assertSame( + "CREATE TABLE \"t\" ( \n {$expected} \n)", + $createTable->getSqlString(new TrustingSql92Platform()), + ); + } + + #[Test] + public function getExpressionDataMergesExpressionSpecAndValues(): void + { + $check = new Check(new Expression('a > ?', [1]), 'chk'); + + $expressionData = $check->getExpressionData(); + + static::assertSame('CONSTRAINT %s CHECK (a > %s)', $expressionData['spec']); + static::assertEquals( + [ + Argument::identifier('chk'), + Argument::value(1), + ], + $expressionData['values'], + ); + } + + #[Test] + public function rendersColumnLevelCheckInline(): void + { + $column = new Integer('age'); + $column->addConstraint(new Check('age >= 0', 'chk_age')); + + $createTable = new CreateTable('t'); + $createTable->addColumn($column); + + static::assertSame( + "CREATE TABLE \"t\" ( \n \"age\" INTEGER NOT NULL CONSTRAINT \"chk_age\" CHECK (age >= 0) \n)", + $createTable->getSqlString(), + ); + } + + #[Test] + public function rendersExpressionIdentifiersQuoted(): void + { + $check = new Check(new Expression('? > ?', [new Identifier('a'), new Identifier('b')]), 'chk'); + + static::assertConstraintRenders('CONSTRAINT "chk" CHECK ("a" > "b")', $check); + } + + #[Test] + public function rendersExpressionValuesQuoted(): void + { + $check = new Check(new Expression('a > ?', [1]), 'chk'); + + static::assertConstraintRenders('CONSTRAINT "chk" CHECK (a > \'1\')', $check); + } + + #[Test] + public function rendersPercentSignsInExpressionVerbatimWhenNamed(): void + { + $check = new Check(new Expression("email LIKE '%@%'"), 'chk'); + + static::assertConstraintRenders('CONSTRAINT "chk" CHECK (email LIKE \'%@%\')', $check); + } + + #[Test] + public function rendersPercentSignsInExpressionVerbatimWhenUnnamed(): void + { + $check = new Check(new Expression("email LIKE '%@%'")); + + static::assertConstraintRenders('CHECK (email LIKE \'%@%\')', $check); + } + + #[Test] + public function rendersWithoutConstraintClauseWhenUnnamed(): void + { + static::assertConstraintRenders('CHECK (id > 0)', new Check('id > 0')); + } + public function testGetExpressionData(): void { $check = new Check('id>0', 'foo'); @@ -28,4 +122,13 @@ public function testGetExpressionData(): void $expressionData['values'], ); } + + #[Test] + public function throwsWhenExpressionIsEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Check constraint expression must not be an empty string.'); + + new Check(''); + } }