diff --git a/src/Concerns/HasMeta.php b/src/Concerns/HasMeta.php deleted file mode 100644 index dee819f6..00000000 --- a/src/Concerns/HasMeta.php +++ /dev/null @@ -1,137 +0,0 @@ - - */ - -namespace Dbout\WpOrm\Concerns; - -use Dbout\WpOrm\MetaMappingConfig; -use Dbout\WpOrm\Models\Meta\AbstractMeta; -use Illuminate\Database\Eloquent\Relations\HasMany; - -trait HasMeta -{ - /** - * @var array - */ - protected array $_tmpMetas = []; - - /** - * @return void - */ - protected static function bootHasMeta(): void - { - static::saved(function ($model) { - $model->saveTmpMetas(); - }); - } - - /** - * @return HasMany - */ - public function metas(): HasMany - { - return $this->hasMany($this->getMetaConfigMapping()->metaClass, $this->getMetaConfigMapping()->foreignKey); - } - - /** - * @param string $metaKey - * @return AbstractMeta|null - */ - public function getMeta(string $metaKey): ?AbstractMeta - { - /** @var ?AbstractMeta $value */ - // @phpstan-ignore-next-line - $value = $this->metas()->firstWhere($this->getMetaConfigMapping()->columnKey, $metaKey); - return $value; - } - - /** - * @param string $metaKey - * @return mixed|null - */ - public function getMetaValue(string $metaKey): mixed - { - if (!$this->exists) { - return $this->_tmpMetas[$metaKey] ?? null; - } - - $meta = $this->getMeta($metaKey); - return $meta?->getValue(); - - } - - /** - * @param string $metaKey - * @return bool - */ - public function hasMeta(string $metaKey): bool - { - // @phpstan-ignore-next-line - return $this->metas() - ->where($this->getMetaConfigMapping()->columnKey, $metaKey) - ->exists(); - } - - /** - * @param string $metaKey - * @param mixed $value - * @return AbstractMeta|null - */ - public function setMeta(string $metaKey, mixed $value): ?AbstractMeta - { - if (!$this->exists) { - $this->_tmpMetas[$metaKey] = $value; - return null; - } - - /** @var AbstractMeta $instance */ - $instance = $this->metas() - ->firstOrNew([ - $this->getMetaConfigMapping()->columnKey => $metaKey, - ]); - - $instance->fill([ - $this->getMetaConfigMapping()->columnValue => $value, - ])->save(); - - return $instance; - } - - /** - * @param string $metaKey - * @return bool - */ - public function deleteMeta(string $metaKey): bool - { - if (!$this->exists) { - unset($this->_tmpMetas[$metaKey]); - return true; - } - - // @phpstan-ignore-next-line - return $this->metas() - ->where($this->getMetaConfigMapping()->columnKey, $metaKey) - ->forceDelete(); - } - - /** - * @return void - */ - protected function saveTmpMetas(): void - { - foreach ($this->_tmpMetas as $metaKey => $value) { - $this->setMeta($metaKey, $value); - } - - $this->_tmpMetas = []; - } - - /** - * @return MetaMappingConfig - */ - abstract public function getMetaConfigMapping(): MetaMappingConfig; -} diff --git a/src/Concerns/HasMetas.php b/src/Concerns/HasMetas.php new file mode 100644 index 00000000..98b1a45e --- /dev/null +++ b/src/Concerns/HasMetas.php @@ -0,0 +1,359 @@ + + */ + +namespace Dbout\WpOrm\Concerns; + +use Dbout\WpOrm\MetaMappingConfig; +use Dbout\WpOrm\Models\Meta\AbstractMeta; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection as BaseCollection; + +trait HasMetas +{ + /** + * @var array + */ + protected array $_tmpMetas = []; + + /** + * The metas that should be cast. + * + * @var array + */ + protected array $metaCasts = []; + + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var string[] + */ + protected static array $primitiveMetaCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'date', + 'datetime', + 'double', + 'float', + 'immutable_date', + 'int', + 'integer', + 'json', + 'object', + 'string', + 'timestamp', + ]; + + /** + * The cache of the converted meta cast types. + * + * @var array + */ + protected static array $metaCastTypeCache = []; + + /** + * Initialize the trait. + * + * @return void + */ + protected function initializeHasMetas(): void + { + $this->metaCasts = $this->ensureCastsAreStringValues( + array_merge($this->metaCasts, $this->metaCasts()), + ); + } + + /** + * @return void + */ + protected static function bootHasMeta(): void + { + static::saved(function ($model) { + $model->saveTmpMetas(); + }); + } + + /** + * @return HasMany + */ + public function metas(): HasMany + { + return $this->hasMany($this->getMetaConfigMapping()->metaClass, $this->getMetaConfigMapping()->foreignKey); + } + + /** + * @param string $metaKey + * @return AbstractMeta|null + */ + public function getMeta(string $metaKey): ?AbstractMeta + { + /** @var AbstractMeta $value */ + // @phpstan-ignore-next-line + $value = $this->metas()->firstWhere($this->getMetaConfigMapping()->columnKey, $metaKey); + return $value; + } + + /** + * @param string $metaKey + * @return mixed|null + */ + public function getMetaValue(string $metaKey): mixed + { + if (!$this->exists) { + return $this->_tmpMetas[$metaKey] ?? null; + } + + $meta = $this->getMeta($metaKey); + if (!$meta instanceof AbstractMeta) { + return $meta; + } + + $value = $meta->getValue(); + if (!$this->metaHasCast($metaKey)) { + return $value; + } + + // If the meta exists within the cast array, we will convert it to + // an appropriate native PHP type dependent upon the associated value + // given with the key in the pair. Dayle made this comment line up. + return $this->castMeta($metaKey, $value); + } + + /** + * @param string $metaKey + * @return bool + */ + public function hasMeta(string $metaKey): bool + { + // @phpstan-ignore-next-line + return $this->metas() + ->where($this->getMetaConfigMapping()->columnKey, $metaKey) + ->exists(); + } + + /** + * @param string $metaKey + * @param mixed $value + * @return AbstractMeta|null + */ + public function setMeta(string $metaKey, mixed $value): ?AbstractMeta + { + if (!$this->exists) { + $this->_tmpMetas[$metaKey] = $value; + return null; + } + + /** @var AbstractMeta $instance */ + $instance = $this->metas() + ->firstOrNew([ + $this->getMetaConfigMapping()->columnKey => $metaKey, + ]); + + $instance->fill([ + $this->getMetaConfigMapping()->columnValue => $value, + ])->save(); + + return $instance; + } + + /** + * @param string $metaKey + * @return bool + */ + public function deleteMeta(string $metaKey): bool + { + if (!$this->exists) { + unset($this->_tmpMetas[$metaKey]); + return true; + } + + // @phpstan-ignore-next-line + return $this->metas() + ->where($this->getMetaConfigMapping()->columnKey, $metaKey) + ->forceDelete(); + } + + /** + * @return void + */ + protected function saveTmpMetas(): void + { + foreach ($this->_tmpMetas as $metaKey => $value) { + $this->setMeta($metaKey, $value); + } + + $this->_tmpMetas = []; + } + + /** + * @return MetaMappingConfig + */ + abstract public function getMetaConfigMapping(): MetaMappingConfig; + + /** + * Get the metas that should be cast. + * + * @return array + */ + public function getMetaCasts(): array + { + return $this->metaCasts; + } + + /** + * Get the metas that should be cast. + * + * @return array + */ + protected function metaCasts(): array + { + return []; + } + + /** + * Cast a meta to a native PHP type. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function castMeta(string $key, mixed $value): mixed + { + $castType = $this->getMetaCastType($key); + if (is_null($value) && in_array($castType, static::$primitiveMetaCastTypes, true)) { + return null; + } + + switch ($castType) { + case 'int': + case 'integer': + return (int)$value; + case 'real': + case 'float': + case 'double': + return (float)$value; + case 'string': + return (string)$value; + case 'bool': + case 'boolean': + return (bool) $value; + case 'array': + case 'json': + return $this->fromJson($value); + case 'object': + return $this->fromJson($value, true); + case 'collection': + return new BaseCollection($this->fromJson($value)); + case 'date': + return $this->asDate($value); + case 'datetime': + return $this->asDateTime($value); + case 'immutable_date': + return $this->asDate($value)->toImmutable(); + case 'timestamp': + return $this->asTimestamp($value); + } + + if ($this->isEnumMetaCastable($key)) { + return $this->getEnumCastableMetaValue($key, $value); + } + + /** + * @todo Support custom class cast + */ + + return $value; + } + + /** + * Determine if the given key is cast using an enum. + * + * @param string $key + * @return bool + */ + protected function isEnumMetaCastable(string $key): bool + { + $casts = $this->getMetaCasts(); + if (!array_key_exists($key, $casts)) { + return false; + } + + $castType = $casts[$key]; + if (in_array($castType, static::$primitiveMetaCastTypes, true)) { + return false; + } + + return enum_exists($castType); + } + + /** + * Cast the given meta to an enum. + * + * @param string $key + * @param mixed $value + * @return \UnitEnum|\BackedEnum|null + */ + protected function getEnumCastableMetaValue(string $key, mixed $value): null|\UnitEnum|\BackedEnum + { + if (is_null($value)) { + return null; + } + + $castType = $this->getMetaCasts()[$key]; + if ($value instanceof $castType) { + return $value; + } + + return $this->getEnumCaseFromValue($castType, $value); + } + + /** + * Determine whether a meta should be cast to a native type. + * + * @param string $key + * @param string|null $types + * @return bool + */ + public function metaHasCast(string $key, string $types = null): bool + { + if (array_key_exists($key, $this->getMetaCasts())) { + return !$types || in_array($this->getMetaCastType($key), (array)$types, true); + } + + return false; + } + + /** + * Get the type of cast for a meta. + * + * @param string $key + * @return string + */ + protected function getMetaCastType(string $key): string + { + $castType = $this->getMetaCasts()[$key] ?? null; + if (isset(static::$metaCastTypeCache[$castType])) { + return static::$metaCastTypeCache[$castType]; + } + + if ($this->isCustomDateTimeCast($castType)) { + $convertedCastType = 'custom_datetime'; + } elseif ($this->isImmutableCustomDateTimeCast($castType)) { + $convertedCastType = 'immutable_custom_datetime'; + } elseif ($this->isDecimalCast($castType)) { + $convertedCastType = 'decimal'; + } elseif (class_exists($castType)) { + $convertedCastType = $castType; + } else { + $convertedCastType = trim(strtolower($castType)); + } + + return static::$metaCastTypeCache[$castType] = $convertedCastType; + } +} diff --git a/src/Models/Post.php b/src/Models/Post.php index e82a3fa4..3c3e6518 100644 --- a/src/Models/Post.php +++ b/src/Models/Post.php @@ -11,7 +11,7 @@ use Carbon\Carbon; use Dbout\WpOrm\Api\WithMetaModelInterface; use Dbout\WpOrm\Builders\PostBuilder; -use Dbout\WpOrm\Concerns\HasMeta; +use Dbout\WpOrm\Concerns\HasMetas; use Dbout\WpOrm\MetaMappingConfig; use Dbout\WpOrm\Models\Meta\PostMeta; use Dbout\WpOrm\Orm\AbstractModel; @@ -67,7 +67,7 @@ */ class Post extends AbstractModel implements WithMetaModelInterface { - use HasMeta; + use HasMetas; public const UPDATED_AT = self::MODIFIED; public const CREATED_AT = self::DATE; diff --git a/src/Models/User.php b/src/Models/User.php index bb3dc9b8..1440de7c 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -11,7 +11,7 @@ use Carbon\Carbon; use Dbout\WpOrm\Api\WithMetaModelInterface; use Dbout\WpOrm\Builders\UserBuilder; -use Dbout\WpOrm\Concerns\HasMeta; +use Dbout\WpOrm\Concerns\HasMetas; use Dbout\WpOrm\MetaMappingConfig; use Dbout\WpOrm\Models\Meta\UserMeta; use Dbout\WpOrm\Orm\AbstractModel; @@ -45,7 +45,7 @@ */ class User extends AbstractModel implements WithMetaModelInterface { - use HasMeta; + use HasMetas; final public const CREATED_AT = self::REGISTERED; final public const UPDATED_AT = null; diff --git a/tests/Unit/Concerns/HasMetasTest.php b/tests/Unit/Concerns/HasMetasTest.php new file mode 100644 index 00000000..beb293f0 --- /dev/null +++ b/tests/Unit/Concerns/HasMetasTest.php @@ -0,0 +1,85 @@ + + */ + +namespace Dbout\WpOrm\Tests\Unit\Concerns; + +use Dbout\WpOrm\Models\Post; +use PHPUnit\Framework\TestCase; + +class HasMetasTest extends TestCase +{ + /** + * @return void + * @covers \Dbout\WpOrm\Concerns\HasMetas::metaHasCast + */ + public function testMetaHasCastWithProperty(): void + { + $model = new class () extends Post { + protected array $metaCasts = [ + 'my_meta' => 'int', + ]; + }; + + $this->assertTrue($model->metaHasCast('my_meta')); + $this->assertTrue($model->metaHasCast('my_meta', 'int')); + $this->assertFalse($model->metaHasCast('my_meta', 'boolean')); + $this->assertFalse($model->metaHasCast('custom-meta')); + } + + /** + * @return void + * @covers \Dbout\WpOrm\Concerns\HasMetas::metaHasCast + */ + public function testMetaHasCastWithMetaCastsFunction(): void + { + $model = new class () extends Post { + /** + * @inheritDoc + */ + protected function metaCasts(): array + { + return [ + 'my_meta' => 'int', + ]; + } + }; + + $this->assertTrue($model->metaHasCast('my_meta')); + $this->assertTrue($model->metaHasCast('my_meta', 'int')); + $this->assertFalse($model->metaHasCast('my_meta', 'boolean')); + $this->assertFalse($model->metaHasCast('custom-meta')); + } + + /** + * @return void + * @covers \Dbout\WpOrm\Concerns\HasMetas::getMetaCasts + */ + public function testGetMetaCasts(): void + { + $model = new class () extends Post { + protected array $metaCasts = [ + 'custom-meta' => 'int', + ]; + + /** + * @inheritDoc + */ + protected function metaCasts(): array + { + return [ + 'my-meta' => 'string', + ]; + } + }; + + $this->assertEquals([ + 'custom-meta' => 'int', + 'my-meta' => 'string', + ], $model->getMetaCasts()); + } +} diff --git a/tests/WordPress/Concerns/HasMetasTest.php b/tests/WordPress/Concerns/HasMetasTest.php new file mode 100644 index 00000000..506e79dc --- /dev/null +++ b/tests/WordPress/Concerns/HasMetasTest.php @@ -0,0 +1,226 @@ + + */ + +namespace Dbout\WpOrm\Tests\WordPress\Concerns; + +use Carbon\Carbon; +use Dbout\WpOrm\Concerns\HasMetas; +use Dbout\WpOrm\Enums\YesNo; +use Dbout\WpOrm\Models\Meta\AbstractMeta; +use Dbout\WpOrm\Models\Meta\PostMeta; +use Dbout\WpOrm\Models\Post; +use Dbout\WpOrm\Tests\WordPress\TestCase; + +class HasMetasTest extends TestCase +{ + /** + * @return void + * @covers HasMetas::getMeta + */ + public function testGetMeta(): void + { + $model = new Post(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + $createMeta = $model->setMeta('author', 'Norman FOSTER'); + + $meta = $model->getMeta('author'); + $this->assertInstanceOf(AbstractMeta::class, $meta); + $this->assertEquals($createMeta->getId(), $meta->getId()); + $this->assertEquals($createMeta->getValue(), $meta->getValue()); + $this->assertEquals('Norman FOSTER', $meta->getValue()); + $this->assertEquals('author', $meta->getKey()); + } + + /** + * @return void + * @covers HasMetas::setMeta + */ + public function testSetMeta(): void + { + $model = new Post(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + $meta = $model->setMeta('build-by', 'John D.'); + + $this->assertEquals('John D.', get_post_meta($model->getId(), 'build-by', true)); + $this->assertInstanceOf(PostMeta::class, $meta); + + $loadedMeta = $model->getMeta('build-by'); + $this->assertEquals($meta->getId(), $loadedMeta->getId()); + } + + /** + * @return void + * @covers HasMetas::hasMeta + */ + public function testHasMeta(): void + { + $model = new Post(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + + $model->setMeta('birthday-date', '17/09/1900'); + $this->assertTrue($model->hasMeta('birthday-date')); + + $wpMetaId = add_post_meta($model->getId(), 'birthday-place', 'France'); + $this->assertTrue($model->hasMeta('birthday-place')); + $this->assertEquals('France', $model->getMetaValue('birthday-place')); + $this->assertEquals($wpMetaId, $model->getMeta('birthday-place')?->getId()); + } + + /** + * @return void + * @covers HasMetas::getMetaValue + */ + public function testGetMetaValueWithoutCast(): void + { + $model = new Post(); + $model->setPostTitle(__FUNCTION__); + + $model->save(); + $model->setMeta('build-by', 'John D.'); + + add_post_meta($model->getId(), 'place', 'Lyon, France'); + $this->assertEquals('Lyon, France', $model->getMetaValue('place')); + } + + /** + * @return void + * @covers HasMetas::getMetaValue + */ + public function testGetMetaValueWithGenericCasts(): void + { + $object = new class () extends Post { + protected array $metaCasts = [ + 'age' => 'int', + 'year' => 'integer', + 'is_active' => 'bool', + 'subscribed' => 'boolean', + ]; + }; + + $model = new $object(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + $model->setMeta('age', '18'); + $model->setMeta('year', '2024'); + $model->setMeta('is_active', '1'); + $model->setMeta('subscribed', '0'); + + $this->assertEquals(18, $model->getMetaValue('age')); + $this->assertEquals(2024, $model->getMetaValue('year')); + $this->assertTrue($model->getMetaValue('is_active')); + $this->assertFalse($model->getMetaValue('subscribed')); + } + + /** + * @return void + * @covers HasMetas::getMetaValue + */ + public function testGetMetaValueWithEnumCasts(): void + { + $object = new class () extends Post { + protected array $metaCasts = [ + 'active' => YesNo::class, + ]; + }; + + $model = new $object(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + $model->setMeta('active', 'yes'); + + /** @var YesNo $value */ + $value = $model->getMetaValue('active'); + + $this->assertInstanceOf(YesNo::class, $value); + $this->assertEquals('yes', $value->value); + } + + /** + * @return void + * @covers HasMetas::getMetaValue + */ + public function testGetMetaValueWithDatetimeCasts(): void + { + $object = new class () extends Post { + protected array $metaCasts = [ + 'created_at' => 'datetime', + 'uploaded_at' => 'date', + ]; + }; + + $model = new $object(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + $model->setMeta('created_at', '2022-09-08 07:30:05'); + $model->setMeta('uploaded_at', '2024-10-08 10:25:35'); + + /** @var Carbon $date */ + $date = $model->getMetaValue('created_at'); + $this->assertInstanceOf(Carbon::class, $date); + $this->assertEquals('2022-09-08 07:30:05', $date->format('Y-m-d H:i:s')); + + /** @var Carbon $date */ + $date = $model->getMetaValue('uploaded_at'); + $this->assertInstanceOf(Carbon::class, $date); + $this->assertEquals('2024-10-08 00:00:00', $date->format('Y-m-d H:i:s'), 'The time must be reset to 00:00:00.'); + } + + /** + * @return void + * @covers HasMetas::getMetaValue + */ + public function testGetMetaValueWithInvalidCasts(): void + { + $object = new class () extends Post { + protected array $metaCasts = [ + 'my_meta' => 'boolean_', + ]; + }; + + $model = new $object(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + $model->setMeta('my_meta', 'yes'); + + $this->assertEquals('yes', $model->getMetaValue('my_meta')); + } + + /** + * @return void + * @covers HasMetas::deleteMeta + */ + public function testDeleteMeta(): void + { + $model = new Post(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + + $model->setMeta('architect-name', 'Norman F.'); + + $this->assertEquals(1, $model->deleteMeta('architect-name'), 'The function must delete only one line.'); + $this->assertFalse($model->hasMeta('architect-name'), 'The meta must no longer exist.'); + } + + /** + * @return void + * @covers HasMetas::deleteMeta + */ + public function testDeleteUndefinedMeta(): void + { + $model = new Post(); + $model->setPostTitle(__FUNCTION__); + $model->save(); + + $model->setMeta('architect-name', 'Norman F.'); + + $this->assertEquals(0, $model->deleteMeta('fake-meta')); + } +} diff --git a/tests/WordPress/Models/ModelWithMetaTest.php b/tests/WordPress/Models/ModelWithMetaTest.php deleted file mode 100644 index c9f5059b..00000000 --- a/tests/WordPress/Models/ModelWithMetaTest.php +++ /dev/null @@ -1,71 +0,0 @@ - - */ - -namespace Dbout\WpOrm\Tests\WordPress\Models; - -use Dbout\WpOrm\Concerns\HasMeta; -use Dbout\WpOrm\Models\Meta\PostMeta; -use Dbout\WpOrm\Models\Post; -use Dbout\WpOrm\Tests\WordPress\TestCase; - -class ModelWithMetaTest extends TestCase -{ - /** - * @return void - * @covers HasMeta::setMeta - */ - public function testSetMetaWithExistingModel(): void - { - $model = new Post(); - $model->setPostTitle('Hello world'); - - $model->save(); - $meta = $model->setMeta('build-by', 'John D.'); - - $this->assertEquals('John D.', get_post_meta($model->getId(), 'build-by', true)); - $this->assertInstanceOf(PostMeta::class, $meta); - - $loadedMeta = $model->getMeta('build-by'); - $this->assertEquals($meta->getId(), $loadedMeta->getId()); - } - - /** - * @return void - * @covers HasMeta::hasMeta - */ - public function testHasMeta(): void - { - $model = new Post(); - $model->setPostTitle('Hello world'); - $model->save(); - - $model->setMeta('birthday-date', '17/09/1900'); - $this->assertTrue($model->hasMeta('birthday-date')); - - $wpMetaId = add_post_meta($model->getId(), 'birthday-place', 'France'); - $this->assertTrue($model->hasMeta('birthday-place')); - $this->assertEquals('France', $model->getMetaValue('birthday-place')); - $this->assertEquals($wpMetaId, $model->getMeta('birthday-place')?->getId()); - } - - /** - * @return void - * @covers HasMeta::deleteMeta - */ - public function getDeleteMeta(): void - { - $model = new Post(); - $model->setPostTitle('Hello world'); - $model->save(); - - $model->setMeta('architect-name', 'Norman F.'); - - $this->assertEquals(1, $model->deleteMeta('architect-name'), 'The function must delete only one line.'); - $this->assertFalse($model->hasMeta('architect-name'), 'The meta must no longer exist.'); - } -} diff --git a/tests/WordPress/TestCase.php b/tests/WordPress/TestCase.php index 71b0a1da..83b2d17b 100644 --- a/tests/WordPress/TestCase.php +++ b/tests/WordPress/TestCase.php @@ -10,6 +10,14 @@ use Dbout\WpOrm\Models\Post; +/** + * @method static|$this assertEquals(mixed $expectedValue, mixed $checkValue, string $reason = null) + * @method static|$this assertInstanceOf(string $className, mixed $object, string $reason = null) + * @method static|$this expectExceptionMessageMatches(string $pattern) + * @method static|$this assertTrue(mixed $value, string $reason = null) + * @method static|$this assertFalse(mixed $value, string $reason = null) + * @method static|$this assertNull(mixed $value, string $reason = null) + */ abstract class TestCase extends \WP_UnitTestCase { /**