diff --git a/phpunit.xml b/phpunit.xml index 300b6ff..a987303 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,6 @@ - - - - ./src - - - ./src/routes.php - - + + ./tests @@ -15,6 +8,7 @@ + @@ -24,4 +18,12 @@ + + + ./src + + + ./src/routes.php + + diff --git a/phpunit.xml.bak b/phpunit.xml.bak new file mode 100644 index 0000000..7a1ac00 --- /dev/null +++ b/phpunit.xml.bak @@ -0,0 +1,28 @@ + + + + + ./src + + + ./src/routes.php + + + + + ./tests + + + + + + + + + + + + + + + diff --git a/src/VirtualColumn.php b/src/VirtualColumn.php index 1eb16b2..c09a6fe 100644 --- a/src/VirtualColumn.php +++ b/src/VirtualColumn.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace Stancl\VirtualColumn; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Support\Facades\Crypt; + /** * This trait lets you add a "data" column functionality to any Eloquent model. * It serializes attributes which don't exist as columns on the model's table @@ -13,74 +16,119 @@ namespace Stancl\VirtualColumn; */ trait VirtualColumn { - public static $afterListeners = []; + /** + * Encrypted castables have to be handled using a special approach that prevents the data from getting encrypted repeatedly. + * + * The default encrypted castables ('encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object') + * are already handled, so you can use this array to add your own encrypted castables. + */ + public static array $customEncryptedCastables = []; /** * We need this property, because both created & saved event listeners * decode the data (to take precedence before other created & saved) * listeners, but we don't want the data to be decoded twice. - * - * @var string */ - public $dataEncodingStatus = 'decoded'; + public bool $dataEncoded = false; - protected static function decodeVirtualColumn(self $model): void + protected function decodeVirtualColumn(): void { - if ($model->dataEncodingStatus === 'decoded') { + if (! $this->dataEncoded) { return; } - foreach ($model->getAttribute(static::getDataColumn()) ?? [] as $key => $value) { - $model->setAttribute($key, $value); - $model->syncOriginalAttribute($key); - } + $encryptedCastables = array_merge( + static::$customEncryptedCastables, + ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'], // Default encrypted castables + ); - $model->setAttribute(static::getDataColumn(), null); + foreach ($this->getAttribute($this->getDataColumn()) ?? [] as $key => $value) { + $attributeHasEncryptedCastable = in_array(data_get($this->getCasts(), $key), $encryptedCastables); - $model->dataEncodingStatus = 'decoded'; - } - - protected static function encodeAttributes(self $model): void - { - if ($model->dataEncodingStatus === 'encoded') { - return; - } - - foreach ($model->getAttributes() as $key => $value) { - if (! in_array($key, static::getCustomColumns())) { - $current = $model->getAttribute(static::getDataColumn()) ?? []; - - $model->setAttribute(static::getDataColumn(), array_merge($current, [ - $key => $value, - ])); - - unset($model->attributes[$key]); - unset($model->original[$key]); + if ($attributeHasEncryptedCastable && $this->valueEncrypted($value)) { + $this->attributes[$key] = $value; + } else { + $this->setAttribute($key, $value); } + + $this->syncOriginalAttribute($key); } - $model->dataEncodingStatus = 'encoded'; + $this->setAttribute($this->getDataColumn(), null); + + $this->dataEncoded = false; } - public static function bootVirtualColumn() + protected function encodeAttributes(): void { - static::registerAfterListener('retrieved', function ($model) { - // We always decode after model retrieval. - $model->dataEncodingStatus = 'encoded'; + if ($this->dataEncoded) { + return; + } - static::decodeVirtualColumn($model); - }); + $dataColumn = $this->getDataColumn(); + $customColumns = $this->getCustomColumns(); + $attributes = array_filter($this->getAttributes(), fn ($key) => ! in_array($key, $customColumns), ARRAY_FILTER_USE_KEY); - // Encode if writing - static::registerAfterListener('saving', [static::class, 'encodeAttributes']); - static::registerAfterListener('creating', [static::class, 'encodeAttributes']); - static::registerAfterListener('updating', [static::class, 'encodeAttributes']); + // Remove data column from the attributes + unset($attributes[$dataColumn]); + + foreach ($attributes as $key => $value) { + // Remove attribute from the model + unset($this->attributes[$key]); + unset($this->original[$key]); + } + + // Add attribute to the data column + $this->setAttribute($dataColumn, $attributes); + + $this->dataEncoded = true; + } + + public function valueEncrypted(string $value): bool + { + try { + Crypt::decryptString($value); + + return true; + } catch (DecryptException) { + return false; + } + } + + protected function decodeAttributes() + { + $this->dataEncoded = true; + + $this->decodeVirtualColumn(); + } + + protected function getAfterListeners(): array + { + return [ + 'retrieved' => [ + function () { + // Always decode after model retrieval + $this->dataEncoded = true; + + $this->decodeVirtualColumn(); + }, + ], + 'saving' => [ + [$this, 'encodeAttributes'], + ], + 'creating' => [ + [$this, 'encodeAttributes'], + ], + 'updating' => [ + [$this, 'encodeAttributes'], + ], + ]; } protected function decodeIfEncoded() { - if ($this->dataEncodingStatus === 'encoded') { - static::decodeVirtualColumn($this); + if ($this->dataEncoded) { + $this->decodeVirtualColumn(); } } @@ -97,7 +145,7 @@ trait VirtualColumn public function runAfterListeners($event, $halt = true) { - $listeners = static::$afterListeners[$event] ?? []; + $listeners = $this->getAfterListeners()[$event] ?? []; if (! $event) { return; @@ -115,27 +163,22 @@ trait VirtualColumn } } - public static function registerAfterListener(string $event, callable $callback) - { - static::$afterListeners[$event][] = $callback; - } - public function getCasts() { return array_merge(parent::getCasts(), [ - static::getDataColumn() => 'array', + $this->getDataColumn() => 'array', ]); } /** * Get the name of the column that stores additional data. */ - public static function getDataColumn(): string + public function getDataColumn(): string { return 'data'; } - public static function getCustomColumns(): array + public function getCustomColumns(): array { return [ 'id', @@ -149,10 +192,10 @@ trait VirtualColumn */ public function getColumnForQuery(string $column): string { - if (in_array($column, static::getCustomColumns(), true)) { + if (in_array($column, $this->getCustomColumns(), true)) { return $column; } - return static::getDataColumn() . '->' . $column; + return $this->getDataColumn() . '->' . $column; } } diff --git a/tests/VirtualColumnTest.php b/tests/VirtualColumnTest.php index 6c1d29c..0d5dd6c 100644 --- a/tests/VirtualColumnTest.php +++ b/tests/VirtualColumnTest.php @@ -2,10 +2,12 @@ namespace Stancl\VirtualColumn\Tests; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\DB; use Orchestra\Testbench\TestCase; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Crypt; +use Illuminate\Database\Eloquent\Model; use Stancl\VirtualColumn\VirtualColumn; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; class VirtualColumnTest extends TestCase { @@ -49,7 +51,7 @@ class VirtualColumnTest extends TestCase $this->assertSame('xyz', $model->getOriginal('abc')); $this->assertSame(null, $model->data); - // Model can be retrieved after update & is structure correctly + // Model can be retrieved after update & is structured correctly $model = MyModel::first(); $this->assertSame('baz', $model->foo); @@ -61,25 +63,25 @@ class VirtualColumnTest extends TestCase public function model_is_always_decoded_when_accessed_by_user_event() { MyModel::retrieved(function (MyModel $model) { - $this->assertSame('decoded', $model->dataEncodingStatus); + $this->assertFalse($model->dataEncoded); }); MyModel::saving(function (MyModel $model) { - $this->assertSame('decoded', $model->dataEncodingStatus); + $this->assertFalse($model->dataEncoded); }); MyModel::updating(function (MyModel $model) { - $this->assertSame('decoded', $model->dataEncodingStatus); + $this->assertFalse($model->dataEncoded); }); MyModel::creating(function (MyModel $model) { - $this->assertSame('decoded', $model->dataEncodingStatus); + $this->assertFalse($model->dataEncoded); }); MyModel::saved(function (MyModel $model) { - $this->assertSame('decoded', $model->dataEncodingStatus); + $this->assertFalse($model->dataEncoded); }); MyModel::updated(function (MyModel $model) { - $this->assertSame('decoded', $model->dataEncodingStatus); + $this->assertFalse($model->dataEncoded); }); MyModel::created(function (MyModel $model) { - $this->assertSame('decoded', $model->dataEncodingStatus); + $this->assertFalse($model->dataEncoded); }); @@ -112,8 +114,9 @@ class VirtualColumnTest extends TestCase // 'foo' is a custom column, 'data' is the virtual column FooChild::create(['foo' => 'foo']); $encodedFoo = DB::select('select * from foo_childs limit 1')[0]; + // Assert that the model was encoded correctly - $this->assertNull($encodedFoo->data); + $this->assertSame($encodedFoo->data, '[]'); $this->assertSame($encodedFoo->foo, 'foo'); // Create another child model of the same parent @@ -121,49 +124,40 @@ class VirtualColumnTest extends TestCase BarChild::create(['bar' => 'bar']); $encodedBar = DB::select('select * from bar_childs limit 1')[0]; - $this->assertNull($encodedBar->data); + $this->assertSame($encodedBar->data, '[]'); $this->assertSame($encodedBar->bar, 'bar'); } // maybe add an explicit test that the saving() and updating() listeners don't run twice? -} -class MyModel extends Model -{ - use VirtualColumn; + /** @test */ + public function encrypted_casts_work_with_virtual_column() { + // Custom encrypted castables have to be specified in the $customEncryptedCastables static property + MyModel::$customEncryptedCastables = [EncryptedCast::class]; - protected $guarded = []; - public $timestamps = false; + /** @var MyModel $model */ + $model = MyModel::create($encryptedAttributes = [ + 'password' => 'foo', // 'encrypted' + 'array' => ['foo', 'bar'], // 'encrypted:array' + 'collection' => collect(['foo', 'bar']), // 'encrypted:collection' + 'json' => json_encode(['foo', 'bar']), // 'encrypted:json' + 'object' => (object) json_encode(['foo', 'bar']), // 'encrypted:object' + 'custom' => 'foo', // Custom castable – 'EncryptedCast::class' + ]); - public static function getCustomColumns(): array - { - return [ - 'id', - 'custom1', - 'custom2', - ]; - } -} + foreach($encryptedAttributes as $key => $expectedValue) { + $savedValue = $model->getAttributes()[$key]; // Encrypted -class FooModel extends Model -{ - use VirtualColumn; + $this->assertTrue($model->valueEncrypted($savedValue)); + $this->assertNotEquals($expectedValue, $savedValue); - protected $guarded = []; - public $timestamps = false; + $retrievedValue = $model->$key; // Decrypted - public static function getCustomColumns(): array - { - return [ - 'id', - 'custom1', - 'custom2', - ]; - } + $this->assertEquals($expectedValue, $retrievedValue); + } - public static function getDataColumn(): string - { - return 'virtual'; + // Reset static property + MyModel::$customEncryptedCastables = []; } } @@ -175,12 +169,53 @@ class ParentModel extends Model protected $guarded = []; } +class MyModel extends ParentModel +{ + public $casts = [ + 'password' => 'encrypted', + 'array' => 'encrypted:array', + 'collection' => 'encrypted:collection', + 'json' => 'encrypted:json', + 'object' => 'encrypted:object', + 'custom' => EncryptedCast::class, + ]; +} + +class FooModel extends ParentModel +{ + public function getCustomColumns(): array + { + return [ + 'id', + 'custom1', + 'custom2', + ]; + } + + public function getDataColumn(): string + { + return 'virtual'; + } +} + +class EncryptedCast implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return Crypt::decryptString($value); + } + + public function set($model, $key, $value, $attributes) + { + return Crypt::encryptString($value); + } +} class FooChild extends ParentModel { public $table = 'foo_childs'; - public static function getCustomColumns(): array + public function getCustomColumns(): array { return [ 'id', @@ -192,7 +227,7 @@ class BarChild extends ParentModel { public $table = 'bar_childs'; - public static function getCustomColumns(): array + public function getCustomColumns(): array { return [ 'id',