From 5d0d693dd9cc70b124750c1eadd8d8f4f6bdb750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 4 Jul 2024 20:42:05 +0200 Subject: [PATCH] Create DocumentModel trait to enable MongoDB on any 3rd party model class --- src/Eloquent/DocumentModel.php | 760 +++++++++++++++++++++++++++ src/Eloquent/HybridRelations.php | 22 +- src/Eloquent/Model.php | 751 +------------------------- src/Helpers/QueriesRelationships.php | 2 +- src/Relations/BelongsToMany.php | 5 +- src/Relations/EmbedsMany.php | 5 +- src/Relations/EmbedsOneOrMany.php | 11 +- tests/ModelTest.php | 17 +- tests/Models/User.php | 10 +- tests/TransactionTest.php | 4 +- 10 files changed, 809 insertions(+), 778 deletions(-) create mode 100644 src/Eloquent/DocumentModel.php diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php new file mode 100644 index 000000000..3b9f3b23a --- /dev/null +++ b/src/Eloquent/DocumentModel.php @@ -0,0 +1,760 @@ +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. + * + * @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; + } +} 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..4f1168e7d 100644 --- a/src/Eloquent/Model.php +++ b/src/Eloquent/Model.php @@ -4,63 +4,15 @@ 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 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_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 +28,10 @@ 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); - } - - /** - * 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. - * - * @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 + /** @param class-string|object $related */ + public static function isDocumentModel(string|object $related): 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; + return is_subclass_of($related, BaseModel::class) + && array_key_exists(DocumentModel::class, class_uses_recursive($related)); } } 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..5548f824a 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} ?: [], }; @@ -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/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/User.php b/tests/Models/User.php index 98f76d931..fc8696b3e 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,14 +30,16 @@ * @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 = [ 'birthday' => 'datetime', 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);