Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
22c9d7f
feat: extend ModelTestState class to differentiate between native JSO…
vitgrams Mar 20, 2026
2c0f541
test: update JSONCustomCast with more complex structure to check cast…
vitgrams Mar 20, 2026
240637a
test: add test cases to check native/custom JSON casts
vitgrams Mar 24, 2026
0356a54
test: minor renaming for better clarity
vitgrams Mar 24, 2026
075f157
test: fix broken tests
vitgrams Mar 24, 2026
d64496a
refactor: remove unused arg
vitgrams Mar 24, 2026
6b1b92a
fix: pass model instance to casts get() method to prevent incompatibl…
vitgrams Mar 25, 2026
19b6717
fix: use setRawAttributes() to fill model before cast to ignore mutators
vitgrams Mar 25, 2026
f3f6048
test: add test case for casts using another model attribute
vitgrams Mar 25, 2026
0b09513
refactor: separate cast testing concerns into dedicated TestModelWith…
vitgrams Mar 25, 2026
3af9add
test: fix broken test fixture after rebase on master
vitgrams Apr 21, 2026
4422f48
fix: merge original record into partial updates before applying custo…
vitgrams Apr 21, 2026
a93a96b
fix: run custom casts before native JSON decoding to preserve raw att…
vitgrams Apr 21, 2026
42de2c8
test: rename test and mocks for better clarity
vitgrams Apr 21, 2026
289c6b7
Merge branch 'master' into 263-fix-model-test-state-to-support-custom…
yburlakov Apr 27, 2026
6813e61
Merge branch 'master' into 263-fix-model-test-state-to-support-custom…
DenTray May 14, 2026
1d247d5
feat: support parameterized custom casts
vitgrams May 14, 2026
28d4337
refactor: adjust methods order, args naming
vitgrams May 14, 2026
3a99466
refactor: extract model instance as class property
vitgrams May 14, 2026
f94fc40
Merge branch 'master' into 263-fix-model-test-state-to-support-custom…
yburlakov May 21, 2026
28ee396
refactor: minor refactoring changes
vitgrams May 29, 2026
b02d347
feat: support Castable interface in custom cast resolution
vitgrams May 29, 2026
095e3e1
test: improve cast mocks naming
vitgrams May 29, 2026
88d2de3
feat: support other cast types - primitives, castables and etc
vitgrams May 29, 2026
0b16c8e
test: improve tests - model mocks/casts
vitgrams May 29, 2026
a280046
refactor: remove redundant code
vitgrams May 30, 2026
1d2ac74
test: consolidate common tests with data provider
vitgrams May 30, 2026
0c21109
test: make model mocks more targetable
vitgrams May 30, 2026
5403811
Merge branch 'master' into 263-fix-model-test-state-to-support-custom…
DenTray Jun 15, 2026
d753cc4
Apply suggestion from @DenTray
DenTray Jun 15, 2026
cdc6d97
Apply suggestion from @DenTray
DenTray Jun 15, 2026
e56fb4f
fix: clone model instance in applyCasts to avoid shared mutable state
DenTray Jun 15, 2026
af43832
Merge branch 'master' into 263-fix-model-test-state-to-support-custom…
DenTray Jun 15, 2026
3408629
feat: decode only JSON-casted fields in fixtures; keep all other cast…
vitgrams Jun 16, 2026
8733454
feat: decode native JSON casts by definition, class casts if JSON-backed
vitgrams Jun 16, 2026
120127f
refactor: minor refactoring
vitgrams Jun 16, 2026
0f4ed1d
refactor: minor refactoring changes
vitgrams Jun 16, 2026
029980a
refactor: move common filter logic to getCastFieldsMatching
vitgrams Jun 18, 2026
2232ded
feat: decode JSON cast fields via json_decode instead of getAttribute
vitgrams Jun 18, 2026
2fc683c
fix: adjust casts to property to support laravel-11
vitgrams Jun 18, 2026
1d6ca46
refactor: fix code style
vitgrams Jun 18, 2026
d6523be
feat: decode class-cast JSON fields to arrays when preparing fixture …
vitgrams Jun 18, 2026
38c1dbd
test: cover non-json custom cast case with test
vitgrams Jun 19, 2026
6cfa25a
Merge branch 'master' into 263-fix-model-test-state-to-support-custom…
DenTray Aug 26, 2026
08f0169
fix: support cast definitions given as instances in ModelTestState
DenTray Aug 26, 2026
069eca0
fix: declare the mock cast instance as Stringable
DenTray Aug 26, 2026
3c36461
refactor: rename getCastFieldsMatching to getFilteredCasts
DenTray Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions src/Testing/ModelTestState.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,63 @@

namespace RonasIT\Support\Testing;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;

class ModelTestState extends TableTestState
{
protected Model $model;

protected array $castFields;

/**
* @param class-string<Model> $modelClassName
*/
public function __construct(string $modelClassName)
{
$model = new $modelClassName();
$this->model = new $modelClassName();

parent::__construct(
tableName: $model->getTable(),
jsonFields: $this->getModelJSONFields($model),
connectionName: $model->getConnectionName($model),
uniqueKey: $model->getKeyName(),
tableName: $this->model->getTable(),
connectionName: $this->model->getConnectionName(),
uniqueKey: $this->model->getKeyName(),
);

$this->castFields = array_keys($this->model->getCasts());
}

protected function prepareChanges(array $changes): array
{
if (empty($this->castFields)) {
return $changes;
}

return array_map(fn (array $item) => $this->applyCasts($item), $changes);
}

protected function getModelJSONFields(Model $model): array
protected function applyCasts(array $item): array
{
$casts = $model->getCasts();
$attributes = $this->resolveModelAttributes($item);

$this->model->setRawAttributes($attributes);

$jsonCasts = array_filter($casts, fn ($cast) => $this->isJsonCast($cast));
foreach ($this->castFields as $field) {
if (Arr::has($item, $field)) {
$item[$field] = $this->model->getAttribute($field);
}
}

return array_keys($jsonCasts);
return $item;
}

protected function isJsonCast(string $cast): bool
protected function resolveModelAttributes(array $item): array
{
return ($cast === 'array') || (class_exists($cast) && is_subclass_of($cast, CastsAttributes::class));
$original = $this->state->first(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DenTray @yburlakov We can convert the state to a HashMap to improve lookup speed from O(n) to O(1). Should we include this in the current MR?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vitgrams I think it makes sence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yburlakov Let me do this in the next MR as an optimization/refactoring

callback: fn (array $record) => $record[$this->uniqueKey] === $item[$this->uniqueKey],
);
Comment thread
DenTray marked this conversation as resolved.
Outdated

return is_null($original)
Comment thread
DenTray marked this conversation as resolved.
Outdated
? $item
: array_merge($original, $item);
}
}
2 changes: 1 addition & 1 deletion src/Testing/TableTestState.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public function assertNotChanged(): void

public function assertChangesEqualsFixture(string $fixture, bool $exportMode = false): void
{
$changes = $this->getChanges();
$changes = json_decode(json_encode($this->getChanges()), true);

$this->assertEqualsFixture($fixture, $changes, $exportMode);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/BaseRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function testGetOrderableFields()
{
$result = $this->callEncapsulatedMethod(new BaseRequest(), 'getOrderableFields', TestModel::class);

$expectedResult = 'id,name,json_field,castable_field,*,created_at,updated_at';
$expectedResult = 'id,name,settings,*,created_at,updated_at';

$this->assertEquals($expectedResult, $result);
}
Expand All @@ -30,7 +30,7 @@ public function testGetOrderableFieldsWithAdditionalFields()

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

$expectedResult = 'id,name,json_field,castable_field,*,created_at,updated_at,additional_field_1,additional_field_2';
$expectedResult = 'id,name,settings,*,created_at,updated_at,additional_field_1,additional_field_2';

$this->assertEquals($expectedResult, $result);
}
Expand Down
112 changes: 72 additions & 40 deletions tests/ModelTestStateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
use RonasIT\Support\Testing\ModelTestState;
use RonasIT\Support\Tests\Support\Mock\Models\TestModel;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelNonIdPrimaryKey;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithoutJsonFields;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithCastable;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithCrossAttributeCast;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithCustomCast;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithNativeJsonCasts;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithParameterizedCast;
use RonasIT\Support\Tests\Support\Mock\Models\TestModelWithPrimitiveCasts;
use RonasIT\Support\Tests\Support\Traits\TableTestStateMockTrait;

class ModelTestStateTest extends TestCase
Expand All @@ -23,20 +28,21 @@ public function setUp(): void
putenv('FAIL_EXPORT_JSON=false');
}

public function testInitialization()
public function testInitialization(): void
{
$datasetMock = collect($this->getJsonFixture('initialization/dataset.json'));
$originRecords = collect($this->getJsonFixture('initialization/origin_records.json'));
$datasetMock = collect($this->getJsonFixture('initialization/dataset'));
$originRecords = collect($this->getJsonFixture('initialization/origin_records'));

$this->mockGettingDataset($datasetMock);

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

$jsonFields = $this->getProtectedProperty($reflectionClass, 'jsonFields', $modelTestState);
$customCastFields = $this->getProtectedProperty($reflectionClass, 'castFields', $modelTestState);
$state = $this->getProtectedProperty($reflectionClass, 'state', $modelTestState);

$this->assertEquals(['json_field', 'castable_field'], $jsonFields);
$this->assertEquals(['id', 'settings', 'deleted_at'], $customCastFields);

$this->assertEquals($originRecords, $state);
}

Expand All @@ -53,60 +59,86 @@ public static function getInitializationViaPrepareModelTestStateFilters(): array
}

#[DataProvider('getInitializationViaPrepareModelTestStateFilters')]
public function testInitializationViaPrepareTableTestState(bool $testCaseGlobalExportMode)
public function testInitializationViaPrepareTableTestState(bool $testCaseGlobalExportMode): void
{
$datasetMock = collect($this->getJsonFixture('initialization/dataset.json'));
$datasetMock = collect($this->getJsonFixture('initialization/dataset'));
$this->mockGettingDataset($datasetMock);

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

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

public function testAssertChangesEqualsFixture()
public static function getChangeScenarios(): array
{
$initialDatasetMock = collect($this->getJsonFixture('changes_equals_fixture/initial_dataset.json'));
$changedDatasetMock = collect($this->getJsonFixture('changes_equals_fixture/changed_dataset.json'));

$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, 'test_models');

$modelTestState = new ModelTestState(TestModel::class);
$modelTestState->assertChangesEqualsFixture('assertion_fixture.json');
return [
'base' => [
'fixtureDir' => 'changes_equals_fixture',
'table' => 'test_models',
'modelClass' => TestModel::class,
],
'primitive casts' => [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping the TestModel case removed the only coverage where a native array cast, a CastsAttributes cast and a Castable cast coexist on one model — the exact scenario #263 is about.

Fixed on the branch: mixed native, custom and castable casts data set restored, together with changes_equals_fixture/* and db_changes/test_models/assertion_fixture.json. The fixtures also pin that a non-JSON value under a class cast stays a raw string.

'fixtureDir' => 'changes_equals_fixture_with_primitive_casts',
'table' => 'test_model_with_primitive_casts',
'modelClass' => TestModelWithPrimitiveCasts::class,
],
'native json casts' => [
'fixtureDir' => 'changes_equals_fixture_with_native_json_casts',
'table' => 'test_model_with_native_json_casts',
'modelClass' => TestModelWithNativeJsonCasts::class,
],
'custom cast' => [
'fixtureDir' => 'changes_equals_fixture_with_custom_cast',
'table' => 'test_model_with_custom_casts',
'modelClass' => TestModelWithCustomCast::class,
],
'parameterized cast' => [
'fixtureDir' => 'changes_equals_fixture_with_parameterized_cast',
'table' => 'test_model_with_parameterized_casts',
'modelClass' => TestModelWithParameterizedCast::class,
],
'castable' => [
'fixtureDir' => 'changes_equals_fixture_with_castable',
'table' => 'test_model_with_castables',
'modelClass' => TestModelWithCastable::class,
],
'cross attribute cast' => [
'fixtureDir' => 'changes_equals_fixture_with_cross_attribute_cast',
'table' => 'test_model_with_cross_attribute_casts',
'modelClass' => TestModelWithCrossAttributeCast::class,
],
'custom primary key' => [
'fixtureDir' => 'changes_equals_fixture_primary_key',
'table' => 'test_model_non_id_primary_keys',
'modelClass' => TestModelNonIdPrimaryKey::class,
'uniqueKey' => 'name',
],
];
}

public function testAssertChangesWithoutJsonFields()
{
$initialDatasetMock = collect(
value: $this->getJsonFixture('changes_equals_fixture_without_json_fields/initial_dataset.json'),
);
$changedDatasetMock = collect(
value: $this->getJsonFixture('changes_equals_fixture_without_json_fields/changed_dataset.json'),
);
#[DataProvider('getChangeScenarios')]
public function testAssertChanges(
string $fixtureDir,
string $table,
string $modelClass,
string $uniqueKey = 'id',
): void {
$initialDatasetMock = collect($this->getJsonFixture("{$fixtureDir}/initial_dataset"));
$changedDatasetMock = collect($this->getJsonFixture("{$fixtureDir}/changed_dataset"));

$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, 'test_model_without_json_fields');
$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, $table, $uniqueKey);

$modelTestState = new ModelTestState(TestModelWithoutJsonFields::class);
$modelTestState->assertChangesEqualsFixture('assertion_fixture_without_json_fields.json');
$modelTestState = new ModelTestState($modelClass);
$modelTestState->assertChangesEqualsFixture('assertion_fixture');
}

public function testAssertNoChanges()
public function testAssertNoChanges(): void
{
$datasetMock = collect($this->getJsonFixture('get_without_changes/dataset.json'));
$datasetMock = collect($this->getJsonFixture('get_without_changes/dataset'));

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

$modelTestState = new ModelTestState(TestModel::class);
$modelTestState->assertNotChanged();
}

public function testAssertChangesWithCustomPrimaryKey()
{
$initialDatasetMock = collect($this->getJsonFixture('changes_equals_fixture_primary_key/initial_dataset'));
$changedDatasetMock = collect($this->getJsonFixture('changes_equals_fixture_primary_key/changed_dataset'));

$this->mockGettingDatasetForChanges($changedDatasetMock, $initialDatasetMock, 'test_model_non_id_primary_keys', 'name');

$modelTestState = new ModelTestState(TestModelNonIdPrimaryKey::class);
$modelTestState->assertChangesEqualsFixture('assertion_fixture_primary_key');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,24 @@
{
"id": 1,
"name": "name1 updated",
"json_field": "{\"field1\": \"value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"castable_field": "{\"field1\": \"value 1\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"id": 2,
"name": "name2",
"json_field": "{}",
"castable_field": "{\"one\": \"first value updated\", \"two\": \"second value\"}",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"id": 3,
"name": "name3",
"castable_field": "{}",
"json_field": "[2, \"two\"]",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"id": 4,
"name": "name4",
"castable_field": "{}",
"json_field": "{}",
"name": "name44",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,24 @@
{
"id": 1,
"name": "name1",
"json_field": "{\"field1\": \"updated value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"castable_field": "{\"field1\": \"updated value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"id": 2,
"name": "name2",
"json_field": "{}",
"castable_field": "{\"one\": \"first value\", \"two\": \"second value\"}",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"id": 3,
"name": "name3",
"castable_field": "{}",
"json_field": "[2, \"two\"]",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"id": 5,
"name": "name5",
"castable_field": "{}",
"json_field": "{}",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
[
{
"name": "name1",
"json_field": "{\"field1\": \"updated_value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"castable_field": "{\"field1\": \"value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"value": "alpha",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"name": "name2",
"json_field": "{\"field1\": \"added_value\"}",
"castable_field": "{\"one\": \"first value\", \"two\": \"second value\"}",
"value": "beta_updated",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"name": "name4",
"castable_field": "{}",
"json_field": "{}",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"name": "name5",
"castable_field": "{}",
"json_field": "{}",
"value": "delta",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
[
{
"name": "name1",
"json_field": "{\"field1\": \"value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"castable_field": "{\"field1\": \"value\", \"field2\": [2, 3], \"field3\": {\"one\": 1, \"two\": 2}}",
"value": "alpha",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"name": "name2",
"json_field": "{}",
"castable_field": "{\"one\": \"first value\", \"two\": \"second value\"}",
"value": "beta",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"name": "name3",
"castable_field": "{}",
"json_field": "[2, \"two\"]",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
},
{
"name": "name4",
"castable_field": "{}",
"json_field": "{}",
"value": "gamma",
"created_at": "2018-10-10 10:10:10",
"updated_at": "2018-10-10 10:10:10"
}
Expand Down
Loading
Loading