From 833388028f0ce42ed84234ab390b403ef73e0540 Mon Sep 17 00:00:00 2001 From: Jeremy Mikola Date: Thu, 20 Jun 2024 15:28:07 -0400 Subject: [PATCH 1/8] PHPORM-175: Use foreign key name for MorphTo relationships (#3011) Incorporates the proposed solution in mongodb/laravel-mongodb#2783 to not default $ownerKey to the current model's key name when constructing a MorphTo in HybridRelations::morphTo(). That change alone caused RelationsTest::testMorph() to fail, since MorphTo::addConstraints() would attempt to use a null ownerKey value. This required an additional change to fall back to the foreign key name when building the constraint. * Allow multiple classes in ticket tests --- phpcs.xml.dist | 4 ++ src/Eloquent/HybridRelations.php | 2 +- src/Relations/MorphTo.php | 2 +- tests/Ticket/GH2783Test.php | 75 ++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/Ticket/GH2783Test.php diff --git a/phpcs.xml.dist b/phpcs.xml.dist index d7dd1e724..3b7cc671c 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -49,4 +49,8 @@ docs/**/*.php + + + tests/Ticket/*.php + diff --git a/src/Eloquent/HybridRelations.php b/src/Eloquent/HybridRelations.php index 5c058f50f..be20327ee 100644 --- a/src/Eloquent/HybridRelations.php +++ b/src/Eloquent/HybridRelations.php @@ -226,7 +226,7 @@ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null $this->newQuery(), $this, $id, - $ownerKey ?: $this->getKeyName(), + $ownerKey, $type, $name, ); diff --git a/src/Relations/MorphTo.php b/src/Relations/MorphTo.php index 1eff5e53b..692991372 100644 --- a/src/Relations/MorphTo.php +++ b/src/Relations/MorphTo.php @@ -17,7 +17,7 @@ public function addConstraints() // or has many relationships, we need to actually query on the primary key // of the related models matching on the foreign key that's on a parent. $this->query->where( - $this->ownerKey, + $this->ownerKey ?? $this->getForeignKeyName(), '=', $this->getForeignKeyFrom($this->parent), ); diff --git a/tests/Ticket/GH2783Test.php b/tests/Ticket/GH2783Test.php new file mode 100644 index 000000000..73324ddc0 --- /dev/null +++ b/tests/Ticket/GH2783Test.php @@ -0,0 +1,75 @@ + 'Lorem ipsum']); + $user = GH2783User::create(['username' => 'jsmith']); + + $imageWithPost = GH2783Image::create(['uri' => 'http://example.com/post.png']); + $imageWithPost->imageable()->associate($post)->save(); + + $imageWithUser = GH2783Image::create(['uri' => 'http://example.com/user.png']); + $imageWithUser->imageable()->associate($user)->save(); + + $queriedImageWithPost = GH2783Image::with('imageable')->find($imageWithPost->getKey()); + $this->assertInstanceOf(GH2783Post::class, $queriedImageWithPost->imageable); + $this->assertEquals($post->_id, $queriedImageWithPost->imageable->getKey()); + + $queriedImageWithUser = GH2783Image::with('imageable')->find($imageWithUser->getKey()); + $this->assertInstanceOf(GH2783User::class, $queriedImageWithUser->imageable); + $this->assertEquals($user->username, $queriedImageWithUser->imageable->getKey()); + } +} + +class GH2783Image extends Model +{ + protected $connection = 'mongodb'; + protected $fillable = ['uri']; + + public function imageable(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'imageable_type', 'imageable_id'); + } +} + +class GH2783Post extends Model +{ + protected $connection = 'mongodb'; + protected $fillable = ['text']; + + public function image(): MorphOne + { + return $this->morphOne(GH2783Image::class, 'imageable'); + } +} + +class GH2783User extends Model +{ + protected $connection = 'mongodb'; + protected $fillable = ['username']; + protected $primaryKey = 'username'; + + public function image(): MorphOne + { + return $this->morphOne(GH2783Image::class, 'imageable'); + } +} From 0a143cc31601417ff07290afa7b7a700f6f8d144 Mon Sep 17 00:00:00 2001 From: Nora Reidy Date: Fri, 28 Jun 2024 12:52:23 -0400 Subject: [PATCH 2/8] DOCSP-41010: Fix transactions code example (#3016) --- docs/transactions.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/transactions.txt b/docs/transactions.txt index 5ef3df19d..89562c795 100644 --- a/docs/transactions.txt +++ b/docs/transactions.txt @@ -99,7 +99,8 @@ to another account: :start-after: begin transaction callback :end-before: end transaction callback -You can optionally pass the maximum number of times to retry a failed transaction as the second parameter as shown in the following code example: +You can optionally pass the maximum number of times to retry a failed transaction +as the second parameter, as shown in the following code example: .. code-block:: php :emphasize-lines: 4 @@ -107,7 +108,7 @@ You can optionally pass the maximum number of times to retry a failed transactio DB::transaction(function() { // transaction code }, - retries: 5, + attempts: 5, ); .. _laravel-transaction-commit: From ffacc6b48b7f8e08006a27e323e587976dfa67ea Mon Sep 17 00:00:00 2001 From: MongoDB PHP Bot <162451593+mongodb-php-bot@users.noreply.github.com> Date: Fri, 28 Jun 2024 19:07:09 +0200 Subject: [PATCH 3/8] DOCSP-41010: Fix transactions code example (#3016) (#3020) Co-authored-by: Nora Reidy --- docs/transactions.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/transactions.txt b/docs/transactions.txt index 5ef3df19d..89562c795 100644 --- a/docs/transactions.txt +++ b/docs/transactions.txt @@ -99,7 +99,8 @@ to another account: :start-after: begin transaction callback :end-before: end transaction callback -You can optionally pass the maximum number of times to retry a failed transaction as the second parameter as shown in the following code example: +You can optionally pass the maximum number of times to retry a failed transaction +as the second parameter, as shown in the following code example: .. code-block:: php :emphasize-lines: 4 @@ -107,7 +108,7 @@ You can optionally pass the maximum number of times to retry a failed transactio DB::transaction(function() { // transaction code }, - retries: 5, + attempts: 5, ); .. _laravel-transaction-commit: From 89463b05cf46e8dc52d4edc65f2beae989175b4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 8 Jul 2024 16:59:05 +0200 Subject: [PATCH 4/8] Create DocumentModel trait to enable MongoDB on any 3rd party model class (#2580) * Create DocumentModel trait to enable MongoDB on any 3rd party model class * Use the trait for every test model * Add method Model::isDocumentModel to check when model classes can be used with MongoDB * Refactor the User class to extend Laravel's User class --- CHANGELOG.md | 8 +- phpstan-baseline.neon | 4 +- src/Auth/User.php | 23 +- src/Eloquent/DocumentModel.php | 749 ++++++++++++++++++++++++++ src/Eloquent/HybridRelations.php | 22 +- src/Eloquent/Model.php | 757 +-------------------------- src/Helpers/QueriesRelationships.php | 2 +- src/Relations/BelongsToMany.php | 7 +- src/Relations/EmbedsMany.php | 5 +- src/Relations/EmbedsOneOrMany.php | 11 +- src/Relations/MorphToMany.php | 20 +- tests/Eloquent/ModelTest.php | 62 +++ tests/ModelTest.php | 17 +- tests/Models/Address.php | 11 +- tests/Models/Birthday.php | 11 +- tests/Models/Book.php | 14 +- tests/Models/CastObjectId.php | 6 +- tests/Models/Casting.php | 6 +- tests/Models/Client.php | 13 +- tests/Models/Experience.php | 13 +- tests/Models/Group.php | 13 +- tests/Models/Guarded.php | 13 +- tests/Models/HiddenAnimal.php | 8 +- tests/Models/IdIsBinaryUuid.php | 13 +- tests/Models/IdIsInt.php | 14 +- tests/Models/IdIsString.php | 13 +- tests/Models/Item.php | 13 +- tests/Models/Label.php | 13 +- tests/Models/Location.php | 13 +- tests/Models/Photo.php | 13 +- tests/Models/Role.php | 13 +- tests/Models/Scoped.php | 13 +- tests/Models/Skill.php | 13 +- tests/Models/Soft.php | 14 +- tests/Models/SqlBook.php | 6 +- tests/Models/SqlRole.php | 4 +- tests/Models/SqlUser.php | 4 +- tests/Models/User.php | 14 +- tests/RelationsTest.php | 1 - tests/TransactionTest.php | 4 +- 40 files changed, 1086 insertions(+), 887 deletions(-) create mode 100644 src/Eloquent/DocumentModel.php create mode 100644 tests/Eloquent/ModelTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0345701b8..0a0a120f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,13 @@ # Changelog All notable changes to this project will be documented in this file. -## [4.5.0] - upcoming +## [4.6.0] - upcoming -* Add GridFS integration for Laravel File Storage by @GromNaN in [#2984](https://github.com/mongodb/laravel-mongodb/pull/2985) +* Add `DocumentTrait` to use any 3rd party model with MongoDB @GromNaN in [#2580](https://github.com/mongodb/laravel-mongodb/pull/2580) + +## [4.5.0] - 2024-06-20 + +* Add GridFS integration for Laravel File Storage by @GromNaN in [#2985](https://github.com/mongodb/laravel-mongodb/pull/2985) ## [4.4.0] - 2024-05-31 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fdef24410..e85adb7d2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -7,12 +7,12 @@ parameters: - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" - count: 2 + count: 3 path: src/Relations/BelongsToMany.php - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" - count: 2 + count: 6 path: src/Relations/MorphToMany.php - diff --git a/src/Auth/User.php b/src/Auth/User.php index d14aa4822..a58a898ad 100644 --- a/src/Auth/User.php +++ b/src/Auth/User.php @@ -4,22 +4,13 @@ namespace MongoDB\Laravel\Auth; -use Illuminate\Auth\Authenticatable; -use Illuminate\Auth\MustVerifyEmail; -use Illuminate\Auth\Passwords\CanResetPassword; -use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract; -use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; -use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; -use Illuminate\Foundation\Auth\Access\Authorizable; -use MongoDB\Laravel\Eloquent\Model; +use Illuminate\Foundation\Auth\User as BaseUser; +use MongoDB\Laravel\Eloquent\DocumentModel; -class User extends Model implements - AuthenticatableContract, - AuthorizableContract, - CanResetPasswordContract +class User extends BaseUser { - use Authenticatable; - use Authorizable; - use CanResetPassword; - use MustVerifyEmail; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; } diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php new file mode 100644 index 000000000..15c33ef16 --- /dev/null +++ b/src/Eloquent/DocumentModel.php @@ -0,0 +1,749 @@ +attributes)) { + $value = $this->attributes['_id']; + } + + // Convert ObjectID to string. + if ($value instanceof ObjectID) { + return (string) $value; + } + + if ($value instanceof Binary) { + return (string) $value->getData(); + } + + return $value; + } + + /** @inheritdoc */ + public function getQualifiedKeyName() + { + return $this->getKeyName(); + } + + /** @inheritdoc */ + public function fromDateTime($value) + { + // If the value is already a UTCDateTime instance, we don't need to parse it. + if ($value instanceof UTCDateTime) { + return $value; + } + + // Let Eloquent convert the value to a DateTime instance. + if (! $value instanceof DateTimeInterface) { + $value = parent::asDateTime($value); + } + + return new UTCDateTime($value); + } + + /** @inheritdoc */ + protected function asDateTime($value) + { + // Convert UTCDateTime instances to Carbon. + if ($value instanceof UTCDateTime) { + return Date::instance($value->toDateTime()) + ->setTimezone(new DateTimeZone(date_default_timezone_get())); + } + + return parent::asDateTime($value); + } + + /** @inheritdoc */ + public function getDateFormat() + { + return $this->dateFormat ?: 'Y-m-d H:i:s'; + } + + /** @inheritdoc */ + public function freshTimestamp() + { + return new UTCDateTime(Date::now()); + } + + /** @inheritdoc */ + public function getTable() + { + return $this->collection ?? parent::getTable(); + } + + /** @inheritdoc */ + public function getAttribute($key) + { + if (! $key) { + return null; + } + + $key = (string) $key; + + // An unset attribute is null or throw an exception. + if (isset($this->unset[$key])) { + return $this->throwMissingAttributeExceptionIfApplicable($key); + } + + // Dot notation support. + if (str_contains($key, '.') && Arr::has($this->attributes, $key)) { + return $this->getAttributeValue($key); + } + + // This checks for embedded relation support. + // Ignore methods defined in the class Eloquent Model or in this trait. + if ( + method_exists($this, $key) + && ! method_exists(Model::class, $key) + && ! method_exists(DocumentModel::class, $key) + && ! $this->hasAttributeGetMutator($key) + ) { + return $this->getRelationValue($key); + } + + return parent::getAttribute($key); + } + + /** @inheritdoc */ + protected function transformModelValue($key, $value) + { + $value = parent::transformModelValue($key, $value); + // Casting attributes to any of date types, will convert that attribute + // to a Carbon or CarbonImmutable instance. + // @see Model::setAttribute() + if ($this->hasCast($key) && $value instanceof CarbonInterface) { + $value->settings(array_merge($value->getSettings(), ['toStringFormat' => $this->getDateFormat()])); + + // "date" cast resets the time to 00:00:00. + $castType = $this->getCasts()[$key]; + if (str_starts_with($castType, 'date:') || str_starts_with($castType, 'immutable_date:')) { + $value = $value->startOfDay(); + } + } + + return $value; + } + + /** @inheritdoc */ + protected function getCastType($key) + { + $castType = $this->getCasts()[$key]; + if ($this->isCustomDateTimeCast($castType) || $this->isImmutableCustomDateTimeCast($castType)) { + $this->setDateFormat(Str::after($castType, ':')); + } + + return parent::getCastType($key); + } + + /** @inheritdoc */ + protected function getAttributeFromArray($key) + { + $key = (string) $key; + + // Support keys in dot notation. + if (str_contains($key, '.')) { + return Arr::get($this->attributes, $key); + } + + return parent::getAttributeFromArray($key); + } + + /** @inheritdoc */ + public function setAttribute($key, $value) + { + $key = (string) $key; + + $casts = $this->getCasts(); + if (array_key_exists($key, $casts)) { + $castType = $this->getCastType($key); + $castOptions = Str::after($casts[$key], ':'); + + // Can add more native mongo type casts here. + $value = match ($castType) { + 'decimal' => $this->fromDecimal($value, $castOptions), + default => $value, + }; + } + + // Convert _id to ObjectID. + if ($key === '_id' && is_string($value)) { + $builder = $this->newBaseQueryBuilder(); + + $value = $builder->convertKey($value); + } + + // Support keys in dot notation. + if (str_contains($key, '.')) { + // Store to a temporary key, then move data to the actual key + parent::setAttribute('__LARAVEL_TEMPORARY_KEY__', $value); + + Arr::set($this->attributes, $key, $this->attributes['__LARAVEL_TEMPORARY_KEY__'] ?? null); + unset($this->attributes['__LARAVEL_TEMPORARY_KEY__']); + + return $this; + } + + // Setting an attribute cancels the unset operation. + unset($this->unset[$key]); + + return parent::setAttribute($key, $value); + } + + /** + * @param mixed $value + * + * @inheritdoc + */ + protected function asDecimal($value, $decimals) + { + // Convert BSON to string. + if ($this->isBSON($value)) { + if ($value instanceof Binary) { + $value = $value->getData(); + } elseif ($value instanceof Stringable) { + $value = (string) $value; + } else { + throw new MathException('BSON type ' . $value::class . ' cannot be converted to string'); + } + } + + return parent::asDecimal($value, $decimals); + } + + /** + * Change to mongo native for decimal cast. + * + * @param mixed $value + * @param int $decimals + * + * @return Decimal128 + */ + protected function fromDecimal($value, $decimals) + { + return new Decimal128($this->asDecimal($value, $decimals)); + } + + /** @inheritdoc */ + public function attributesToArray() + { + $attributes = parent::attributesToArray(); + + // Because the original Eloquent never returns objects, we convert + // MongoDB related objects to a string representation. This kind + // of mimics the SQL behaviour so that dates are formatted + // nicely when your models are converted to JSON. + foreach ($attributes as $key => &$value) { + if ($value instanceof ObjectID) { + $value = (string) $value; + } elseif ($value instanceof Binary) { + $value = (string) $value->getData(); + } + } + + return $attributes; + } + + /** @inheritdoc */ + public function getCasts() + { + return $this->casts; + } + + /** @inheritdoc */ + public function getDirty() + { + $dirty = parent::getDirty(); + + // The specified value in the $unset expression does not impact the operation. + if ($this->unset !== []) { + $dirty['$unset'] = $this->unset; + } + + return $dirty; + } + + /** @inheritdoc */ + public function originalIsEquivalent($key) + { + if (! array_key_exists($key, $this->original)) { + return false; + } + + // Calling unset on an attribute marks it as "not equivalent". + if (isset($this->unset[$key])) { + return false; + } + + $attribute = Arr::get($this->attributes, $key); + $original = Arr::get($this->original, $key); + + if ($attribute === $original) { + return true; + } + + if ($attribute === null) { + return false; + } + + if ($this->isDateAttribute($key)) { + $attribute = $attribute instanceof UTCDateTime ? $this->asDateTime($attribute) : $attribute; + $original = $original instanceof UTCDateTime ? $this->asDateTime($original) : $original; + + // Comparison on DateTimeInterface values + // phpcs:disable SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator + return $attribute == $original; + } + + if ($this->hasCast($key, static::$primitiveCastTypes)) { + return $this->castAttribute($key, $attribute) === + $this->castAttribute($key, $original); + } + + return is_numeric($attribute) && is_numeric($original) + && strcmp((string) $attribute, (string) $original) === 0; + } + + /** @inheritdoc */ + public function offsetUnset($offset): void + { + $offset = (string) $offset; + + if (str_contains($offset, '.')) { + // Update the field in the subdocument + Arr::forget($this->attributes, $offset); + } else { + parent::offsetUnset($offset); + + // Force unsetting even if the attribute is not set. + // End user can optimize DB calls by checking if the attribute is set before unsetting it. + $this->unset[$offset] = true; + } + } + + /** @inheritdoc */ + public function offsetSet($offset, $value): void + { + parent::offsetSet($offset, $value); + + // Setting an attribute cancels the unset operation. + unset($this->unset[$offset]); + } + + /** + * Remove one or more fields. + * + * @deprecated Use unset() instead. + * + * @param string|string[] $columns + * + * @return void + */ + public function drop($columns) + { + $this->unset($columns); + } + + /** + * Remove one or more fields. + * + * @param string|string[] $columns + * + * @return void + */ + public function unset($columns) + { + $columns = Arr::wrap($columns); + + // Unset attributes + foreach ($columns as $column) { + $this->__unset($column); + } + } + + /** @inheritdoc */ + public function push() + { + $parameters = func_get_args(); + if ($parameters) { + $unique = false; + + if (count($parameters) === 3) { + [$column, $values, $unique] = $parameters; + } else { + [$column, $values] = $parameters; + } + + // Do batch push by default. + $values = Arr::wrap($values); + + $query = $this->setKeysForSaveQuery($this->newQuery()); + + $this->pushAttributeValues($column, $values, $unique); + + return $query->push($column, $values, $unique); + } + + return parent::push(); + } + + /** + * Remove one or more values from an array. + * + * @param string $column + * @param mixed $values + * + * @return mixed + */ + public function pull($column, $values) + { + // Do batch pull by default. + $values = Arr::wrap($values); + + $query = $this->setKeysForSaveQuery($this->newQuery()); + + $this->pullAttributeValues($column, $values); + + return $query->pull($column, $values); + } + + /** + * Append one or more values to the underlying attribute value and sync with original. + * + * @param string $column + * @param bool $unique + */ + protected function pushAttributeValues($column, array $values, $unique = false) + { + $current = $this->getAttributeFromArray($column) ?: []; + + foreach ($values as $value) { + // Don't add duplicate values when we only want unique values. + if ($unique && (! is_array($current) || in_array($value, $current))) { + continue; + } + + $current[] = $value; + } + + $this->attributes[$column] = $current; + + $this->syncOriginalAttribute($column); + } + + /** + * Remove one or more values to the underlying attribute value and sync with original. + * + * @param string $column + */ + protected function pullAttributeValues($column, array $values) + { + $current = $this->getAttributeFromArray($column) ?: []; + + if (is_array($current)) { + foreach ($values as $value) { + $keys = array_keys($current, $value); + + foreach ($keys as $key) { + unset($current[$key]); + } + } + } + + $this->attributes[$column] = array_values($current); + + $this->syncOriginalAttribute($column); + } + + /** @inheritdoc */ + public function getForeignKey() + { + return Str::snake(class_basename($this)) . '_' . ltrim($this->primaryKey, '_'); + } + + /** + * Set the parent relation. + */ + public function setParentRelation(Relation $relation) + { + $this->parentRelation = $relation; + } + + /** + * Get the parent relation. + */ + public function getParentRelation(): ?Relation + { + return $this->parentRelation ?? null; + } + + /** @inheritdoc */ + public function newEloquentBuilder($query) + { + return new Builder($query); + } + + /** @inheritdoc */ + public function qualifyColumn($column) + { + return $column; + } + + /** @inheritdoc */ + protected function newBaseQueryBuilder() + { + $connection = $this->getConnection(); + + return new QueryBuilder($connection, $connection->getQueryGrammar(), $connection->getPostProcessor()); + } + + /** @inheritdoc */ + protected function removeTableFromKey($key) + { + return $key; + } + + /** + * Get the queueable relationships for the entity. + * + * @return array + */ + public function getQueueableRelations() + { + $relations = []; + + foreach ($this->getRelationsWithoutParent() as $key => $relation) { + if (method_exists($this, $key)) { + $relations[] = $key; + } + + if ($relation instanceof QueueableCollection) { + foreach ($relation->getQueueableRelations() as $collectionValue) { + $relations[] = $key . '.' . $collectionValue; + } + } + + if ($relation instanceof QueueableEntity) { + foreach ($relation->getQueueableRelations() as $entityKey => $entityValue) { + $relations[] = $key . '.' . $entityValue; + } + } + } + + return array_unique($relations); + } + + /** + * Get loaded relations for the instance without parent. + * + * @return array + */ + protected function getRelationsWithoutParent() + { + $relations = $this->getRelations(); + + $parentRelation = $this->getParentRelation(); + if ($parentRelation) { + unset($relations[$parentRelation->getQualifiedForeignKeyName()]); + } + + return $relations; + } + + /** + * Checks if column exists on a table. As this is a document model, just return true. This also + * prevents calls to non-existent function Grammar::compileColumnListing(). + * + * @param string $key + * + * @return bool + */ + protected function isGuardableColumn($key) + { + return true; + } + + /** @inheritdoc */ + protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes) + { + foreach ($this->getCasts() as $key => $castType) { + if (! Arr::has($attributes, $key) || Arr::has($mutatedAttributes, $key)) { + continue; + } + + $originalValue = Arr::get($attributes, $key); + + // Here we will cast the attribute. Then, if the cast is a date or datetime cast + // then we will serialize the date for the array. This will convert the dates + // to strings based on the date format specified for these Eloquent models. + $castValue = $this->castAttribute( + $key, + $originalValue, + ); + + // If the attribute cast was a date or a datetime, we will serialize the date as + // a string. This allows the developers to customize how dates are serialized + // into an array without affecting how they are persisted into the storage. + if ($castValue !== null && in_array($castType, ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) { + $castValue = $this->serializeDate($castValue); + } + + if ($castValue !== null && ($this->isCustomDateTimeCast($castType) || $this->isImmutableCustomDateTimeCast($castType))) { + $castValue = $castValue->format(explode(':', $castType, 2)[1]); + } + + if ($castValue instanceof DateTimeInterface && $this->isClassCastable($key)) { + $castValue = $this->serializeDate($castValue); + } + + if ($castValue !== null && $this->isClassSerializable($key)) { + $castValue = $this->serializeClassCastableAttribute($key, $castValue); + } + + if ($this->isEnumCastable($key) && (! $castValue instanceof Arrayable)) { + $castValue = $castValue !== null ? $this->getStorableEnumValueFromLaravel11($this->getCasts()[$key], $castValue) : null; + } + + if ($castValue instanceof Arrayable) { + $castValue = $castValue->toArray(); + } + + Arr::set($attributes, $key, $castValue); + } + + return $attributes; + } + + /** + * Duplicate of {@see HasAttributes::getStorableEnumValue()} for Laravel 11 as the signature of the method has + * changed in a non-backward compatible way. + * + * @todo Remove this method when support for Laravel 10 is dropped. + */ + private function getStorableEnumValueFromLaravel11($expectedEnum, $value) + { + if (! $value instanceof $expectedEnum) { + throw new ValueError(sprintf('Value [%s] is not of the expected enum type [%s].', var_export($value, true), $expectedEnum)); + } + + return $value instanceof BackedEnum + ? $value->value + : $value->name; + } + + /** + * Is a value a BSON type? + * + * @param mixed $value + * + * @return bool + */ + protected function isBSON(mixed $value): bool + { + return $value instanceof Type; + } + + /** + * {@inheritDoc} + */ + public function save(array $options = []) + { + // SQL databases would use autoincrement the id field if set to null. + // Apply the same behavior to MongoDB with _id only, otherwise null would be stored. + if (array_key_exists('_id', $this->attributes) && $this->attributes['_id'] === null) { + unset($this->attributes['_id']); + } + + $saved = parent::save($options); + + // Clear list of unset fields + $this->unset = []; + + return $saved; + } + + /** + * {@inheritDoc} + */ + public function refresh() + { + parent::refresh(); + + // Clear list of unset fields + $this->unset = []; + + return $this; + } +} diff --git a/src/Eloquent/HybridRelations.php b/src/Eloquent/HybridRelations.php index be20327ee..8ca4ea289 100644 --- a/src/Eloquent/HybridRelations.php +++ b/src/Eloquent/HybridRelations.php @@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\Concerns\HasRelationships; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Support\Str; -use MongoDB\Laravel\Eloquent\Model as MongoDBModel; use MongoDB\Laravel\Helpers\EloquentBuilder; use MongoDB\Laravel\Relations\BelongsTo; use MongoDB\Laravel\Relations\BelongsToMany; @@ -20,7 +19,6 @@ use function array_pop; use function debug_backtrace; use function implode; -use function is_subclass_of; use function preg_split; use const DEBUG_BACKTRACE_IGNORE_ARGS; @@ -46,7 +44,7 @@ trait HybridRelations public function hasOne($related, $foreignKey = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (! Model::isDocumentModel($related)) { return parent::hasOne($related, $foreignKey, $localKey); } @@ -75,7 +73,7 @@ public function hasOne($related, $foreignKey = null, $localKey = null) public function morphOne($related, $name, $type = null, $id = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (! Model::isDocumentModel($related)) { return parent::morphOne($related, $name, $type, $id, $localKey); } @@ -102,7 +100,7 @@ public function morphOne($related, $name, $type = null, $id = null, $localKey = public function hasMany($related, $foreignKey = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (! Model::isDocumentModel($related)) { return parent::hasMany($related, $foreignKey, $localKey); } @@ -131,7 +129,7 @@ public function hasMany($related, $foreignKey = null, $localKey = null) public function morphMany($related, $name, $type = null, $id = null, $localKey = null) { // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (! Model::isDocumentModel($related)) { return parent::morphMany($related, $name, $type, $id, $localKey); } @@ -171,7 +169,7 @@ public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relat } // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (! Model::isDocumentModel($related)) { return parent::belongsTo($related, $foreignKey, $ownerKey, $relation); } @@ -242,7 +240,7 @@ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null $ownerKey ??= $instance->getKeyName(); // Check if it is a relation with an original model. - if (! is_subclass_of($instance, MongoDBModel::class)) { + if (! Model::isDocumentModel($instance)) { return parent::morphTo($name, $type, $id, $ownerKey); } @@ -288,7 +286,7 @@ public function belongsToMany( } // Check if it is a relation with an original model. - if (! is_subclass_of($related, MongoDBModel::class)) { + if (! Model::isDocumentModel($related)) { return parent::belongsToMany( $related, $collection, @@ -367,7 +365,7 @@ public function morphToMany( } // Check if it is a relation with an original model. - if (! is_subclass_of($related, Model::class)) { + if (! Model::isDocumentModel($related)) { return parent::morphToMany( $related, $name, @@ -434,7 +432,7 @@ public function morphedByMany( ) { // If the related model is an instance of eloquent model class, leave pivot keys // as default. It's necessary for supporting hybrid relationship - if (is_subclass_of($related, Model::class)) { + if (Model::isDocumentModel($related)) { // For the inverse of the polymorphic many-to-many relations, we will change // the way we determine the foreign and other keys, as it is the opposite // of the morph-to-many method since we're figuring out these inverses. @@ -459,7 +457,7 @@ public function morphedByMany( /** @inheritdoc */ public function newEloquentBuilder($query) { - if ($this instanceof MongoDBModel) { + if (Model::isDocumentModel($this)) { return new Builder($query); } diff --git a/src/Eloquent/Model.php b/src/Eloquent/Model.php index f7b4f1f36..fcb9c4f04 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -4,63 +4,17 @@ namespace MongoDB\Laravel\Eloquent; -use BackedEnum; -use Carbon\CarbonInterface; -use DateTimeInterface; -use DateTimeZone; -use Illuminate\Contracts\Queue\QueueableCollection; -use Illuminate\Contracts\Queue\QueueableEntity; -use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Model as BaseModel; -use Illuminate\Database\Eloquent\Relations\Relation; -use Illuminate\Support\Arr; -use Illuminate\Support\Exceptions\MathException; -use Illuminate\Support\Facades\Date; -use Illuminate\Support\Str; -use MongoDB\BSON\Binary; -use MongoDB\BSON\Decimal128; -use MongoDB\BSON\ObjectID; -use MongoDB\BSON\Type; -use MongoDB\BSON\UTCDateTime; -use MongoDB\Laravel\Query\Builder as QueryBuilder; -use Stringable; -use ValueError; +use MongoDB\Laravel\Auth\User; use function array_key_exists; -use function array_keys; -use function array_merge; -use function array_unique; -use function array_values; -use function class_basename; -use function count; -use function date_default_timezone_get; -use function explode; -use function func_get_args; -use function in_array; -use function is_array; -use function is_numeric; -use function is_string; -use function ltrim; -use function method_exists; -use function sprintf; -use function str_contains; -use function str_starts_with; -use function strcmp; -use function var_export; +use function class_uses_recursive; +use function is_object; +use function is_subclass_of; abstract class Model extends BaseModel { - use HybridRelations; - use EmbedsRelations; - - private const TEMPORARY_KEY = '__LARAVEL_TEMPORARY_KEY__'; - - /** - * The collection associated with the model. - * - * @var string - */ - protected $collection; + use DocumentModel; /** * The primary key for the model. @@ -76,699 +30,38 @@ abstract class Model extends BaseModel */ protected $keyType = 'string'; - /** - * The parent relation instance. - * - * @var Relation - */ - protected $parentRelation; - - /** - * List of field names to unset from the document on save. - * - * @var array{string, true} - */ - private array $unset = []; - - /** - * Custom accessor for the model's id. - * - * @param mixed $value - * - * @return mixed - */ - public function getIdAttribute($value = null) - { - // If we don't have a value for 'id', we will use the MongoDB '_id' value. - // This allows us to work with models in a more sql-like way. - if (! $value && array_key_exists('_id', $this->attributes)) { - $value = $this->attributes['_id']; - } - - // Convert ObjectID to string. - if ($value instanceof ObjectID) { - return (string) $value; - } - - if ($value instanceof Binary) { - return (string) $value->getData(); - } - - return $value; - } - - /** @inheritdoc */ - public function getQualifiedKeyName() - { - return $this->getKeyName(); - } - - /** @inheritdoc */ - public function fromDateTime($value) - { - // If the value is already a UTCDateTime instance, we don't need to parse it. - if ($value instanceof UTCDateTime) { - return $value; - } - - // Let Eloquent convert the value to a DateTime instance. - if (! $value instanceof DateTimeInterface) { - $value = parent::asDateTime($value); - } - - return new UTCDateTime($value); - } - - /** @inheritdoc */ - protected function asDateTime($value) - { - // Convert UTCDateTime instances to Carbon. - if ($value instanceof UTCDateTime) { - return Date::instance($value->toDateTime()) - ->setTimezone(new DateTimeZone(date_default_timezone_get())); - } - - return parent::asDateTime($value); - } - - /** @inheritdoc */ - public function getDateFormat() - { - return $this->dateFormat ?: 'Y-m-d H:i:s'; - } - - /** @inheritdoc */ - public function freshTimestamp() - { - return new UTCDateTime(Date::now()); - } - - /** @inheritdoc */ - public function getTable() - { - return $this->collection ?: parent::getTable(); - } - - /** @inheritdoc */ - public function getAttribute($key) - { - if (! $key) { - return null; - } - - $key = (string) $key; - - // An unset attribute is null or throw an exception. - if (isset($this->unset[$key])) { - return $this->throwMissingAttributeExceptionIfApplicable($key); - } - - // Dot notation support. - if (str_contains($key, '.') && Arr::has($this->attributes, $key)) { - return $this->getAttributeValue($key); - } - - // This checks for embedded relation support. - if ( - method_exists($this, $key) - && ! method_exists(self::class, $key) - && ! $this->hasAttributeGetMutator($key) - ) { - return $this->getRelationValue($key); - } - - return parent::getAttribute($key); - } - - /** @inheritdoc */ - protected function transformModelValue($key, $value) - { - $value = parent::transformModelValue($key, $value); - // Casting attributes to any of date types, will convert that attribute - // to a Carbon or CarbonImmutable instance. - // @see Model::setAttribute() - if ($this->hasCast($key) && $value instanceof CarbonInterface) { - $value->settings(array_merge($value->getSettings(), ['toStringFormat' => $this->getDateFormat()])); - - // "date" cast resets the time to 00:00:00. - $castType = $this->getCasts()[$key]; - if (str_starts_with($castType, 'date:') || str_starts_with($castType, 'immutable_date:')) { - $value = $value->startOfDay(); - } - } - - return $value; - } - - /** @inheritdoc */ - protected function getCastType($key) - { - $castType = $this->getCasts()[$key]; - if ($this->isCustomDateTimeCast($castType) || $this->isImmutableCustomDateTimeCast($castType)) { - $this->setDateFormat(Str::after($castType, ':')); - } - - return parent::getCastType($key); - } - - /** @inheritdoc */ - protected function getAttributeFromArray($key) - { - $key = (string) $key; - - // Support keys in dot notation. - if (str_contains($key, '.')) { - return Arr::get($this->attributes, $key); - } - - return parent::getAttributeFromArray($key); - } - - /** @inheritdoc */ - public function setAttribute($key, $value) - { - $key = (string) $key; - - $casts = $this->getCasts(); - if (array_key_exists($key, $casts)) { - $castType = $this->getCastType($key); - $castOptions = Str::after($casts[$key], ':'); - - // Can add more native mongo type casts here. - $value = match ($castType) { - 'decimal' => $this->fromDecimal($value, $castOptions), - default => $value, - }; - } - - // Convert _id to ObjectID. - if ($key === '_id' && is_string($value)) { - $builder = $this->newBaseQueryBuilder(); - - $value = $builder->convertKey($value); - } - - // Support keys in dot notation. - if (str_contains($key, '.')) { - // Store to a temporary key, then move data to the actual key - parent::setAttribute(self::TEMPORARY_KEY, $value); - - Arr::set($this->attributes, $key, $this->attributes[self::TEMPORARY_KEY] ?? null); - unset($this->attributes[self::TEMPORARY_KEY]); - - return $this; - } - - // Setting an attribute cancels the unset operation. - unset($this->unset[$key]); - - return parent::setAttribute($key, $value); - } - - /** - * @param mixed $value - * - * @inheritdoc - */ - protected function asDecimal($value, $decimals) - { - // Convert BSON to string. - if ($this->isBSON($value)) { - if ($value instanceof Binary) { - $value = $value->getData(); - } elseif ($value instanceof Stringable) { - $value = (string) $value; - } else { - throw new MathException('BSON type ' . $value::class . ' cannot be converted to string'); - } - } - - return parent::asDecimal($value, $decimals); - } + private static $documentModelClasses = [ + User::class => true, + ]; /** - * Change to mongo native for decimal cast. + * Indicates if the given model class is a MongoDB document model. + * It must be a subclass of {@see BaseModel} and use the + * {@see DocumentModel} trait. * - * @param mixed $value - * @param int $decimals - * - * @return Decimal128 + * @param class-string|object $class */ - protected function fromDecimal($value, $decimals) - { - return new Decimal128($this->asDecimal($value, $decimals)); - } - - /** @inheritdoc */ - public function attributesToArray() - { - $attributes = parent::attributesToArray(); - - // Because the original Eloquent never returns objects, we convert - // MongoDB related objects to a string representation. This kind - // of mimics the SQL behaviour so that dates are formatted - // nicely when your models are converted to JSON. - foreach ($attributes as $key => &$value) { - if ($value instanceof ObjectID) { - $value = (string) $value; - } elseif ($value instanceof Binary) { - $value = (string) $value->getData(); - } - } - - return $attributes; - } - - /** @inheritdoc */ - public function getCasts() + final public static function isDocumentModel(string|object $class): bool { - return $this->casts; - } - - /** @inheritdoc */ - public function getDirty() - { - $dirty = parent::getDirty(); - - // The specified value in the $unset expression does not impact the operation. - if ($this->unset !== []) { - $dirty['$unset'] = $this->unset; + if (is_object($class)) { + $class = $class::class; } - return $dirty; - } - - /** @inheritdoc */ - public function originalIsEquivalent($key) - { - if (! array_key_exists($key, $this->original)) { - return false; + if (array_key_exists($class, self::$documentModelClasses)) { + return self::$documentModelClasses[$class]; } - // Calling unset on an attribute marks it as "not equivalent". - if (isset($this->unset[$key])) { - return false; + // We know all child classes of this class are document models. + if (is_subclass_of($class, self::class)) { + return self::$documentModelClasses[$class] = true; } - $attribute = Arr::get($this->attributes, $key); - $original = Arr::get($this->original, $key); - - if ($attribute === $original) { - return true; + // Document models must be subclasses of Laravel's base model class. + if (! is_subclass_of($class, BaseModel::class)) { + return self::$documentModelClasses[$class] = false; } - if ($attribute === null) { - return false; - } - - if ($this->isDateAttribute($key)) { - $attribute = $attribute instanceof UTCDateTime ? $this->asDateTime($attribute) : $attribute; - $original = $original instanceof UTCDateTime ? $this->asDateTime($original) : $original; - - // Comparison on DateTimeInterface values - // phpcs:disable SlevomatCodingStandard.Operators.DisallowEqualOperators.DisallowedEqualOperator - return $attribute == $original; - } - - if ($this->hasCast($key, static::$primitiveCastTypes)) { - return $this->castAttribute($key, $attribute) === - $this->castAttribute($key, $original); - } - - return is_numeric($attribute) && is_numeric($original) - && strcmp((string) $attribute, (string) $original) === 0; - } - - /** @inheritdoc */ - public function offsetUnset($offset): void - { - $offset = (string) $offset; - - if (str_contains($offset, '.')) { - // Update the field in the subdocument - Arr::forget($this->attributes, $offset); - } else { - parent::offsetUnset($offset); - - // Force unsetting even if the attribute is not set. - // End user can optimize DB calls by checking if the attribute is set before unsetting it. - $this->unset[$offset] = true; - } - } - - /** @inheritdoc */ - public function offsetSet($offset, $value): void - { - parent::offsetSet($offset, $value); - - // Setting an attribute cancels the unset operation. - unset($this->unset[$offset]); - } - - /** - * Remove one or more fields. - * - * @deprecated Use unset() instead. - * - * @param string|string[] $columns - * - * @return void - */ - public function drop($columns) - { - $this->unset($columns); - } - - /** - * Remove one or more fields. - * - * @param string|string[] $columns - * - * @return void - */ - public function unset($columns) - { - $columns = Arr::wrap($columns); - - // Unset attributes - foreach ($columns as $column) { - $this->__unset($column); - } - } - - /** @inheritdoc */ - public function push() - { - $parameters = func_get_args(); - if ($parameters) { - $unique = false; - - if (count($parameters) === 3) { - [$column, $values, $unique] = $parameters; - } else { - [$column, $values] = $parameters; - } - - // Do batch push by default. - $values = Arr::wrap($values); - - $query = $this->setKeysForSaveQuery($this->newQuery()); - - $this->pushAttributeValues($column, $values, $unique); - - return $query->push($column, $values, $unique); - } - - return parent::push(); - } - - /** - * Remove one or more values from an array. - * - * @param string $column - * @param mixed $values - * - * @return mixed - */ - public function pull($column, $values) - { - // Do batch pull by default. - $values = Arr::wrap($values); - - $query = $this->setKeysForSaveQuery($this->newQuery()); - - $this->pullAttributeValues($column, $values); - - return $query->pull($column, $values); - } - - /** - * Append one or more values to the underlying attribute value and sync with original. - * - * @param string $column - * @param bool $unique - */ - protected function pushAttributeValues($column, array $values, $unique = false) - { - $current = $this->getAttributeFromArray($column) ?: []; - - foreach ($values as $value) { - // Don't add duplicate values when we only want unique values. - if ($unique && (! is_array($current) || in_array($value, $current))) { - continue; - } - - $current[] = $value; - } - - $this->attributes[$column] = $current; - - $this->syncOriginalAttribute($column); - } - - /** - * Remove one or more values to the underlying attribute value and sync with original. - * - * @param string $column - */ - protected function pullAttributeValues($column, array $values) - { - $current = $this->getAttributeFromArray($column) ?: []; - - if (is_array($current)) { - foreach ($values as $value) { - $keys = array_keys($current, $value); - - foreach ($keys as $key) { - unset($current[$key]); - } - } - } - - $this->attributes[$column] = array_values($current); - - $this->syncOriginalAttribute($column); - } - - /** @inheritdoc */ - public function getForeignKey() - { - return Str::snake(class_basename($this)) . '_' . ltrim($this->primaryKey, '_'); - } - - /** - * Set the parent relation. - */ - public function setParentRelation(Relation $relation) - { - $this->parentRelation = $relation; - } - - /** - * Get the parent relation. - * - * @return Relation - */ - public function getParentRelation() - { - return $this->parentRelation; - } - - /** @inheritdoc */ - public function newEloquentBuilder($query) - { - return new Builder($query); - } - - /** @inheritdoc */ - public function qualifyColumn($column) - { - return $column; - } - - /** @inheritdoc */ - protected function newBaseQueryBuilder() - { - $connection = $this->getConnection(); - - return new QueryBuilder($connection, $connection->getQueryGrammar(), $connection->getPostProcessor()); - } - - /** @inheritdoc */ - protected function removeTableFromKey($key) - { - return $key; - } - - /** - * Get the queueable relationships for the entity. - * - * @return array - */ - public function getQueueableRelations() - { - $relations = []; - - foreach ($this->getRelationsWithoutParent() as $key => $relation) { - if (method_exists($this, $key)) { - $relations[] = $key; - } - - if ($relation instanceof QueueableCollection) { - foreach ($relation->getQueueableRelations() as $collectionValue) { - $relations[] = $key . '.' . $collectionValue; - } - } - - if ($relation instanceof QueueableEntity) { - foreach ($relation->getQueueableRelations() as $entityKey => $entityValue) { - $relations[] = $key . '.' . $entityValue; - } - } - } - - return array_unique($relations); - } - - /** - * Get loaded relations for the instance without parent. - * - * @return array - */ - protected function getRelationsWithoutParent() - { - $relations = $this->getRelations(); - - $parentRelation = $this->getParentRelation(); - if ($parentRelation) { - unset($relations[$parentRelation->getQualifiedForeignKeyName()]); - } - - return $relations; - } - - /** - * Checks if column exists on a table. As this is a document model, just return true. This also - * prevents calls to non-existent function Grammar::compileColumnListing(). - * - * @param string $key - * - * @return bool - */ - protected function isGuardableColumn($key) - { - return true; - } - - /** @inheritdoc */ - protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes) - { - foreach ($this->getCasts() as $key => $castType) { - if (! Arr::has($attributes, $key) || Arr::has($mutatedAttributes, $key)) { - continue; - } - - $originalValue = Arr::get($attributes, $key); - - // Here we will cast the attribute. Then, if the cast is a date or datetime cast - // then we will serialize the date for the array. This will convert the dates - // to strings based on the date format specified for these Eloquent models. - $castValue = $this->castAttribute( - $key, - $originalValue, - ); - - // If the attribute cast was a date or a datetime, we will serialize the date as - // a string. This allows the developers to customize how dates are serialized - // into an array without affecting how they are persisted into the storage. - if ($castValue !== null && in_array($castType, ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) { - $castValue = $this->serializeDate($castValue); - } - - if ($castValue !== null && ($this->isCustomDateTimeCast($castType) || $this->isImmutableCustomDateTimeCast($castType))) { - $castValue = $castValue->format(explode(':', $castType, 2)[1]); - } - - if ($castValue instanceof DateTimeInterface && $this->isClassCastable($key)) { - $castValue = $this->serializeDate($castValue); - } - - if ($castValue !== null && $this->isClassSerializable($key)) { - $castValue = $this->serializeClassCastableAttribute($key, $castValue); - } - - if ($this->isEnumCastable($key) && (! $castValue instanceof Arrayable)) { - $castValue = $castValue !== null ? $this->getStorableEnumValueFromLaravel11($this->getCasts()[$key], $castValue) : null; - } - - if ($castValue instanceof Arrayable) { - $castValue = $castValue->toArray(); - } - - Arr::set($attributes, $key, $castValue); - } - - return $attributes; - } - - /** - * Duplicate of {@see HasAttributes::getStorableEnumValue()} for Laravel 11 as the signature of the method has - * changed in a non-backward compatible way. - * - * @todo Remove this method when support for Laravel 10 is dropped. - */ - private function getStorableEnumValueFromLaravel11($expectedEnum, $value) - { - if (! $value instanceof $expectedEnum) { - throw new ValueError(sprintf('Value [%s] is not of the expected enum type [%s].', var_export($value, true), $expectedEnum)); - } - - return $value instanceof BackedEnum - ? $value->value - : $value->name; - } - - /** - * Is a value a BSON type? - * - * @param mixed $value - * - * @return bool - */ - protected function isBSON(mixed $value): bool - { - return $value instanceof Type; - } - - /** - * {@inheritDoc} - */ - public function save(array $options = []) - { - // SQL databases would use autoincrement the id field if set to null. - // Apply the same behavior to MongoDB with _id only, otherwise null would be stored. - if (array_key_exists('_id', $this->attributes) && $this->attributes['_id'] === null) { - unset($this->attributes['_id']); - } - - $saved = parent::save($options); - - // Clear list of unset fields - $this->unset = []; - - return $saved; - } - - /** - * {@inheritDoc} - */ - public function refresh() - { - parent::refresh(); - - // Clear list of unset fields - $this->unset = []; - - return $this; + // Document models must use the DocumentModel trait. + return self::$documentModelClasses[$class] = array_key_exists(DocumentModel::class, class_uses_recursive($class)); } } diff --git a/src/Helpers/QueriesRelationships.php b/src/Helpers/QueriesRelationships.php index b1234124b..933b6ec32 100644 --- a/src/Helpers/QueriesRelationships.php +++ b/src/Helpers/QueriesRelationships.php @@ -54,7 +54,7 @@ public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ? // If this is a hybrid relation then we can not use a normal whereExists() query that relies on a subquery // We need to use a `whereIn` query - if ($this->getModel() instanceof Model || $this->isAcrossConnections($relation)) { + if (Model::isDocumentModel($this->getModel()) || $this->isAcrossConnections($relation)) { return $this->addHybridHas($relation, $operator, $count, $boolean, $callback); } diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php index 8ff311f3f..b68c79d4c 100644 --- a/src/Relations/BelongsToMany.php +++ b/src/Relations/BelongsToMany.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany as EloquentBelongsToMany; use Illuminate\Support\Arr; +use MongoDB\Laravel\Eloquent\Model as DocumentModel; use function array_diff; use function array_keys; @@ -125,7 +126,7 @@ public function sync($ids, $detaching = true) // First we need to attach any of the associated models that are not currently // in this joining table. We'll spin through the given IDs, checking to see // if they exist in the array of current ones, and if not we will insert. - $current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $current = match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { true => $this->parent->{$this->relatedPivotKey} ?: [], false => $this->parent->{$this->relationName} ?: [], }; @@ -201,7 +202,7 @@ public function attach($id, array $attributes = [], $touch = true) } // Attach the new ids to the parent model. - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { $this->parent->push($this->relatedPivotKey, (array) $id, true); } else { $instance = new $this->related(); @@ -232,7 +233,7 @@ public function detach($ids = [], $touch = true) $ids = (array) $ids; // Detach all ids from the parent model. - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (DocumentModel::isDocumentModel($this->parent)) { $this->parent->pull($this->relatedPivotKey, $ids); } else { $value = $this->parent->{$this->relationName} diff --git a/src/Relations/EmbedsMany.php b/src/Relations/EmbedsMany.php index 2d68af70b..be7039506 100644 --- a/src/Relations/EmbedsMany.php +++ b/src/Relations/EmbedsMany.php @@ -10,7 +10,6 @@ use Illuminate\Pagination\Paginator; use MongoDB\BSON\ObjectID; use MongoDB\Driver\Exception\LogicException; -use MongoDB\Laravel\Eloquent\Model as MongoDBModel; use function array_key_exists; use function array_values; @@ -231,9 +230,9 @@ public function detach($ids = []) /** * Save alias. * - * @return MongoDBModel + * @return Model */ - public function attach(MongoDBModel $model) + public function attach(Model $model) { return $this->save($model); } diff --git a/src/Relations/EmbedsOneOrMany.php b/src/Relations/EmbedsOneOrMany.php index 56fc62041..9c83aa299 100644 --- a/src/Relations/EmbedsOneOrMany.php +++ b/src/Relations/EmbedsOneOrMany.php @@ -7,10 +7,11 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Expression; use MongoDB\Driver\Exception\LogicException; -use MongoDB\Laravel\Eloquent\Model; +use MongoDB\Laravel\Eloquent\Model as DocumentModel; use Throwable; use function array_merge; @@ -46,6 +47,14 @@ abstract class EmbedsOneOrMany extends Relation */ public function __construct(Builder $query, Model $parent, Model $related, string $localKey, string $foreignKey, string $relation) { + if (! DocumentModel::isDocumentModel($parent)) { + throw new LogicException('Parent model must be a document model.'); + } + + if (! DocumentModel::isDocumentModel($related)) { + throw new LogicException('Related model must be a document model.'); + } + parent::__construct($query, $parent); $this->related = $related; diff --git a/src/Relations/MorphToMany.php b/src/Relations/MorphToMany.php index 163e7e67f..f11d25473 100644 --- a/src/Relations/MorphToMany.php +++ b/src/Relations/MorphToMany.php @@ -77,7 +77,7 @@ public function addEagerConstraints(array $models) protected function setWhere() { if ($this->getInverse()) { - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { $ids = $this->extractIds((array) $this->parent->{$this->table}); $this->query->whereIn($this->relatedKey, $ids); @@ -86,7 +86,7 @@ protected function setWhere() ->whereIn($this->foreignPivotKey, (array) $this->parent->{$this->parentKey}); } } else { - match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { true => $this->query->whereIn($this->relatedKey, (array) $this->parent->{$this->relatedPivotKey}), false => $this->query ->whereIn($this->getQualifiedForeignPivotKeyName(), (array) $this->parent->{$this->parentKey}), @@ -140,7 +140,7 @@ public function sync($ids, $detaching = true) // in this joining table. We'll spin through the given IDs, checking to see // if they exist in the array of current ones, and if not we will insert. if ($this->getInverse()) { - $current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $current = match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { true => $this->parent->{$this->table} ?: [], false => $this->parent->{$this->relationName} ?: [], }; @@ -151,7 +151,7 @@ public function sync($ids, $detaching = true) $current = $this->extractIds($current); } } else { - $current = match ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + $current = match (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { true => $this->parent->{$this->relatedPivotKey} ?: [], false => $this->parent->{$this->relationName} ?: [], }; @@ -213,7 +213,7 @@ public function attach($id, array $attributes = [], $touch = true) if ($this->getInverse()) { // Attach the new ids to the parent model. - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { $this->parent->push($this->table, [ [ $this->relatedPivotKey => $model->{$this->relatedKey}, @@ -236,7 +236,7 @@ public function attach($id, array $attributes = [], $touch = true) ], true); // Attach the new ids to the parent model. - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { $this->parent->push($this->relatedPivotKey, (array) $id, true); } else { $this->addIdToParentRelationData($id); @@ -257,7 +257,7 @@ public function attach($id, array $attributes = [], $touch = true) $query->push($this->foreignPivotKey, $this->parent->{$this->parentKey}); // Attach the new ids to the parent model. - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { foreach ($id as $item) { $this->parent->push($this->table, [ [ @@ -281,7 +281,7 @@ public function attach($id, array $attributes = [], $touch = true) ], true); // Attach the new ids to the parent model. - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { $this->parent->push($this->relatedPivotKey, $id, true); } else { foreach ($id as $item) { @@ -324,7 +324,7 @@ public function detach($ids = [], $touch = true) ]; } - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { $this->parent->pull($this->table, $data); } else { $value = $this->parent->{$this->relationName} @@ -341,7 +341,7 @@ public function detach($ids = [], $touch = true) $query->pull($this->foreignPivotKey, $this->parent->{$this->parentKey}); } else { // Remove the relation from the parent. - if ($this->parent instanceof \MongoDB\Laravel\Eloquent\Model) { + if (\MongoDB\Laravel\Eloquent\Model::isDocumentModel($this->parent)) { $this->parent->pull($this->relatedPivotKey, $ids); } else { $value = $this->parent->{$this->relationName} diff --git a/tests/Eloquent/ModelTest.php b/tests/Eloquent/ModelTest.php new file mode 100644 index 000000000..b3ea0a532 --- /dev/null +++ b/tests/Eloquent/ModelTest.php @@ -0,0 +1,62 @@ +assertSame($expected, Model::isDocumentModel($classOrObject)); + } + + public static function provideDocumentModelClasses(): Generator + { + // Test classes + yield [false, SqlBook::class]; + yield [true, Casting::class]; + yield [true, Book::class]; + + // Provided by the Laravel MongoDB package. + yield [true, User::class]; + + // Instances of objects + yield [false, new SqlBook()]; + yield [true, new Book()]; + + // Anonymous classes + yield [ + true, + new class extends Model { + }, + ]; + yield [ + true, + new class extends BaseModel { + use DocumentModel; + }, + ]; + yield [ + false, + new class { + use DocumentModel; + }, + ]; + yield [ + false, + new class extends BaseModel { + }, + ]; + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 73374ce57..9d2b58b6e 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -58,7 +58,7 @@ public function tearDown(): void public function testNewModel(): void { $user = new User(); - $this->assertInstanceOf(Model::class, $user); + $this->assertTrue(Model::isDocumentModel($user)); $this->assertInstanceOf(Connection::class, $user->getConnection()); $this->assertFalse($user->exists); $this->assertEquals('users', $user->getTable()); @@ -234,8 +234,7 @@ public function testFind(): void $check = User::find($user->_id); $this->assertInstanceOf(User::class, $check); - - $this->assertInstanceOf(Model::class, $check); + $this->assertTrue(Model::isDocumentModel($check)); $this->assertTrue($check->exists); $this->assertEquals($user->_id, $check->_id); @@ -259,7 +258,7 @@ public function testGet(): void $users = User::get(); $this->assertCount(2, $users); $this->assertInstanceOf(EloquentCollection::class, $users); - $this->assertInstanceOf(Model::class, $users[0]); + $this->assertInstanceOf(User::class, $users[0]); } public function testFirst(): void @@ -271,7 +270,7 @@ public function testFirst(): void $user = User::first(); $this->assertInstanceOf(User::class, $user); - $this->assertInstanceOf(Model::class, $user); + $this->assertTrue(Model::isDocumentModel($user)); $this->assertEquals('John Doe', $user->name); } @@ -299,7 +298,7 @@ public function testCreate(): void $user = User::create(['name' => 'Jane Poe']); $this->assertInstanceOf(User::class, $user); - $this->assertInstanceOf(Model::class, $user); + $this->assertTrue(Model::isDocumentModel($user)); $this->assertTrue($user->exists); $this->assertEquals('Jane Poe', $user->name); @@ -872,13 +871,13 @@ public function testRaw(): void return $collection->find(['age' => 35]); }); $this->assertInstanceOf(EloquentCollection::class, $users); - $this->assertInstanceOf(Model::class, $users[0]); + $this->assertInstanceOf(User::class, $users[0]); $user = User::raw(function (Collection $collection) { return $collection->findOne(['age' => 35]); }); - $this->assertInstanceOf(Model::class, $user); + $this->assertTrue(Model::isDocumentModel($user)); $count = User::raw(function (Collection $collection) { return $collection->count(); @@ -1008,7 +1007,7 @@ public function testFirstOrCreate(): void $user = User::firstOrCreate(['name' => $name]); $this->assertInstanceOf(User::class, $user); - $this->assertInstanceOf(Model::class, $user); + $this->assertTrue(Model::isDocumentModel($user)); $this->assertTrue($user->exists); $this->assertEquals($name, $user->name); diff --git a/tests/Models/Address.php b/tests/Models/Address.php index b827dc85f..d94e31d24 100644 --- a/tests/Models/Address.php +++ b/tests/Models/Address.php @@ -4,12 +4,17 @@ namespace MongoDB\Laravel\Tests\Models; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\Model; +use MongoDB\Laravel\Eloquent\DocumentModel; use MongoDB\Laravel\Relations\EmbedsMany; -class Address extends Eloquent +class Address extends Model { - protected $connection = 'mongodb'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; protected static $unguarded = true; public function addresses(): EmbedsMany diff --git a/tests/Models/Birthday.php b/tests/Models/Birthday.php index 4131357f6..65b703af1 100644 --- a/tests/Models/Birthday.php +++ b/tests/Models/Birthday.php @@ -4,17 +4,22 @@ namespace MongoDB\Laravel\Tests\Models; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\Model; +use MongoDB\Laravel\Eloquent\DocumentModel; /** * @property string $name * @property string $birthday * @property string $time */ -class Birthday extends Eloquent +class Birthday extends Model { + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; protected $connection = 'mongodb'; - protected $collection = 'birthday'; + protected string $collection = 'birthday'; protected $fillable = ['name', 'birthday']; protected $casts = ['birthday' => 'datetime']; diff --git a/tests/Models/Book.php b/tests/Models/Book.php index 70d566fe2..5bee76e5c 100644 --- a/tests/Models/Book.php +++ b/tests/Models/Book.php @@ -4,20 +4,24 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; /** * @property string $title * @property string $author * @property array $chapters */ -class Book extends Eloquent +class Book extends Model { - protected $connection = 'mongodb'; - protected $collection = 'books'; + use DocumentModel; + + protected $primaryKey = 'title'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'books'; protected static $unguarded = true; - protected $primaryKey = 'title'; public function author(): BelongsTo { diff --git a/tests/Models/CastObjectId.php b/tests/Models/CastObjectId.php index 2f4e7f5d5..d3d5571c4 100644 --- a/tests/Models/CastObjectId.php +++ b/tests/Models/CastObjectId.php @@ -5,11 +5,11 @@ namespace MongoDB\Laravel\Tests\Models; use MongoDB\Laravel\Eloquent\Casts\ObjectId; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\Model; -class CastObjectId extends Eloquent +class CastObjectId extends Model { - protected $connection = 'mongodb'; + protected $connection = 'mongodb'; protected static $unguarded = true; protected $casts = [ 'oid' => ObjectId::class, diff --git a/tests/Models/Casting.php b/tests/Models/Casting.php index f44f08a62..d033cf444 100644 --- a/tests/Models/Casting.php +++ b/tests/Models/Casting.php @@ -5,12 +5,12 @@ namespace MongoDB\Laravel\Tests\Models; use MongoDB\Laravel\Eloquent\Casts\BinaryUuid; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\Model; -class Casting extends Eloquent +class Casting extends Model { protected $connection = 'mongodb'; - protected $collection = 'casting'; + protected string $collection = 'casting'; protected $fillable = [ 'uuid', diff --git a/tests/Models/Client.php b/tests/Models/Client.php index 4e7e7ecc9..47fd91d03 100644 --- a/tests/Models/Client.php +++ b/tests/Models/Client.php @@ -4,15 +4,20 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphOne; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Client extends Eloquent +class Client extends Model { - protected $connection = 'mongodb'; - protected $collection = 'clients'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'clients'; protected static $unguarded = true; public function users(): BelongsToMany diff --git a/tests/Models/Experience.php b/tests/Models/Experience.php index 2852ece5f..37a44e4d1 100644 --- a/tests/Models/Experience.php +++ b/tests/Models/Experience.php @@ -4,13 +4,18 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Experience extends Eloquent +class Experience extends Model { - protected $connection = 'mongodb'; - protected $collection = 'experiences'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'experiences'; protected static $unguarded = true; protected $casts = ['years' => 'int']; diff --git a/tests/Models/Group.php b/tests/Models/Group.php index eda017a03..689c6d599 100644 --- a/tests/Models/Group.php +++ b/tests/Models/Group.php @@ -4,13 +4,18 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Group extends Eloquent +class Group extends Model { - protected $connection = 'mongodb'; - protected $collection = 'groups'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'groups'; protected static $unguarded = true; public function users(): BelongsToMany diff --git a/tests/Models/Guarded.php b/tests/Models/Guarded.php index 540d68996..9837e9222 100644 --- a/tests/Models/Guarded.php +++ b/tests/Models/Guarded.php @@ -4,11 +4,16 @@ namespace MongoDB\Laravel\Tests\Models; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\Model; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Guarded extends Eloquent +class Guarded extends Model { + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; protected $connection = 'mongodb'; - protected $collection = 'guarded'; - protected $guarded = ['foobar', 'level1->level2']; + protected string $collection = 'guarded'; + protected $guarded = ['foobar', 'level1->level2']; } diff --git a/tests/Models/HiddenAnimal.php b/tests/Models/HiddenAnimal.php index 81e666d37..a47184fe7 100644 --- a/tests/Models/HiddenAnimal.php +++ b/tests/Models/HiddenAnimal.php @@ -4,6 +4,8 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; +use MongoDB\Laravel\Eloquent\DocumentModel; use MongoDB\Laravel\Eloquent\Model as Eloquent; use MongoDB\Laravel\Query\Builder; @@ -16,8 +18,12 @@ * @method static Builder truncate() * @method static Eloquent sole(...$parameters) */ -final class HiddenAnimal extends Eloquent +final class HiddenAnimal extends Model { + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; protected $fillable = [ 'name', 'country', diff --git a/tests/Models/IdIsBinaryUuid.php b/tests/Models/IdIsBinaryUuid.php index 56ae89dca..2314b4b19 100644 --- a/tests/Models/IdIsBinaryUuid.php +++ b/tests/Models/IdIsBinaryUuid.php @@ -4,14 +4,19 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use MongoDB\Laravel\Eloquent\Casts\BinaryUuid; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class IdIsBinaryUuid extends Eloquent +class IdIsBinaryUuid extends Model { - protected $connection = 'mongodb'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; protected static $unguarded = true; - protected $casts = [ + protected $casts = [ '_id' => BinaryUuid::class, ]; } diff --git a/tests/Models/IdIsInt.php b/tests/Models/IdIsInt.php index 1243fc217..1f8d1ba88 100644 --- a/tests/Models/IdIsInt.php +++ b/tests/Models/IdIsInt.php @@ -4,12 +4,16 @@ namespace MongoDB\Laravel\Tests\Models; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\Model; +use MongoDB\Laravel\Eloquent\DocumentModel; -class IdIsInt extends Eloquent +class IdIsInt extends Model { - protected $keyType = 'int'; - protected $connection = 'mongodb'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'int'; + protected $connection = 'mongodb'; protected static $unguarded = true; - protected $casts = ['_id' => 'int']; + protected $casts = ['_id' => 'int']; } diff --git a/tests/Models/IdIsString.php b/tests/Models/IdIsString.php index ed89803ca..37ba1c424 100644 --- a/tests/Models/IdIsString.php +++ b/tests/Models/IdIsString.php @@ -4,11 +4,16 @@ namespace MongoDB\Laravel\Tests\Models; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\Model; +use MongoDB\Laravel\Eloquent\DocumentModel; -class IdIsString extends Eloquent +class IdIsString extends Model { - protected $connection = 'mongodb'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; protected static $unguarded = true; - protected $casts = ['_id' => 'string']; + protected $casts = ['_id' => 'string']; } diff --git a/tests/Models/Item.php b/tests/Models/Item.php index 8aafc1446..bc0b29b7b 100644 --- a/tests/Models/Item.php +++ b/tests/Models/Item.php @@ -5,15 +5,20 @@ namespace MongoDB\Laravel\Tests\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use MongoDB\Laravel\Eloquent\Builder; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; /** @property Carbon $created_at */ -class Item extends Eloquent +class Item extends Model { - protected $connection = 'mongodb'; - protected $collection = 'items'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'items'; protected static $unguarded = true; public function user(): BelongsTo diff --git a/tests/Models/Label.php b/tests/Models/Label.php index 5bd1cf4da..b392184d7 100644 --- a/tests/Models/Label.php +++ b/tests/Models/Label.php @@ -4,18 +4,23 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphToMany; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; /** * @property string $title * @property string $author * @property array $chapters */ -class Label extends Eloquent +class Label extends Model { - protected $connection = 'mongodb'; - protected $collection = 'labels'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'labels'; protected static $unguarded = true; protected $fillable = [ diff --git a/tests/Models/Location.php b/tests/Models/Location.php index e273fa455..9621d388f 100644 --- a/tests/Models/Location.php +++ b/tests/Models/Location.php @@ -4,11 +4,16 @@ namespace MongoDB\Laravel\Tests\Models; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use Illuminate\Database\Eloquent\Model; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Location extends Eloquent +class Location extends Model { - protected $connection = 'mongodb'; - protected $collection = 'locations'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'locations'; protected static $unguarded = true; } diff --git a/tests/Models/Photo.php b/tests/Models/Photo.php index 74852dc28..ea3321337 100644 --- a/tests/Models/Photo.php +++ b/tests/Models/Photo.php @@ -4,13 +4,18 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Photo extends Eloquent +class Photo extends Model { - protected $connection = 'mongodb'; - protected $collection = 'photos'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'photos'; protected static $unguarded = true; public function hasImage(): MorphTo diff --git a/tests/Models/Role.php b/tests/Models/Role.php index ab5eaa029..7d0dce7b1 100644 --- a/tests/Models/Role.php +++ b/tests/Models/Role.php @@ -4,13 +4,18 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Role extends Eloquent +class Role extends Model { - protected $connection = 'mongodb'; - protected $collection = 'roles'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'roles'; protected static $unguarded = true; public function user(): BelongsTo diff --git a/tests/Models/Scoped.php b/tests/Models/Scoped.php index d728b6bec..84b8b81f7 100644 --- a/tests/Models/Scoped.php +++ b/tests/Models/Scoped.php @@ -4,14 +4,19 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use MongoDB\Laravel\Eloquent\Builder; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Scoped extends Eloquent +class Scoped extends Model { + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; protected $connection = 'mongodb'; - protected $collection = 'scoped'; - protected $fillable = ['name', 'favorite']; + protected string $collection = 'scoped'; + protected $fillable = ['name', 'favorite']; protected static function boot() { diff --git a/tests/Models/Skill.php b/tests/Models/Skill.php index 3b9a434ee..90c9455b9 100644 --- a/tests/Models/Skill.php +++ b/tests/Models/Skill.php @@ -4,13 +4,18 @@ namespace MongoDB\Laravel\Tests\Models; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\DocumentModel; -class Skill extends Eloquent +class Skill extends Model { - protected $connection = 'mongodb'; - protected $collection = 'skills'; + use DocumentModel; + + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'skills'; protected static $unguarded = true; public function sqlUsers(): BelongsToMany diff --git a/tests/Models/Soft.php b/tests/Models/Soft.php index 763aafb41..549e63758 100644 --- a/tests/Models/Soft.php +++ b/tests/Models/Soft.php @@ -5,21 +5,25 @@ namespace MongoDB\Laravel\Tests\Models; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Model; use MongoDB\Laravel\Eloquent\Builder; +use MongoDB\Laravel\Eloquent\DocumentModel; use MongoDB\Laravel\Eloquent\MassPrunable; -use MongoDB\Laravel\Eloquent\Model as Eloquent; use MongoDB\Laravel\Eloquent\SoftDeletes; /** @property Carbon $deleted_at */ -class Soft extends Eloquent +class Soft extends Model { + use DocumentModel; use SoftDeletes; use MassPrunable; - protected $connection = 'mongodb'; - protected $collection = 'soft'; + protected $primaryKey = '_id'; + protected $keyType = 'string'; + protected $connection = 'mongodb'; + protected string $collection = 'soft'; protected static $unguarded = true; - protected $casts = ['deleted_at' => 'datetime']; + protected $casts = ['deleted_at' => 'datetime']; public function prunable(): Builder { diff --git a/tests/Models/SqlBook.php b/tests/Models/SqlBook.php index babc984eb..228b6d3eb 100644 --- a/tests/Models/SqlBook.php +++ b/tests/Models/SqlBook.php @@ -17,10 +17,10 @@ class SqlBook extends EloquentModel { use HybridRelations; - protected $connection = 'sqlite'; - protected $table = 'books'; + protected $connection = 'sqlite'; + protected $table = 'books'; protected static $unguarded = true; - protected $primaryKey = 'title'; + protected $primaryKey = 'title'; public function author(): BelongsTo { diff --git a/tests/Models/SqlRole.php b/tests/Models/SqlRole.php index 17c01e819..1d4b542a5 100644 --- a/tests/Models/SqlRole.php +++ b/tests/Models/SqlRole.php @@ -17,8 +17,8 @@ class SqlRole extends EloquentModel { use HybridRelations; - protected $connection = 'sqlite'; - protected $table = 'roles'; + protected $connection = 'sqlite'; + protected $table = 'roles'; protected static $unguarded = true; public function user(): BelongsTo diff --git a/tests/Models/SqlUser.php b/tests/Models/SqlUser.php index 4cb77faa5..9b389ac08 100644 --- a/tests/Models/SqlUser.php +++ b/tests/Models/SqlUser.php @@ -20,8 +20,8 @@ class SqlUser extends EloquentModel { use HybridRelations; - protected $connection = 'sqlite'; - protected $table = 'users'; + protected $connection = 'sqlite'; + protected $table = 'users'; protected static $unguarded = true; public function books(): HasMany diff --git a/tests/Models/User.php b/tests/Models/User.php index 98f76d931..22048f282 100644 --- a/tests/Models/User.php +++ b/tests/Models/User.php @@ -11,12 +11,12 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use MongoDB\Laravel\Eloquent\Builder; -use MongoDB\Laravel\Eloquent\HybridRelations; +use MongoDB\Laravel\Eloquent\DocumentModel; use MongoDB\Laravel\Eloquent\MassPrunable; -use MongoDB\Laravel\Eloquent\Model as Eloquent; /** * @property string $_id @@ -30,22 +30,24 @@ * @property string $username * @property MemberStatus member_status */ -class User extends Eloquent implements AuthenticatableContract, CanResetPasswordContract +class User extends Model implements AuthenticatableContract, CanResetPasswordContract { + use DocumentModel; use Authenticatable; use CanResetPassword; - use HybridRelations; use Notifiable; use MassPrunable; + protected $primaryKey = '_id'; + protected $keyType = 'string'; protected $connection = 'mongodb'; - protected $casts = [ + protected $casts = [ 'birthday' => 'datetime', 'entry.date' => 'datetime', 'member_status' => MemberStatus::class, ]; - protected $fillable = [ + protected $fillable = [ 'name', 'email', 'title', diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index 368406feb..02efbc77b 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -331,7 +331,6 @@ public function testBelongsToManyAttachArray(): void $client1 = Client::create(['name' => 'Test 1'])->_id; $client2 = Client::create(['name' => 'Test 2'])->_id; - $user = User::where('name', '=', 'John Doe')->first(); $user->clients()->attach([$client1, $client2]); $this->assertCount(2, $user->clients); } diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php index 1086171d7..3338c6832 100644 --- a/tests/TransactionTest.php +++ b/tests/TransactionTest.php @@ -40,7 +40,7 @@ public function testCreateWithCommit(): void $this->assertInstanceOf(User::class, $klinson); DB::commit(); - $this->assertInstanceOf(Model::class, $klinson); + $this->assertTrue(Model::isDocumentModel($klinson)); $this->assertTrue($klinson->exists); $this->assertEquals('klinson', $klinson->name); @@ -56,7 +56,7 @@ public function testCreateRollBack(): void $this->assertInstanceOf(User::class, $klinson); DB::rollBack(); - $this->assertInstanceOf(Model::class, $klinson); + $this->assertTrue(Model::isDocumentModel($klinson)); $this->assertTrue($klinson->exists); $this->assertEquals('klinson', $klinson->name); From cb3fa4ef27cbf65f8e0edbd7b632c7707ab3cb28 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Mon, 8 Jul 2024 11:38:52 -0400 Subject: [PATCH 5/8] DOCSP-38380: array reads (#3028) * DOCSP-38380: array reads * fix * NR PR fixes 1 * remove extra doc in test --- docs/fundamentals/read-operations.txt | 67 ++++++++++++++++--- .../read-operations/ReadOperationsTest.php | 31 +++++++++ 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/docs/fundamentals/read-operations.txt b/docs/fundamentals/read-operations.txt index 8025f0087..29437aa59 100644 --- a/docs/fundamentals/read-operations.txt +++ b/docs/fundamentals/read-operations.txt @@ -57,16 +57,13 @@ You can use Laravel's Eloquent object-relational mapper (ORM) to create models that represent MongoDB collections and chain methods on them to specify query criteria. -To retrieve documents that match a set of criteria, pass a query filter to the -``where()`` method. +To retrieve documents that match a set of criteria, call the ``where()`` +method on the collection's corresponding Eloquent model, then pass a query +filter to the method. A query filter specifies field value requirements and instructs the find operation to return only documents that meet these requirements. -You can use Laravel's Eloquent object-relational mapper (ORM) to create models -that represent MongoDB collections. To retrieve documents from a collection, -call the ``where()`` method on the collection's corresponding Eloquent model. - You can use one of the following ``where()`` method calls to build a query: - ``where('', )`` builds a query that matches documents in @@ -79,7 +76,7 @@ You can use one of the following ``where()`` method calls to build a query: To apply multiple sets of criteria to the find operation, you can chain a series of ``where()`` methods together. -After building your query with the ``where()`` method, chain the ``get()`` +After building your query by using the ``where()`` method, chain the ``get()`` method to retrieve the query results. This example calls two ``where()`` methods on the ``Movie`` Eloquent model to @@ -150,6 +147,60 @@ retrieve documents that meet the following criteria: To learn how to query by using the Laravel query builder instead of the Eloquent ORM, see the :ref:`laravel-query-builder` page. +Match Array Field Elements +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can specify a query filter to match array field elements when +retrieving documents. If your documents contain an array field, you can +match documents based on if the value contains all or some specified +array elements. + +You can use one of the following ``where()`` method calls to build a +query on an array field: + +- ``where('', )`` builds a query that matches documents in + which the array field value is exactly the specified array + +- ``where('', 'in', )`` builds a query + that matches documents in which the array field value contains one or + more of the specified array elements + +After building your query by using the ``where()`` method, chain the ``get()`` +method to retrieve the query results. + +Select from the following :guilabel:`Exact Array Match` and +:guilabel:`Element Match` tabs to view the query syntax for each pattern: + +.. tabs:: + + .. tab:: Exact Array Match + :tabid: exact-array + + This example retrieves documents in which the ``countries`` array is + exactly ``['Indonesia', 'Canada']``: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-exact-array + :end-before: end-exact-array + + .. tab:: Element Match + :tabid: element-match + + This example retrieves documents in which the ``countries`` array + contains one of the values in the array ``['Canada', 'Egypt']``: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-elem-match + :end-before: end-elem-match + +To learn how to query array fields by using the Laravel query builder instead of the +Eloquent ORM, see the :ref:`laravel-query-builder-elemMatch` section in +the Query Builder guide. + .. _laravel-retrieve-all: Retrieve All Documents in a Collection @@ -200,7 +251,7 @@ by the ``$search`` field in your query filter that you pass to the ``where()`` method. The ``$text`` operator performs a text search on the text-indexed fields. The ``$search`` field specifies the text to search for. -After building your query with the ``where()`` method, chain the ``get()`` +After building your query by using the ``where()`` method, chain the ``get()`` method to retrieve the query results. This example calls the ``where()`` method on the ``Movie`` Eloquent model to diff --git a/docs/includes/fundamentals/read-operations/ReadOperationsTest.php b/docs/includes/fundamentals/read-operations/ReadOperationsTest.php index a2080ec8f..c27680fb5 100644 --- a/docs/includes/fundamentals/read-operations/ReadOperationsTest.php +++ b/docs/includes/fundamentals/read-operations/ReadOperationsTest.php @@ -133,4 +133,35 @@ public function testTextRelevance(): void $this->assertCount(1, $movies); $this->assertEquals('this is a love story', $movies[0]->plot); } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function exactArrayMatch(): void + { + // start-exact-array + $movies = Movie::where('countries', ['Indonesia', 'Canada']) + ->get(); + // end-exact-array + + $this->assertNotNull($movies); + $this->assertCount(1, $movies); + $this->assertEquals('Title 1', $movies[0]->title); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function arrayElemMatch(): void + { + // start-elem-match + $movies = Movie::where('countries', 'in', ['Canada', 'Egypt']) + ->get(); + // end-elem-match + + $this->assertNotNull($movies); + $this->assertCount(2, $movies); + } } From f6ee9cc739285c2930974dafaba91af3eac341a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 9 Jul 2024 09:44:27 +0200 Subject: [PATCH 6/8] Use PHPUnit's Attributes instead of annotation (#3035) --- tests/Casts/BinaryUuidTest.php | 3 ++- tests/Casts/ObjectIdTest.php | 3 ++- tests/ConnectionTest.php | 3 ++- tests/ModelTest.php | 5 +++-- tests/Query/BuilderTest.php | 7 ++++--- tests/QueryTest.php | 7 +++---- tests/Seeder/DatabaseSeeder.php | 7 +------ tests/TestCase.php | 12 +++--------- 8 files changed, 20 insertions(+), 27 deletions(-) diff --git a/tests/Casts/BinaryUuidTest.php b/tests/Casts/BinaryUuidTest.php index 2183c12fa..0d76c6927 100644 --- a/tests/Casts/BinaryUuidTest.php +++ b/tests/Casts/BinaryUuidTest.php @@ -8,6 +8,7 @@ use MongoDB\BSON\Binary; use MongoDB\Laravel\Tests\Models\Casting; use MongoDB\Laravel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use function hex2bin; @@ -20,7 +21,7 @@ protected function setUp(): void Casting::truncate(); } - /** @dataProvider provideBinaryUuidCast */ + #[DataProvider('provideBinaryUuidCast')] public function testBinaryUuidCastModel(string $expectedUuid, string|Binary $saveUuid, Binary $queryUuid): void { Casting::create(['uuid' => $saveUuid]); diff --git a/tests/Casts/ObjectIdTest.php b/tests/Casts/ObjectIdTest.php index 8d3e9daf4..57201b4eb 100644 --- a/tests/Casts/ObjectIdTest.php +++ b/tests/Casts/ObjectIdTest.php @@ -8,6 +8,7 @@ use MongoDB\BSON\ObjectId; use MongoDB\Laravel\Tests\Models\CastObjectId; use MongoDB\Laravel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class ObjectIdTest extends TestCase { @@ -18,7 +19,7 @@ protected function setUp(): void CastObjectId::truncate(); } - /** @dataProvider provideObjectIdCast */ + #[DataProvider('provideObjectIdCast')] public function testStoreObjectId(string|ObjectId $saveObjectId, ObjectId $queryObjectId): void { $stringObjectId = (string) $saveObjectId; diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 83097973b..586452109 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -14,6 +14,7 @@ use MongoDB\Laravel\Connection; use MongoDB\Laravel\Query\Builder; use MongoDB\Laravel\Schema\Builder as SchemaBuilder; +use PHPUnit\Framework\Attributes\DataProvider; use function env; use function spl_object_hash; @@ -186,7 +187,7 @@ public static function dataConnectionConfig(): Generator ]; } - /** @dataProvider dataConnectionConfig */ + #[DataProvider('dataConnectionConfig')] public function testConnectionConfig(string $expectedUri, string $expectedDatabaseName, array $config): void { $connection = new Connection($config); diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 9d2b58b6e..3c4cbd8df 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -28,6 +28,7 @@ use MongoDB\Laravel\Tests\Models\Soft; use MongoDB\Laravel\Tests\Models\SqlUser; use MongoDB\Laravel\Tests\Models\User; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use function abs; @@ -370,7 +371,7 @@ public function testSoftDelete(): void $this->assertEquals(2, Soft::count()); } - /** @dataProvider provideId */ + #[DataProvider('provideId')] public function testPrimaryKey(string $model, $id, $expected, bool $expectedFound): void { $model::truncate(); @@ -755,7 +756,7 @@ public static function provideDate(): Generator yield 'DateTime date, time and ms before unix epoch' => [new DateTime('1965-08-08 04.08.37.324')]; } - /** @dataProvider provideDate */ + #[DataProvider('provideDate')] public function testDateInputs($date): void { // Test with create and standard property diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 4076b3028..3ec933499 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -19,6 +19,7 @@ use MongoDB\Laravel\Query\Builder; use MongoDB\Laravel\Query\Grammar; use MongoDB\Laravel\Query\Processor; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use stdClass; @@ -29,7 +30,7 @@ class BuilderTest extends TestCase { - /** @dataProvider provideQueryBuilderToMql */ + #[DataProvider('provideQueryBuilderToMql')] public function testMql(array $expected, Closure $build): void { $builder = $build(self::getBuilder()); @@ -1298,7 +1299,7 @@ function (Builder $elemMatchQuery): void { } } - /** @dataProvider provideExceptions */ + #[DataProvider('provideExceptions')] public function testException($class, $message, Closure $build): void { $builder = self::getBuilder(); @@ -1396,7 +1397,7 @@ public static function provideExceptions(): iterable ]; } - /** @dataProvider getEloquentMethodsNotSupported */ + #[DataProvider('getEloquentMethodsNotSupported')] public function testEloquentMethodsNotSupported(Closure $callback) { $builder = self::getBuilder(); diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 60645c985..2fd66bf70 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -12,6 +12,7 @@ use MongoDB\Laravel\Tests\Models\Birthday; use MongoDB\Laravel\Tests\Models\Scoped; use MongoDB\Laravel\Tests\Models\User; +use PHPUnit\Framework\Attributes\TestWith; use function str; @@ -662,10 +663,8 @@ public function testDelete(): void $this->assertEquals(0, User::count()); } - /** - * @testWith [0] - * [2] - */ + #[TestWith([0])] + #[TestWith([2])] public function testDeleteException(int $limit): void { $this->expectException(LogicException::class); diff --git a/tests/Seeder/DatabaseSeeder.php b/tests/Seeder/DatabaseSeeder.php index ef512b869..eade44a96 100644 --- a/tests/Seeder/DatabaseSeeder.php +++ b/tests/Seeder/DatabaseSeeder.php @@ -8,12 +8,7 @@ class DatabaseSeeder extends Seeder { - /** - * Run the database seeds. - * - * @return void - */ - public function run() + public function run(): void { $this->call(UserTableSeeder::class); } diff --git a/tests/TestCase.php b/tests/TestCase.php index e2be67a04..5f37ea170 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,10 +21,8 @@ class TestCase extends OrchestraTestCase * Get application providers. * * @param Application $app - * - * @return array */ - protected function getApplicationProviders($app) + protected function getApplicationProviders($app): array { $providers = parent::getApplicationProviders($app); @@ -37,10 +35,8 @@ protected function getApplicationProviders($app) * Get package providers. * * @param Application $app - * - * @return array */ - protected function getPackageProviders($app) + protected function getPackageProviders($app): array { return [ MongoDBServiceProvider::class, @@ -54,10 +50,8 @@ protected function getPackageProviders($app) * Define environment setup. * * @param Application $app - * - * @return void */ - protected function getEnvironmentSetUp($app) + protected function getEnvironmentSetUp($app): void { // reset base path to point to our package's src directory //$app['path.base'] = __DIR__ . '/../src'; From 65f0a6747688efe08dbdc33d5ccd11f94540de84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 9 Jul 2024 14:37:48 +0200 Subject: [PATCH 7/8] Embedded paginator total override and accept Closure (#3027) Applies change from laravel/framework#46410 and laravel/framework#42429 --- CHANGELOG.md | 1 + src/Relations/EmbedsMany.php | 19 +++++++++++-------- tests/EmbeddedRelationsTest.php | 6 ++++++ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0a120f2..777a22304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [4.6.0] - upcoming * Add `DocumentTrait` to use any 3rd party model with MongoDB @GromNaN in [#2580](https://github.com/mongodb/laravel-mongodb/pull/2580) +* Add support for Closure for Embed pagination @GromNaN in [#3027](https://github.com/mongodb/laravel-mongodb/pull/3027) ## [4.5.0] - 2024-06-20 diff --git a/src/Relations/EmbedsMany.php b/src/Relations/EmbedsMany.php index be7039506..72c77b598 100644 --- a/src/Relations/EmbedsMany.php +++ b/src/Relations/EmbedsMany.php @@ -4,6 +4,7 @@ namespace MongoDB\Laravel\Relations; +use Closure; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Pagination\LengthAwarePaginator; @@ -18,6 +19,7 @@ use function is_array; use function method_exists; use function throw_if; +use function value; class EmbedsMany extends EmbedsOneOrMany { @@ -288,21 +290,22 @@ protected function associateExisting($model) } /** - * @param int|null $perPage - * @param array $columns - * @param string $pageName - * @param int|null $page + * @param int|Closure $perPage + * @param array|string $columns + * @param string $pageName + * @param int|null $page + * @param Closure|int|null $total * * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator */ - public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) + public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null) { $page = $page ?: Paginator::resolveCurrentPage($pageName); - $perPage = $perPage ?: $this->related->getPerPage(); - $results = $this->getEmbedded(); $results = $this->toCollection($results); - $total = $results->count(); + $total = value($total) ?? $results->count(); + $perPage = $perPage ?: $this->related->getPerPage(); + $perPage = $perPage instanceof Closure ? $perPage($total) : $perPage; $start = ($page - 1) * $perPage; $sliced = $results->slice( diff --git a/tests/EmbeddedRelationsTest.php b/tests/EmbeddedRelationsTest.php index 2dd558679..00a84360c 100644 --- a/tests/EmbeddedRelationsTest.php +++ b/tests/EmbeddedRelationsTest.php @@ -925,6 +925,12 @@ public function testPaginateEmbedsMany() $results = $user->addresses()->paginate(2); $this->assertEquals(2, $results->count()); $this->assertEquals(3, $results->total()); + + // With Closures + $results = $user->addresses()->paginate(fn () => 3, page: 1, total: fn () => 5); + $this->assertEquals(3, $results->count()); + $this->assertEquals(5, $results->total()); + $this->assertEquals(3, $results->perPage()); } public function testGetQueueableRelationsEmbedsMany() From 179c6a6fe589d6721ca0d12072e0502de402b796 Mon Sep 17 00:00:00 2001 From: Jacques Florian Date: Tue, 9 Jul 2024 15:14:18 +0200 Subject: [PATCH 8/8] Add document version feature (#3021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jérôme Tamarelle --- CHANGELOG.md | 5 +- src/Eloquent/HasSchemaVersion.php | 82 +++++++++++++++++++++++++++++++ tests/Models/SchemaVersion.php | 26 ++++++++++ tests/SchemaVersionTest.php | 58 ++++++++++++++++++++++ 4 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/Eloquent/HasSchemaVersion.php create mode 100644 tests/Models/SchemaVersion.php create mode 100644 tests/SchemaVersionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 777a22304..5f2a2f9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. -## [4.6.0] - upcoming +## [4.6.0] - 2024-07-09 -* Add `DocumentTrait` to use any 3rd party model with MongoDB @GromNaN in [#2580](https://github.com/mongodb/laravel-mongodb/pull/2580) +* Add `DocumentModel` trait to use any 3rd party model with MongoDB @GromNaN in [#2580](https://github.com/mongodb/laravel-mongodb/pull/2580) +* Add `HasSchemaVersion` trait to help implementing the [schema versioning pattern](https://www.mongodb.com/docs/manual/tutorial/model-data-for-schema-versioning/) @florianJacques in [#3021](https://github.com/mongodb/laravel-mongodb/pull/3021) * Add support for Closure for Embed pagination @GromNaN in [#3027](https://github.com/mongodb/laravel-mongodb/pull/3027) ## [4.5.0] - 2024-06-20 diff --git a/src/Eloquent/HasSchemaVersion.php b/src/Eloquent/HasSchemaVersion.php new file mode 100644 index 000000000..8849f655a --- /dev/null +++ b/src/Eloquent/HasSchemaVersion.php @@ -0,0 +1,82 @@ +getAttribute($model::getSchemaVersionKey()) === null) { + $model->setAttribute($model::getSchemaVersionKey(), $model->getModelSchemaVersion()); + } + }); + + static::retrieved(function (self $model) { + $version = $model->getSchemaVersion(); + + if ($version < $model->getModelSchemaVersion()) { + $model->migrateSchema($version); + $model->setAttribute($model::getSchemaVersionKey(), $model->getModelSchemaVersion()); + } + }); + } + + /** + * Get Current document version, fallback to 0 if not set + */ + public function getSchemaVersion(): int + { + return $this->{static::getSchemaVersionKey()} ?? 0; + } + + protected static function getSchemaVersionKey(): string + { + return 'schema_version'; + } + + protected function getModelSchemaVersion(): int + { + try { + return $this::SCHEMA_VERSION; + } catch (Error) { + throw new LogicException(sprintf('Constant %s::SCHEMA_VERSION is required when using HasSchemaVersion', $this::class)); + } + } +} diff --git a/tests/Models/SchemaVersion.php b/tests/Models/SchemaVersion.php new file mode 100644 index 000000000..cacfc3f65 --- /dev/null +++ b/tests/Models/SchemaVersion.php @@ -0,0 +1,26 @@ +age = 35; + } + } +} diff --git a/tests/SchemaVersionTest.php b/tests/SchemaVersionTest.php new file mode 100644 index 000000000..dfe2f5122 --- /dev/null +++ b/tests/SchemaVersionTest.php @@ -0,0 +1,58 @@ + 'Luc']); + $this->assertEmpty($document->getSchemaVersion()); + $document->save(); + + // The current schema version of the model is stored by default + $this->assertEquals(2, $document->getSchemaVersion()); + + // Test automatic migration + SchemaVersion::insert([ + ['name' => 'Vador', 'schema_version' => 1], + ]); + $document = SchemaVersion::where('name', 'Vador')->first(); + $this->assertEquals(2, $document->getSchemaVersion()); + $this->assertEquals(35, $document->age); + + $document->save(); + + // The migrated version is saved + $data = DB::connection('mongodb') + ->collection('documentVersion') + ->where('name', 'Vador') + ->get(); + + $this->assertEquals(2, $data[0]['schema_version']); + } + + public function testIncompleteImplementation(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('::SCHEMA_VERSION is required when using HasSchemaVersion'); + $document = new class extends Model { + use HasSchemaVersion; + }; + + $document->save(); + } +}