Skip to content

Commit 9a62e46

Browse files
committed
safeUpdateRecursive should back out any changes that cause saving handler to exception or return false
1 parent 3d75b3e commit 9a62e46

File tree

2 files changed

+80
-39
lines changed

2 files changed

+80
-39
lines changed

app/JsonModel.php

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
use ArrayAccess;
2121
use Carsdotcom\JsonSchemaValidation\Exceptions\JsonSchemaValidationException;
22+
use Carsdotcom\JsonSchemaValidation\Helpers\FriendlyClassName;
2223
use Carsdotcom\JsonSchemaValidation\Traits\ValidatesWithJsonSchema;
2324
use Carsdotcom\LaravelJsonModel\Contracts\CanCascadeEvents;
2425
use Carsdotcom\JsonSchemaValidation\Contracts\CanValidate;
@@ -420,53 +421,43 @@ public function updateRecursive(array $attributes, bool $isRootOfChange = true)
420421
}
421422

422423
/**
423-
* Given a set of changes that *might* contain some invalid data,
424-
* take the good parts and throw out the rest.
425-
* Assumes that $this was valid before the changes, and that each key could be an independent change,
426-
* so if you have validation states where two attributes have to agree, choose `update` instead.
427-
* @param array $attributes
428-
* @return void
424+
* @deprecated It is always safer to use safeUpdateRecursive, so this non-recursive variant is deprecated
425+
* "If I am only for myself, what am I?"
429426
*/
430427
public function safeUpdate(array $attributes): void
431428
{
432-
foreach ($attributes as $key => $updatedAttribute) {
433-
$wasSet = isset($this->{$key});
434-
$previousValue = $this->{$key}; // __get will fill null even if it wasn't null
435-
$this->{$key} = $updatedAttribute;
436-
try {
437-
$this->validateOrThrow();
438-
} catch (JsonSchemaValidationException) {
439-
if ($wasSet) {
440-
$this->{$key} = $previousValue;
441-
} else {
442-
unset($this->{$key});
443-
}
444-
}
445-
}
446-
$this->save();
429+
$this->safeUpdateRecursive($attributes);
447430
}
448431

449432
/**
450433
* Given a set of changes that *might* contain some invalid data,
451434
* take the good parts and throw out the rest.
452435
* Assumes that $this was valid before the changes, and that each key could be an independent change,
453436
* so if you have validation states where two attributes have to agree, choose `update` instead.
437+
* @return bool was save successful?
454438
*/
455-
public function safeUpdateRecursive(array $attributes, bool $isRootOfChange = true): void
439+
public function safeUpdateRecursive(array $attributes, bool $isRootOfChange = true, array &$caughtExceptions = null): bool
456440
{
441+
if ($caughtExceptions === null) {
442+
$caughtExceptions = [];
443+
}
457444
foreach ($attributes as $key => $updatedAttribute) {
458445
$wasSet = isset($this->{$key});
459446
$previousValue = $this->{$key}; // __get will fill null even if it wasn't null
460447

461448
if ($this->{$key} instanceof JsonModel) {
462-
$this->{$key}->safeUpdateRecursive($updatedAttribute, false);
449+
$this->{$key}->safeUpdateRecursive($updatedAttribute, false, $caughtExceptions);
463450
} else {
464451
$this->{$key} = $updatedAttribute;
465452
}
466453

467454
try {
468-
$this->validateOrThrow();
469-
} catch (JsonSchemaValidationException) {
455+
$canSave = $this->preSave();
456+
if (!$canSave) {
457+
throw new \DomainException("A saving handler on " . (new FriendlyClassName())($this) . " returned false but provided no reason.");
458+
}
459+
} catch (\Throwable $e) {
460+
$caughtExceptions[] = $e;
470461
if ($wasSet) {
471462
$this->{$key} = $previousValue;
472463
} else {
@@ -475,8 +466,9 @@ public function safeUpdateRecursive(array $attributes, bool $isRootOfChange = tr
475466
}
476467
}
477468
if ($isRootOfChange) {
478-
$this->save();
469+
return $this->save();
479470
}
471+
return true;
480472
}
481473

482474
/**
@@ -587,6 +579,7 @@ public function preSave(): bool
587579
if ($this->fireModelEvent('saving') === false) {
588580
return false;
589581
}
582+
$this->validateOrThrow(); // We validate after the handlers, because the handlers could clean up data, validate never does
590583
return $this->cascadePreSave();
591584
}
592585

tests/Unit/JsonModelTest.php

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,7 @@ public function testLinkedJsonModelValidatesBeforeSave(): void
386386
{
387387
[$model, $jsonmodel] = $this->mockLinkedValidatedJsonModel();
388388
SchemaValidator::shouldReceive('validateOrThrow')
389-
->once()
389+
->atLeast()->once()
390390
->with(
391391
$jsonmodel,
392392
$jsonmodel::SCHEMA,
@@ -808,17 +808,12 @@ public function testSafeUpdate(): void
808808
'first_name' => 'Jeremy',
809809
];
810810

811-
$model = mock(Model::class)->makePartial();
811+
$model = $this->makeMockModel();
812812
$jsonModel = new class ($model, 'data') extends EventedJsonModel {
813813
const SCHEMA = 'person.json';
814814
};
815815
$jsonModel->mobile_phone = '8885551111';
816816

817-
$model
818-
->shouldReceive('save')
819-
->once()
820-
->andReturn(true);
821-
822817
$jsonModel->safeUpdate($mixedChanges);
823818
self::assertFalse(isset($jsonModel->email)); // attribute is invalid, revert to unset
824819
self::assertSame('8885551111', $jsonModel->mobile_phone); // attribute is invalid, revert to previous value
@@ -835,21 +830,59 @@ public function testSafeUpdateRecursive(): void
835830
],
836831
];
837832

838-
$model = mock(Model::class)->makePartial();
833+
$model = $this->makeMockModel();
839834
$jsonModel = new Person($model, 'data');
840835
$jsonModel->address->country = 'CA';
841836

842-
$model
843-
->shouldReceive('save')
844-
->once()
845-
->andReturn(true);
846-
847837
$jsonModel->safeUpdateRecursive($mixedChanges);
848838
self::assertFalse(isset($jsonModel->email), 'attribute is invalid, should revert to unset');
849839
self::assertSame('CA', $jsonModel->address->country, 'attribute is invalid, revert to previous value');
850840
self::assertSame('Jeremy', $jsonModel->first_name, 'attribute is valid, set');
851841
}
842+
843+
public function makeMockModel(): Model
844+
{
845+
$model = mock(Model::class)->makePartial();
846+
$model
847+
->shouldReceive('save')
848+
->andReturn(true);
849+
return $model;
850+
}
851+
852+
public function testSafeUpdateRecursiveIncludesSavingHandlers(): void
853+
{
854+
$model = $this->makeMockModel();
855+
$jsonModel = new CustomSavingHandler($model, 'data');
856+
$caughtExceptions = [];
857+
$jsonModel->safeUpdateRecursive(['shirt' => 'red', 'bestCaptain' => 'Solo'], caughtExceptions: $caughtExceptions);
858+
self::assertCanonicallySame(['shirt' => 'red'], $jsonModel);
859+
self::assertCanonicallySame(["Sorry, Solo is not a valid choice for Best Star Trek Captain."], array_map(fn ($e) => $e->getMessage(), $caughtExceptions));
860+
861+
// can mix schema and custom handler problems
862+
$caughtExceptions = [];
863+
$jsonModel->safeUpdateRecursive(['shirt' => 'orange', 'bestCaptain' => 'Starbuck', 'tribbles' => 14], caughtExceptions: $caughtExceptions);
864+
self::assertCanonicallySame([
865+
'shirt' => 'red', // orange is invalid in the schema, reverted
866+
'tribbles' => 14
867+
], $jsonModel);
868+
self::assertCanonicallySame([
869+
"The properties must match schema: shirt\nThe data should match one item from enum",
870+
"Sorry, Starbuck is not a valid choice for Best Star Trek Captain."
871+
], array_map(fn ($e) => ($e instanceof JsonSchemaValidationException) ? $e->errorsAsMultilineString() : $e->getMessage(), $caughtExceptions));
872+
873+
// False saving handler is also a problem.
874+
$caughtExceptions = [];
875+
$jsonModel->safeUpdateRecursive(['tribbles' => 101], caughtExceptions: $caughtExceptions);
876+
self::assertCanonicallySame([
877+
'shirt' => 'red',
878+
'tribbles' => 14, // excess tribbles caused saving to return false, that gets reverted but no message
879+
], $jsonModel);
880+
self::assertCanonicallySame([
881+
"A saving handler on Custom Saving Handler returned false but provided no reason."
882+
], array_map(fn ($e) => ($e instanceof JsonSchemaValidationException) ? $e->errorsAsMultilineString() : $e->getMessage(), $caughtExceptions));
883+
}
852884
}
885+
853886
/**
854887
* These are classes and models needed to test inheritance, etc.
855888
*/
@@ -863,6 +896,21 @@ class UpstreamModel extends JsonModel
863896
];
864897
}
865898

899+
class CustomSavingHandler extends JsonModel
900+
{
901+
public const SCHEMA = '{"properties":{"shirt":{"enum":["red", "gold"]}}}';
902+
protected static function boot()
903+
{
904+
parent::boot();
905+
static::saving(function (self $model) {
906+
if ($model->isDirty('bestCaptain') && $model->bestCaptain !== 'Saru' ) {
907+
throw new DomainException("Sorry, {$model->bestCaptain} is not a valid choice for Best Star Trek Captain.");
908+
}
909+
return $model->tribbles < 99;
910+
});
911+
}
912+
}
913+
866914
class DownstreamModel extends JsonModel
867915
{
868916
use HasJsonModelAttributes;

0 commit comments

Comments
 (0)