diff --git a/composer.json b/composer.json index 39ea6589a7f7..6f82e0dbb8f8 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "php": "^8.2", "ext-ctype": "*", "ext-filter": "*", + "ext-gmp": "*", "ext-hash": "*", "ext-mbstring": "*", "ext-openssl": "*", @@ -57,6 +58,7 @@ "symfony/uid": "^7.0.3", "symfony/var-dumper": "^7.0.3", "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "visus/cuid2": "^5.0.0", "vlucas/phpdotenv": "^5.6.1", "voku/portable-ascii": "^2.0.2" }, @@ -98,7 +100,6 @@ "spatie/once": "*" }, "require-dev": { - "ext-gmp": "*", "ably/ably-php": "^1.0", "aws/aws-sdk-php": "^3.322.9", "fakerphp/faker": "^1.24", diff --git a/config/cuid.php b/config/cuid.php new file mode 100644 index 000000000000..a298863bdab0 --- /dev/null +++ b/config/cuid.php @@ -0,0 +1,13 @@ + env('CUID_LENGTH', 24), +]; diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasCuid.php b/src/Illuminate/Database/Eloquent/Concerns/HasCuid.php new file mode 100644 index 000000000000..098ff5426912 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/HasCuid.php @@ -0,0 +1,32 @@ + 32) ? 24 : $size); + + return $cuid->toString(); + } + + /** + * Determine if given key is valid. + * + * @param mixed $value + */ + protected function isValidUniqueId($value): bool + { + return Str::isCuid($value); + } +} diff --git a/src/Illuminate/Database/Schema/Blueprint.php b/src/Illuminate/Database/Schema/Blueprint.php index ca2eed4eb55b..d58e25e8dd06 100755 --- a/src/Illuminate/Database/Schema/Blueprint.php +++ b/src/Illuminate/Database/Schema/Blueprint.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Database\Connection; +use Illuminate\Database\Eloquent\Concerns\HasCuid; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Grammars\Grammar; @@ -1053,6 +1054,12 @@ public function foreignIdFor($model, $column = null) ->referencesModelColumn($model->getKeyName()); } + if (in_array(HasCuid::class, $modelTraits, true)) { + return $this->foreignCuid($column, 32) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + return $this->foreignUuid($column) ->table($model->getTable()) ->referencesModelColumn($model->getKeyName()); @@ -1399,6 +1406,34 @@ public function foreignUlid($column, $length = 26) ])); } + /** + * Create a new CUID column on the table. + * + * @param string $column + * @param int|null $length + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function cuid($column = 'cuid', $length = 32) + { + return $this->char($column, $length); + } + + /** + * Create a new CUID column on the table with a foreign key constraint. + * + * @param string $column + * @param int|null $length + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignCuid($column, $length = 32) + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'char', + 'name' => $column, + 'length' => $length, + ])); + } + /** * Create a new IP address column on the table. * @@ -1486,6 +1521,8 @@ public function morphs($name, $indexName = null) $this->uuidMorphs($name, $indexName); } elseif (Builder::$defaultMorphKeyType === 'ulid') { $this->ulidMorphs($name, $indexName); + } elseif (Builder::$defaultMorphKeyType === 'cuid') { + $this->cuidMorphs($name, $indexName); } else { $this->numericMorphs($name, $indexName); } @@ -1504,6 +1541,8 @@ public function nullableMorphs($name, $indexName = null) $this->nullableUuidMorphs($name, $indexName); } elseif (Builder::$defaultMorphKeyType === 'ulid') { $this->nullableUlidMorphs($name, $indexName); + } elseif (Builder::$defaultMorphKeyType === 'cuid') { + $this->nullableCuidMorphs($name, $indexName); } else { $this->nullableNumericMorphs($name, $indexName); } @@ -1605,6 +1644,38 @@ public function nullableUlidMorphs($name, $indexName = null) $this->index(["{$name}_type", "{$name}_id"], $indexName); } + /** + * Add the proper columns for a polymorphic table using CUIDs. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function cuidMorphs($name, $indexName = null) + { + $this->string("{$name}_type"); + + $this->cuid("{$name}_id", 32); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add nullable columns for a polymorphic table using CUIDs. + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function nullableCuidMorphs($name, $indexName = null) + { + $this->string("{$name}_type")->nullable(); + + $this->cuid("{$name}_id", 32)->nullable(); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + /** * Add the `remember_token` column to the table. * diff --git a/src/Illuminate/Database/Schema/Builder.php b/src/Illuminate/Database/Schema/Builder.php index 9af11e2e0836..63eb04c239c1 100755 --- a/src/Illuminate/Database/Schema/Builder.php +++ b/src/Illuminate/Database/Schema/Builder.php @@ -81,8 +81,8 @@ public static function defaultStringLength($length) */ public static function defaultMorphKeyType(string $type) { - if (! in_array($type, ['int', 'uuid', 'ulid'])) { - throw new InvalidArgumentException("Morph key type must be 'int', 'uuid', or 'ulid'."); + if (! in_array($type, ['int', 'uuid', 'ulid', 'cuid'])) { + throw new InvalidArgumentException("Morph key type must be 'int', 'uuid', 'ulid', or 'cuid'."); } static::$defaultMorphKeyType = $type; @@ -108,6 +108,16 @@ public static function morphUsingUlids() static::defaultMorphKeyType('ulid'); } + /** + * Set the default morph key type for migrations to CUIDs. + * + * @return void + */ + public static function morphUsingCuids() + { + static::defaultMorphKeyType('cuid'); + } + /** * Create a database in the schema. * diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index af8f0ea6568b..e09f3b84a1bb 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -626,6 +626,31 @@ public static function isUlid($value) return Ulid::isValid($value); } + /** + * Determine if a given value is a valid CUID. + * + * @param mixed $value + * @return bool + */ + public static function isCuid($value) + { + if (! is_string($value)) { + return false; + } + + // validate length + $minLength = 2; + $maxLength = 32; + + $length = strlen($value); + + if ($length >= $minLength && $length <= $maxLength && preg_match('/^[a-z][0-9a-z]+$/', $value)) { + return true; + } + + return false; + } + /** * Convert a string to kebab case. * diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 3271749ddb61..4bae13a7b9c6 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -29,6 +29,7 @@ use Illuminate\Database\Eloquent\Casts\AsStringable; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Concerns\HasCuid; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\Factory; @@ -2058,6 +2059,52 @@ public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUlid() $this->assertEquals(['bar'], $clone->foo); } + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasCuidPrimaryKey() + { + $class = new EloquentPrimaryCuidModelStub(); + $class->cuid = 'uzmbdw1jp9yvi2nm3s8zx27p'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->cuid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasCuid() + { + $class = new EloquentNonPrimaryCuidModelStub(); + $class->id = 1; + $class->cuid = 'uzmbdw1jp9yvi2nm3s8zx27p'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->id); + $this->assertNull($clone->cuid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + public function testModelObserversCanBeAttachedToModels() { EloquentModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); @@ -3728,6 +3775,34 @@ public function uniqueIds() } } +class EloquentPrimaryCuidModelStub extends EloquentModelStub +{ + use HasCuid; + + public $incrementing = false; + protected $keyType = 'string'; + + public function getKeyName() + { + return 'cuid'; + } +} + +class EloquentNonPrimaryCuidModelStub extends EloquentModelStub +{ + use HasCuid; + + public function getKeyName() + { + return 'id'; + } + + public function uniqueIds() + { + return ['cuid']; + } +} + #[ObservedBy(EloquentTestObserverStub::class)] class EloquentModelWithObserveAttributeStub extends EloquentModelStub { diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index f92672b2f63e..d7bb9814e0fd 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -390,6 +390,44 @@ public function testDefaultUsingNullableUlidMorph() ], $blueprint->toSql($connection, new MySqlGrammar)); } + public function testDefaultUsingCuidMorph() + { + Builder::defaultMorphKeyType('cuid'); + + $base = new Blueprint('comments', function ($table) { + $table->morphs('commentable'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) not null', + 'alter table `comments` add `commentable_id` char(32) not null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + + public function testDefaultUsingNullableCuidMorph() + { + Builder::defaultMorphKeyType('cuid'); + + $base = new Blueprint('comments', function ($table) { + $table->nullableMorphs('commentable'); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `comments` add `commentable_type` varchar(255) null', + 'alter table `comments` add `commentable_id` char(32) null', + 'alter table `comments` add index `comments_commentable_type_commentable_id_index`(`commentable_type`, `commentable_id`)', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + public function testGenerateRelationshipColumnWithIncrementalModel() { $base = new Blueprint('posts', function ($table) { @@ -435,6 +473,27 @@ public function testGenerateRelationshipColumnWithUuidModel() ], $blueprint->toSql($connection, new MySqlGrammar)); } + public function testGenerateRelationshipColumnWithCuidModel() + { + $base = new Blueprint('posts', function ($table) { + $table->foreignIdFor(Fixtures\Models\EloquentModelUsingCuid::class); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table "posts" add column "model_using_cuid_id" char(32) not null', + ], $blueprint->toSql($connection, new PostgresGrammar)); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `posts` add `model_using_cuid_id` char(32) not null', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + public function testGenerateRelationshipColumnWithUlidModel() { $base = new Blueprint('posts', function (Blueprint $table) { @@ -534,6 +593,21 @@ public function testDropConstrainedRelationshipColumnWithIncrementalModel() ], $blueprint->toSql($connection, new MySqlGrammar)); } + public function testDropRelationshipColumnWithCuidModel() + { + $base = new Blueprint('posts', function ($table) { + $table->dropForeignIdFor(Fixtures\Models\EloquentModelUsingCuid::class); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `posts` drop foreign key `posts_model_using_cuid_id_foreign`', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + public function testDropConstrainedRelationshipColumnWithUuidModel() { $base = new Blueprint('posts', function ($table) { @@ -550,6 +624,22 @@ public function testDropConstrainedRelationshipColumnWithUuidModel() ], $blueprint->toSql($connection, new MySqlGrammar)); } + public function testDropConstrainedRelationshipColumnWithCuidModel() + { + $base = new Blueprint('posts', function ($table) { + $table->dropConstrainedForeignIdFor(Fixtures\Models\EloquentModelUsingCuid::class); + }); + + $connection = m::mock(Connection::class); + + $blueprint = clone $base; + + $this->assertEquals([ + 'alter table `posts` drop foreign key `posts_model_using_cuid_id_foreign`', + 'alter table `posts` drop `model_using_cuid_id`', + ], $blueprint->toSql($connection, new MySqlGrammar)); + } + public function testTinyTextColumn() { $base = new Blueprint('posts', function ($table) { diff --git a/tests/Database/Fixtures/Models/EloquentModelUsingCuid.php b/tests/Database/Fixtures/Models/EloquentModelUsingCuid.php new file mode 100644 index 000000000000..60abf15d16a3 --- /dev/null +++ b/tests/Database/Fixtures/Models/EloquentModelUsingCuid.php @@ -0,0 +1,42 @@ +assertFalse(Str::isUuid($uuid)); } + #[DataProvider('validCuidList')] + public function testIsCuidWithValidCuid($cuid) + { + $this->assertTrue(Str::isCuid($cuid)); + } + + #[DataProvider('invalidCuidList')] + public function testIsCuidWithInvalidCuid($cuid) + { + $this->assertFalse(Str::isCuid($cuid)); + } + public function testIsJson() { $this->assertTrue(Str::isJson('1')); @@ -1269,6 +1281,28 @@ public static function invalidUuidList() ]; } + public static function validCuidList() + { + return [ + ['zm2x9igk6ian853ux1iikr93'], + ['c0qapxb62ghwol2l8vpxryv8'], + ['zcodvpsaeznjd5ygsedu1il7'], + ['eb4nv3psuv9xqks4kca7ut74'], + ['xss09xjvzvulul04na8kkll0'], + ['xss09xjvzvulul04na8kkll0eb4v9xqk'], + ]; + } + + public static function invalidCuidList() + { + return [ + ['zm2x9igk6iAn853Ux1iikr93'], + ['c0qApXb62ghw@l2l8vpxryv8'], + ['not a valid cuid so we can test this'], + ['eb4nv3psuv9xqVSs4kZDvedZca7ut743'], + ]; + } + public static function strContainsProvider() { return [