diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php index b991fd08227c..6fc306dfa7d2 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasRelationships.php @@ -190,9 +190,10 @@ protected function newHasOneThrough(Builder $query, Model $farParent, Model $thr * @param string|null $type * @param string|null $id * @param string|null $localKey + * @param string|null $morphKeyType * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ - public function morphOne($related, $name, $type = null, $id = null, $localKey = null) + public function morphOne($related, $name, $type = null, $id = null, $localKey = null, $morphKeyType = null) { $instance = $this->newRelatedInstance($related); @@ -200,7 +201,9 @@ public function morphOne($related, $name, $type = null, $id = null, $localKey = $localKey = $localKey ?: $this->getKeyName(); - return $this->newMorphOne($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey); + $morphKeyType ??= $this->getKeySchemaType(); + + return $this->newMorphOne($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey, $morphKeyType); } /** @@ -214,11 +217,12 @@ public function morphOne($related, $name, $type = null, $id = null, $localKey = * @param string $type * @param string $id * @param string $localKey + * @param string $morphKeyType * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ - protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey) + protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey, $morphKeyType) { - return new MorphOne($query, $parent, $type, $id, $localKey); + return new MorphOne($query, $parent, $type, $id, $localKey, $morphKeyType); } /** @@ -514,9 +518,10 @@ protected function newHasManyThrough(Builder $query, Model $farParent, Model $th * @param string|null $type * @param string|null $id * @param string|null $localKey + * @param string|null $morphKeyType * @return \Illuminate\Database\Eloquent\Relations\MorphMany */ - public function morphMany($related, $name, $type = null, $id = null, $localKey = null) + public function morphMany($related, $name, $type = null, $id = null, $localKey = null, $morphKeyType = null) { $instance = $this->newRelatedInstance($related); @@ -527,7 +532,9 @@ public function morphMany($related, $name, $type = null, $id = null, $localKey = $localKey = $localKey ?: $this->getKeyName(); - return $this->newMorphMany($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey); + $morphKeyType ??= $this->getKeySchemaType(); + + return $this->newMorphMany($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey, $morphKeyType); } /** @@ -541,11 +548,12 @@ public function morphMany($related, $name, $type = null, $id = null, $localKey = * @param string $type * @param string $id * @param string $localKey + * @param string $morphKeyType * @return \Illuminate\Database\Eloquent\Relations\MorphMany */ - protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey) + protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey, $morphKeyType) { - return new MorphMany($query, $parent, $type, $id, $localKey); + return new MorphMany($query, $parent, $type, $id, $localKey, $morphKeyType); } /** diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 44878f7bf880..a52b39a0ec2e 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -1948,6 +1948,28 @@ public function getKeyType() return $this->keyType; } + /** + * Get the schema key type. + * + * @return string + */ + public function getKeySchemaType() + { + if ($this->getKeyType() === 'int') { + return 'int'; + } + + $uses = class_uses_recursive($this); + + if (in_array(Concerns\HasUlids::class, $uses, true)) { + return 'ulid'; + } elseif (in_array(Concerns\HasUuids::class, $uses, true)) { + return 'uuid'; + } + + return $this->getKeyType(); + } + /** * Set the data type for the primary key. * diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php index 1e879c1dcef1..b6f3d99f6707 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php @@ -4,7 +4,10 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Schema\Builder as SchemaBuilder; +use Illuminate\Support\Collection; use Illuminate\Support\Str; +use InvalidArgumentException; /** * @template TRelatedModel of \Illuminate\Database\Eloquent\Model @@ -29,6 +32,13 @@ abstract class MorphOneOrMany extends HasOneOrMany */ protected $morphClass; + /** + * The morph key type. + * + * @var string|null + */ + protected $morphKeyType = null; + /** * Create a new morph one or many relationship instance. * @@ -37,32 +47,71 @@ abstract class MorphOneOrMany extends HasOneOrMany * @param string $type * @param string $id * @param string $localKey + * @param string|null $morphKeyType * @return void */ - public function __construct(Builder $query, Model $parent, $type, $id, $localKey) + public function __construct(Builder $query, Model $parent, $type, $id, $localKey, $morphKeyType = null) { $this->morphType = $type; $this->morphClass = $parent->getMorphClass(); + if (! is_null($morphKeyType)) { + $this->morphKeyType($morphKeyType); + } + parent::__construct($query, $parent, $id, $localKey); } + /** + * Define the morph key type. + * + * @param string $type + * @return $this + */ + public function morphKeyType(string $type) + { + if (! in_array($type, ['int', 'uuid', 'ulid', 'string'])) { + throw new InvalidArgumentException("Morph key type must be 'int', 'uuid', 'ulid', or 'string'."); + } + + $this->morphKeyType = $type; + + return $this; + } + /** * Set the base constraints on the relation query. * * @return void */ + #[\Override] public function addConstraints() { if (static::$constraints) { $this->getRelationQuery()->where($this->morphType, $this->morphClass); - parent::addConstraints(); + if (is_null(SchemaBuilder::$defaultMorphKeyType)) { + parent::addConstraints(); + + return; + } + + $query = $this->getRelationQuery(); + + $morphKeyType = $this->morphKeyType ?? SchemaBuilder::$defaultMorphKeyType; + + $query->where($this->foreignKey, '=', transform($this->getParentKey(), fn ($key) => match ($morphKeyType) { + 'uuid', 'ulid', 'string' => (string) $key, + default => $key, + })); + + $query->whereNotNull($this->foreignKey); } } /** @inheritDoc */ + #[\Override] public function addEagerConstraints(array $models) { parent::addEagerConstraints($models); @@ -115,6 +164,7 @@ protected function setForeignAttributesForCreate(Model $model) * @param array|null $update * @return int */ + #[\Override] public function upsert(array $values, $uniqueBy, $update = null) { if (! empty($values) && ! is_array(reset($values))) { @@ -129,6 +179,7 @@ public function upsert(array $values, $uniqueBy, $update = null) } /** @inheritDoc */ + #[\Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return parent::getRelationExistenceQuery($query, $parentQuery, $columns)->where( @@ -178,4 +229,30 @@ protected function getPossibleInverseRelations(): array ...parent::getPossibleInverseRelations(), ]); } + + /** @inheritDoc */ + #[\Override] + protected function getKeys(array $models, $key = null) + { + $morphKeyType = $this->morphKeyType ?? SchemaBuilder::$defaultMorphKeyType; + + $castKeyToString = in_array($morphKeyType, ['uuid', 'ulid', 'string']); + + return (new Collection(parent::getKeys($models, $key))) + ->transform(fn ($key) => $castKeyToString === true ? (string) $key : $key) + ->all(); + } + + /** @inheritDoc */ + #[\Override] + protected function whereInMethod(Model $model, $key) + { + $morphKeyType = $this->morphKeyType ?? SchemaBuilder::$defaultMorphKeyType; + + if (! in_array($morphKeyType, ['uuid', 'ulid', 'string'])) { + return parent::whereInMethod($model, $key); + } + + return 'whereIn'; + } } diff --git a/src/Illuminate/Database/Schema/Blueprint.php b/src/Illuminate/Database/Schema/Blueprint.php index ca2eed4eb55b..73d29a83f1ba 100755 --- a/src/Illuminate/Database/Schema/Blueprint.php +++ b/src/Illuminate/Database/Schema/Blueprint.php @@ -4,7 +4,6 @@ use Closure; use Illuminate\Database\Connection; -use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Grammars\Grammar; use Illuminate\Database\Schema\Grammars\MySqlGrammar; @@ -1039,23 +1038,13 @@ public function foreignIdFor($model, $column = null) $column = $column ?: $model->getForeignKey(); - if ($model->getKeyType() === 'int') { - return $this->foreignId($column) - ->table($model->getTable()) - ->referencesModelColumn($model->getKeyName()); - } - - $modelTraits = class_uses_recursive($model); - - if (in_array(HasUlids::class, $modelTraits, true)) { - return $this->foreignUlid($column, 26) - ->table($model->getTable()) - ->referencesModelColumn($model->getKeyName()); - } + $definition = match ($model->getKeySchemaType()) { + 'int' => $this->foreignId($column), + 'ulid' => $this->foreignUlid($column, 26), + default => $this->foreignUuid($column), + }; - return $this->foreignUuid($column) - ->table($model->getTable()) - ->referencesModelColumn($model->getKeyName()); + return $definition->table($model->getTable())->referencesModelColumn($model->getKeyName()); } /** @@ -1486,6 +1475,8 @@ public function morphs($name, $indexName = null) $this->uuidMorphs($name, $indexName); } elseif (Builder::$defaultMorphKeyType === 'ulid') { $this->ulidMorphs($name, $indexName); + } elseif (Builder::$defaultMorphKeyType === 'string') { + $this->stringableMorphs($name, $indexName); } else { $this->numericMorphs($name, $indexName); } @@ -1504,11 +1495,45 @@ public function nullableMorphs($name, $indexName = null) $this->nullableUuidMorphs($name, $indexName); } elseif (Builder::$defaultMorphKeyType === 'ulid') { $this->nullableUlidMorphs($name, $indexName); + } elseif (Builder::$defaultMorphKeyType === 'string') { + $this->nullableStringableMorphs($name, $indexName); } else { $this->nullableNumericMorphs($name, $indexName); } } + /** + * Add the proper columns for a polymorphic table using string as IDs (mixed of UUID/ULID & incremental integer). + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function stringableMorphs($name, $indexName = null) + { + $this->string("{$name}_type"); + + $this->string("{$name}_id"); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add nullable columns for a polymorphic table using string as IDs (mixed of UUID/ULID & incremental integer). + * + * @param string $name + * @param string|null $indexName + * @return void + */ + public function nullableStringableMorphs($name, $indexName = null) + { + $this->string("{$name}_type")->nullable(); + + $this->string("{$name}_id")->nullable(); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + /** * Add the proper columns for a polymorphic table using numeric IDs (incremental). * diff --git a/src/Illuminate/Database/Schema/Builder.php b/src/Illuminate/Database/Schema/Builder.php index 9af11e2e0836..bd1f2dcaab74 100755 --- a/src/Illuminate/Database/Schema/Builder.php +++ b/src/Illuminate/Database/Schema/Builder.php @@ -44,9 +44,9 @@ class Builder /** * The default relationship morph key type. * - * @var string + * @var string|null */ - public static $defaultMorphKeyType = 'int'; + public static $defaultMorphKeyType = null; /** * Create a new database Schema manager. @@ -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', 'string'])) { + throw new InvalidArgumentException("Morph key type must be 'int', 'uuid', 'ulid', or 'string'."); } static::$defaultMorphKeyType = $type; @@ -108,6 +108,16 @@ public static function morphUsingUlids() static::defaultMorphKeyType('ulid'); } + /** + * Set the default morph key type for migrations to string as IDs (mixed of UUID/ULID & incremental integer). + * + * @return void + */ + public static function morphUsingString() + { + static::defaultMorphKeyType('string'); + } + /** * Create a database in the schema. * diff --git a/tests/Database/DatabaseEloquentRelationshipsTest.php b/tests/Database/DatabaseEloquentRelationshipsTest.php index 3bc33b89246a..81d4d9ec3c19 100644 --- a/tests/Database/DatabaseEloquentRelationshipsTest.php +++ b/tests/Database/DatabaseEloquentRelationshipsTest.php @@ -335,14 +335,14 @@ protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localK return new CustomHasOne($query, $parent, $foreignKey, $localKey); } - protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey) + protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey, $morphKeyType = null) { - return new CustomMorphOne($query, $parent, $type, $id, $localKey); + return new CustomMorphOne($query, $parent, $type, $id, $localKey, $morphKeyType); } - protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey) + protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey, $morphKeyType = null) { - return new CustomMorphMany($query, $parent, $type, $id, $localKey); + return new CustomMorphMany($query, $parent, $type, $id, $localKey, $morphKeyType); } protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index f92672b2f63e..b5a0facade8f 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -19,7 +19,7 @@ class DatabaseSchemaBlueprintTest extends TestCase protected function tearDown(): void { m::close(); - Builder::$defaultMorphKeyType = 'int'; + Builder::$defaultMorphKeyType = null; } public function testToSqlRunsCommandsFromBlueprint() diff --git a/tests/Integration/Database/EloquentPolymorphicWithStringMorphTypeTest.php b/tests/Integration/Database/EloquentPolymorphicWithStringMorphTypeTest.php new file mode 100644 index 000000000000..082569151556 --- /dev/null +++ b/tests/Integration/Database/EloquentPolymorphicWithStringMorphTypeTest.php @@ -0,0 +1,93 @@ +id(); + $table->nullableStringableMorphs('owner'); + $table->string('provider'); + }); + + $user = UserFactory::new()->create([ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + ]); + + DB::table('integrations')->insert([ + 'owner_type' => EloquentPolymorphicWithStringMorphTypeTestUser::class, + 'owner_id' => $user->id, + 'provider' => 'dummy_provider', + ]); + } + + public function test_it_can_query_from_polymorphic_model() + { + $user = EloquentPolymorphicWithStringMorphTypeTestUser::first(); + + $user->loadMissing('integrations'); + + Assert::assertArraySubset([ + ['owner_type' => EloquentPolymorphicWithStringMorphTypeTestUser::class, 'owner_id' => $user->getKey(), 'provider' => 'dummy_provider'], + ], EloquentPolymorphicWithStringMorphTypeTestIntegration::where('owner_id', $user->id)->where('owner_type', EloquentPolymorphicWithStringMorphTypeTestUser::class)->get()->toArray()); + } + + public function test_it_can_query_using_relationship() + { + $user = EloquentPolymorphicWithStringMorphTypeTestUser::first(); + + Assert::assertArraySubset([ + ['owner_type' => EloquentPolymorphicWithStringMorphTypeTestUser::class, 'owner_id' => $user->getKey(), 'provider' => 'dummy_provider'], + ], $user->integrations()->get()->toArray()); + } + + public function test_it_can_query_using_load_missing() + { + $user = EloquentPolymorphicWithStringMorphTypeTestUser::query()->where('email', 'taylor@laravel.com')->first(); + + $user->loadMissing('integrations'); + + Assert::assertArraySubset([ + 'name' => 'Taylor Otwell', + 'integrations' => [ + ['owner_type' => EloquentPolymorphicWithStringMorphTypeTestUser::class, 'owner_id' => $user->getKey(), 'provider' => 'dummy_provider'], + ], + ], $user->toArray()); + } +} + +class EloquentPolymorphicWithStringMorphTypeTestUser extends Authenticatable +{ + protected $fillable = ['*']; + protected $table = 'users'; + + public function integrations() + { + return $this->morphMany(EloquentPolymorphicWithStringMorphTypeTestIntegration::class, 'owner', morphKeyType: 'string'); + } +} + +class EloquentPolymorphicWithStringMorphTypeTestIntegration extends Model +{ + protected $fillable = ['*']; + protected $table = 'integrations'; + + public function owner() + { + return $this->morphTo('owner'); + } +}