Skip to content

Fix casting issues (issue: #2703) #2705

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jan 16, 2024
83 changes: 52 additions & 31 deletions src/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@

namespace MongoDB\Laravel\Eloquent;

use Brick\Math\BigDecimal;
use Brick\Math\Exception\MathException as BrickMathException;
use Brick\Math\RoundingMode;
use Carbon\CarbonInterface;
use DateTimeInterface;
use Illuminate\Contracts\Queue\QueueableCollection;
use Illuminate\Contracts\Queue\QueueableEntity;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Casts\Json;
use Illuminate\Database\Eloquent\Model as BaseModel;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Arr;
Expand All @@ -22,10 +18,11 @@
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 function abs;
use function array_key_exists;
use function array_keys;
use function array_merge;
Expand All @@ -41,7 +38,6 @@
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;
Expand Down Expand Up @@ -139,15 +135,9 @@ public function fromDateTime($value)
/** @inheritdoc */
protected function asDateTime($value)
{
// Convert UTCDateTime instances.
// Convert UTCDateTime instances to Carbon.
if ($value instanceof UTCDateTime) {
$date = $value->toDateTime();

$seconds = $date->format('U');
$milliseconds = abs((int) $date->format('v'));
$timestampMs = sprintf('%d%03d', $seconds, $milliseconds);

return Date::createFromTimestampMs($timestampMs);
return Date::instance($value->toDateTime());
}

return parent::asDateTime($value);
Expand Down Expand Up @@ -250,9 +240,16 @@ public function setAttribute($key, $value)
{
$key = (string) $key;

// Add casts
if ($this->hasCast($key)) {
$value = $this->castAttribute($key, $value);
$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.
Expand Down Expand Up @@ -281,26 +278,38 @@ public function setAttribute($key, $value)
return parent::setAttribute($key, $value);
}

/** @inheritdoc */
/**
* @param mixed $value
*
* @inheritdoc
*/
protected function asDecimal($value, $decimals)
{
try {
$value = (string) BigDecimal::of((string) $value)->toScale((int) $decimals, RoundingMode::HALF_UP);

return new Decimal128($value);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Good catch, the method asDecimal must return a string, as mandated in the phpdoc of the parent method.

} catch (BrickMathException $e) {
throw new MathException('Unable to cast value to a decimal.', previous: $e);
// 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);
}

/** @inheritdoc */
public function fromJson($value, $asObject = false)
/**
* Change to mongo native for decimal cast.
*
* @param mixed $value
* @param int $decimals
*
* @return Decimal128
*/
protected function fromDecimal($value, $decimals)
{
if (! is_string($value)) {
$value = Json::encode($value);
}

return Json::decode($value, ! $asObject);
return new Decimal128($this->asDecimal($value, $decimals));
}

/** @inheritdoc */
Expand Down Expand Up @@ -707,4 +716,16 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt

return $attributes;
}

/**
* Is a value a BSON type?
*
* @param mixed $value
*
* @return bool
*/
protected function isBSON(mixed $value): bool
{
return $value instanceof Type;
}
}
37 changes: 37 additions & 0 deletions tests/Casts/BooleanTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,42 @@ public function testBoolAsString(): void

self::assertIsBool($model->booleanValue);
self::assertSame(false, $model->booleanValue);

$model->update(['booleanValue' => 'false']);

self::assertIsBool($model->booleanValue);
self::assertSame(true, $model->booleanValue);

$model->update(['booleanValue' => '0.0']);

self::assertIsBool($model->booleanValue);
self::assertSame(true, $model->booleanValue);

$model->update(['booleanValue' => 'true']);

self::assertIsBool($model->booleanValue);
self::assertSame(true, $model->booleanValue);
}

public function testBoolAsNumber(): void
{
$model = Casting::query()->create(['booleanValue' => 1]);

self::assertIsBool($model->booleanValue);
self::assertSame(true, $model->booleanValue);

$model->update(['booleanValue' => 0]);

self::assertIsBool($model->booleanValue);
self::assertSame(false, $model->booleanValue);

$model->update(['booleanValue' => 1.79]);

self::assertIsBool($model->booleanValue);
self::assertSame(true, $model->booleanValue);

$model->update(['booleanValue' => 0.0]);
self::assertIsBool($model->booleanValue);
self::assertSame(false, $model->booleanValue);
}
}
8 changes: 8 additions & 0 deletions tests/Casts/CollectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,19 @@ public function testCollection(): void
$model = Casting::query()->create(['collectionValue' => ['g' => 'G-Eazy']]);

self::assertInstanceOf(Collection::class, $model->collectionValue);
self::assertIsString($model->getRawOriginal('collectionValue'));
self::assertEquals(collect(['g' => 'G-Eazy']), $model->collectionValue);

$model->update(['collectionValue' => ['Dont let me go' => 'Even the longest of nights turn days']]);

self::assertInstanceOf(Collection::class, $model->collectionValue);
self::assertIsString($model->getRawOriginal('collectionValue'));
self::assertEquals(collect(['Dont let me go' => 'Even the longest of nights turn days']), $model->collectionValue);

$model->update(['collectionValue' => [['Dont let me go' => 'Even the longest of nights turn days']]]);

self::assertInstanceOf(Collection::class, $model->collectionValue);
self::assertIsString($model->getRawOriginal('collectionValue'));
self::assertEquals(collect([['Dont let me go' => 'Even the longest of nights turn days']]), $model->collectionValue);
}
}
10 changes: 10 additions & 0 deletions tests/Casts/DateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Carbon\CarbonImmutable;
use DateTime;
use Illuminate\Support\Carbon;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Laravel\Tests\Models\Casting;
use MongoDB\Laravel\Tests\TestCase;

Expand All @@ -31,17 +32,26 @@ public function testDate(): void
$model->update(['dateField' => now()->subDay()]);

self::assertInstanceOf(Carbon::class, $model->dateField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('dateField'));
self::assertEquals(now()->subDay()->startOfDay()->format('Y-m-d H:i:s'), (string) $model->dateField);

$model->update(['dateField' => new DateTime()]);

self::assertInstanceOf(Carbon::class, $model->dateField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('dateField'));
self::assertEquals(now()->startOfDay()->format('Y-m-d H:i:s'), (string) $model->dateField);

$model->update(['dateField' => (new DateTime())->modify('-1 day')]);

self::assertInstanceOf(Carbon::class, $model->dateField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('dateField'));
self::assertEquals(now()->subDay()->startOfDay()->format('Y-m-d H:i:s'), (string) $model->dateField);

$refetchedModel = Casting::query()->find($model->getKey());

self::assertInstanceOf(Carbon::class, $refetchedModel->dateField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('dateField'));
self::assertEquals(now()->subDay()->startOfDay()->format('Y-m-d H:i:s'), (string) $refetchedModel->dateField);
}

public function testDateAsString(): void
Expand Down
6 changes: 6 additions & 0 deletions tests/Casts/DatetimeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Carbon\CarbonImmutable;
use DateTime;
use Illuminate\Support\Carbon;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Laravel\Tests\Models\Casting;
use MongoDB\Laravel\Tests\TestCase;

Expand All @@ -27,11 +28,13 @@ public function testDatetime(): void
$model = Casting::query()->create(['datetimeField' => now()]);

self::assertInstanceOf(Carbon::class, $model->datetimeField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('datetimeField'));
self::assertEquals(now()->format('Y-m-d H:i:s'), (string) $model->datetimeField);

$model->update(['datetimeField' => now()->subDay()]);

self::assertInstanceOf(Carbon::class, $model->datetimeField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('datetimeField'));
self::assertEquals(now()->subDay()->format('Y-m-d H:i:s'), (string) $model->datetimeField);
}

Expand All @@ -40,6 +43,7 @@ public function testDatetimeAsString(): void
$model = Casting::query()->create(['datetimeField' => '2023-10-29']);

self::assertInstanceOf(Carbon::class, $model->datetimeField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('datetimeField'));
self::assertEquals(
Carbon::createFromTimestamp(1698577443)->startOfDay()->format('Y-m-d H:i:s'),
(string) $model->datetimeField,
Expand All @@ -48,6 +52,7 @@ public function testDatetimeAsString(): void
$model->update(['datetimeField' => '2023-10-28 11:04:03']);

self::assertInstanceOf(Carbon::class, $model->datetimeField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('datetimeField'));
self::assertEquals(
Carbon::createFromTimestamp(1698577443)->subDay()->format('Y-m-d H:i:s'),
(string) $model->datetimeField,
Expand Down Expand Up @@ -82,6 +87,7 @@ public function testImmutableDatetime(): void
$model->update(['immutableDatetimeField' => '2023-10-28 11:04:03']);

self::assertInstanceOf(CarbonImmutable::class, $model->immutableDatetimeField);
self::assertInstanceOf(UTCDateTime::class, $model->getRawOriginal('immutableDatetimeField'));
self::assertEquals(
Carbon::createFromTimestamp(1698577443)->subDay()->format('Y-m-d H:i:s'),
(string) $model->immutableDatetimeField,
Expand Down
Loading