Skip to content

Commit 69729a3

Browse files
authored
5.x - Simpler check constraints (#932)
- With CheckConstraint in cakephp/database we don't need as much code in migrations. - Fix SQLite implementation. - Add test coverage for sqlite + postgres. - Remove reflection logic and use cakephp/database - Fix test for mariadb
1 parent 23bf8ca commit 69729a3

File tree

7 files changed

+147
-181
lines changed

7 files changed

+147
-181
lines changed

src/Db/Adapter/MysqlAdapter.php

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -915,34 +915,9 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr
915915
*/
916916
protected function getCheckConstraints(string $tableName): array
917917
{
918-
$database = (string)$this->getOption('database');
919-
$query = $this->getSelectBuilder()
920-
->select(['cc.CONSTRAINT_NAME', 'cc.CHECK_CLAUSE'])
921-
->from(['cc' => 'INFORMATION_SCHEMA.CHECK_CONSTRAINTS'])
922-
->innerJoin(
923-
['tc' => 'INFORMATION_SCHEMA.TABLE_CONSTRAINTS'],
924-
[
925-
'tc.CONSTRAINT_SCHEMA = cc.CONSTRAINT_SCHEMA',
926-
'tc.CONSTRAINT_NAME = cc.CONSTRAINT_NAME',
927-
],
928-
)
929-
->where([
930-
'tc.CONSTRAINT_SCHEMA' => $database,
931-
'tc.TABLE_NAME' => $tableName,
932-
'tc.CONSTRAINT_TYPE' => 'CHECK',
933-
]);
934-
935-
$rows = $query->execute()->fetchAll('assoc');
936-
$constraints = [];
937-
938-
foreach ($rows as $row) {
939-
$constraints[] = [
940-
'name' => $row['CONSTRAINT_NAME'],
941-
'expression' => $row['CHECK_CLAUSE'],
942-
];
943-
}
918+
$dialect = $this->getSchemaDialect();
944919

945-
return $constraints;
920+
return $dialect->describeCheckConstraints($tableName);
946921
}
947922

948923
/**

src/Db/Adapter/PostgresAdapter.php

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -755,35 +755,8 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr
755755
*/
756756
protected function getCheckConstraints(string $tableName): array
757757
{
758-
$parts = $this->getSchemaName($tableName);
759-
$query = $this->getSelectBuilder()
760-
->select(['con.conname', 'pg_get_constraintdef(con.oid)'])
761-
->from(['con' => 'pg_constraint'])
762-
->innerJoin(['ns' => 'pg_namespace'], ['ns.oid = con.connamespace'])
763-
->innerJoin(['cls' => 'pg_class'], ['cls.oid = con.conrelid'])
764-
->where([
765-
'ns.nspname' => $parts['schema'],
766-
'cls.relname' => $parts['table'],
767-
'con.contype' => 'c',
768-
]);
769-
770-
$rows = $query->execute()->fetchAll('assoc');
771-
$constraints = [];
772-
773-
foreach ($rows as $row) {
774-
// Extract the expression from the constraint definition (remove "CHECK (" and trailing ")")
775-
$definition = $row['pg_get_constraintdef'];
776-
if (preg_match('/^CHECK \((.+)\)$/s', $definition, $matches)) {
777-
$expression = $matches[1];
778-
} else {
779-
$expression = $definition;
780-
}
781-
782-
$constraints[] = [
783-
'name' => $row['conname'],
784-
'expression' => $expression,
785-
];
786-
}
758+
$dialect = $this->getSchemaDialect();
759+
$constraints = $dialect->describeCheckConstraints($tableName);
787760

788761
return $constraints;
789762
}

src/Db/Adapter/SqliteAdapter.php

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,28 +1517,9 @@ protected function getDropForeignKeyByColumnsInstructions(string $tableName, arr
15171517
*/
15181518
protected function getCheckConstraints(string $tableName): array
15191519
{
1520-
$constraints = [];
1521-
$createSql = $this->getDeclaringSql($tableName);
1522-
1523-
// Parse CHECK constraints from CREATE TABLE statement
1524-
// Match CONSTRAINT name CHECK (expression) or just CHECK (expression)
1525-
$pattern = '/(?:CONSTRAINT\s+([^\s]+)\s+)?CHECK\s*\(([^)]+(?:\([^)]*\)[^)]*)*)\)/is';
1526-
1527-
if (preg_match_all($pattern, $createSql, $matches, PREG_SET_ORDER)) {
1528-
foreach ($matches as $index => $match) {
1529-
$name = !empty($match[1])
1530-
? trim($match[1], '"`[]')
1531-
: 'check_' . $index;
1532-
$expression = trim($match[2]);
1533-
1534-
$constraints[] = [
1535-
'name' => $name,
1536-
'expression' => $expression,
1537-
];
1538-
}
1539-
}
1520+
$dialect = $this->getSchemaDialect();
15401521

1541-
return $constraints;
1522+
return $dialect->describeCheckConstraints($tableName);
15421523
}
15431524

15441525
/**
@@ -1588,11 +1569,10 @@ protected function getDropCheckConstraintInstructions(string $tableName, string
15881569
$instructions->addPostStep(function ($state) use ($constraintName) {
15891570
// Remove the check constraint from the CREATE TABLE statement
15901571
// Match CONSTRAINT name CHECK (expression) or just CHECK (expression)
1591-
$quotedName = preg_quote($this->possiblyQuotedIdentifierRegex($constraintName, false), '/');
1572+
$quotedName = $this->possiblyQuotedIdentifierRegex($constraintName, false);
15921573
$pattern = "/,?\s*CONSTRAINT\s+{$quotedName}\s+CHECK\s*\([^)]+(?:\([^)]*\)[^)]*)*\)/is";
15931574

15941575
$sql = preg_replace($pattern, '', (string)$state['createSQL'], 1);
1595-
15961576
if ($sql) {
15971577
$this->execute($sql);
15981578
}

src/Db/Table/CheckConstraint.php

Lines changed: 2 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,89 +8,13 @@
88

99
namespace Migrations\Db\Table;
1010

11-
use InvalidArgumentException;
11+
use Cake\Database\Schema\CheckConstraint as DatabaseCheckConstraint;
1212

1313
/**
1414
* Check constraint value object
1515
*
1616
* Used to define check constraints that are added to tables as part of migrations.
1717
*/
18-
class CheckConstraint
18+
class CheckConstraint extends DatabaseCheckConstraint
1919
{
20-
/**
21-
* @var string|null
22-
*/
23-
protected ?string $name = null;
24-
25-
/**
26-
* @var string
27-
*/
28-
protected string $expression;
29-
30-
/**
31-
* Constructor
32-
*
33-
* @param string|null $name Constraint name (optional, will be auto-generated if null)
34-
* @param string $expression The check constraint expression (e.g., "age >= 18")
35-
*/
36-
public function __construct(?string $name = null, string $expression = '')
37-
{
38-
if ($name !== null) {
39-
$this->name = $name;
40-
}
41-
if ($expression !== '') {
42-
$this->expression = $expression;
43-
}
44-
}
45-
46-
/**
47-
* Set the constraint name.
48-
*
49-
* @param string $name Constraint name
50-
* @return $this
51-
*/
52-
public function setName(string $name)
53-
{
54-
$this->name = $name;
55-
56-
return $this;
57-
}
58-
59-
/**
60-
* Get the constraint name.
61-
*
62-
* @return string|null
63-
*/
64-
public function getName(): ?string
65-
{
66-
return $this->name;
67-
}
68-
69-
/**
70-
* Set the check constraint expression.
71-
*
72-
* @param string $expression The SQL expression for the check constraint
73-
* @return $this
74-
* @throws \InvalidArgumentException
75-
*/
76-
public function setExpression(string $expression)
77-
{
78-
if (trim($expression) === '') {
79-
throw new InvalidArgumentException('Check constraint expression cannot be empty');
80-
}
81-
82-
$this->expression = $expression;
83-
84-
return $this;
85-
}
86-
87-
/**
88-
* Get the check constraint expression.
89-
*
90-
* @return string
91-
*/
92-
public function getExpression(): string
93-
{
94-
return $this->expression;
95-
}
9620
}

tests/TestCase/Db/Adapter/MysqlAdapterTest.php

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Cake\Console\TestSuite\StubConsoleOutput;
99
use Cake\Core\Configure;
1010
use Cake\Database\Connection;
11+
use Cake\Database\Driver\Mysql;
1112
use Cake\Datasource\ConnectionManager;
1213
use InvalidArgumentException;
1314
use Migrations\Db\Adapter\MysqlAdapter;
@@ -2327,10 +2328,7 @@ public function testAddCheckConstraint()
23272328
$table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2])
23282329
->create();
23292330

2330-
$checkConstraint = new CheckConstraint();
2331-
$checkConstraint->setName('price_positive')
2332-
->setExpression('price > 0');
2333-
2331+
$checkConstraint = new CheckConstraint('price_positive', 'price > 0');
23342332
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
23352333

23362334
$this->assertTrue($this->adapter->hasCheckConstraint('check_table', 'price_positive'));
@@ -2342,19 +2340,18 @@ public function testAddCheckConstraintWithAutoGeneratedName()
23422340
$table->addColumn('age', 'integer')
23432341
->create();
23442342

2345-
$checkConstraint = new CheckConstraint();
2346-
$checkConstraint->setExpression('age >= 18');
2343+
$checkConstraint = new CheckConstraint('', 'age >= 18');
23472344

23482345
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
23492346

2350-
// The constraint should exist with an auto-generated name
2351-
$constraints = $this->adapter->fetchAll(sprintf(
2352-
"SELECT cc.CONSTRAINT_NAME FROM INFORMATION_SCHEMA.CHECK_CONSTRAINTS cc INNER JOIN INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc ON cc.CONSTRAINT_SCHEMA = tc.CONSTRAINT_SCHEMA AND cc.CONSTRAINT_NAME = tc.CONSTRAINT_NAME WHERE tc.CONSTRAINT_SCHEMA = '%s' AND tc.TABLE_NAME = 'check_table2'",
2353-
$this->config['database'],
2354-
));
2347+
$driver = $this->adapter->getConnection()->getDriver();
2348+
assert($driver instanceof Mysql);
23552349

2350+
$dialect = $driver->schemaDialect();
2351+
$constraints = $dialect->describeCheckConstraints('check_table2');
23562352
$this->assertCount(1, $constraints);
2357-
$this->assertStringContainsString('check_table2_chk_', $constraints[0]['CONSTRAINT_NAME']);
2353+
$expected = $driver->isMariaDb() ? 'CONSTRAINT_1' : 'check_table2_chk_';
2354+
$this->assertStringContainsString($expected, $constraints[0]['name']);
23582355
}
23592356

23602357
public function testHasCheckConstraint()
@@ -2363,10 +2360,7 @@ public function testHasCheckConstraint()
23632360
$table->addColumn('quantity', 'integer')
23642361
->create();
23652362

2366-
$checkConstraint = new CheckConstraint();
2367-
$checkConstraint->setName('quantity_positive')
2368-
->setExpression('quantity > 0');
2369-
2363+
$checkConstraint = new CheckConstraint('quantity_positive', 'quantity > 0');
23702364
$this->assertFalse($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive'));
23712365

23722366
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
@@ -2380,10 +2374,7 @@ public function testDropCheckConstraint()
23802374
$table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2])
23812375
->create();
23822376

2383-
$checkConstraint = new CheckConstraint();
2384-
$checkConstraint->setName('price_check')
2385-
->setExpression('price BETWEEN 0 AND 1000');
2386-
2377+
$checkConstraint = new CheckConstraint('price_check', 'price BETWEEN 0 AND 1000');
23872378
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
23882379
$this->assertTrue($this->adapter->hasCheckConstraint('check_table4', 'price_check'));
23892380

@@ -2398,10 +2389,10 @@ public function testCheckConstraintWithComplexExpression()
23982389
->addColumn('status', 'string', ['limit' => 20])
23992390
->create();
24002391

2401-
$checkConstraint = new CheckConstraint();
2402-
$checkConstraint->setName('status_valid')
2403-
->setExpression("status IN ('active', 'inactive', 'pending')");
2404-
2392+
$checkConstraint = new CheckConstraint(
2393+
'status_valid',
2394+
"status IN ('active', 'inactive', 'pending')",
2395+
);
24052396
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
24062397
$this->assertTrue($this->adapter->hasCheckConstraint('check_table5', 'status_valid'));
24072398

tests/TestCase/Db/Adapter/PostgresAdapterTest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
use Migrations\Db\Adapter\PostgresAdapter;
1414
use Migrations\Db\Literal;
1515
use Migrations\Db\Table;
16+
use Migrations\Db\Table\CheckConstraint;
1617
use Migrations\Db\Table\Column;
1718
use Migrations\Db\Table\ForeignKey;
1819
use Migrations\Db\Table\Index;
1920
use PDO;
21+
use PDOException;
2022
use PHPUnit\Framework\Attributes\DataProvider;
2123
use PHPUnit\Framework\Attributes\Depends;
2224
use PHPUnit\Framework\TestCase;
@@ -2757,4 +2759,64 @@ public function testSerialAliases(string $columnType): void
27572759
$this->assertTrue($column->isIdentity());
27582760
$this->assertFalse($column->isNull());
27592761
}
2762+
2763+
public function testAddCheckConstraint()
2764+
{
2765+
$table = new Table('check_table', [], $this->adapter);
2766+
$table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2])
2767+
->create();
2768+
2769+
$checkConstraint = new CheckConstraint('price_positive', 'price > 0');
2770+
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
2771+
2772+
$this->assertTrue($this->adapter->hasCheckConstraint('check_table', 'price_positive'));
2773+
}
2774+
2775+
public function testHasCheckConstraint()
2776+
{
2777+
$table = new Table('check_table3', [], $this->adapter);
2778+
$table->addColumn('quantity', 'integer')
2779+
->create();
2780+
2781+
$checkConstraint = new CheckConstraint('quantity_positive', 'quantity > 0');
2782+
$this->assertFalse($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive'));
2783+
2784+
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
2785+
2786+
$this->assertTrue($this->adapter->hasCheckConstraint('check_table3', 'quantity_positive'));
2787+
}
2788+
2789+
public function testDropCheckConstraint()
2790+
{
2791+
$table = new Table('check_table4', [], $this->adapter);
2792+
$table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2])
2793+
->create();
2794+
2795+
$checkConstraint = new CheckConstraint('price_check', 'price BETWEEN 0 AND 1000');
2796+
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
2797+
$this->assertTrue($this->adapter->hasCheckConstraint('check_table4', 'price_check'));
2798+
2799+
$this->adapter->dropCheckConstraint('check_table4', 'price_check');
2800+
$this->assertFalse($this->adapter->hasCheckConstraint('check_table4', 'price_check'));
2801+
}
2802+
2803+
public function testCheckConstraintWithComplexExpression()
2804+
{
2805+
$table = new Table('check_table5', [], $this->adapter);
2806+
$table->addColumn('email', 'string', ['limit' => 255])
2807+
->addColumn('status', 'string', ['limit' => 20])
2808+
->create();
2809+
2810+
$checkConstraint = new CheckConstraint(
2811+
'status_valid',
2812+
"status IN ('active', 'inactive', 'pending')",
2813+
);
2814+
$this->adapter->addCheckConstraint($table->getTable(), $checkConstraint);
2815+
$this->assertTrue($this->adapter->hasCheckConstraint('check_table5', 'status_valid'));
2816+
2817+
// Verify the constraint is actually enforced
2818+
$quotedTableName = $this->adapter->getConnection()->getDriver()->quoteIdentifier('check_table5');
2819+
$this->expectException(PDOException::class);
2820+
$this->adapter->execute("INSERT INTO {$quotedTableName} (email, status) VALUES ('[email protected]', 'invalid')");
2821+
}
27602822
}

0 commit comments

Comments
 (0)