Skip to content

Commit 8169db7

Browse files
authored
Merge pull request #265 from RonasIT/263-fix-model-test-state-to-support-custom-eloquent-casts-over-json-columns
[263]: fix `ModelTestState` to support custom Eloquent casts over JSON columns
2 parents c9fdad9 + 3c36461 commit 8169db7

44 files changed

Lines changed: 839 additions & 268 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/Testing/ModelTestState.php

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,89 @@
22

33
namespace RonasIT\Support\Testing;
44

5+
use Illuminate\Contracts\Database\Eloquent\Castable;
56
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
67
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Support\Arr;
9+
use Illuminate\Support\Str;
710

811
class ModelTestState extends TableTestState
912
{
13+
protected const array NATIVE_JSON_CASTS = ['array', 'json', 'object', 'collection'];
14+
15+
protected array $classCastFields = [];
16+
17+
/**
18+
* @param class-string<Model> $modelClassName
19+
*/
1020
public function __construct(string $modelClassName)
1121
{
1222
$model = new $modelClassName();
1323

24+
$this->classCastFields = $this->resolveClassCastFields($model);
25+
1426
parent::__construct(
1527
tableName: $model->getTable(),
16-
jsonFields: $this->getModelJSONFields($model),
17-
connectionName: $model->getConnectionName($model),
28+
jsonFields: $this->resolveNativeJsonFields($model),
29+
connectionName: $model->getConnectionName(),
1830
uniqueKey: $model->getKeyName(),
1931
);
2032
}
2133

22-
protected function getModelJSONFields(Model $model): array
34+
protected function resolveNativeJsonFields(Model $model): array
2335
{
24-
$casts = $model->getCasts();
36+
return $this->getFilteredCasts($model, fn (string $type) => in_array($type, self::NATIVE_JSON_CASTS, true));
37+
}
2538

26-
$jsonCasts = array_filter($casts, fn ($cast) => $this->isJsonCast($cast));
39+
protected function resolveClassCastFields(Model $model): array
40+
{
41+
return $this->getFilteredCasts($model, fn (string $type) => $this->isClassCast($type));
42+
}
43+
44+
protected function isClassCast(string $type): bool
45+
{
46+
return is_subclass_of($type, CastsAttributes::class)
47+
|| is_subclass_of($type, Castable::class);
48+
}
49+
50+
protected function prepareChanges(array $changes): array
51+
{
52+
if (!empty($this->classCastFields)) {
53+
$changes = array_map(fn (array $item) => $this->decodeClassCastFields($item), $changes);
54+
}
55+
56+
return parent::prepareChanges($changes);
57+
}
58+
59+
protected function decodeClassCastFields(array $item): array
60+
{
61+
foreach ($this->classCastFields as $field) {
62+
if (Arr::has($item, $field) && is_string($item[$field])) {
63+
$decoded = json_decode($item[$field], true);
64+
65+
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
66+
$item[$field] = $decoded;
67+
}
68+
}
69+
}
70+
71+
return $item;
72+
}
73+
74+
protected function getFilteredCasts(Model $model, callable $callback): array
75+
{
76+
$filtered = array_filter(
77+
array: $model->getCasts(),
78+
callback: fn (mixed $definition) => $callback($this->resolveCastType($definition)),
79+
);
2780

28-
return array_keys($jsonCasts);
81+
return array_keys($filtered);
2982
}
3083

31-
protected function isJsonCast(string $cast): bool
84+
protected function resolveCastType(mixed $definition): string
3285
{
33-
return ($cast === 'array') || (class_exists($cast) && is_subclass_of($cast, CastsAttributes::class));
86+
return (is_string($definition))
87+
? Str::before($definition, ':')
88+
: $definition::class;
3489
}
3590
}

tests/BaseRequestTest.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public function testGetOrderableFields()
1616
{
1717
$result = $this->callEncapsulatedMethod(new BaseRequest(), 'getOrderableFields', TestModel::class);
1818

19-
$expectedResult = 'id,name,json_field,castable_field,*,created_at,updated_at';
19+
$expectedResult = 'id,name,json_field,custom_cast_field,castable_field,*,created_at,updated_at';
2020

2121
$this->assertEquals($expectedResult, $result);
2222
}
@@ -30,7 +30,7 @@ public function testGetOrderableFieldsWithAdditionalFields()
3030

3131
$result = $this->callEncapsulatedMethod(new BaseRequest(), 'getOrderableFields', ...$args);
3232

33-
$expectedResult = 'id,name,json_field,castable_field,*,created_at,updated_at,additional_field_1,additional_field_2';
33+
$expectedResult = 'id,name,json_field,custom_cast_field,castable_field,*,created_at,updated_at,additional_field_1,additional_field_2';
3434

3535
$this->assertEquals($expectedResult, $result);
3636
}

tests/ModelTestStateTest.php

Lines changed: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
use RonasIT\Support\Testing\ModelTestState;
88
use RonasIT\Support\Tests\Support\Mock\Models\TestModel;
99
use RonasIT\Support\Tests\Support\Mock\Models\TestModelNonIdPrimaryKey;
10-
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithoutJsonFields;
10+
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithCastInstance;
11+
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithCustomJsonCast;
12+
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithCustomNonJsonCast;
13+
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithNativeJsonCasts;
14+
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithPrimitiveCasts;
1115
use RonasIT\Support\Tests\Support\Traits\TableTestStateMockTrait;
1216

1317
class ModelTestStateTest extends TestCase
@@ -23,20 +27,23 @@ public function setUp(): void
2327
putenv('FAIL_EXPORT_JSON=false');
2428
}
2529

26-
public function testInitialization()
30+
public function testInitialization(): void
2731
{
28-
$datasetMock = collect($this->getJsonFixture('initialization/dataset.json'));
29-
$originRecords = collect($this->getJsonFixture('initialization/origin_records.json'));
32+
$datasetMock = collect($this->getJsonFixture('initialization/dataset'));
33+
$originRecords = collect($this->getJsonFixture('initialization/origin_records'));
3034

3135
$this->mockGettingDataset($datasetMock);
3236

3337
$modelTestState = new ModelTestState(TestModel::class);
3438
$reflectionClass = new ReflectionClass($modelTestState);
3539

3640
$jsonFields = $this->getProtectedProperty($reflectionClass, 'jsonFields', $modelTestState);
41+
$classCastFields = $this->getProtectedProperty($reflectionClass, 'classCastFields', $modelTestState);
3742
$state = $this->getProtectedProperty($reflectionClass, 'state', $modelTestState);
3843

39-
$this->assertEquals(['json_field', 'castable_field'], $jsonFields);
44+
$this->assertEquals(['json_field'], $jsonFields);
45+
$this->assertEquals(['custom_cast_field', 'castable_field'], $classCastFields);
46+
4047
$this->assertEquals($originRecords, $state);
4148
}
4249

@@ -53,60 +60,81 @@ public static function getInitializationViaPrepareModelTestStateFilters(): array
5360
}
5461

5562
#[DataProvider('getInitializationViaPrepareModelTestStateFilters')]
56-
public function testInitializationViaPrepareTableTestState(bool $testCaseGlobalExportMode)
63+
public function testInitializationViaPrepareTableTestState(bool $testCaseGlobalExportMode): void
5764
{
58-
$datasetMock = collect($this->getJsonFixture('initialization/dataset.json'));
65+
$datasetMock = collect($this->getJsonFixture('initialization/dataset'));
5966
$this->mockGettingDataset($datasetMock);
6067

6168
$actualGlobalExportModeValue = $this->mockTestStateCreationSetGlobalExportMode('prepareModelTestState', TestModel::class, $testCaseGlobalExportMode);
6269

6370
$this->assertEquals($actualGlobalExportModeValue, $testCaseGlobalExportMode);
6471
}
6572

66-
public function testAssertChangesEqualsFixture()
73+
public static function getAssertChangesData(): array
6774
{
68-
$initialDatasetMock = collect($this->getJsonFixture('changes_equals_fixture/initial_dataset.json'));
69-
$changedDatasetMock = collect($this->getJsonFixture('changes_equals_fixture/changed_dataset.json'));
70-
71-
$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, 'test_models');
72-
73-
$modelTestState = new ModelTestState(TestModel::class);
74-
$modelTestState->assertChangesEqualsFixture('assertion_fixture.json');
75+
return [
76+
'mixed native, custom and castable casts' => [
77+
'fixtureDir' => 'changes_equals_fixture',
78+
'table' => 'test_models',
79+
'modelClass' => TestModel::class,
80+
],
81+
'cast defined as an instance' => [
82+
'fixtureDir' => 'changes_equals_fixture_with_cast_instance',
83+
'table' => 'test_model_with_cast_instances',
84+
'modelClass' => TestModelWithCastInstance::class,
85+
],
86+
'primitive casts' => [
87+
'fixtureDir' => 'changes_equals_fixture_with_primitive_casts',
88+
'table' => 'test_model_with_primitive_casts',
89+
'modelClass' => TestModelWithPrimitiveCasts::class,
90+
],
91+
'native json casts' => [
92+
'fixtureDir' => 'changes_equals_fixture_with_native_json_casts',
93+
'table' => 'test_model_with_native_json_casts',
94+
'modelClass' => TestModelWithNativeJsonCasts::class,
95+
],
96+
'custom json cast' => [
97+
'fixtureDir' => 'changes_equals_fixture_with_custom_json_cast',
98+
'table' => 'test_model_with_custom_json_casts',
99+
'modelClass' => TestModelWithCustomJsonCast::class,
100+
],
101+
'custom non-json cast' => [
102+
'fixtureDir' => 'changes_equals_fixture_with_custom_non_json_cast',
103+
'table' => 'test_model_with_custom_non_json_casts',
104+
'modelClass' => TestModelWithCustomNonJsonCast::class,
105+
],
106+
'custom primary key' => [
107+
'fixtureDir' => 'changes_equals_fixture_primary_key',
108+
'table' => 'test_model_non_id_primary_keys',
109+
'modelClass' => TestModelNonIdPrimaryKey::class,
110+
'uniqueKey' => 'name',
111+
],
112+
];
75113
}
76114

77-
public function testAssertChangesWithoutJsonFields()
78-
{
79-
$initialDatasetMock = collect(
80-
value: $this->getJsonFixture('changes_equals_fixture_without_json_fields/initial_dataset.json'),
81-
);
82-
$changedDatasetMock = collect(
83-
value: $this->getJsonFixture('changes_equals_fixture_without_json_fields/changed_dataset.json'),
84-
);
115+
#[DataProvider('getAssertChangesData')]
116+
public function testAssertChanges(
117+
string $fixtureDir,
118+
string $table,
119+
string $modelClass,
120+
string $uniqueKey = 'id',
121+
): void {
122+
$initialDatasetMock = collect($this->getJsonFixture("{$fixtureDir}/initial_dataset"));
123+
$changedDatasetMock = collect($this->getJsonFixture("{$fixtureDir}/changed_dataset"));
85124

86-
$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, 'test_model_without_json_fields');
125+
$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, $table, $uniqueKey);
87126

88-
$modelTestState = new ModelTestState(TestModelWithoutJsonFields::class);
89-
$modelTestState->assertChangesEqualsFixture('assertion_fixture_without_json_fields.json');
127+
$modelTestState = new ModelTestState($modelClass);
128+
$modelTestState->assertChangesEqualsFixture('assertion_fixture');
90129
}
91130

92-
public function testAssertNoChanges()
131+
public function testAssertNoChanges(): void
93132
{
94-
$datasetMock = collect($this->getJsonFixture('get_without_changes/dataset.json'));
133+
$datasetMock = collect($this->getJsonFixture('get_without_changes/dataset'));
95134

96135
$this->mockGettingDatasetForChanges($datasetMock, $datasetMock, 'test_models');
97136

98137
$modelTestState = new ModelTestState(TestModel::class);
99138
$modelTestState->assertNotChanged();
100139
}
101-
102-
public function testAssertChangesWithCustomPrimaryKey()
103-
{
104-
$initialDatasetMock = collect($this->getJsonFixture('changes_equals_fixture_primary_key/initial_dataset'));
105-
$changedDatasetMock = collect($this->getJsonFixture('changes_equals_fixture_primary_key/changed_dataset'));
106-
107-
$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, 'test_model_non_id_primary_keys', 'name');
108-
109-
$modelTestState = new ModelTestState(TestModelNonIdPrimaryKey::class);
110-
$modelTestState->assertChangesEqualsFixture('assertion_fixture_primary_key');
111-
}
112140
}

tests/fixtures/ModelTestStateTest/changes_equals_fixture/changed_dataset.json

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,36 @@
22
{
33
"id": 1,
44
"name": "name1 updated",
5-
"json_field": "{\"field1\": \"value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
6-
"castable_field": "{\"field1\": \"value 1\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
5+
"json_field": "{\"field1\": \"updated value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
6+
"custom_cast_field": "{\"one\": \"first value updated\", \"two\": \"second value\"}",
7+
"castable_field": "{\"theme\": \"dark\"}",
78
"created_at": "2018-10-10 10:10:10",
89
"updated_at": "2018-10-10 10:10:10"
910
},
1011
{
1112
"id": 2,
1213
"name": "name2",
1314
"json_field": "{}",
14-
"castable_field": "{\"one\": \"first value updated\", \"two\": \"second value\"}",
15+
"custom_cast_field": "{}",
16+
"castable_field": "{\"theme\": \"light\"}",
1517
"created_at": "2018-10-10 10:10:10",
1618
"updated_at": "2018-10-10 10:10:10"
1719
},
1820
{
19-
"id": 3,
20-
"name": "name3",
21+
"id": 4,
22+
"name": "name4",
23+
"json_field": "[1, 2]",
24+
"custom_cast_field": "not a json",
2125
"castable_field": "{}",
22-
"json_field": "[2, \"two\"]",
2326
"created_at": "2018-10-10 10:10:10",
2427
"updated_at": "2018-10-10 10:10:10"
2528
},
2629
{
27-
"id": 4,
28-
"name": "name4",
29-
"castable_field": "{}",
30+
"id": 5,
31+
"name": "name5",
3032
"json_field": "{}",
33+
"custom_cast_field": "{}",
34+
"castable_field": "{}",
3135
"created_at": "2018-10-10 10:10:10",
3236
"updated_at": "2018-10-10 10:10:10"
3337
}

tests/fixtures/ModelTestStateTest/changes_equals_fixture/initial_dataset.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,36 @@
22
{
33
"id": 1,
44
"name": "name1",
5-
"json_field": "{\"field1\": \"updated value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
6-
"castable_field": "{\"field1\": \"updated value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
5+
"json_field": "{\"field1\": \"value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
6+
"custom_cast_field": "{\"one\": \"first value\", \"two\": \"second value\"}",
7+
"castable_field": "{\"theme\": \"dark\"}",
78
"created_at": "2018-10-10 10:10:10",
89
"updated_at": "2018-10-10 10:10:10"
910
},
1011
{
1112
"id": 2,
1213
"name": "name2",
1314
"json_field": "{}",
14-
"castable_field": "{\"one\": \"first value\", \"two\": \"second value\"}",
15+
"custom_cast_field": "{}",
16+
"castable_field": "{\"theme\": \"dark\"}",
1517
"created_at": "2018-10-10 10:10:10",
1618
"updated_at": "2018-10-10 10:10:10"
1719
},
1820
{
1921
"id": 3,
2022
"name": "name3",
21-
"castable_field": "{}",
2223
"json_field": "[2, \"two\"]",
24+
"custom_cast_field": "plain text",
25+
"castable_field": "{}",
2326
"created_at": "2018-10-10 10:10:10",
2427
"updated_at": "2018-10-10 10:10:10"
2528
},
2629
{
2730
"id": 5,
2831
"name": "name5",
29-
"castable_field": "{}",
3032
"json_field": "{}",
33+
"custom_cast_field": "{}",
34+
"castable_field": "{}",
3135
"created_at": "2018-10-10 10:10:10",
3236
"updated_at": "2018-10-10 10:10:10"
3337
}

tests/fixtures/ModelTestStateTest/changes_equals_fixture_primary_key/changed_dataset.json

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,19 @@
11
[
22
{
33
"name": "name1",
4-
"json_field": "{\"field1\": \"updated_value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
5-
"castable_field": "{\"field1\": \"value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
4+
"value": "alpha",
65
"created_at": "2018-10-10 10:10:10",
76
"updated_at": "2018-10-10 10:10:10"
87
},
98
{
109
"name": "name2",
11-
"json_field": "{\"field1\": \"added_value\"}",
12-
"castable_field": "{\"one\": \"first value\", \"two\": \"second value\"}",
10+
"value": "beta_updated",
1311
"created_at": "2018-10-10 10:10:10",
1412
"updated_at": "2018-10-10 10:10:10"
1513
},
1614
{
1715
"name": "name4",
18-
"castable_field": "{}",
19-
"json_field": "{}",
20-
"created_at": "2018-10-10 10:10:10",
21-
"updated_at": "2018-10-10 10:10:10"
22-
},
23-
{
24-
"name": "name5",
25-
"castable_field": "{}",
26-
"json_field": "{}",
16+
"value": "delta",
2717
"created_at": "2018-10-10 10:10:10",
2818
"updated_at": "2018-10-10 10:10:10"
2919
}

0 commit comments

Comments
 (0)