diff --git a/CHANGELOG.md b/CHANGELOG.md index d61cb1f..b50f096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Release Notes +## v2.2.1 + +### Fixed + +* Fixed a bug that was caused by a null value or a null cast. [PR #104](https://github.com/kodeine/laravel-meta/pull/104) + ## v2.2.0 ### Added diff --git a/src/Kodeine/Metable/Metable.php b/src/Kodeine/Metable/Metable.php index 04a7564..46e5c79 100644 --- a/src/Kodeine/Metable/Metable.php +++ b/src/Kodeine/Metable/Metable.php @@ -442,6 +442,15 @@ public function getAttribute($key) { return $attr; } + // It is possible that attribute exists, or it has a cast, but it's null, so we check for that + if ( array_key_exists( $key, $this->attributes ) || + array_key_exists( $key, $this->casts ) || + $this->hasGetMutator( $key ) || + $this->hasAttributeMutator( $key ) || + $this->isClassCastable( $key ) ) { + return $attr; + } + // If key is a relation name, then return parent value. // The reason for this is that it's possible that the relation does not exist and parent call returns null for that. if ( $this->isRelation( $key ) && $this->relationLoaded( $key ) ) { diff --git a/tests/Casts/StateCaster.php b/tests/Casts/StateCaster.php new file mode 100644 index 0000000..4ec4a69 --- /dev/null +++ b/tests/Casts/StateCaster.php @@ -0,0 +1,46 @@ +baseStateClass = $baseStateClass; + } + + public function get($model, $key, $value, $attributes) { + if ( is_null( $value ) ) return null; + + if ( ! is_subclass_of( $value, $this->baseStateClass ) ) { + return null; + } + + $stateClassName = $value::config()['default']; + + return new $stateClassName( $model ); + } + + /** + * @throws Exception + */ + public function set($model, $key, $value, $attributes) { + if ( is_null( $value ) ) return null; + + if ( ! is_subclass_of( $value, $this->baseStateClass ) ) { + throw new Exception( 'Invalid state class.' ); + } + + $value = new $value( $model ); + + if ( $value instanceof $this->baseStateClass ) { + $value->setField( $key ); + } + + return $value->getMorphClass(); + } +} \ No newline at end of file diff --git a/tests/Casts/UserCastedObject.php b/tests/Casts/UserCastedObject.php new file mode 100644 index 0000000..5d8e668 --- /dev/null +++ b/tests/Casts/UserCastedObject.php @@ -0,0 +1,46 @@ +model = $model; + $this->stateConfig = static::config(); + } + + public static function config() { + return [ + 'default' => null, + ]; + } + + public static function castUsing(array $arguments) { + return new StateCaster( static::class ); + } + + public function setField(string $field): self { + $this->field = $field; + + return $this; + } + + public static function getMorphClass(): string { + return static::$name ?? static::class; + } + + public function make(?string $name, $model) { + if ( is_null( $name ) ) { + return null; + } + + return new $name( $model ); + } +} \ No newline at end of file diff --git a/tests/Casts/UserState/DefaultState.php b/tests/Casts/UserState/DefaultState.php new file mode 100644 index 0000000..d669192 --- /dev/null +++ b/tests/Casts/UserState/DefaultState.php @@ -0,0 +1,17 @@ +description = $this->description(); + } + + public function description(): string { + return 'This is a default description.'; + } +} \ No newline at end of file diff --git a/tests/Casts/UserState/State.php b/tests/Casts/UserState/State.php new file mode 100644 index 0000000..9a91a1e --- /dev/null +++ b/tests/Casts/UserState/State.php @@ -0,0 +1,17 @@ + DefaultState::class, + ]; + } +} \ No newline at end of file diff --git a/tests/MetableTest.php b/tests/MetableTest.php index dd36069..97e5eca 100644 --- a/tests/MetableTest.php +++ b/tests/MetableTest.php @@ -10,6 +10,7 @@ use Kodeine\Metable\Tests\Models\UserTest; use Illuminate\Database\Capsule\Manager as Capsule; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Kodeine\Metable\Tests\Casts\UserState\DefaultState; class MetableTest extends TestCase { @@ -31,6 +32,8 @@ public static function setUpBeforeClass(): void { $table->string( 'name' )->default( 'john' ); $table->string( 'email' )->default( 'john@doe.com' ); $table->string( 'password' )->nullable(); + $table->string( 'state' )->nullable(); + $table->string( 'null_value' )->nullable(); $table->integer( 'user_test_id' )->unsigned()->nullable(); $table->foreign( 'user_test_id' )->references( 'id' )->on( 'user_tests' ); $table->timestamps(); @@ -47,6 +50,16 @@ public static function setUpBeforeClass(): void { } ); } + public function testCast() { + $user = new UserTest; + + $this->assertNull( $user->state, 'Casted object should be null by default' ); + + $user->state = DefaultState::class; + + $this->assertInstanceOf( DefaultState::class, $user->state, 'Casted object should be instanceof DefaultState' ); + } + public function testFluentMeta() { $user = new UserTest; @@ -112,6 +125,21 @@ public function testFluentMeta() { $this->assertTrue( $user->isMetaDirty( 'foo', 'bar' ), 'isMetaDirty should return true even if one of metas has changed' ); $this->assertTrue( $user->isMetaDirty( 'foo,bar' ), 'isMetaDirty should return true even if one of metas has changed' ); + //re retrieve user from database + /** @var UserTest $user */ + $user = UserTest::find( $user->id ); + + $this->assertNull( $user->null_value, 'null_value property should be null' ); + $this->assertNull( $user->null_cast, 'null_cast property should be null' ); + + $user->setMeta( 'null_value', true ); + $user->setMeta( 'null_cast', true ); + + $this->assertTrue( $user->getMeta( 'null_value' ), 'Meta should be set' ); + $this->assertTrue( $user->getMeta( 'null_cast' ), 'Meta should be set' ); + $this->assertNull( $user->null_value, 'null_value property should be null' ); + $this->assertNull( $user->null_cast, 'null_cast property should be null' ); + $user->delete(); $this->assertEquals( 0, $metaData->count(), 'Meta should be deleted from database after deleting user.' ); @@ -227,6 +255,10 @@ public function testMetaMethods() { $user->save(); + $meta = $user->getMeta(); + $this->assertInstanceOf( 'Illuminate\Support\Collection', $meta, 'Meta method getMeta is not typeof Collection' ); + $this->assertTrue( $meta->isNotEmpty(), 'Meta method getMeta did return empty collection' ); + // re retrieve user to make sure meta is saved $user = UserTest::with( ['metas'] )->find( $user->getKey() ); diff --git a/tests/Models/UserTest.php b/tests/Models/UserTest.php index 0bcfdb6..347bf45 100644 --- a/tests/Models/UserTest.php +++ b/tests/Models/UserTest.php @@ -6,10 +6,13 @@ use Illuminate\Events\Dispatcher; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne; +use Kodeine\Metable\Tests\Casts\UserState\State; +use Kodeine\Metable\Tests\Traits\HasUserStates; class UserTest extends Model { use Metable; + use HasUserStates; public $defaultMetaValues = [ 'default_meta_key' => 'default_meta_value', @@ -19,6 +22,14 @@ class UserTest extends Model public $disableFluentMeta = false; + protected $casts = [ + 'state' => State::class, + ]; + + public function getNullCastAttribute() { + return null; + } + /** * This is dummy relation to itself. * diff --git a/tests/Traits/HasUserStates.php b/tests/Traits/HasUserStates.php new file mode 100644 index 0000000..89c620b --- /dev/null +++ b/tests/Traits/HasUserStates.php @@ -0,0 +1,53 @@ + State::class, + ]; + + public static function bootHasUserCasts() { + self::creating( function ($model) { + $model->setDefaultCastedProperties(); + } ); + } + + public function initializeHasUserCasts() { + $this->setDefaultCastedProperties(); + } + + private function getStateConfigs() { + $casts = $this->getCasts(); + + $states = []; + + foreach ($casts as $prop => $state) { + if ( ! is_subclass_of( $state, UserCastedObject::class ) ) { + continue; + } + + $states[$prop] = $state::config(); + } + + return $states; + } + + private function setDefaultCastedProperties() { + foreach ($this->getStateConfigs() as $prop => $config) { + if ( $this->{$prop} === null ) { + continue; + } + + if ( ! isset( $config['default'] ) ) { + continue; + } + + $this->{$prop} = $config['default']; + } + } +} \ No newline at end of file