@@ -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 /**
0 commit comments