Validate DAL writes against their outcome
INFO
This document represents an architecture decision record (ADR) and has been mirrored from the ADR section in our Shopware 6 repository. You can find the original version here
Context
Some DAL validations inspect a record as it will be after it is written to the database.
About eight checks in core do this, and each one works out the record on its own, from the write commands and its own database query. They use one of two patterns:
- Last command per record. The check keeps one command per record and loads the stored rows in one query. A later write in the same Sync request hides an earlier one, so invalid data can be stored.
- Each command on its own. The check runs once per command and fills in the stored values for the fields the command doesn't set. This costs one query per command, and it rejects requests that are only valid once all their writes are applied.
Most checks in core use the second pattern.
To be correct, a check has to merge all writes to a record in order, on top of the stored values. Every check would have to repeat that.
Decision
The DAL works out each record as it will be after the write.
A new WriteOutcome service does this in its rows() method. You pass it the commands for one entity and the columns your check needs. It returns one row per record, with all writes to that record merged in order on top of the stored values. It loads the stored values for all records in one query.
rows()only returns records that are new, or whose writes set one of the columns.- Records the request deletes are not returned.
- Values come back the way they are stored, for example ids as binary.
Consequences
- Checks no longer merge commands or query the rows they validate themselves.
Example
The rule: a promotion's discount may be at most 50%, unless the promotion is marked as clearance.
Request A writes the same promotion twice in one Sync request (POST /api/_action/sync). The promotion ends up with a 90% discount and no clearance flag, so the request must be rejected:
[
{
"entity": "promotion",
"action": "upsert",
"payload": [{ "id": "0190d6c4a1b27c3f8e5d9a4b6c2f1e07", "discount": 90 }]
},
{
"entity": "promotion",
"action": "upsert",
"payload": [{ "id": "0190d6c4a1b27c3f8e5d9a4b6c2f1e07", "name": "Summer sale" }]
}
]Request B is the same, except the second write sets "clearance": true instead of the name. The promotion would end up with a 90% discount and the clearance flag, so it should be accepted.
Last command per record
$promotions = [];
foreach ($event->getCommandsForEntity('promotion') as $command) {
// a later write replaces an earlier one
$promotions[$command->getPrimaryKey()['id']] = $command;
}Request A: only the rename is checked, because the discount is set in the first command. The request is accepted and the 90% discount is stored. Wrong.
Each command on its own
foreach ($event->getCommandsForEntity('promotion') as $command) {
$discount = $command->getPayload()['discount'] ?? $this->loadStored($command)['discount'];
}Request A: the first write has a 90% discount and no clearance flag. The request is rejected. Right.
Request B: the first write is checked before the clearance flag is set in the second command. The request is rejected. Wrong.
With WriteOutcome
$commands = $event->getCommandsForEntity('promotion');
foreach ($this->writeOutcome->rows($commands, ['discount', 'clearance']) as $promotion) {
if ($promotion->getResultingValue('discount') > 50 && !$promotion->getResultingValue('clearance')) {
$this->reject($promotion->path);
}
}Request A: rows() returns the promotion with a 90% discount and no clearance flag. The request is rejected. Right.
Request B: rows() returns the promotion with a 90% discount and the clearance flag. The request is accepted. Right.