diff --git a/CHANGELOG.md b/CHANGELOG.md index 15cad40..09d4d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,10 @@ Version 6 contains a number of changes to improve the security and performance o - A value of `null` is ignored by all cast types. - Added `mergeMetaCasts()` method which can be used to override the defined cast on a meta key at runtime. +### Meta Attributes + +- Added optional trait `MetableAttributes` which can further extend the `Metable` trait allowing access to meta values as model attributes using a `meta_` prefix. This can be useful for type hinting, IDE autocompletion, static analysis, and usage in Blade templates. + ### Meta - Added `$meta->string_value` and `$meta->numeric_value` attributes, which are used for optimizing queries filtering by meta value - Added `$meta->hmac` attribute, which is used by some data type handlers to validate that the payload has not been tampered with. diff --git a/UPGRADING.md b/UPGRADING.md index b9cb473..3e9368a 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -35,6 +35,10 @@ * Review the documentation about which data types can be queried with the various `whereMeta*` and `whereMeta*Numeric` query scopes. If you are querying the serialized `value` column directly, be aware that the formatting of array/object data types may have changed. +### Metable Attributes + +* Optional: if you intend to access meta with property access, add the new `\Plank\Metable\MetableAttributes` traits to your `Metable`. + ## 4.X -> 5.X - New migration file added which adds a new composite unique index to the meta table on `metable_type`, `metable_id`, and `key`. Make sure that you have no duplicate keys for a given entity (previously possible as a race condition) before applying the new migration. diff --git a/docs/source/handling_meta.rst b/docs/source/handling_meta.rst index 2913d3f..1dece1e 100644 --- a/docs/source/handling_meta.rst +++ b/docs/source/handling_meta.rst @@ -254,3 +254,54 @@ You can also instruct your model class to `always` eager load the meta relations protected $with = ['meta']; } + + +Meta As Attributes +------------------ + +If you prefer to access meta as if they were attributes of the model, you can use the ``MetableAttributes`` trait insin addition to the ``Metable`` trait. This will allow you to access meta as if they were attributes of the model by prefixing them with ``meta_``. Meta attributes can be combined with type annotations, casts and/or default values to provide consistent typing. This can be useful for IDE completions and static analysis, as well as for use in Blade templates. + +:: + + 'boolean', + 'published_at' => 'datetime', + 'likes' => 'integer', + ]; + + $defaultMetaValues = [ + 'approved' => false, + 'published_at' => null, + 'likes' => 0, + ]; + + // ... + } + + $page = new Page(); + $page->meta_likes = 5; // equivalent to $page->setMeta('likes', 5); + $page->fill(['meta_approved' => true, 'meta_published_at' => now()]); // equivalent to $page->setManyMeta([...]); + if ($page->meta_likes > 0) {} // equivalent $page->getMeta('likes'); + unset($page->meta_likes); // equivalent to $page->removeMeta('likes'); + + +Most attribute operations will translate meta attributes to their corresponding meta operations. However, the ``getAttributes()`` method will **not** include meta attributes. The ``getMetaAttributes()`` method can be used to retrieve all meta values keyed by their attribute name. + +The ``toArray()`` method will include meta attributes by default. The ``$visible``/``$hidden`` properties of the model will be respected if any meta attributes are listed. The ``makeMetaHidden()`` method can be used to quickly hide all currently assigned meta attributes from the array representation of the model. \ No newline at end of file diff --git a/src/MetableAttributes.php b/src/MetableAttributes.php new file mode 100644 index 0000000..9ec321b --- /dev/null +++ b/src/MetableAttributes.php @@ -0,0 +1,116 @@ +isMetaAttribute($key)) { + return $this->getMeta($this->metaAttributeToKey($key)); + } + + return parent::getAttribute($key); + } + + public function setAttribute($key, $value) + { + if ($this->isMetaAttribute($key)) { + $this->setMeta($this->metaAttributeToKey($key), $value); + return; + } + + parent::setAttribute($key, $value); + } + + public function fill(array $attributes) + { + foreach ($attributes as $key => $value) { + if ($this->isMetaAttribute($key)) { + $this->setMeta($this->metaAttributeToKey($key), $value); + unset($attributes[$key]); + } + } + + parent::fill($attributes); + } + + public function getMetaAttributes(): Collection + { + $attributes = []; + foreach ($this->getAllMeta() as $key => $value) { + $attributes[$this->metaKeyToAttribute($key)] = $value; + } + return collect($attributes); + } + + public function offsetExists($key): bool + { + if ($this->isMetaAttribute($key)) { + return $this->hasMeta($this->metaAttributeToKey($key)); + } + + return parent::isset($key); + } + + public function offsetUnset($key): void + { + if ($this->isMetaAttribute($key)) { + $this->removeMeta($this->metaAttributeToKey($key)); + return; + } + + parent::offsetUnset($key); + } + + protected function isMetaAttribute($key): bool + { + return str_starts_with($key, 'meta_') + && !array_key_exists($key, $this->attributes) + && !array_key_exists($key, $this->casts); + } + + public function toArray() + { + if (property_exists($this, 'includeMetaInArray') + && !$this->includeMetaInArray + ) { + return parent::toArray(); + } + + return array_merge( + parent::toArray(), + $this->getArrayableItems($this->getMetaAttributes()->toArray()) + ); + } + + public function makeMetaHidden(): void + { + $this->hidden = array_merge( + $this->hidden, + array_keys($this->getMetaAttributes()->toArray()) + ); + } + + protected function metaAttributeToKey(string $attribute): string + { + return substr($attribute, 5); + } + + protected function metaKeyToAttribute(string $key): string + { + return 'meta_' . $key; + } + + abstract public function getMeta(string $key, mixed $default = null): mixed; + + abstract public function setMeta(string $key, mixed $value, bool $encrypt = false): void; + + abstract public function removeMeta(string $key): void; + + abstract public function getAllMeta(): \Illuminate\Support\Collection; + + abstract protected function getArrayableItems(array $values); +} \ No newline at end of file diff --git a/tests/Integration/MetableAttributesTest.php b/tests/Integration/MetableAttributesTest.php new file mode 100644 index 0000000..1ef6807 --- /dev/null +++ b/tests/Integration/MetableAttributesTest.php @@ -0,0 +1,96 @@ +useDatabase(); + + $model = $this->createMetable(); + $this->assertFalse($model->hasMeta('var')); + $this->assertFalse(isset($model->meta_var)); + $this->assertNull($model->getAttribute('meta_var')); + + $model->setAttribute('meta_var', 'bar'); + $this->assertEquals('bar', $model->getMeta('var')); + $this->assertTrue(isset($model->meta_var)); + $this->assertEquals('bar', $model->getAttribute('meta_var')); + + $model->meta_var = 'baz'; + $this->assertEquals('baz', $model->getMeta('var')); + $this->assertTrue(isset($model->meta_var)); + $this->assertEquals('baz', $model->getAttribute('meta_var')); + + $model->offsetUnset('meta_var'); + $this->assertFalse($model->hasMeta('var')); + $this->assertFalse(isset($model->meta_var)); + $this->assertNull($model->getAttribute('meta_var')); + + $model->fill(['meta_var' => 'qux']); + $this->assertEquals('qux', $model->getMeta('var')); + $this->assertTrue(isset($model->meta_var)); + $this->assertEquals('qux', $model->getAttribute('meta_var')); + + unset($model['meta_var']); + $this->assertFalse($model->hasMeta('var')); + $this->assertFalse(isset($model->meta_var)); + $this->assertNull($model->getAttribute('meta_var')); + } + + public function test_it_doesnt_overwrite_existing_attributes() + { + $this->useDatabase(); + + $model = $this->createMetable(); + $model->meta_attribute = 'bar'; + $this->assertFalse($model->hasMeta('attribute')); + + $model->setAttribute('meta_attribute', 'baz'); + $this->assertFalse($model->hasMeta('attribute')); + } + + public function test_it_converts_to_array() + { + $this->useDatabase(); + $model = $this->createMetable(); + $model->meta_var = 'foo'; + $model->meta_var2 = 'foo2'; + + $this->assertEquals( + collect([ + 'meta_var' => 'foo', + 'meta_var2' => 'foo2', + 'meta_foo' => 'bar' // default value + ]), + $model->getMetaAttributes() + ); + + $model->makeHidden('meta_var2', 'created_at', 'updated_at', 'meta'); + + $array = $model->toArray(); + $this->assertEquals([ + 'meta_attribute' => '', + 'id' => $model->getKey(), + 'meta_foo' => 'bar', + 'meta_var' => 'foo' + ], $array); + + $model->makeMetaHidden(); + + $array = $model->toArray(); + $this->assertEquals([ + 'meta_attribute' => '', + 'id' => $model->getKey(), + ], $array); + } + + private function createMetable(array $attributes = []): SampleMetable + { + return factory(SampleMetable::class)->create($attributes); + } +} \ No newline at end of file diff --git a/tests/Integration/MorphTest.php b/tests/Integration/MorphTest.php index d99f2fc..b3e41dc 100644 --- a/tests/Integration/MorphTest.php +++ b/tests/Integration/MorphTest.php @@ -49,7 +49,7 @@ public function test_it_can_join_correctly_from_morphed_class(): void $results1 = SampleMorph::orderByMeta('foo', 'asc')->get(); $results2 = Meta::select('metable_type')->get(); - $this->assertCount(3, $results1->pluck('id')->toArray()); + $this->assertEquals([3, 1, 2], $results1->modelKeys()); $this->assertEquals([$class, $class, $class], $results2->pluck('metable_type')->toArray()); } diff --git a/tests/Mocks/SampleMetable.php b/tests/Mocks/SampleMetable.php index bb26315..adf1848 100644 --- a/tests/Mocks/SampleMetable.php +++ b/tests/Mocks/SampleMetable.php @@ -4,11 +4,17 @@ use Illuminate\Database\Eloquent\Model; use Plank\Metable\Metable; +use Plank\Metable\MetableAttributes; use Plank\Metable\MetableInterface; class SampleMetable extends Model implements MetableInterface { use Metable; + use MetableAttributes; + + protected $attributes = [ + 'meta_attribute' => '', + ]; protected $defaultMetaValues = [ 'foo' => 'bar' diff --git a/tests/migrations/2017_01_01_000000_create_sample_classes_tables.php b/tests/migrations/2017_01_01_000000_create_sample_classes_tables.php index 6403a2b..4c6441b 100644 --- a/tests/migrations/2017_01_01_000000_create_sample_classes_tables.php +++ b/tests/migrations/2017_01_01_000000_create_sample_classes_tables.php @@ -15,6 +15,7 @@ public function up() { Schema::create('sample_metables', function (Blueprint $table) { $table->id(); + $table->string('meta_attribute')->nullable(); $table->softDeletes(); $table->timestamps(); });