diff --git a/src/Illuminate/Database/Schema/Blueprint.php b/src/Illuminate/Database/Schema/Blueprint.php index 07cb721eefbc..1c38c71c112f 100755 --- a/src/Illuminate/Database/Schema/Blueprint.php +++ b/src/Illuminate/Database/Schema/Blueprint.php @@ -48,6 +48,13 @@ class Blueprint */ protected $commands = []; + /** + * All defined check constraints for the table. + * + * @var \Illuminate\Database\Schema\CheckConstraintBuilder[] + */ + protected array $checkConstraints = []; + /** * The storage engine that should be used for the table. * @@ -1903,4 +1910,44 @@ protected function defaultTimePrecision(): ?int { return $this->connection->getSchemaBuilder()::$defaultTimePrecision; } + + /** + * Add a new check constraint to the blueprint. + * + * @param string $name + * @param Closure $callback + * @return void + */ + public function checkConstraint(string $name, Closure $callback) + { + $builder = new CheckConstraintBuilder($name); + $callback($builder); + + $this->checkConstraints[] = $builder; + + if (! $this->creating()) { + $this->addCommand('addCheckConstraint', compact('builder')); + } + } + + /** + * Get the check constraints. + * + * @return CheckConstraintBuilder[] + */ + public function getCheckConstraints(): array + { + return $this->checkConstraints; + } + + /** + * Drop a constraint. + * + * @param string $name + * @return void + */ + public function dropConstraint(string $name): void + { + $this->addCommand('dropConstraint')->set('constraintName', $name); + } } diff --git a/src/Illuminate/Database/Schema/CheckConstraintBuilder.php b/src/Illuminate/Database/Schema/CheckConstraintBuilder.php new file mode 100644 index 000000000000..a5b337a5daf8 --- /dev/null +++ b/src/Illuminate/Database/Schema/CheckConstraintBuilder.php @@ -0,0 +1,191 @@ +> + */ + protected array $groups = []; + + /** + * The constraint expressions. + * + * @var array + */ + protected array $expressions = []; + + /** + * @param string $name + */ + public function __construct( + protected string $name, + ) { + + } + + /** + * Get the constraint name. + * + * @return string + */ + public function name(): string + { + return $this->name; + } + + /** + * Add a new constraint expression and group it with an AND operator. + * + * @param string $column + * @param string $operator + * @param mixed $value + * @param Closure $callback + * @return $this + */ + public function where(string $column, string $operator, mixed $value, Closure $callback): static + { + return $this->addGroup('AND', $column, $operator, $value, $callback); + } + + /** + * Add a new constraint expression and group it with an OR operator. + * + * @param string $column + * @param string $operator + * @param mixed $value + * @param Closure $callback + * @return $this + */ + public function orWhere(string $column, string $operator, mixed $value, Closure $callback): static + { + return $this->addGroup('OR', $column, $operator, $value, $callback); + } + + /** + * Add a new constraint expression and group it with the given glue operator. + * + * @param string $glue + * @param string $column + * @param string $operator + * @param mixed $value + * @param Closure $callback + * @return $this + */ + protected function addGroup(string $glue, string $column, string $operator, mixed $value, Closure $callback): static + { + $groupBuilder = new static('_inline_'); // temp inner builder + $callback($groupBuilder); + + $this->groups[] = [ + 'glue' => $glue, + 'condition' => "$column $operator ".$this->quote($value), + 'expressions' => $groupBuilder->expressions, + ]; + + return $this; + } + + /** + * Add a new constraint expression to the builder. + * + * @param string $column + * @param array $values + * @return $this + */ + public function whereNotIn(string $column, array $values): static + { + $quoted = implode(', ', array_map([$this, 'quote'], $values)); + $this->expressions[] = "$column NOT IN ($quoted)"; + + return $this; + } + + /** + * Add a new constraint expression to the builder. + * + * @param string $column + * @param array $values + * @return $this + */ + public function whereIn(string $column, array $values): static + { + $quoted = implode(', ', array_map([$this, 'quote'], $values)); + $this->expressions[] = "$column IN ($quoted)"; + + return $this; + } + + /** + * Compile the check constraint to SQL. + * + * @return string + */ + public function toSql(): string + { + $parts = []; + + // Handle groups (from where/orWhere) + foreach ($this->groups as $index => $group) { + $expr = implode(' AND ', $group['expressions']); + $logic = $index > 0 ? $group['glue'].' ' : ''; + $parts[] = $logic."({$group['condition']} AND {$expr})"; + } + + // Handle standalone expressions (from whereIn, whereNotIn, rule) + if (! empty($this->expressions) && empty($this->groups)) { + $parts[] = implode(' AND ', $this->expressions); + } + + return 'CHECK ('.implode(' ', $parts).')'; + } + + /** + * Quote the given value. + * + * @param mixed $value + * @return string + */ + protected function quote(mixed $value): string + { + if (is_string($value)) { + return "'".str_replace("'", "''", $value)."'"; + } + + if (is_bool($value)) { + return $value ? 'TRUE' : 'FALSE'; + } + + if (is_null($value)) { + return 'NULL'; + } + + return (string) $value; + } + + /** + * Add a new rule to the constraint. + * + * @param string $column + * @param string $operator + * @param mixed|null $value + * @return $this + */ + public function rule(string $column, string $operator, mixed $value = null): static + { + $operator = strtoupper(trim($operator)); + $expression = "$column $operator"; + + if (! str_ends_with($operator, 'NULL')) { + $expression .= ' '.$this->quote($value); + } + + $this->expressions[] = $expression; + + return $this; + } +} diff --git a/src/Illuminate/Database/Schema/Grammars/Grammar.php b/src/Illuminate/Database/Schema/Grammars/Grammar.php index ed683d256d30..b9b3508c7bfd 100755 --- a/src/Illuminate/Database/Schema/Grammars/Grammar.php +++ b/src/Illuminate/Database/Schema/Grammars/Grammar.php @@ -7,6 +7,7 @@ use Illuminate\Database\Concerns\CompilesJsonPaths; use Illuminate\Database\Grammar as BaseGrammar; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\CheckConstraintBuilder; use Illuminate\Support\Fluent; use RuntimeException; @@ -505,4 +506,55 @@ public function supportsSchemaTransactions() { return $this->transactions; } + + /** + * Build the constraints for the table. + * + * @param Blueprint $blueprint + * @return string[] + */ + protected function getCheckConstraints(Blueprint $blueprint): array + { + return collect($blueprint->getCheckConstraints()) + ->map(fn (CheckConstraintBuilder $builder) => "CONSTRAINT {$builder->name()} {$builder->toSql()}") + ->all(); + } + + /** + * Compile a drop constraint command. + * + * @param Blueprint $blueprint + * @param Fluent $command + * @return string[] + */ + public function compileAddCheckConstraint(Blueprint $blueprint, Fluent $command): array + { + $sql = []; + foreach ($blueprint->getCheckConstraints() as $constraint) { + $sql[] = sprintf( + 'ALTER TABLE %s ADD CONSTRAINT %s %s', + $this->wrapTable($blueprint), + $this->wrap($constraint->name()), + $constraint->toSql() + ); + } + + return $sql; + } + + /** + * Compile a drop constraint command. + * + * @param Blueprint $blueprint + * @param Fluent $command + * @return string + */ + public function compileDropConstraint(Blueprint $blueprint, Fluent $command): string + { + return sprintf( + 'ALTER TABLE %s DROP CONSTRAINT %s', + $this->wrapTable($blueprint), + $this->wrap($command->get('constraintName')) + ); + } } diff --git a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php index 16e8634d3e6b..c112b473c97c 100755 --- a/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/MySqlGrammar.php @@ -234,7 +234,10 @@ public function compileCreate(Blueprint $blueprint, Fluent $command) */ protected function compileCreateTable($blueprint, $command) { - $tableStructure = $this->getColumns($blueprint); + $tableStructure = array_merge( + $this->getColumns($blueprint), + $this->getCheckConstraints($blueprint) + ); if ($primaryKey = $this->getCommandByName($blueprint, 'primary')) { $tableStructure[] = sprintf( @@ -1390,4 +1393,48 @@ protected function wrapJsonSelector($value) return 'json_unquote(json_extract('.$field.$path.'))'; } + + /** + * Check version compatibility before calling main handler. + * + * @throws RuntimeException + */ + private function checkCheckConstraintCompatibility(): void + { + $isMaria = $this->connection->isMaria(); + $version = $this->connection->getServerVersion(); + + $isCompatible = $isMaria + ? version_compare($version, '10.2.1', '>=') + : version_compare($version, '8.0.16', '>='); + + if (! $isCompatible) { + $driverWithVersion = $isMaria ? 'MariaDB 10.2.1' : 'MySQL 8.0.16'; + throw new RuntimeException("$driverWithVersion or higher is required to use check constraints."); + } + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function compileAddCheckConstraint(Blueprint $blueprint, Fluent $command): array + { + $this->checkCheckConstraintCompatibility(); + + return parent::compileAddCheckConstraint($blueprint, $command); + } + + /** + * {@inheritDoc} + * + * @throws RuntimeException + */ + public function compileDropConstraint(Blueprint $blueprint, Fluent $command): string + { + $this->checkCheckConstraintCompatibility(); + + return parent::compileDropConstraint($blueprint, $command); + } } diff --git a/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php b/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php index 7e1e6a1d2fa8..4947626b3fb5 100755 --- a/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/PostgresGrammar.php @@ -236,10 +236,15 @@ public function compileForeignKeys($schema, $table) */ public function compileCreate(Blueprint $blueprint, Fluent $command) { + $columns = array_merge( + $this->getColumns($blueprint), + $this->getCheckConstraints($blueprint) + ); + return sprintf('%s table %s (%s)', $blueprint->temporary ? 'create temporary' : 'create', $this->wrapTable($blueprint), - implode(', ', $this->getColumns($blueprint)) + implode(', ', $columns) ); } diff --git a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php index 8908836dd9c7..9ddcf815e30f 100644 --- a/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/SQLiteGrammar.php @@ -230,10 +230,15 @@ public function compileForeignKeys($schema, $table) */ public function compileCreate(Blueprint $blueprint, Fluent $command) { + $columns = array_merge( + $this->getColumns($blueprint), + $this->getCheckConstraints($blueprint) + ); + return sprintf('%s table %s (%s%s%s)', $blueprint->temporary ? 'create temporary' : 'create', $this->wrapTable($blueprint), - implode(', ', $this->getColumns($blueprint)), + implode(', ', $columns), $this->addForeignKeys($this->getCommandsByName($blueprint, 'foreign')), $this->addPrimaryKeys($this->getCommandByName($blueprint, 'primary')) ); @@ -1201,4 +1206,20 @@ protected function wrapJsonSelector($value) return 'json_extract('.$field.$path.')'; } + + /** + * {@inheritDoc} + */ + public function compileAddCheckConstraint(Blueprint $blueprint, Fluent $command): array + { + throw new RuntimeException('SQLite does not support adding constraints to existing tables.'); + } + + /** + * {@inheritDoc} + */ + public function compileDropConstraint(Blueprint $blueprint, Fluent $command): string + { + throw new RuntimeException('SQLite does not support dropping constraints.'); + } } diff --git a/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php b/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php index f0608eb2e4dc..5501a330a786 100755 --- a/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php +++ b/src/Illuminate/Database/Schema/Grammars/SqlServerGrammar.php @@ -205,9 +205,14 @@ public function compileForeignKeys($schema, $table) */ public function compileCreate(Blueprint $blueprint, Fluent $command) { + $columns = array_merge( + $this->getColumns($blueprint), + $this->getCheckConstraints($blueprint) + ); + return sprintf('create table %s (%s)', $this->wrapTable($blueprint, $blueprint->temporary ? '#'.$this->connection->getTablePrefix() : null), - implode(', ', $this->getColumns($blueprint)) + implode(', ', $columns) ); } diff --git a/tests/Database/DatabaseCheckConstraintBuilderTest.php b/tests/Database/DatabaseCheckConstraintBuilderTest.php new file mode 100644 index 000000000000..d9d4484cf176 --- /dev/null +++ b/tests/Database/DatabaseCheckConstraintBuilderTest.php @@ -0,0 +1,143 @@ +where('status', '=', 'active', function (CheckConstraintBuilder $q) { + $q->rule('created_at', 'IS NOT NULL'); + }); + + $expected = "CHECK ((status = 'active' AND created_at IS NOT NULL))"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testMultipleWhereConstraints() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->where('type', '=', 'premium', function (CheckConstraintBuilder $q) { + $q->rule('price', '>', 0); + })->orWhere('type', '=', 'free', function (CheckConstraintBuilder $q) { + $q->rule('price', '=', 0); + }); + + $expected = "CHECK ((type = 'premium' AND price > 0) OR (type = 'free' AND price = 0))"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testWhereInConstraint() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->whereIn('status', ['active', 'pending', 'inactive']); + + $expected = "CHECK (status IN ('active', 'pending', 'inactive'))"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testWhereNotInConstraint() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->whereNotIn('status', ['deleted', 'banned']); + + $expected = "CHECK (status NOT IN ('deleted', 'banned'))"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testMixedStandaloneExpressions() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->rule('age', '>=', 18) + ->whereIn('status', ['active', 'pending']) + ->rule('email', 'IS NOT NULL'); + + $expected = "CHECK (age >= 18 AND status IN ('active', 'pending') AND email IS NOT NULL)"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testRuleWithNullOperator() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->rule('email', 'IS NOT NULL') + ->rule('deleted_at', 'IS NULL'); + + $expected = 'CHECK (email IS NOT NULL AND deleted_at IS NULL)'; + $this->assertSame($expected, $builder->toSql()); + } + + public function testRuleWithValue() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->rule('age', '>=', 18) + ->rule('status', '!=', 'banned'); + + $expected = "CHECK (age >= 18 AND status != 'banned')"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testValueQuoting() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->rule('name', '=', "O'Connor") + ->rule('active', '=', true) + ->rule('count', '=', 0) + ->rule('description', '=', null); + + $expected = "CHECK (name = 'O''Connor' AND active = TRUE AND count = 0 AND description = NULL)"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testComplexNestedConstraint() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->where('type', '=', 'subscription', function (CheckConstraintBuilder $q) { + $q->rule('start_date', 'IS NOT NULL') + ->rule('end_date', 'IS NOT NULL') + ->whereIn('plan', ['basic', 'premium']); + })->orWhere('type', '=', 'one_time', function (CheckConstraintBuilder $q) { + $q->rule('amount', '>', 0) + ->rule('start_date', 'IS NULL'); + }); + + $expected = "CHECK ((type = 'subscription' AND start_date IS NOT NULL AND end_date IS NOT NULL AND plan IN ('basic', 'premium')) OR (type = 'one_time' AND amount > 0 AND start_date IS NULL))"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testEmptyExpressions() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->where('status', '=', 'active', function (CheckConstraintBuilder $q) { + // Empty callback + }); + + $expected = "CHECK ((status = 'active' AND ))"; + $this->assertSame($expected, $builder->toSql()); + } + + public function testNumericValues() + { + $builder = new CheckConstraintBuilder('test_constraint'); + + $builder->rule('price', '>=', 0) + ->rule('discount', '<=', 100.50) + ->rule('quantity', '!=', -1); + + $expected = 'CHECK (price >= 0 AND discount <= 100.5 AND quantity != -1)'; + $this->assertSame($expected, $builder->toSql()); + } +} diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php index 6fca5a006116..0a66bd33b775 100644 --- a/tests/Integration/Database/SchemaBuilderTest.php +++ b/tests/Integration/Database/SchemaBuilderTest.php @@ -3,7 +3,9 @@ namespace Illuminate\Tests\Integration\Database; use Illuminate\Database\Query\Expression; +use Illuminate\Database\QueryException; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\CheckConstraintBuilder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Orchestra\Testbench\Attributes\RequiresDatabase; @@ -820,4 +822,138 @@ public function testAddingMacros() $this->assertTrue(Schema::hasForeignKeyForColumn('question_id', 'answers', 'questions')); $this->assertFalse(Schema::hasForeignKeyForColumn('body', 'answers', 'questions')); } + + #[RequiresDatabase('mariadb', '>=10.2.1')] + public function testCheckConstraintsOnMariaDb() + { + $this->runCheckConstraintTest(); + } + + #[RequiresDatabase('mysql', '>=8.0.16')] + public function testCheckConstraintsOnMysql() + { + $this->runCheckConstraintTest(); + } + + #[RequiresDatabase(['sqlite', 'pgsql', 'sqlsrv'])] + public function testCheckConstraints() + { + $this->runCheckConstraintTest(); + } + + private function runCheckConstraintTest(): void + { + Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->string('status'); + $table->decimal('amount', 10, 2); + $table->timestamp('shipped_at')->nullable(); + + $table->checkConstraint('shipping_logic', function (CheckConstraintBuilder $constraint) { + $constraint->where('status', '=', 'shipped', function ($q) { + $q->rule('shipped_at', 'IS NOT NULL'); + })->orWhere('status', '!=', 'shipped', function ($q) { + $q->rule('shipped_at', 'IS NULL'); + }); + }); + }); + + if ($this->driver !== 'sqlite') { + $this->assertTrue(DB::table('information_schema.table_constraints') + ->where('constraint_type', 'CHECK') + ->where('table_name', 'orders') + ->where('constraint_name', 'shipping_logic') + ->exists()); + } + + DB::table('orders')->insert(['status' => 'dispatched', 'amount' => 100.00, 'shipped_at' => null]); + + $this->expectException(QueryException::class); + DB::table('orders')->insert(['status' => 'shipped', 'amount' => 100.00, 'shipped_at' => null]); + } + + #[RequiresDatabase('mariadb', '>=10.2.1')] + public function testDropCheckConstraintOnMariaDb() + { + $this->runDropCheckConstraintTest(); + } + + #[RequiresDatabase('mysql', '>=8.0.16')] + public function testDropCheckConstraintOnMysql() + { + $this->runDropCheckConstraintTest(); + } + + #[RequiresDatabase(['pgsql', 'sqlsrv'])] + public function testDropCheckConstraint() + { + $this->runDropCheckConstraintTest(); + } + + private function runDropCheckConstraintTest(): void + { + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('firstname'); + $table->string('surname'); + $table->checkConstraint('check_name', function (CheckConstraintBuilder $constraintBuilder) { + $constraintBuilder->where('firstname', '=', 'John', + function (CheckConstraintBuilder $constraintBuilder) { + $constraintBuilder->rule('surname', 'IS NOT NULL'); + }); + }); + }); + + Schema::table('users', function (Blueprint $table) { + $table->dropConstraint('check_name'); + }); + + $this->assertFalse(DB::table('information_schema.table_constraints') + ->where('constraint_type', 'CHECK') + ->where('table_name', 'users') + ->where('constraint_name', 'check_name') + ->exists()); + } + + #[RequiresDatabase('mariadb', '>=10.2.1')] + public function testAddCheckConstraintOnMariaDb() + { + $this->runAddCheckConstraintTest(); + } + + #[RequiresDatabase('mysql', '>=8.0.16')] + public function testAddCheckConstraintOnMysql() + { + $this->runAddCheckConstraintTest(); + } + + #[RequiresDatabase(['pgsql', 'sqlsrv'])] + public function testAddCheckConstraint() + { + $this->runAddCheckConstraintTest(); + } + + private function runAddCheckConstraintTest(): void + { + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('firstname'); + $table->string('surname')->nullable(); + }); + + Schema::table('users', function (Blueprint $table) { + $table->checkConstraint('check_name', function (CheckConstraintBuilder $constraintBuilder) { + $constraintBuilder->where('firstname', '=', 'John', + function (CheckConstraintBuilder $constraintBuilder) { + $constraintBuilder->rule('surname', 'IS NOT NULL'); + }); + }); + }); + + $this->assertTrue(DB::table('information_schema.table_constraints') + ->where('constraint_type', 'CHECK') + ->where('table_name', 'users') + ->where('constraint_name', 'check_name') + ->exists()); + } }