diff --git a/docs/en/writing-migrations.rst b/docs/en/writing-migrations.rst index 6da4195d..cea197ff 100644 --- a/docs/en/writing-migrations.rst +++ b/docs/en/writing-migrations.rst @@ -1547,7 +1547,7 @@ You can add a check constraint to a table using the ``addCheckConstraint()`` met { $table = $this->table('products'); $table->addColumn('price', 'decimal', ['precision' => 10, 'scale' => 2]) - ->addCheckConstraint('price_positive', 'price > 0') + ->addCheckConstraint('price > 0', ['name' => 'price_positive']) ->save(); } @@ -1562,18 +1562,19 @@ You can add a check constraint to a table using the ``addCheckConstraint()`` met } } -The first argument is the constraint name, and the second is the SQL expression -that defines the constraint. The expression should evaluate to a boolean value. +The first argument is the SQL expression that defines the constraint. The expression +should evaluate to a boolean value. The second argument is an options array where +you can specify the constraint ``name``. -Using the CheckConstraint Fluent Builder -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Using the CheckConstraint Object +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -For more complex scenarios, you can use the ``checkConstraint()`` method to get -a fluent builder:: +For more complex scenarios, you can create a ``CheckConstraint`` object directly:: addColumn('age', 'integer') ->addColumn('status', 'string', ['limit' => 20]) ->addCheckConstraint( - $this->checkConstraint() - ->setName('age_valid') - ->setExpression('age >= 18 AND age <= 120') + new CheckConstraint('age_valid', 'age >= 18 AND age <= 120') ) ->addCheckConstraint( - $this->checkConstraint() - ->setName('status_valid') - ->setExpression("status IN ('active', 'inactive', 'pending')") + new CheckConstraint('status_valid', "status IN ('active', 'inactive', 'pending')") ) ->save(); } @@ -1602,8 +1599,7 @@ a fluent builder:: Auto-Generated Constraint Names ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If you don't specify a constraint name, one will be automatically generated based -on the table name and expression hash:: +If you don't specify a constraint name, one will be automatically generated:: table('inventory'); $table->addColumn('quantity', 'integer') - // Name will be auto-generated like 'inventory_chk_a1b2c3d4' - ->addCheckConstraint( - $this->checkConstraint() - ->setExpression('quantity >= 0') - ) + // Name will be auto-generated + ->addCheckConstraint('quantity >= 0') ->save(); } } @@ -1647,8 +1640,8 @@ Check constraints can reference multiple columns and use complex SQL expressions $table->addColumn('start_date', 'date') ->addColumn('end_date', 'date') ->addColumn('discount', 'decimal', ['precision' => 5, 'scale' => 2]) - ->addCheckConstraint('valid_date_range', 'end_date >= start_date') - ->addCheckConstraint('valid_discount', 'discount BETWEEN 0 AND 100') + ->addCheckConstraint('end_date >= start_date', ['name' => 'valid_date_range']) + ->addCheckConstraint('discount BETWEEN 0 AND 100', ['name' => 'valid_discount']) ->save(); } } @@ -1674,7 +1667,7 @@ You can verify if a check constraint exists using the ``hasCheckConstraint()`` m if ($exists) { // do something } else { - $table->addCheckConstraint('price_positive', 'price > 0') + $table->addCheckConstraint('price > 0', ['name' => 'price_positive']) ->save(); } } @@ -1708,7 +1701,7 @@ constraint name:: public function down(): void { $table = $this->table('products'); - $table->addCheckConstraint('price_positive', 'price > 0') + $table->addCheckConstraint('price > 0', ['name' => 'price_positive']) ->save(); } } diff --git a/src/Db/Action/AddCheckConstraint.php b/src/Db/Action/AddCheckConstraint.php new file mode 100644 index 00000000..81b2ebab --- /dev/null +++ b/src/Db/Action/AddCheckConstraint.php @@ -0,0 +1,65 @@ +checkConstraint = $checkConstraint; + } + + /** + * Creates a new AddCheckConstraint object after building the check constraint with + * the passed attributes + * + * @param \Migrations\Db\Table\TableMetadata $table The table object to add the check constraint to + * @param string $expression The check constraint expression (e.g., "age >= 18") + * @param array $options Options for the check constraint (e.g., 'name') + * @return self + */ + public static function build( + TableMetadata $table, + string $expression, + array $options = [], + ): self { + $name = $options['name'] ?? ''; + + $checkConstraint = new CheckConstraint($name, $expression); + + return new AddCheckConstraint($table, $checkConstraint); + } + + /** + * Returns the check constraint to be added + * + * @return \Migrations\Db\Table\CheckConstraint + */ + public function getCheckConstraint(): CheckConstraint + { + return $this->checkConstraint; + } +} diff --git a/src/Db/Action/DropCheckConstraint.php b/src/Db/Action/DropCheckConstraint.php new file mode 100644 index 00000000..559cfb19 --- /dev/null +++ b/src/Db/Action/DropCheckConstraint.php @@ -0,0 +1,43 @@ +constraintName = $constraintName; + } + + /** + * Returns the name of the check constraint to drop + * + * @return string + */ + public function getConstraintName(): string + { + return $this->constraintName; + } +} diff --git a/src/Db/Adapter/AbstractAdapter.php b/src/Db/Adapter/AbstractAdapter.php index 01497ea1..bcb61058 100644 --- a/src/Db/Adapter/AbstractAdapter.php +++ b/src/Db/Adapter/AbstractAdapter.php @@ -23,6 +23,7 @@ use Exception; use InvalidArgumentException; use Migrations\Config\Config; +use Migrations\Db\Action\AddCheckConstraint; use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; @@ -32,6 +33,7 @@ use Migrations\Db\Action\ChangePrimaryKey; use Migrations\Db\Action\CreateTrigger; use Migrations\Db\Action\CreateView; +use Migrations\Db\Action\DropCheckConstraint; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropPartition; @@ -1850,6 +1852,22 @@ public function executeActions(TableMetadata $table, array $actions): void )); break; + case $action instanceof AddCheckConstraint: + /** @var \Migrations\Db\Action\AddCheckConstraint $action */ + $instructions->merge($this->getAddCheckConstraintInstructions( + $table, + $action->getCheckConstraint(), + )); + break; + + case $action instanceof DropCheckConstraint: + /** @var \Migrations\Db\Action\DropCheckConstraint $action */ + $instructions->merge($this->getDropCheckConstraintInstructions( + $table->getName(), + $action->getConstraintName(), + )); + break; + default: throw new InvalidArgumentException( sprintf("Don't know how to execute action `%s`", get_class($action)), diff --git a/src/Db/Plan/Plan.php b/src/Db/Plan/Plan.php index 6c493033..c25ba0a0 100644 --- a/src/Db/Plan/Plan.php +++ b/src/Db/Plan/Plan.php @@ -9,6 +9,7 @@ namespace Migrations\Db\Plan; use ArrayObject; +use Migrations\Db\Action\AddCheckConstraint; use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; @@ -19,6 +20,7 @@ use Migrations\Db\Action\CreateTable; use Migrations\Db\Action\CreateTrigger; use Migrations\Db\Action\CreateView; +use Migrations\Db\Action\DropCheckConstraint; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropPartition; @@ -497,7 +499,9 @@ protected function gatherIndexes(array $actions): void } /** - * Collects all foreign key creation and drops from the given intent + * Collects all constraint creation and drops from the given intent + * + * This includes foreign keys and check constraints. * * @param \Migrations\Db\Action\Action[] $actions The actions to parse * @return void @@ -505,7 +509,12 @@ protected function gatherIndexes(array $actions): void protected function gatherConstraints(array $actions): void { foreach ($actions as $action) { - if (!($action instanceof AddForeignKey || $action instanceof DropForeignKey)) { + if ( + !($action instanceof AddForeignKey) + && !($action instanceof DropForeignKey) + && !($action instanceof AddCheckConstraint) + && !($action instanceof DropCheckConstraint) + ) { continue; } $table = $action->getTable(); diff --git a/src/Db/Table.php b/src/Db/Table.php index 6ba2c036..59bb1b0c 100644 --- a/src/Db/Table.php +++ b/src/Db/Table.php @@ -11,6 +11,7 @@ use Cake\Collection\Collection; use Cake\Core\Configure; use InvalidArgumentException; +use Migrations\Db\Action\AddCheckConstraint; use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; @@ -21,6 +22,7 @@ use Migrations\Db\Action\CreateTable; use Migrations\Db\Action\CreateTrigger; use Migrations\Db\Action\CreateView; +use Migrations\Db\Action\DropCheckConstraint; use Migrations\Db\Action\DropForeignKey; use Migrations\Db\Action\DropIndex; use Migrations\Db\Action\DropPartition; @@ -35,6 +37,7 @@ use Migrations\Db\Adapter\MysqlAdapter; use Migrations\Db\Plan\Intent; use Migrations\Db\Plan\Plan; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; @@ -625,6 +628,50 @@ public function hasForeignKey(string|array $columns, ?string $constraint = null) return $this->getAdapter()->hasForeignKey($this->getName(), $columns, $constraint); } + /** + * Add a check constraint to a database table. + * + * @param string|\Migrations\Db\Table\CheckConstraint $expression The check constraint expression or object + * @param array $options Options for the check constraint (e.g., 'name') + * @return $this + */ + public function addCheckConstraint(string|CheckConstraint $expression, array $options = []) + { + if ($expression instanceof CheckConstraint) { + $action = new AddCheckConstraint($this->table, $expression); + } else { + $action = AddCheckConstraint::build($this->table, $expression, $options); + } + $this->actions->addAction($action); + + return $this; + } + + /** + * Removes the given check constraint from the table. + * + * @param string $constraintName The name of the check constraint to drop + * @return $this + */ + public function dropCheckConstraint(string $constraintName) + { + $action = new DropCheckConstraint($this->table, $constraintName); + $this->actions->addAction($action); + + return $this; + } + + /** + * Checks to see if a check constraint exists. + * + * @param string $constraintName The name of the check constraint + * @return bool + */ + public function hasCheckConstraint(string $constraintName): bool + { + return $this->getAdapter()->hasCheckConstraint($this->getName(), $constraintName); + } + /** * Add partitioning to the table. * diff --git a/tests/TestCase/Db/Table/TableTest.php b/tests/TestCase/Db/Table/TableTest.php index 1be0f352..4865e4a1 100644 --- a/tests/TestCase/Db/Table/TableTest.php +++ b/tests/TestCase/Db/Table/TableTest.php @@ -4,9 +4,11 @@ namespace Migrations\Test\TestCase\Db\Table; use InvalidArgumentException; +use Migrations\Db\Action\AddCheckConstraint; use Migrations\Db\Action\AddColumn; use Migrations\Db\Action\AddForeignKey; use Migrations\Db\Action\AddIndex; +use Migrations\Db\Action\DropCheckConstraint; use Migrations\Db\Action\DropIndex; use Migrations\Db\Adapter\AdapterInterface; use Migrations\Db\Adapter\MysqlAdapter; @@ -14,6 +16,7 @@ use Migrations\Db\Adapter\SqliteAdapter; use Migrations\Db\Adapter\SqlserverAdapter; use Migrations\Db\Table; +use Migrations\Db\Table\CheckConstraint; use Migrations\Db\Table\Column; use Migrations\Db\Table\ForeignKey; use Migrations\Db\Table\Index; @@ -521,6 +524,44 @@ public static function removeIndexDataprovider() ]; } + public function testAddCheckConstraintWithExpression(): void + { + $adapter = new MysqlAdapter([]); + $table = new Table('ntable', [], $adapter); + $table->addCheckConstraint('age >= 18', ['name' => 'age_check']); + + $actions = $this->getPendingActions($table); + $this->assertInstanceOf(AddCheckConstraint::class, $actions[0]); + $constraint = $actions[0]->getCheckConstraint(); + $this->assertSame('age_check', $constraint->getName()); + $this->assertSame('age >= 18', $constraint->getExpression()); + } + + public function testAddCheckConstraintWithObject(): void + { + $adapter = new MysqlAdapter([]); + $table = new Table('ntable', [], $adapter); + $constraint = new CheckConstraint('price_positive', 'price > 0'); + $table->addCheckConstraint($constraint); + + $actions = $this->getPendingActions($table); + $this->assertInstanceOf(AddCheckConstraint::class, $actions[0]); + $this->assertSame($constraint, $actions[0]->getCheckConstraint()); + $this->assertSame('price_positive', $actions[0]->getCheckConstraint()->getName()); + $this->assertSame('price > 0', $actions[0]->getCheckConstraint()->getExpression()); + } + + public function testDropCheckConstraint(): void + { + $adapter = new MysqlAdapter([]); + $table = new Table('ntable', [], $adapter); + $table->dropCheckConstraint('age_check'); + + $actions = $this->getPendingActions($table); + $this->assertInstanceOf(DropCheckConstraint::class, $actions[0]); + $this->assertSame('age_check', $actions[0]->getConstraintName()); + } + protected function getPendingActions($table) { $prop = new ReflectionProperty(get_class($table), 'actions');