Skip to content

Commit b6ddd93

Browse files
Merge pull request #220 from leroy-merlin-br/feat/add-datetime-casts
feat: add datetime casts
2 parents 5335128 + 9f0ce95 commit b6ddd93

16 files changed

+479
-0
lines changed

docs/docs/casting.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
sidebar_position: 6
3+
---
4+
5+
# Casting attributes
6+
7+
## Casting to DateTime
8+
9+
10+
With Mongolid, you can define attributes to be cast to `DateTime` or `DateTimeImmutable` using `$casts` property in your models.
11+
12+
```php
13+
class Person extends \Mongolid\Model\AbstractModel {
14+
protected $casts = [
15+
'expires_at' => 'datetime',
16+
'birthdate' => 'immutable_datetime',
17+
];
18+
}
19+
```
20+
21+
When you define an attribute to be cast as `DateTime` or `DateTimeImmutable`, Mongolid will load it from database will do its trick to return an `DateTime` instance(or `DateTimeImmutable`) anytime you try to access it with property accessor operator (`->`).
22+
23+
If you need to manipulate its original value on MongoDB, then you can access it through `getDocumentAttributes()` method
24+
25+
To write a value on an attribute with `DateTime` cast, you can use both an `\MongoDB\BSON\UTCDateTime`, `\DateTime` or `\DateTimeImmutable` instance.
26+
Internally, Mongolid will manage to set the property as an UTCDateTime, because it is the datetime format accepted by MongoDB.
27+
28+
Check out some usages and examples:
29+
30+
```php
31+
32+
$user = Person::first();
33+
$user->birthdate; // Returns birthdate as a DateTimeImmutable instance
34+
$user->expires_at; // Returns expires_at as DateTime instance
35+
36+
$user->getOriginalDocumentAttributes()['birthdate']; // Returns birthdate as an \MongoDB\BSON\UTCDateTime instance
37+
38+
// To set a new birthdate, you can pass both UTCDateTime or native's PHP DateTime
39+
$user->birthdate = new \MongoDB\BSON\UTCDateTime($anyDateTime);
40+
$user->birthdate = DateTime::createFromFormat('d/m/Y', '01/03/1970');
41+
42+
43+
```
44+

src/Model/Casts/CastInterface.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Mongolid\Model\Casts;
4+
5+
interface CastInterface
6+
{
7+
public function get(mixed $value): mixed;
8+
9+
public function set(mixed $value): mixed;
10+
}

src/Model/Casts/CastResolver.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Mongolid\Model\Casts;
4+
5+
use Mongolid\Model\Casts\DateTime\DateTimeCast;
6+
use Mongolid\Model\Casts\DateTime\ImmutableDateTimeCast;
7+
use Mongolid\Model\Casts\Exceptions\InvalidCastException;
8+
9+
class CastResolver
10+
{
11+
private const DATE_TIME = 'datetime';
12+
private const IMMUTABLE_DATE_TIME = 'immutable_datetime';
13+
14+
private static array $cache = [];
15+
16+
public static array $validCasts = [
17+
self::DATE_TIME,
18+
self::IMMUTABLE_DATE_TIME,
19+
];
20+
21+
public static function resolve(string $castName): CastInterface
22+
{
23+
if ($cast = self::$cache[$castName] ?? null) {
24+
return $cast;
25+
}
26+
27+
self::$cache[$castName] = match($castName) {
28+
self::DATE_TIME => new DateTimeCast(),
29+
self::IMMUTABLE_DATE_TIME => new ImmutableDateTimeCast(),
30+
default => throw new InvalidCastException($castName),
31+
};
32+
33+
return self::$cache[$castName];
34+
}
35+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace Mongolid\Model\Casts\DateTime;
4+
5+
use DateTimeInterface;
6+
use MongoDB\BSON\UTCDateTime;
7+
use MongoDB\BSON\UTCDateTimeInterface;
8+
use Mongolid\Model\Casts\CastInterface;
9+
10+
abstract class BaseDateTimeCast implements CastInterface
11+
{
12+
/**
13+
* @param UTCDateTime|null $value
14+
*/
15+
abstract public function get(mixed $value): ?DateTimeInterface;
16+
17+
/**
18+
* @param DateTimeInterface|UTCDateTimeInterface|null $value
19+
*/
20+
public function set(mixed $value): UTCDateTime|null
21+
{
22+
if (is_null($value)) {
23+
return null;
24+
}
25+
26+
if ($value instanceof UTCDateTimeInterface) {
27+
return $value;
28+
}
29+
30+
return new UTCDateTime($value);
31+
}
32+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace Mongolid\Model\Casts\DateTime;
4+
5+
use DateTime;
6+
use MongoDB\BSON\UTCDateTime;
7+
use Mongolid\Util\LocalDateTime;
8+
9+
class DateTimeCast extends BaseDateTimeCast
10+
{
11+
/**
12+
* @param UTCDateTime|null $value
13+
*/
14+
public function get(mixed $value): ?DateTime
15+
{
16+
if (is_null($value)) {
17+
return null;
18+
}
19+
20+
return LocalDateTime::get($value);
21+
}
22+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace Mongolid\Model\Casts\DateTime;
4+
5+
use DateTimeImmutable;
6+
use MongoDB\BSON\UTCDateTime;
7+
use Mongolid\Util\LocalDateTime;
8+
9+
class ImmutableDateTimeCast extends BaseDateTimeCast
10+
{
11+
/**
12+
* @param UTCDateTime|null $value
13+
*/
14+
public function get(mixed $value): ?DateTimeImmutable
15+
{
16+
if (is_null($value)) {
17+
return null;
18+
}
19+
20+
return DateTimeImmutable::createFromMutable(
21+
LocalDateTime::get($value)
22+
);
23+
}
24+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace Mongolid\Model\Casts\Exceptions;
4+
5+
use InvalidArgumentException;
6+
use Mongolid\Model\Casts\CastResolver;
7+
8+
class InvalidCastException extends InvalidArgumentException
9+
{
10+
public function __construct(string $cast)
11+
{
12+
$available = implode(',', CastResolver::$validCasts);
13+
$message = "Invalid cast attribute: $cast. Use a valid one like $available";
14+
15+
parent::__construct($message);
16+
}
17+
}

src/Model/HasAttributesTrait.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
use Exception;
55
use Illuminate\Support\Str;
66
use Mongolid\Container\Container;
7+
use Mongolid\Model\Casts\CastResolver;
78
use stdClass;
89

910
/**
@@ -65,6 +66,11 @@ trait HasAttributesTrait
6566
*/
6667
private $originalAttributes = [];
6768

69+
/**
70+
* Attributes that are cast to another types when fetched from database.
71+
*/
72+
protected array $casts = [];
73+
6874
/**
6975
* {@inheritdoc}
7076
*/
@@ -123,6 +129,13 @@ public function &getDocumentAttribute(string $key)
123129
return $this->mutableCache[$key];
124130
}
125131

132+
if ($casterName = $this->casts[$key] ?? null) {
133+
$caster = CastResolver::resolve($casterName);
134+
$value = $caster->get($this->attributes[$key] ?? null);
135+
136+
return $value;
137+
}
138+
126139
if (array_key_exists($key, $this->attributes)) {
127140
return $this->attributes[$key];
128141
}
@@ -171,6 +184,11 @@ public function setDocumentAttribute(string $key, $value)
171184
$value = $this->{$this->buildMutatorMethod($key, 'set')}($value);
172185
}
173186

187+
if ($casterName = $this->casts[$key] ?? null) {
188+
$caster = CastResolver::resolve($casterName);
189+
$value = $caster->set($value);
190+
}
191+
174192
if (null === $value) {
175193
$this->cleanDocumentAttribute($key);
176194

src/Model/HasLegacyAttributesTrait.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<?php
22
namespace Mongolid\Model;
33

4+
use Mongolid\Model\Casts\CastResolver;
5+
46
/**
57
* This trait adds attribute getter, setters and also a useful
68
* `fill` method that can be used with $fillable and $guarded
@@ -53,6 +55,11 @@ trait HasLegacyAttributesTrait
5355
*/
5456
public $mutable = false;
5557

58+
/**
59+
* Attributes that are cast to another types when fetched from database.
60+
*/
61+
protected array $casts = [];
62+
5663
/**
5764
* Get an attribute from the model.
5865
*
@@ -64,6 +71,12 @@ public function getAttribute(string $key)
6471
{
6572
$inAttributes = array_key_exists($key, $this->attributes);
6673

74+
if ($casterName = $this->casts[$key] ?? null) {
75+
$caster = CastResolver::resolve($casterName);
76+
77+
return $caster->get($this->attributes[$key] ?? null);
78+
}
79+
6780
if ($inAttributes) {
6881
return $this->attributes[$key];
6982
} elseif ('attributes' == $key) {
@@ -122,6 +135,11 @@ public function cleanAttribute(string $key)
122135
*/
123136
public function setAttribute(string $key, $value)
124137
{
138+
if ($casterName = $this->casts[$key] ?? null) {
139+
$caster = CastResolver::resolve($casterName);
140+
$value = $caster->set($value);
141+
}
142+
125143
$this->attributes[$key] = $value;
126144
}
127145

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace Integration;
4+
5+
use DateTime;
6+
use MongoDB\BSON\UTCDateTime;
7+
use Mongolid\Tests\Integration\IntegrationTestCase;
8+
use Mongolid\Tests\Stubs\ExpirablePrice;
9+
use Mongolid\Tests\Stubs\Legacy\LegacyRecordUser;
10+
use Mongolid\Util\LocalDateTime;
11+
12+
class DateTimeCastTest extends IntegrationTestCase
13+
{
14+
public function testShouldCreateAndSavePricesWithCastedAttributes(): void
15+
{
16+
// Set
17+
$price = new ExpirablePrice();
18+
$price->value = '100.0';
19+
$price->expires_at = DateTime::createFromFormat('d/m/Y', '02/10/2025');
20+
21+
// Actions
22+
$price->save();
23+
24+
// Assertions
25+
$this->assertInstanceOf(DateTime::class, $price->expires_at);
26+
$this->assertInstanceOf(UTCDateTime::class, $price->getOriginalDocumentAttributes()['expires_at']);
27+
28+
$price = ExpirablePrice::first($price->_id);
29+
$this->assertSame('02/10/2025', $price->expires_at->format('d/m/Y'));
30+
$this->assertSame(
31+
'02/10/2025',
32+
LocalDateTime::get($price->getOriginalDocumentAttributes()['expires_at'])
33+
->format('d/m/Y')
34+
);
35+
}
36+
37+
public function testShouldUpdatePriceWithCastedAttributes(): void
38+
{
39+
// Set
40+
$price = new ExpirablePrice();
41+
$price->value = '100.0';
42+
$price->expires_at = DateTime::createFromFormat('d/m/Y', '02/10/2025');
43+
44+
// Actions
45+
$price->expires_at = DateTime::createFromFormat('d/m/Y', '02/10/2030');
46+
47+
// Assertions
48+
$this->assertInstanceOf(DateTime::class, $price->expires_at);
49+
$this->assertSame('02/10/2030', $price->expires_at->format('d/m/Y'));
50+
}
51+
52+
public function testShouldSaveAndReadLegacyRecordWithCastedAttibutes(): void
53+
{
54+
// Set
55+
$entity = new class extends LegacyRecordUser {
56+
protected array $casts = [
57+
'expires_at' => 'datetime',
58+
];
59+
};
60+
$entity->expires_at = DateTime::createFromFormat('d/m/Y', '02/10/2025');
61+
62+
// Actions
63+
$entity->save();
64+
65+
// Assertions
66+
$this->assertInstanceOf(DateTime::class, $entity->expires_at);
67+
$this->assertInstanceOf(UTCDateTime::class, $entity->getOriginalDocumentAttributes()['expires_at']);
68+
}
69+
}

0 commit comments

Comments
 (0)