Skip to content

Commit 5c9b554

Browse files
committed
Add validateEntity hook
Add an optional validateEntity() hook, which receives every writable property in getMap() with the values that will be saved, along with the existing entity when updating (null when inserting). For partial updates, the patch is merged into the existing entity, so rules involving multiple properties can be validated without looking up the existing row. When updating, both arrays also include the ID property. When the hook is implemented, every property in getMap() must be selectable, and updateEntities() only updates rows that can be selected, so rows hidden by processFilter() or the base query can't be updated without being validated. processEntity() now only handles inserts, since updates validate each existing row before running processRow() with the validated IDs.
1 parent 98ee789 commit 5c9b554

8 files changed

Lines changed: 274 additions & 16 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
when `$partial` is true.
1212
- Protected `processEntity()` and `insertRows()` methods, so an overridden `addEntities()` can access
1313
the processed rows without calling `processValues()` twice. `processEntity()` runs `processValues()`,
14-
converts the properties to a column/value row, then runs `processRow()`. `insertRows()` inserts
15-
the rows and returns their IDs.
14+
converts the properties to a column/value row, runs `validateEntity()`, then runs `processRow()`.
15+
`insertRows()` inserts the rows and returns their IDs.
16+
- `validateEntity()` hook for validating the complete set of writable properties that will be saved.
17+
When updating, it is called for each row with the patch merged into the existing entity, along with
18+
the existing entity, and both include the ID property. If implemented, every property in `getMap()`
19+
must be selectable, and `updateEntities()` only updates rows that can be selected.
1620

1721
### Changed
1822
- `addEntities()` no longer ignores an `id` property in the posted entities. If `getMap()` includes

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,24 @@ protected function getBaseSelect(QueryOptions $options): SqlParams
102102
// ...
103103
```
104104

105+
To validate entities before they are inserted or updated, implement `validateEntity()`. `$entity` contains
106+
every property from `getMap()` with the values that will be saved. For partial updates, this is the existing
107+
entity with the patch merged in. When updating, `$entity` and `$existing` also contain the ID property.
108+
`$existing` is null when inserting.
109+
Every property in `getMap()` must also be selectable, so the existing entities can be retrieved.
110+
111+
```php
112+
use Teapot\{HttpException, StatusCode};
113+
// ...
114+
protected function validateEntity(array $entity, ?array $existing): void
115+
{
116+
if ($existing !== null && $existing['isDisabled'] && $entity['username'] !== $existing['username']) {
117+
throw new HttpException('Disabled users cannot be renamed', StatusCode::BAD_REQUEST);
118+
}
119+
}
120+
// ...
121+
```
122+
105123
```php
106124
<?php
107125

‎src/Entities.php‎

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ abstract class Entities
1515
private array $fullPropMap;
1616
/** @var array<string, mixed> */
1717
private array $map;
18+
private bool $validatesEntities;
19+
/** @var list<string> */
20+
private array $writableFields = [];
1821

1922
public function __construct(PeachySql $db)
2023
{
@@ -38,6 +41,20 @@ public function __construct(PeachySql $db)
3841
$this->idColumn = $idParts[array_key_last($idParts)];
3942
$this->fullPropMap = $propMap;
4043
$this->map = $this->getMap();
44+
$this->validatesEntities = (new \ReflectionMethod($this, 'validateEntity'))->class !== self::class;
45+
46+
if ($this->validatesEntities) {
47+
// the existing entities are selected when updating, so every writable property must be selectable
48+
$writableFields = array_keys(Helpers::selectMapToPropMap($this->map));
49+
50+
foreach ($writableFields as $field) {
51+
if (!isset($propMap[$field])) {
52+
throw new \Exception("Writable {$field} property must be selectable to validate entities");
53+
}
54+
}
55+
56+
$this->writableFields = array_values(array_unique([$this->idField, ...$writableFields]));
57+
}
4158
}
4259

4360
/**
@@ -141,6 +158,20 @@ protected function processRow(array $row, array $ids): array
141158
return $row;
142159
}
143160

161+
/**
162+
* Validate an entity before it is inserted or updated, by throwing an HttpException if it is invalid.
163+
* $entity contains every writable property in getMap(), with the values that will be saved after
164+
* processValues() runs. For partial updates, it is the existing entity with the patch merged in.
165+
* When updating, this is called for each row, with the existing values of its writable properties
166+
* (selected the same way as getEntities()), and both arrays also contain the row's ID property.
167+
* $existing is null when inserting.
168+
* Since the existing entities are selected before the update query runs, rules which must hold
169+
* when rows are updated concurrently should also be enforced by database constraints.
170+
* @param array<string, mixed> $entity
171+
* @param array<string, mixed>|null $existing
172+
*/
173+
protected function validateEntity(array $entity, ?array $existing): void {}
174+
144175
/**
145176
* @param list<string|int> $ids
146177
*/
@@ -154,7 +185,9 @@ public function deleteByIds(array $ids): int
154185
}
155186

156187
/**
157-
* Replace one or more rows, or update them via a JSON Merge Patch (https://tools.ietf.org/html/rfc7396) if $partial is true
188+
* Replace one or more rows, or update them via a JSON Merge Patch (https://tools.ietf.org/html/rfc7396) if $partial is true.
189+
* All mapped properties are required unless $partial is true.
190+
* If validateEntity() is implemented, only rows which can be selected are validated and updated.
158191
* @param list<string|int> $ids
159192
* @param mixed[] $data
160193
*/
@@ -164,36 +197,77 @@ public function updateEntities(array $ids, array $data, bool $partial = false):
164197
return 0;
165198
}
166199

167-
$row = $this->processEntity($data, $ids, $partial);
200+
$data = $this->processValues($data, $ids);
201+
$row = $partial
202+
? self::propertiesToColumns($this->map, $data, complexValues: false)
203+
: Helpers::allPropertiesToColumns($this->map, $data);
204+
205+
if ($this->validatesEntities) {
206+
$ids = $this->validateUpdates($ids, $data, $partial);
207+
208+
if (count($ids) === 0) {
209+
return 0;
210+
}
211+
}
212+
213+
$row = $this->processRow($row, $ids);
168214

169215
return $this->db->updateRows($this->getTableName(), $row, [$this->idColumn => $ids]);
170216
}
171217

218+
/**
219+
* Calls validateEntity() for each existing row, and returns the IDs of the rows that were validated.
220+
* @param list<string|int> $ids
221+
* @param mixed[] $data
222+
* @return list<string|int>
223+
*/
224+
private function validateUpdates(array $ids, array $data, bool $partial): array
225+
{
226+
$validatedIds = [];
227+
228+
// select in batches, so the IDs don't exceed the database's bound parameter limit
229+
foreach (array_chunk(array_values(array_unique($ids)), 1000) as $batch) {
230+
foreach ($this->getEntitiesByIds($batch, $this->writableFields) as $entity) {
231+
/** @var int|string $id */
232+
$id = $entity[$this->idField];
233+
// the ID is first, but a mapped ID property overrides its value
234+
$existing = [$this->idField => $id, ...Helpers::getMappedValues($this->map, $entity)];
235+
$updated = $partial ? array_replace_recursive($existing, $data) : $data;
236+
$updated = [$this->idField => $id, ...Helpers::getMappedValues($this->map, $updated)];
237+
$this->validateEntity($updated, $existing);
238+
$validatedIds[] = $id;
239+
}
240+
}
241+
242+
return $validatedIds;
243+
}
244+
172245
/**
173246
* Returns an array containing the IDs of the inserted rows
174247
* @param list<array> $entities
175248
* @return list<int>
176249
*/
177250
public function addEntities(array $entities): array
178251
{
179-
return $this->insertRows(array_map(fn($e) => $this->processEntity($e, []), $entities));
252+
return $this->insertRows(array_map(fn($e) => $this->processEntity($e), $entities));
180253
}
181254

182255
/**
183-
* Runs processValues(), converts the properties to a column/value row, then runs processRow().
184-
* All mapped properties are required unless $partial is true.
256+
* Runs processValues(), converts the properties to a column/value row, runs validateEntity(),
257+
* then runs processRow() to return the row to insert. All mapped properties are required.
185258
* @param mixed[] $data
186-
* @param list<string|int> $ids
187259
* @return array<string, mixed>
188260
*/
189-
final protected function processEntity(array $data, array $ids, bool $partial = false): array
261+
final protected function processEntity(array $data): array
190262
{
191-
$data = $this->processValues($data, $ids);
192-
$row = $partial
193-
? self::propertiesToColumns($this->map, $data, complexValues: false)
194-
: Helpers::allPropertiesToColumns($this->map, $data);
263+
$data = $this->processValues($data, []);
264+
$row = Helpers::allPropertiesToColumns($this->map, $data);
265+
266+
if ($this->validatesEntities) {
267+
$this->validateEntity(Helpers::getMappedValues($this->map, $data), null);
268+
}
195269

196-
return $this->processRow($row, $ids);
270+
return $this->processRow($row, []);
197271
}
198272

199273
/**

‎src/Helpers.php‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,31 @@ public static function allPropertiesToColumns(array $map, array $properties): ar
278278
return self::propsToColumns($map, $properties, true, false, true);
279279
}
280280

281+
/**
282+
* Returns the values of the properties in the map, in the same nested structure.
283+
* Properties without a value are set to null.
284+
* @param array<string, mixed> $map
285+
* @param mixed[] $properties
286+
* @return array<string, mixed>
287+
*/
288+
public static function getMappedValues(array $map, array $properties): array
289+
{
290+
$values = [];
291+
292+
foreach ($map as $property => $val) {
293+
$value = $properties[$property] ?? null;
294+
295+
if (is_array($val)) {
296+
/** @var array<string, mixed> $val */
297+
$values[$property] = self::getMappedValues($val, is_array($value) ? $value : []);
298+
} else {
299+
$values[$property] = $value;
300+
}
301+
}
302+
303+
return $values;
304+
}
305+
281306
/**
282307
* @param mixed[] $map
283308
* @param mixed[] $properties

‎tests/DbTestCase.php‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
namespace DevTheorem\Phaster\Test;
44

55
use DevTheorem\PeachySQL\PeachySql;
6-
use DevTheorem\Phaster\Test\src\{ConcurrentRows, LegacyUsers, ModernUsers, Users};
6+
use DevTheorem\Phaster\Test\src\{ConcurrentRows, LegacyUsers, ModernUsers, Users, ValidatedUsers};
77
use PDO;
88
use PHPUnit\Framework\TestCase;
9+
use Teapot\HttpException;
910

1011
abstract class DbTestCase extends TestCase
1112
{
@@ -120,6 +121,86 @@ public function testUpdateEntities(): void
120121
$this->assertSame([], $entities->getEntitiesByIds($ids));
121122
}
122123

124+
public function testValidateEntity(): void
125+
{
126+
$db = static::dbProvider();
127+
$entities = new ValidatedUsers($db);
128+
$weight = fn(float $w) => $db->options->floatSelectedAsString ? (string) $w : $w;
129+
130+
try {
131+
$entities->addEntities([
132+
['name' => 'Validated 1', 'birthday' => '2019-01-02', 'weight' => 20.0],
133+
['name' => 'Validated 2', 'birthday' => '2018-03-04', 'weight' => 0.0],
134+
]);
135+
136+
throw new \Exception('Failed to throw validation exception');
137+
} catch (HttpException $e) {
138+
$this->assertSame('Weight must be positive', $e->getMessage());
139+
}
140+
141+
// no rows are inserted if any entity is invalid
142+
$this->assertSame(0, $entities->countEntities(['name' => ['lk' => 'Validated %']]));
143+
144+
// inserted entities are validated after processValues() sets defaults, without unmapped properties
145+
$entities->validated = [];
146+
$ids = $entities->addEntities([
147+
['name' => 'Validated 1', 'birthday' => '2019-01-02', 'weight' => 20.0, 'extra' => 1],
148+
['name' => 'Validated 2', 'birthday' => '2018-03-04', 'weight' => 30.0, 'isDisabled' => true],
149+
]);
150+
151+
$expected = [
152+
['entity' => ['name' => 'Validated 1', 'birthday' => '2019-01-02', 'weight' => 20.0, 'isDisabled' => false], 'existing' => null],
153+
['entity' => ['name' => 'Validated 2', 'birthday' => '2018-03-04', 'weight' => 30.0, 'isDisabled' => true], 'existing' => null],
154+
];
155+
$this->assertSame($expected, $entities->validated);
156+
157+
// a partial update validates each existing row with the patch merged in, and skips missing rows
158+
$entities->validated = [];
159+
$this->assertSame(2, $entities->updateEntities([(string) $ids[0], $ids[1], 0], ['weight' => 25.0], partial: true));
160+
161+
$existing1 = ['id' => $ids[0], 'name' => 'Validated 1', 'birthday' => '2019-01-02', 'weight' => $weight(20.0), 'isDisabled' => false];
162+
$existing2 = ['id' => $ids[1], 'name' => 'Validated 2', 'birthday' => '2018-03-04', 'weight' => $weight(30.0), 'isDisabled' => true];
163+
$expected = [
164+
['entity' => [...$existing1, 'weight' => 25.0], 'existing' => $existing1],
165+
['entity' => [...$existing2, 'weight' => 25.0], 'existing' => $existing2],
166+
];
167+
$this->assertSame($expected, $entities->validated);
168+
169+
try {
170+
$entities->updateEntities($ids, ['name' => 'Renamed'], partial: true);
171+
throw new \Exception('Failed to throw validation exception');
172+
} catch (HttpException $e) {
173+
$this->assertSame('Disabled users cannot be renamed', $e->getMessage());
174+
}
175+
176+
// neither row is updated if any is invalid
177+
$names = array_column($entities->getEntitiesByIds($ids, ['name']), 'name');
178+
$this->assertSame(['Validated 1', 'Validated 2'], $names);
179+
180+
// a replacement is validated along with the existing entity, and an unmapped posted ID is ignored
181+
$entities->validated = [];
182+
$replacement = ['name' => 'Validated 3', 'birthday' => '2017-05-06', 'weight' => 40.0, 'isDisabled' => true];
183+
$this->assertSame(1, $entities->updateEntities([$ids[0]], ['id' => -1, ...$replacement]));
184+
$expected = [['entity' => ['id' => $ids[0], ...$replacement], 'existing' => [...$existing1, 'weight' => $weight(25.0)]]];
185+
$this->assertSame($expected, $entities->validated);
186+
187+
$entities->validated = [];
188+
$this->assertSame(0, $entities->updateEntities([0], ['weight' => 10.0], partial: true));
189+
$this->assertSame([], $entities->validated);
190+
191+
try {
192+
new class ($db) extends ModernUsers {
193+
protected function validateEntity(array $entity, ?array $existing): void {}
194+
};
195+
196+
throw new \Exception('Failed to throw exception for unselectable property');
197+
} catch (\Exception $e) {
198+
$this->assertSame('Writable birthday property must be selectable to validate entities', $e->getMessage());
199+
}
200+
201+
$this->assertSame(2, $entities->deleteByIds($ids));
202+
}
203+
123204
public function testEmptyQueries(): void
124205
{
125206
$entities = static::entitiesProvider();

‎tests/HelpersTest.php‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,4 +342,31 @@ public function testAllPropertiesToColumns(): void
342342

343343
$this->assertSame($expected, Helpers::allPropertiesToColumns($map, $valid));
344344
}
345+
346+
public function testGetMappedValues(): void
347+
{
348+
$properties = [
349+
'extra' => 'unmapped properties are excluded',
350+
'name' => 'Test Name',
351+
'client' => ['name' => 'Client Name', 'id' => 123, 'extra' => true],
352+
'group' => null, // null groups are expanded to null properties
353+
];
354+
355+
$expected = [
356+
'name' => 'Test Name',
357+
'client' => [
358+
'id' => 123,
359+
'name' => 'Client Name',
360+
'isDisabled' => null,
361+
],
362+
'group' => [
363+
'type' => [
364+
'id' => null,
365+
'name' => null,
366+
],
367+
],
368+
];
369+
370+
$this->assertSame($expected, Helpers::getMappedValues($this->propertyMap, $properties));
371+
}
345372
}

‎tests/src/ModernUsers.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public function addEntities(array $entities): array
6363
if (($entity['name'] ?? null) === 'Modern user 2') {
6464
$existingIds[$key] = -42; // don't insert row for this item
6565
} else {
66-
$rows[] = $this->processEntity($entity, []);
66+
$rows[] = $this->processEntity($entity);
6767
}
6868
}
6969

‎tests/src/ValidatedUsers.php‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace DevTheorem\Phaster\Test\src;
4+
5+
use Teapot\{HttpException, StatusCode};
6+
7+
class ValidatedUsers extends Users
8+
{
9+
/** @var list<array{entity: array<string, mixed>, existing: array<string, mixed>|null}> */
10+
public array $validated = [];
11+
12+
protected function getTableName(): string
13+
{
14+
return 'Users';
15+
}
16+
17+
protected function validateEntity(array $entity, ?array $existing): void
18+
{
19+
$this->validated[] = ['entity' => $entity, 'existing' => $existing];
20+
21+
if ($entity['weight'] <= 0) {
22+
throw new HttpException('Weight must be positive', StatusCode::BAD_REQUEST);
23+
}
24+
25+
if ($existing !== null && $existing['isDisabled'] === true && $entity['name'] !== $existing['name']) {
26+
throw new HttpException('Disabled users cannot be renamed', StatusCode::BAD_REQUEST);
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)