diff --git a/Dockerfile b/Dockerfile index 68eac169d..e7bb98a68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ + ARG PHP_VERSION=7.2 FROM php:${PHP_VERSION}-cli diff --git a/README.md b/README.md index 0aa40e5c7..4073209df 100644 --- a/README.md +++ b/README.md @@ -711,6 +711,25 @@ $users = User::where('birthday', '>', new DateTime('-18 years'))->get(); ### Relations +By default, relationships are not stored as ObjectIds. If you need relationships to be stored as ObjectIds, you can specify that they should be by adding them to the $casts array: + +```php +use Jenssegers\Mongodb\Eloquent\Model as Eloquent; + +class User extends Eloquent { + + protected $casts = [ + 'account_id' => 'objectid' // 'objectid' has to be lowercase! + ]; + + public function account() + { + return $this->belongsTo('Account'); + } + +} +``` + Supported relations are: - hasOne diff --git a/src/Jenssegers/Mongodb/Eloquent/Model.php b/src/Jenssegers/Mongodb/Eloquent/Model.php index 894ebe41a..fffce4058 100644 --- a/src/Jenssegers/Mongodb/Eloquent/Model.php +++ b/src/Jenssegers/Mongodb/Eloquent/Model.php @@ -97,6 +97,10 @@ protected function asDateTime($value) return Carbon::createFromTimestamp($value->toDateTime()->getTimestamp()); } + if ($value instanceof Carbon) { + return $value; + } + return parent::asDateTime($value); } @@ -159,28 +163,95 @@ protected function getAttributeFromArray($key) return parent::getAttributeFromArray($key); } + /** + * Is the given array an associative array + * + * @param array $arr + * + * @return bool + */ + private function isAssoc(array $arr) + { + // if it's empty, we presume that it's sequential + if ([] === $arr) { + return false; + } + + // if $arr[0] does not exist, it must to be a associative array + if (!array_key_exists(0, $arr)) { + return true; + } + + // strict compare the array keys against a range of the same length + return array_keys($arr) !== range(0, count($arr) - 1); + } + + /** + * Flatten an associative array of attributes to dot notation + * + * @param string $key + * @param $value + * + * @return \Illuminate\Support\Collection + */ + private function flattenAttributes($key, $value) + { + $values = collect(); + + if (is_array($value) && $this->isAssoc($value)) { + foreach ($value as $k => $value) { + $values = $values->merge($this->flattenAttributes("$key.$k", $value)); + } + } else { + $values->put($key, $value); + } + + return $values; + } + /** * @inheritdoc */ public function setAttribute($key, $value) { - // Convert _id to ObjectID. - if ($key == '_id' && is_string($value)) { - $builder = $this->newBaseQueryBuilder(); - - $value = $builder->convertKey($value); - } // Support keys in dot notation. - elseif (Str::contains($key, '.')) { - if (in_array($key, $this->getDates()) && $value) { - $value = $this->fromDateTime($value); + $values = $this->flattenAttributes($key, $value); + + $values->each(function ($val, $k) { + // Convert to ObjectID. + if ($this->isCastableToObjectId($k)) { + $builder = $this->newBaseQueryBuilder(); + + if (is_array($val)) { + foreach ($val as &$v) { + $v = $builder->convertKey($v); + } + } else { + $val = $builder->convertKey($val); + } + } // Convert to UTCDateTime + else { + if (in_array($k, $this->getDates()) && $val) { + if (is_array($val)) { + foreach ($val as &$v) { + $v = $this->fromDateTime($v); + } + } else { + $val = $this->fromDateTime($val); + } + } } - Arr::set($this->attributes, $key, $value); + // Support keys in dot notation. + if (Str::contains($k, '.')) { + Arr::set($this->attributes, $k, $val); - return; - } + return; + } + + parent::setAttribute($k, $val); + }); - return parent::setAttribute($key, $value); + return $this; } /** @@ -188,24 +259,48 @@ public function setAttribute($key, $value) */ public function attributesToArray() { + // Convert dates before parent method call so that all dates have the same format + foreach ($this->getDates() as $key) { + if (!Arr::has($this->attributes, $key)) { + continue; + } + + $val = Arr::get($this->attributes, $key); + + if (is_array($val)) { + foreach ($val as &$v) { + $v = $this->asDateTime($v)->format('Y-m-d H:i:s.v'); + } + } elseif ($val) { + $val = $this->asDateTime($val)->format('Y-m-d H:i:s.v'); + } + + Arr::set($this->attributes, $key, $val); + } + $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; + $this->getObjectIds()->each(function ($key) use (&$attributes) { + if (!Arr::has($attributes, $key)) { + return; } - } - // Convert dot-notation dates. - foreach ($this->getDates() as $key) { - if (Str::contains($key, '.') && Arr::has($attributes, $key)) { - Arr::set($attributes, $key, (string) $this->asDateTime(Arr::get($attributes, $key))); + $value = Arr::get($attributes, $key); + + if (is_array($value)) { + foreach ($value as &$val) { + $val = (string) $val; + } + } else { + $value = (string) $value; } - } + + Arr::set($attributes, $key, $value); + }); return $attributes; } @@ -218,6 +313,18 @@ public function getCasts() return $this->casts; } + /** + * Get a list of the castable ObjectId fields + * + * @return \Illuminate\Support\Collection + */ + public function getObjectIds() + { + return collect($this->getCasts())->filter(function ($val) { + return 'objectid' === $val; + })->keys()->push('_id'); + } + /** * @inheritdoc */ @@ -293,6 +400,22 @@ public function push() $this->pushAttributeValues($column, $values, $unique); + // Convert $casts on push() + if ($this->isCastableToObjectId($column)) { + foreach ($values as &$value) { + if (is_string($value)) { + $value = new ObjectId($value); + } + } + } + + // Convert dates on push() + if (in_array($column, $this->getDates())) { + foreach ($values as &$value) { + $value = $this->fromDateTime($value); + } + } + return $query->push($column, $values, $unique); } @@ -338,11 +461,94 @@ protected function pushAttributeValues($column, array $values, $unique = false) $current[] = $value; } - $this->attributes[$column] = $current; + // Support for dot notation keys + if (Str::contains($column, '.')) { + Arr::set($this->attributes, $column, $current); + } else { + $this->attributes[$column] = $current; + } $this->syncOriginalAttribute($column); } + /** + * @inheritdoc + */ + public function syncOriginalAttribute($attribute) + { + // Support for dot notation keys + if (Str::contains($attribute, '.')) { + $value = Arr::get($this->attributes, $attribute); + + Arr::set($this->original, $attribute, $value); + } else { + $this->original[$attribute] = $this->attributes[$attribute]; + } + + return $this; + } + + /** + * @inheritdoc + */ + protected function addDateAttributesToArray(array $attributes) + { + foreach ($this->getDates() as $key) { + if (!isset($attributes[$key])) { + continue; + } + + // Support for array of dates + if (is_array($attributes[$key])) { + foreach ($attributes[$key] as &$value) { + $value = $this->serializeDate($this->asDateTime($value)); + } + } else { + $attributes[$key] = $this->serializeDate($this->asDateTime($attributes[$key])); + } + } + + return $attributes; + } + + /** + * @inheritdoc + */ + public function getAttributeValue($key) + { + $value = $this->getAttributeFromArray($key); + + // If the attribute is listed as a date, we will convert it to a DateTime + // instance on retrieval, which makes it quite convenient to work with + // date fields without having to create a mutator for each property. + if (in_array($key, $this->getDates()) && !is_null($value)) { + if (is_array($value)) { + foreach ($value as &$val) { + $val = $this->asDateTime($val); + } + } else { + $value = $this->asDateTime($value); + } + + return $value; + } + + // Also convert objectIds to strings + if ($this->getObjectIds()->search($key) !== false && !is_null($value)) { + if (is_array($value)) { + foreach ($value as &$val) { + $val = (string) $val; + } + } else { + $value = (string) $value; + } + + return $value; + } + + return parent::getAttributeFromArray($key); + } + /** * Remove one or more values to the underlying attribute value and sync with original. * @@ -411,7 +617,7 @@ protected function newBaseQueryBuilder() { $connection = $this->getConnection(); - return new QueryBuilder($connection, $connection->getPostProcessor()); + return (new QueryBuilder($connection, $connection->getPostProcessor()))->casts($this->casts); } /** @@ -480,4 +686,39 @@ public function __call($method, $parameters) return parent::__call($method, $parameters); } + + /** + * @inheritdoc + */ + protected function castAttribute($key, $value) + { + if (is_null($value)) { + return $value; + } + + if ($this->getCastType($key) === 'objectid') { + if (is_array($value)) { + foreach ($value as &$val) { + $val = (string) $val; + } + + return $value; + } + + return (string) $value; + } + + return parent::castAttribute($key, $value); + } + + /** + * Is the field an ObjectId + * + * @param string $key + * @return bool + */ + protected function isCastableToObjectId($key) + { + return '_id' === $key || $this->hasCast($key, ['objectid']); + } } diff --git a/src/Jenssegers/Mongodb/Query/Builder.php b/src/Jenssegers/Mongodb/Query/Builder.php index c425f5165..7681f3c2f 100644 --- a/src/Jenssegers/Mongodb/Query/Builder.php +++ b/src/Jenssegers/Mongodb/Query/Builder.php @@ -52,6 +52,13 @@ class Builder extends BaseBuilder */ public $options = []; + /** + * An array of properties that need to be cast to their original type + * + * @var array + */ + public $casts = []; + /** * Indicate if we are executing a pagination query. * @@ -811,7 +818,7 @@ public function drop($columns) */ public function newQuery() { - return new Builder($this->connection, $this->processor); + return (new Builder($this->connection, $this->processor))->casts($this->casts); } /** @@ -868,6 +875,21 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' } } + // Convert $casts + if (!($column instanceof \Closure) && $this->isCastableToObjectId($column)) { + if (is_array($params[2])) { + foreach ($params[2] as &$value) { + if (is_string($value)) { + $value = new ObjectId($value); + } + } + } else { + if (is_string($params[2])) { + $params[2] = new ObjectId($params[2]); + } + } + } + return call_user_func_array('parent::where', $params); } @@ -907,7 +929,14 @@ protected function compileWheres() } // Convert id's. - if (isset($where['column']) && ($where['column'] == '_id' || Str::endsWith($where['column'], '._id'))) { + if ( + isset($where['column']) + && ( + $where['column'] == '_id' + || Str::endsWith($where['column'], '._id') + || $this->isCastableToObjectId($where['column']) + ) + ) { // Multiple values. if (isset($where['values'])) { foreach ($where['values'] as &$value) { @@ -1144,6 +1173,19 @@ public function options(array $options) return $this; } + /** + * Set the casts for the query + * + * @param array $casts + * @return $this + */ + public function casts(array $casts) + { + $this->casts = $casts; + + return $this; + } + /** * @inheritdoc */ @@ -1155,4 +1197,15 @@ public function __call($method, $parameters) return parent::__call($method, $parameters); } + + /** + * Should the given field be casted to an ObjectId + * + * @param string $field + * @return boolean + */ + private function isCastableToObjectId($field) + { + return isset($this->casts[$field]) && $this->casts[$field] === 'objectid'; + } }