diff --git a/src/Phaseolies/Database/Entity/Casts/Attributes/ToArray.php b/src/Phaseolies/Database/Entity/Casts/Attributes/ToArray.php new file mode 100644 index 0000000..8313836 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Attributes/ToArray.php @@ -0,0 +1,15 @@ +type = $type instanceof Type ? $type->value : $type; + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/CastManager.php b/src/Phaseolies/Database/Entity/Casts/CastManager.php new file mode 100644 index 0000000..cb776be --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/CastManager.php @@ -0,0 +1,167 @@ +> + */ + private static array $primitives = [ + 'int' => IntegerCast::class, + 'integer' => IntegerCast::class, + 'float' => FloatCast::class, + 'double' => FloatCast::class, + 'real' => FloatCast::class, + 'bool' => BooleanCast::class, + 'boolean' => BooleanCast::class, + 'string' => StringCast::class, + 'array' => ArrayCast::class, + 'json' => ArrayCast::class, + 'object' => ObjectCast::class, + 'collection' => CollectionCast::class, + 'datetime' => DateTimeCast::class, + 'date' => DateCast::class, + 'timestamp' => TimestampCast::class, + ]; + + /** + * Cache of resolved cast handler instances keyed by cast string. + * + * @var array + */ + private static array $resolved = []; + + /** + * Cast a raw database value to its PHP type on get. + * + * @param string $cast + * @param mixed $value + * @return mixed + */ + public static function get(string $cast, mixed $value): mixed + { + if ($value === null) { + return null; + } + + return self::resolve($cast)->get($value); + } + + /** + * Cast a PHP value to a database-compatible format on set. + * + * @param string $cast + * @param mixed $value + * @return mixed + */ + public static function set(string $cast, mixed $value): mixed + { + if ($value === null) { + return null; + } + + return self::resolve($cast)->set($value); + } + + /** + * Resolve the cast handler instance for a given cast string + * + * @param string $cast + * @return CastableInterface + * @throws \InvalidArgumentException + */ + public static function resolve(string $cast): CastableInterface + { + if (isset(self::$resolved[$cast])) { + return self::$resolved[$cast]; + } + + // Primitive cast — 'integer', 'float', 'boolean', etc. + if (isset(self::$primitives[$cast])) { + return self::$resolved[$cast] = new (self::$primitives[$cast])(); + } + + // Parameterised decimal cast — 'decimal:2', 'decimal:4' + if (str_starts_with($cast, 'decimal:')) { + $precision = (int) explode(':', $cast, 2)[1]; + return self::$resolved[$cast] = new DecimalCast($precision); + } + + // Parameterised datetime cast — 'datetime:d/m/Y' + if (str_starts_with($cast, 'datetime:')) { + $format = explode(':', $cast, 2)[1]; + return self::$resolved[$cast] = new DateTimeCast($format); + } + + // Enum cast — any PHP backed or unit enum + if (enum_exists($cast)) { + return self::$resolved[$cast] = new EnumCast($cast); + } + + // Custom cast class — must implement CastableInterface + if (class_exists($cast) && is_subclass_of($cast, CastableInterface::class)) { + return self::$resolved[$cast] = new $cast(); + } + + throw new \InvalidArgumentException( + "Unsupported cast type [{$cast}]. Use a built-in type, a backed enum, or a class implementing CastableInterface." + ); + } + + /** + * Flush the resolved cast handler cache. + * + * @return void + */ + public static function flush(): void + { + self::$resolved = []; + } + + /** + * Determine if a cast string is a complex type that requires + * set-casting before writing to the database. + * + * @param string $cast + * @return bool + */ + public static function requiresSetCast(string $cast): bool + { + $complex = ['array', 'json', 'object', 'collection', 'datetime', 'date', 'timestamp']; + + if (in_array($cast, $complex, true)) { + return true; + } + + if (str_starts_with($cast, 'decimal:') || str_starts_with($cast, 'datetime:')) { + return true; + } + + if (enum_exists($cast)) { + return true; + } + + if (class_exists($cast) && is_subclass_of($cast, CastableInterface::class)) { + return true; + } + + return false; + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/Contracts/CastableInterface.php b/src/Phaseolies/Database/Entity/Casts/Contracts/CastableInterface.php new file mode 100644 index 0000000..1718895 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Contracts/CastableInterface.php @@ -0,0 +1,22 @@ +all(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if (is_array($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + if (is_string($value)) { + return $value; + } + + return json_encode($value); + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/Handlers/DateCast.php b/src/Phaseolies/Database/Entity/Casts/Handlers/DateCast.php new file mode 100644 index 0000000..e5f6e9c --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Handlers/DateCast.php @@ -0,0 +1,52 @@ +startOfDay(); + } + + if ($value instanceof DateTimeInterface) { + return Carbon::instance($value)->startOfDay(); + } + + return Carbon::parse($value)->startOfDay(); + } + + /** + * Set the value as a date string (Y-m-d) + * + * @param mixed $value + * @return mixed + */ + public function set(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof DateTimeInterface) { + return $value->format('Y-m-d'); + } + + return $value; + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/Handlers/DateTimeCast.php b/src/Phaseolies/Database/Entity/Casts/Handlers/DateTimeCast.php new file mode 100644 index 0000000..628c00b --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Handlers/DateTimeCast.php @@ -0,0 +1,59 @@ +format($this->format); + } + + return $value; + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/Handlers/DecimalCast.php b/src/Phaseolies/Database/Entity/Casts/Handlers/DecimalCast.php new file mode 100644 index 0000000..9109635 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Handlers/DecimalCast.php @@ -0,0 +1,45 @@ +precision); + } + + /** + * Set the value as a decimal. + * + * @param mixed $value + * @return float|null + */ + public function set(mixed $value): mixed + { + if ($value === null || $value === '') { + return null; + } + + return round((float) $value, $this->precision); + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/Handlers/EnumCast.php b/src/Phaseolies/Database/Entity/Casts/Handlers/EnumCast.php new file mode 100644 index 0000000..922352f --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Handlers/EnumCast.php @@ -0,0 +1,72 @@ +enumClass) { + return $value; + } + + // Backed enum — from(value) + if (is_subclass_of($this->enumClass, \BackedEnum::class)) { + return ($this->enumClass)::from($value); + } + + // Pure enum — match by name + foreach (($this->enumClass)::cases() as $case) { + if ($case->name === $value) { + return $case; + } + } + + throw new \ValueError( + "Value [{$value}] is not a valid case for enum [{$this->enumClass}]" + ); + } + + /** + * Convert the enum case to a storable value. + * + * @param mixed $value + * @return mixed + */ + public function set(mixed $value): mixed + { + if ($value === null) { + return null; + } + + if ($value instanceof \BackedEnum) { + return $value->value; + } + + if ($value instanceof \UnitEnum) { + return $value->name; + } + + return $value; + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/Handlers/FloatCast.php b/src/Phaseolies/Database/Entity/Casts/Handlers/FloatCast.php new file mode 100644 index 0000000..47b5d24 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Handlers/FloatCast.php @@ -0,0 +1,30 @@ +getTimestamp(); + } + + return $value; + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/InteractsWithCasting.php b/src/Phaseolies/Database/Entity/Casts/InteractsWithCasting.php new file mode 100644 index 0000000..4379c22 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/InteractsWithCasting.php @@ -0,0 +1,165 @@ +> + */ + private static array $castAttributeCache = []; + + /** + * Ensure cast metadata is scanned for the current class. + * + * @return void + */ + private function ensureCastsCached(): void + { + $class = static::class; + + if (!array_key_exists($class, self::$castAttributeCache)) { + self::$castAttributeCache[$class] = $this->scanCastAttributes($class); + } + } + + /** + * Scan a class for properties decorated with #[Cast]. + * + * @param string $class + * @return array + */ + private function scanCastAttributes(string $class): array + { + $found = []; + $reflection = new \ReflectionClass($class); + + foreach ($reflection->getProperties() as $property) { + $castAttributes = $property->getAttributes(Transform::class, \ReflectionAttribute::IS_INSTANCEOF); + + if (empty($castAttributes)) { + continue; + } + + $cast = $castAttributes[0]->newInstance(); + $found[$property->getName()] = $cast->type; + } + + return $found; + } + + /** + * Apply the get cast — converts a raw DB value to its PHP representation. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function castForGet(string $key, mixed $value): mixed + { + if ($value === null || !$this->hasCast($key)) { + return $value; + } + + return CastManager::get(self::$castAttributeCache[static::class][$key], $value); + } + + /** + * Apply the set cast — converts a PHP value to a DB-compatible format + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function castForSet(string $key, mixed $value): mixed + { + if ($value === null || !$this->hasCast($key)) { + return $value; + } + + $cast = self::$castAttributeCache[static::class][$key]; + + if (!CastManager::requiresSetCast($cast)) { + return $value; + } + + return CastManager::set($cast, $value); + } + + /** + * Determine if an attribute has a #[Cast] definition. + * + * @param string $key + * @return bool + */ + public function hasCast(string $key): bool + { + $this->ensureCastsCached(); + + return isset(self::$castAttributeCache[static::class][$key]); + } + + /** + * Get the cast type for a given attribute. + * + * @param string $key + * @return string|null + */ + public function getCastType(string $key): ?string + { + $this->ensureCastsCached(); + + return self::$castAttributeCache[static::class][$key] ?? null; + } + + /** + * Get all cast definitions. + * + * @return array + */ + public function getCasts(): array + { + $this->ensureCastsCached(); + + return self::$castAttributeCache[static::class] ?? []; + } + + /** + * Get all model attributes with casts applied + * + * @return array + */ + public function getCastAttributes(): array + { + $this->ensureCastsCached(); + + $result = []; + + foreach ($this->attributes as $key => $value) { + $result[$key] = $this->hasCast($key) + ? $this->castForGet($key, $value) + : $value; + } + + return $result; + } + + /** + * Reset the #[Cast] attribute scan cache. + * + * @param string|null $class + * @return void + */ + public static function resetCastCache(?string $class = null): void + { + if ($class !== null) { + unset(self::$castAttributeCache[$class]); + } else { + self::$castAttributeCache = []; + } + } +} diff --git a/src/Phaseolies/Database/Entity/Casts/Type.php b/src/Phaseolies/Database/Entity/Casts/Type.php new file mode 100644 index 0000000..f622e30 --- /dev/null +++ b/src/Phaseolies/Database/Entity/Casts/Type.php @@ -0,0 +1,18 @@ +sanitize($value); + $value = $this->castForSet($key, $value); // Always track original value, when first setting if (!array_key_exists($key, $this->originalAttributes)) { @@ -552,7 +555,7 @@ public function makeVisible(): array foreach ($this->attributes as $key => $value) { if (!in_array($key, $this->unexposable)) { - $visibleAttributes[$key] = $value; + $visibleAttributes[$key] = $this->castForGet($key, $value); } } @@ -860,7 +863,7 @@ public function __get($name) { try { if (array_key_exists($name, $this->attributes)) { - return $this->attributes[$name]; + return $this->castForGet($name, $this->attributes[$name]); } if (array_key_exists($name, $this->relations)) { diff --git a/tests/Model/CastSystemTest.php b/tests/Model/CastSystemTest.php new file mode 100644 index 0000000..57a3ed9 --- /dev/null +++ b/tests/Model/CastSystemTest.php @@ -0,0 +1,826 @@ +bind('request', fn() => new Request()); + $container->bind('url', fn() => UrlGenerator::class); + $container->bind('db', fn() => new Database('default')); + + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->createTestTable(); + $this->setupDatabaseConnections(); + + CastManager::flush(); + MockPrimitiveCastModel::resetCastCache(); + MockDateCastModel::resetCastCache(); + MockStructuralCastModel::resetCastCache(); + MockDecimalCastModel::resetCastCache(); + MockEnumCastModel::resetCastCache(); + MockCustomCastModel::resetCastCache(); + MockCustomDateFormatModel::resetCastCache(); + MockTransformEnumModel::resetCastCache(); + MockNoCastsModel::resetCastCache(); + } + + protected function tearDown(): void + { + $this->pdo = null; + $this->tearDownDatabaseConnections(); + CastManager::flush(); + } + + private function createTestTable(): void + { + $this->pdo->exec(" + CREATE TABLE cast_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT, + score TEXT, + weight TEXT, + is_active TEXT, + published_at TEXT, + birthday TEXT, + created_ts TEXT, + options TEXT, + tags TEXT, + config TEXT, + items TEXT, + price TEXT, + tax_rate TEXT, + status TEXT, + priority TEXT, + color TEXT, + name TEXT, + invoiced_at TEXT + ) + "); + } + + private function setupDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', [ + 'default' => $this->pdo, + 'sqlite' => $this->pdo, + ]); + $this->setStaticProperty(Database::class, 'transactions', []); + } + + private function tearDownDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + } + + private function setStaticProperty(string $class, string $property, mixed $value): void + { + $ref = new \ReflectionClass($class); + $prop = $ref->getProperty($property); + $prop->setAccessible(true); + $prop->setValue(null, $value); + $prop->setAccessible(false); + } + + private function insertRecord(array $data): int + { + $cols = implode(', ', array_keys($data)); + $placeholders = implode(', ', array_fill(0, count($data), '?')); + $stmt = $this->pdo->prepare("INSERT INTO cast_records ({$cols}) VALUES ({$placeholders})"); + $stmt->execute(array_values($data)); + return (int) $this->pdo->lastInsertId(); + } + + public function testHasCastReturnsTrueForDeclaredProperties(): void + { + $model = new MockPrimitiveCastModel(); + + $this->assertTrue($model->hasCast('label')); + $this->assertTrue($model->hasCast('score')); + $this->assertTrue($model->hasCast('weight')); + $this->assertTrue($model->hasCast('is_active')); + } + + public function testHasCastReturnsFalseForUndeclaredProperties(): void + { + $model = new MockPrimitiveCastModel(); + + $this->assertFalse($model->hasCast('nonexistent')); + $this->assertFalse($model->hasCast('id')); + } + + public function testGetCastTypeReturnsCorrectType(): void + { + $model = new MockPrimitiveCastModel(); + + $this->assertSame('string', $model->getCastType('label')); + $this->assertSame('integer', $model->getCastType('score')); + $this->assertSame('float', $model->getCastType('weight')); + $this->assertSame('boolean', $model->getCastType('is_active')); + } + + public function testGetCastTypeReturnsNullForUnknownKey(): void + { + $model = new MockPrimitiveCastModel(); + + $this->assertNull($model->getCastType('nonexistent')); + } + + public function testGetCastsReturnsAllDefinitions(): void + { + $model = new MockPrimitiveCastModel(); + $casts = $model->getCasts(); + + $this->assertIsArray($casts); + $this->assertArrayHasKey('label', $casts); + $this->assertArrayHasKey('score', $casts); + $this->assertArrayHasKey('weight', $casts); + $this->assertArrayHasKey('is_active', $casts); + $this->assertCount(4, $casts); + } + + public function testModelWithNoCastsReturnsEmptyArray(): void + { + $model = new MockNoCastsModel(); + + $this->assertSame([], $model->getCasts()); + $this->assertFalse($model->hasCast('label')); + } + + public function testCacheIsNotPopulatedUntilFirstAccess(): void + { + MockPrimitiveCastModel::resetCastCache(); + + $ref = new \ReflectionClass(Model::class); + $cache = $ref->getProperty('castAttributeCache'); + $cache->setAccessible(true); + + $before = $cache->getValue(null); + $this->assertArrayNotHasKey(MockPrimitiveCastModel::class, $before); + + $model = new MockPrimitiveCastModel(); + $model->hasCast('label'); // triggers lazy load + + $after = $cache->getValue(null); + $this->assertArrayHasKey(MockPrimitiveCastModel::class, $after); + + $cache->setAccessible(false); + } + + public function testCacheIsSharedAcrossInstancesOfSameClass(): void + { + $a = new MockPrimitiveCastModel(); + $b = new MockPrimitiveCastModel(); + + $a->hasCast('label'); // prime cache via instance a + + $ref = new \ReflectionClass(Model::class); + $cache = $ref->getProperty('castAttributeCache'); + $cache->setAccessible(true); + $data = $cache->getValue(null); + $cache->setAccessible(false); + + // Both instances share the same static cache entry + $this->assertArrayHasKey(MockPrimitiveCastModel::class, $data); + $this->assertTrue($b->hasCast('label')); + } + + public function testResetCastCacheFlushesSpecificClass(): void + { + $model = new MockPrimitiveCastModel(); + $model->hasCast('label'); + + MockPrimitiveCastModel::resetCastCache(MockPrimitiveCastModel::class); + + $ref = new \ReflectionClass(Model::class); + $cache = $ref->getProperty('castAttributeCache'); + $cache->setAccessible(true); + $data = $cache->getValue(null); + $cache->setAccessible(false); + + $this->assertArrayNotHasKey(MockPrimitiveCastModel::class, $data); + } + + public function testResetCastCacheFlushesAll(): void + { + (new MockPrimitiveCastModel())->hasCast('label'); + (new MockDateCastModel())->hasCast('published_at'); + + MockPrimitiveCastModel::resetCastCache(); + + $ref = new \ReflectionClass(Model::class); + $cache = $ref->getProperty('castAttributeCache'); + $cache->setAccessible(true); + $data = $cache->getValue(null); + $cache->setAccessible(false); + + $this->assertEmpty($data); + } + + public function testStringCast(): void + { + $model = new MockPrimitiveCastModel(['label' => 123]); + + $this->assertSame('123', $model->label); + $this->assertIsString($model->label); + } + + public function testIntegerCast(): void + { + $model = new MockPrimitiveCastModel(['score' => '42']); + + $this->assertSame(42, $model->score); + $this->assertIsInt($model->score); + } + + public function testFloatCast(): void + { + $model = new MockPrimitiveCastModel(['weight' => '3.14']); + + $this->assertSame(3.14, $model->weight); + $this->assertIsFloat($model->weight); + } + + public function testBooleanCastTrueValues(): void + { + foreach (['1', 'true', 'yes', 'on'] as $truthy) { + $model = new MockPrimitiveCastModel(['is_active' => $truthy]); + $this->assertTrue($model->is_active, "Expected true for value: {$truthy}"); + } + } + + public function testBooleanCastFalseValues(): void + { + foreach (['0', 'false', 'no', 'off', ''] as $falsy) { + $model = new MockPrimitiveCastModel(['is_active' => $falsy]); + $this->assertFalse($model->is_active, "Expected false for value: {$falsy}"); + } + } + + public function testTransformWithTypeEnumString(): void + { + $model = new MockTransformEnumModel(['label' => 99]); + + $this->assertSame('99', $model->label); + $this->assertIsString($model->label); + } + + public function testTransformWithTypeEnumInteger(): void + { + $model = new MockTransformEnumModel(['score' => '7']); + + $this->assertSame(7, $model->score); + $this->assertIsInt($model->score); + } + + public function testDateTimeCastReturnsCarbon(): void + { + $model = new MockDateCastModel(['published_at' => '2024-06-15 12:00:00']); + + $this->assertInstanceOf(Carbon::class, $model->published_at); + $this->assertSame('2024-06-15', $model->published_at->format('Y-m-d')); + } + + public function testDateCastReturnsCarbon(): void + { + $model = new MockDateCastModel(['birthday' => '1990-03-25']); + + $this->assertInstanceOf(Carbon::class, $model->birthday); + $this->assertSame('1990-03-25', $model->birthday->format('Y-m-d')); + } + + public function testTimestampCastGetReturnsCarbon(): void + { + // TimestampCast::get parses any stored value into a Carbon instance + $model = new MockDateCastModel(['created_ts' => '2024-01-01 00:00:00']); + + $this->assertInstanceOf(Carbon::class, $model->created_ts); + $this->assertSame('2024-01-01', $model->created_ts->format('Y-m-d')); + } + + public function testTimestampCastSetConvertsDateTimeToUnixInt(): void + { + // TimestampCast::set serialises a DateTimeInterface to a Unix timestamp integer + $model = new MockDateCastModel(); + $model->fill(['created_ts' => Carbon::parse('2024-01-01 00:00:00')]); + + $stored = $model->getAttributes()['created_ts']; + $this->assertIsInt($stored); + $this->assertSame(Carbon::parse('2024-01-01 00:00:00')->getTimestamp(), $stored); + } + + public function testCustomDateTimeFormatCast(): void + { + $model = new MockCustomDateFormatModel(['invoiced_at' => '2024-06-15 09:30:00']); + + $this->assertInstanceOf(Carbon::class, $model->invoiced_at); + $this->assertSame('2024-06-15', $model->invoiced_at->format('Y-m-d')); + } + + public function testDateTimeCastNullPassthrough(): void + { + $model = new MockDateCastModel(['published_at' => null]); + + $this->assertNull($model->published_at); + } + + public function testArrayCast(): void + { + $model = new MockStructuralCastModel(['options' => '{"color":"red","size":"M"}']); + + $this->assertIsArray($model->options); + $this->assertSame('red', $model->options['color']); + $this->assertSame('M', $model->options['size']); + } + + public function testJsonCast(): void + { + $model = new MockStructuralCastModel(['tags' => '["php","doppar"]']); + + $this->assertIsArray($model->tags); + $this->assertContains('php', $model->tags); + $this->assertContains('doppar', $model->tags); + } + + public function testObjectCast(): void + { + $model = new MockStructuralCastModel(['config' => '{"debug":true,"version":"1.0"}']); + + $this->assertIsObject($model->config); + $this->assertTrue($model->config->debug); + $this->assertSame('1.0', $model->config->version); + } + + public function testCollectionCast(): void + { + $model = new MockStructuralCastModel(['items' => '["a","b","c"]']); + + $this->assertInstanceOf(Collection::class, $model->items); + $this->assertCount(3, $model->items); + } + + public function testDecimalCastTwoPlaces(): void + { + $model = new MockDecimalCastModel(['price' => '19.9999']); + + $this->assertSame(20.0, $model->price); + } + + public function testDecimalCastFourPlaces(): void + { + $model = new MockDecimalCastModel(['tax_rate' => '0.12345']); + + $this->assertSame(0.1235, $model->tax_rate); + } + + public function testDecimalCastSetRoundTrip(): void + { + $model = new MockDecimalCastModel(); + $model->fill(['price' => '9.9999']); + + $this->assertSame(10.0, $model->price); + } + + public function testBackedStringEnumCast(): void + { + $model = new MockEnumCastModel(['status' => 'active']); + + $this->assertInstanceOf(CastTestStatus::class, $model->status); + $this->assertSame(CastTestStatus::Active, $model->status); + } + + public function testBackedIntEnumCast(): void + { + $model = new MockEnumCastModel(['priority' => '2']); + + $this->assertInstanceOf(CastTestPriority::class, $model->priority); + $this->assertSame(CastTestPriority::Medium, $model->priority); + } + + public function testPureUnitEnumCast(): void + { + $model = new MockEnumCastModel(['color' => 'Green']); + + $this->assertInstanceOf(CastTestColor::class, $model->color); + $this->assertSame(CastTestColor::Green, $model->color); + } + + public function testEnumSetCastSerializesBackedEnum(): void + { + $model = new MockEnumCastModel(); + $model->fill(['status' => CastTestStatus::Inactive]); + + $this->assertSame('inactive', $model->getAttributes()['status']); + } + + public function testEnumSetCastSerializesUnitEnum(): void + { + $model = new MockEnumCastModel(); + $model->fill(['color' => CastTestColor::Blue]); + + $this->assertSame('Blue', $model->getAttributes()['color']); + } + + public function testInvalidEnumValueThrows(): void + { + // __get swallows all Throwables, so test the handler directly + $this->expectException(\ValueError::class); + + CastManager::resolve(CastTestColor::class)->get('Purple'); + } + public function testCustomCastGetUppercases(): void + { + $model = new MockCustomCastModel(['name' => 'doppar']); + + $this->assertSame('DOPPAR', $model->name); + } + + public function testCustomCastSetLowercases(): void + { + $model = new MockCustomCastModel(); + $model->fill(['name' => 'DOPPAR']); + + $this->assertSame('doppar', $model->getAttributes()['name']); + } + + public function testNullValuePassesThroughAllCasts(): void + { + $model = new MockPrimitiveCastModel([ + 'label' => null, + 'score' => null, + 'weight' => null, + 'is_active' => null, + ]); + + $this->assertNull($model->label); + $this->assertNull($model->score); + $this->assertNull($model->weight); + $this->assertNull($model->is_active); + } + + public function testGetCastAttributesAppliesCastsToAll(): void + { + $model = new MockPrimitiveCastModel([ + 'label' => 42, + 'score' => '10', + 'weight' => '1.5', + 'is_active' => '1', + ]); + + $result = $model->getCastAttributes(); + + $this->assertSame('42', $result['label']); + $this->assertSame(10, $result['score']); + $this->assertSame(1.5, $result['weight']); + $this->assertTrue($result['is_active']); + } + + public function testCastManagerResolvesKnownPrimitives(): void + { + foreach (['string', 'integer', 'float', 'boolean', 'array', 'json', 'object', 'collection', 'datetime', 'date', 'timestamp'] as $type) { + $handler = CastManager::resolve($type); + $this->assertInstanceOf(CastableInterface::class, $handler); + } + } + + public function testCastManagerResolvesDecimalWithPrecision(): void + { + $handler = CastManager::resolve('decimal:3'); + $this->assertSame(1.235, $handler->get('1.2345')); + } + + public function testCastManagerResolvesDatetimeWithFormat(): void + { + $handler = CastManager::resolve('datetime:Y/m/d'); + $result = $handler->get('2024-06-15 00:00:00'); + $this->assertInstanceOf(Carbon::class, $result); + } + + public function testCastManagerResolvesBackedEnum(): void + { + $handler = CastManager::resolve(CastTestStatus::class); + $result = $handler->get('inactive'); + $this->assertSame(CastTestStatus::Inactive, $result); + } + + public function testCastManagerResolvesCustomCastClass(): void + { + $handler = CastManager::resolve(UpperCaseCast::class); + $this->assertSame('HELLO', $handler->get('hello')); + } + + public function testCastManagerThrowsOnUnsupportedType(): void + { + $this->expectException(\InvalidArgumentException::class); + + CastManager::resolve('totally_unknown_cast_xyz'); + } + + public function testCastManagerFlushClearsResolvedCache(): void + { + CastManager::resolve('string'); // populate cache + CastManager::flush(); + + $ref = new \ReflectionClass(CastManager::class); + $resolved = $ref->getProperty('resolved'); + $resolved->setAccessible(true); + $value = $resolved->getValue(null); + $resolved->setAccessible(false); + + $this->assertEmpty($value); + } + + public function testRequiresSetCastForComplexTypes(): void + { + foreach (['array', 'json', 'object', 'collection', 'datetime', 'date', 'timestamp', 'decimal:2', 'datetime:d/m/Y'] as $type) { + $this->assertTrue(CastManager::requiresSetCast($type), "Expected true for: {$type}"); + } + } + + public function testRequiresSetCastFalseForPrimitives(): void + { + foreach (['string', 'integer', 'float', 'boolean'] as $type) { + $this->assertFalse(CastManager::requiresSetCast($type), "Expected false for: {$type}"); + } + } + + public function testRequiresSetCastTrueForEnum(): void + { + $this->assertTrue(CastManager::requiresSetCast(CastTestStatus::class)); + } + + public function testRequiresSetCastTrueForCustomCastClass(): void + { + $this->assertTrue(CastManager::requiresSetCast(UpperCaseCast::class)); + } + + public function testStringCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['label' => 'hello']); + + $model = MockPrimitiveCastModel::find($id); + + $this->assertSame('hello', $model->label); + $this->assertIsString($model->label); + } + + public function testIntegerCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['score' => '99']); + + $model = MockPrimitiveCastModel::find($id); + + $this->assertSame(99, $model->score); + $this->assertIsInt($model->score); + } + + public function testBooleanCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['is_active' => '1']); + + $model = MockPrimitiveCastModel::find($id); + + $this->assertTrue($model->is_active); + } + + public function testDateTimeCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['published_at' => '2024-03-10 08:30:00']); + + $model = MockDateCastModel::find($id); + + $this->assertInstanceOf(Carbon::class, $model->published_at); + $this->assertSame('2024-03-10', $model->published_at->format('Y-m-d')); + $this->assertSame('08:30:00', $model->published_at->format('H:i:s')); + } + + public function testArrayCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['options' => '{"size":"L","color":"blue"}']); + + $model = MockStructuralCastModel::find($id); + + $this->assertIsArray($model->options); + $this->assertSame('L', $model->options['size']); + $this->assertSame('blue', $model->options['color']); + } + + public function testDecimalCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['price' => '49.9999']); + + $model = MockDecimalCastModel::find($id); + + $this->assertSame(50.0, $model->price); + } + + public function testEnumCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['status' => 'inactive']); + + $model = MockEnumCastModel::find($id); + + $this->assertInstanceOf(CastTestStatus::class, $model->status); + $this->assertSame(CastTestStatus::Inactive, $model->status); + } + + public function testCollectionCastRoundTripFromDatabase(): void + { + $id = $this->insertRecord(['items' => '["x","y","z"]']); + + $model = MockStructuralCastModel::find($id); + + $this->assertInstanceOf(Collection::class, $model->items); + $this->assertCount(3, $model->items); + } +}