Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions analyzer-baseline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 32 additions & 8 deletions docs/book/sql-ddl/constraints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand Down
23 changes: 18 additions & 5 deletions src/Sql/Ddl/Constraint/Check.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
Expand All @@ -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 = [];

Expand All @@ -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 [
Expand Down
103 changes: 103 additions & 0 deletions test/unit/Sql/Ddl/Constraint/CheckTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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('');
}
}
Loading