Skip to content

Commit 72de33a

Browse files
lukinovecstancl
andauthored
Add encrypted casts support + allow using the trait on multiple models (#14)
* Add encrypted casts test (wip) * Handle and test 'encrypted' casts * Add APP_KEY to phpunit.xml * Update attribute casting in VirtualColumn * Test casting of all default 'encrypted' castables * Fix code style (php-cs-fixer) * Handle custom castables in VirtualColumn * Add custom encrypted castable * Test custom encrypted castable, refactor test * Move EncryptedCast class to VirtualColumnTest * Correct expected/actual value order in assertions * Break code style (testing) * Fix code style (php-cs-fixer) * Check Laravel CI version (testing) * dd() Laravel version * Delete dd() * Delete get() and set() types * Use non-lowercase custom cast class strings * Check hasCast manually * Correct encrypted castable logic * Update src/VirtualColumn.php * Use `$dataEncoded` bool instead of `$dataEncodingStatus` string * Don't accept unused `$e` * Refactor `encodeAttributes()` * Use `$model->getCustomColumns()` instead of `static::getCustomColumns()` * Use `$model` instead of `static` where possible * Correct test * Revert `static` -> `$model` changes * Correct typo * Refactor `$afterListeneres` * Fix code style (php-cs-fixer) * Make static things non-static in VirtualColumn * Change method to non-static in test * Add base class that uses VirtualColumn in tests * Add encrypted castables docblock * Fix merge * Fix ParentModel change * make $this and $model use clear and consistent --------- Co-authored-by: Samuel Štancl <[email protected]> Co-authored-by: Samuel Štancl <[email protected]>
1 parent 925249b commit 72de33a

File tree

4 files changed

+199
-91
lines changed

4 files changed

+199
-91
lines changed

phpunit.xml

+11-9
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd">
3-
<coverage>
4-
<include>
5-
<directory suffix=".php">./src</directory>
6-
</include>
7-
<exclude>
8-
<file>./src/routes.php</file>
9-
</exclude>
10-
</coverage>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.4/phpunit.xsd">
3+
<coverage/>
114
<testsuites>
125
<testsuite name="Unit">
136
<directory suffix="Test.php">./tests</directory>
147
</testsuite>
158
</testsuites>
169
<php>
1710
<env name="APP_ENV" value="testing"/>
11+
<env name="APP_KEY" value="base64:+osRhaqQtOcYM79fhVU8YdNBs/1iVJPWYUr9zvTPCs0="/>
1812
<env name="BCRYPT_ROUNDS" value="4"/>
1913
<env name="CACHE_DRIVER" value="redis"/>
2014
<env name="MAIL_DRIVER" value="array"/>
@@ -24,4 +18,12 @@
2418
<env name="DB_DATABASE" value=":memory:"/>
2519
<env name="AWS_DEFAULT_REGION" value="us-west-2"/>
2620
</php>
21+
<source>
22+
<include>
23+
<directory suffix=".php">./src</directory>
24+
</include>
25+
<exclude>
26+
<file>./src/routes.php</file>
27+
</exclude>
28+
</source>
2729
</phpunit>

phpunit.xml.bak

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" backupGlobals="false" bootstrap="vendor/autoload.php" colors="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd">
3+
<coverage>
4+
<include>
5+
<directory suffix=".php">./src</directory>
6+
</include>
7+
<exclude>
8+
<file>./src/routes.php</file>
9+
</exclude>
10+
</coverage>
11+
<testsuites>
12+
<testsuite name="Unit">
13+
<directory suffix="Test.php">./tests</directory>
14+
</testsuite>
15+
</testsuites>
16+
<php>
17+
<env name="APP_ENV" value="testing"/>
18+
<env name="APP_KEY" value="base64:+osRhaqQtOcYM79fhVU8YdNBs/1iVJPWYUr9zvTPCs0="/>
19+
<env name="BCRYPT_ROUNDS" value="4"/>
20+
<env name="CACHE_DRIVER" value="redis"/>
21+
<env name="MAIL_DRIVER" value="array"/>
22+
<env name="QUEUE_CONNECTION" value="sync"/>
23+
<env name="SESSION_DRIVER" value="array"/>
24+
<env name="DB_CONNECTION" value="sqlite"/>
25+
<env name="DB_DATABASE" value=":memory:"/>
26+
<env name="AWS_DEFAULT_REGION" value="us-west-2"/>
27+
</php>
28+
</phpunit>

src/VirtualColumn.php

+89-46
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
namespace Stancl\VirtualColumn;
66

7+
use Illuminate\Contracts\Encryption\DecryptException;
8+
use Illuminate\Support\Facades\Crypt;
9+
710
/**
811
* This trait lets you add a "data" column functionality to any Eloquent model.
912
* It serializes attributes which don't exist as columns on the model's table
@@ -13,74 +16,119 @@
1316
*/
1417
trait VirtualColumn
1518
{
16-
public static $afterListeners = [];
19+
/**
20+
* Encrypted castables have to be handled using a special approach that prevents the data from getting encrypted repeatedly.
21+
*
22+
* The default encrypted castables ('encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object')
23+
* are already handled, so you can use this array to add your own encrypted castables.
24+
*/
25+
public static array $customEncryptedCastables = [];
1726

1827
/**
1928
* We need this property, because both created & saved event listeners
2029
* decode the data (to take precedence before other created & saved)
2130
* listeners, but we don't want the data to be decoded twice.
22-
*
23-
* @var string
2431
*/
25-
public $dataEncodingStatus = 'decoded';
32+
public bool $dataEncoded = false;
2633

27-
protected static function decodeVirtualColumn(self $model): void
34+
protected function decodeVirtualColumn(): void
2835
{
29-
if ($model->dataEncodingStatus === 'decoded') {
36+
if (! $this->dataEncoded) {
3037
return;
3138
}
3239

33-
foreach ($model->getAttribute(static::getDataColumn()) ?? [] as $key => $value) {
34-
$model->setAttribute($key, $value);
35-
$model->syncOriginalAttribute($key);
40+
$encryptedCastables = array_merge(
41+
static::$customEncryptedCastables,
42+
['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object'], // Default encrypted castables
43+
);
44+
45+
foreach ($this->getAttribute($this->getDataColumn()) ?? [] as $key => $value) {
46+
$attributeHasEncryptedCastable = in_array(data_get($this->getCasts(), $key), $encryptedCastables);
47+
48+
if ($attributeHasEncryptedCastable && $this->valueEncrypted($value)) {
49+
$this->attributes[$key] = $value;
50+
} else {
51+
$this->setAttribute($key, $value);
52+
}
53+
54+
$this->syncOriginalAttribute($key);
3655
}
3756

38-
$model->setAttribute(static::getDataColumn(), null);
57+
$this->setAttribute($this->getDataColumn(), null);
3958

40-
$model->dataEncodingStatus = 'decoded';
59+
$this->dataEncoded = false;
4160
}
4261

43-
protected static function encodeAttributes(self $model): void
62+
protected function encodeAttributes(): void
4463
{
45-
if ($model->dataEncodingStatus === 'encoded') {
64+
if ($this->dataEncoded) {
4665
return;
4766
}
4867

49-
foreach ($model->getAttributes() as $key => $value) {
50-
if (! in_array($key, static::getCustomColumns())) {
51-
$current = $model->getAttribute(static::getDataColumn()) ?? [];
68+
$dataColumn = $this->getDataColumn();
69+
$customColumns = $this->getCustomColumns();
70+
$attributes = array_filter($this->getAttributes(), fn ($key) => ! in_array($key, $customColumns), ARRAY_FILTER_USE_KEY);
5271

53-
$model->setAttribute(static::getDataColumn(), array_merge($current, [
54-
$key => $value,
55-
]));
72+
// Remove data column from the attributes
73+
unset($attributes[$dataColumn]);
5674

57-
unset($model->attributes[$key]);
58-
unset($model->original[$key]);
59-
}
75+
foreach ($attributes as $key => $value) {
76+
// Remove attribute from the model
77+
unset($this->attributes[$key]);
78+
unset($this->original[$key]);
6079
}
6180

62-
$model->dataEncodingStatus = 'encoded';
81+
// Add attribute to the data column
82+
$this->setAttribute($dataColumn, $attributes);
83+
84+
$this->dataEncoded = true;
6385
}
6486

65-
public static function bootVirtualColumn()
87+
public function valueEncrypted(string $value): bool
6688
{
67-
static::registerAfterListener('retrieved', function ($model) {
68-
// We always decode after model retrieval.
69-
$model->dataEncodingStatus = 'encoded';
89+
try {
90+
Crypt::decryptString($value);
7091

71-
static::decodeVirtualColumn($model);
72-
});
92+
return true;
93+
} catch (DecryptException) {
94+
return false;
95+
}
96+
}
97+
98+
protected function decodeAttributes()
99+
{
100+
$this->dataEncoded = true;
101+
102+
$this->decodeVirtualColumn();
103+
}
73104

74-
// Encode if writing
75-
static::registerAfterListener('saving', [static::class, 'encodeAttributes']);
76-
static::registerAfterListener('creating', [static::class, 'encodeAttributes']);
77-
static::registerAfterListener('updating', [static::class, 'encodeAttributes']);
105+
protected function getAfterListeners(): array
106+
{
107+
return [
108+
'retrieved' => [
109+
function () {
110+
// Always decode after model retrieval
111+
$this->dataEncoded = true;
112+
113+
$this->decodeVirtualColumn();
114+
},
115+
],
116+
'saving' => [
117+
[$this, 'encodeAttributes'],
118+
],
119+
'creating' => [
120+
[$this, 'encodeAttributes'],
121+
],
122+
'updating' => [
123+
[$this, 'encodeAttributes'],
124+
],
125+
];
78126
}
79127

80128
protected function decodeIfEncoded()
81129
{
82-
if ($this->dataEncodingStatus === 'encoded') {
83-
static::decodeVirtualColumn($this);
130+
if ($this->dataEncoded) {
131+
$this->decodeVirtualColumn();
84132
}
85133
}
86134

@@ -97,7 +145,7 @@ protected function fireModelEvent($event, $halt = true)
97145

98146
public function runAfterListeners($event, $halt = true)
99147
{
100-
$listeners = static::$afterListeners[$event] ?? [];
148+
$listeners = $this->getAfterListeners()[$event] ?? [];
101149

102150
if (! $event) {
103151
return;
@@ -115,27 +163,22 @@ public function runAfterListeners($event, $halt = true)
115163
}
116164
}
117165

118-
public static function registerAfterListener(string $event, callable $callback)
119-
{
120-
static::$afterListeners[$event][] = $callback;
121-
}
122-
123166
public function getCasts()
124167
{
125168
return array_merge(parent::getCasts(), [
126-
static::getDataColumn() => 'array',
169+
$this->getDataColumn() => 'array',
127170
]);
128171
}
129172

130173
/**
131174
* Get the name of the column that stores additional data.
132175
*/
133-
public static function getDataColumn(): string
176+
public function getDataColumn(): string
134177
{
135178
return 'data';
136179
}
137180

138-
public static function getCustomColumns(): array
181+
public function getCustomColumns(): array
139182
{
140183
return [
141184
'id',
@@ -149,10 +192,10 @@ public static function getCustomColumns(): array
149192
*/
150193
public function getColumnForQuery(string $column): string
151194
{
152-
if (in_array($column, static::getCustomColumns(), true)) {
195+
if (in_array($column, $this->getCustomColumns(), true)) {
153196
return $column;
154197
}
155198

156-
return static::getDataColumn() . '->' . $column;
199+
return $this->getDataColumn() . '->' . $column;
157200
}
158201
}

0 commit comments

Comments
 (0)