Skip to content

Commit 4b16b97

Browse files
committed
Save Multiline using structured changes
1 parent e94a62e commit 4b16b97

File tree

2 files changed

+102
-112
lines changed

2 files changed

+102
-112
lines changed

src/Form/Control/Multiline.php

Lines changed: 25 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,8 @@ class Multiline extends Form\Control
161161
/** @var list<string> The fields names used in each row. */
162162
public $rowFields;
163163

164-
/** @var list<array<string, mixed>> The data sent for each row. */
165-
protected $rowData;
164+
/** The changes set by self::setInputValue(). */
165+
protected RefChanges $changes;
166166

167167
/** @var int The max number of records (rows) that can be added to Multiline. 0 means no limit. */
168168
public $rowLimit = 0;
@@ -295,53 +295,6 @@ public function getInputValue(): string
295295
return $this->getApp()->encodeJson($rowsUi);
296296
}
297297

298-
/**
299-
* Same as Persistence::typecastSaveRow() but allow null in not-nullable fields.
300-
*
301-
* @param array<string, mixed> $row
302-
*
303-
* @return array<string, scalar|null>
304-
*/
305-
private function typecastContainedSaveRow(array $row): array
306-
{
307-
$res = [];
308-
foreach ($row as $fieldName => $value) {
309-
$field = $this->model->getField($fieldName);
310-
311-
$res[$field->getPersistenceName()] = $value === null
312-
? null
313-
: $this->model->getPersistence()->typecastSaveField($field, $value);
314-
}
315-
316-
return $res;
317-
}
318-
319-
/**
320-
* @param \Closure(): void $fx
321-
*/
322-
private function invokeWithContainsXxxNormalizeHookIgnored(\Closure $fx): void
323-
{
324-
// TOD hack to supress "Contained model data cannot be modified directly" exception
325-
// https://github.com/atk4/data/blob/fca1cd55b0/src/Reference/ContainsBase.php#L58-L81
326-
$ourModel = $this->entityField->getModel();
327-
\Closure::bind(static function () use ($fx, $ourModel) {
328-
$spot = Model::HOOK_NORMALIZE;
329-
$priority = \PHP_INT_MIN;
330-
$normalizeHooks = $ourModel->hooks[$spot][$priority] ?? [];
331-
foreach (array_keys($normalizeHooks) as $hookIndex) {
332-
$ourModel->hooks[$spot][$priority][$hookIndex][0] = static fn () => null;
333-
}
334-
335-
try {
336-
$fx();
337-
} finally {
338-
foreach (array_keys($normalizeHooks) as $hookIndex) {
339-
$ourModel->hooks[$spot][$priority][$hookIndex][0] = $normalizeHooks[$hookIndex][0];
340-
}
341-
}
342-
}, null, Model::class)();
343-
}
344-
345298
/**
346299
* @return array{list<array<string, mixed>>, list<string>}
347300
*/
@@ -353,6 +306,13 @@ private function decodeInput(string $json): array
353306
foreach ($rowDataWithMlid as $row) {
354307
$mlids[] = $row['__atkml'];
355308
unset($row['__atkml']);
309+
310+
foreach ($row as $k => $v) {
311+
if ($this->model->getField($k)->readOnly) {
312+
unset($row[$k]);
313+
}
314+
}
315+
356316
$rowData[] = $this->typecastUiLoadRow($row);
357317
}
358318

@@ -364,56 +324,31 @@ public function setInputValue(string $value): void
364324
{
365325
[$rowData, $mlids] = $this->decodeInput($value);
366326

367-
$this->rowData = $rowData;
368-
if ($this->rowData !== []) {
369-
$this->rowErrors = $this->validate($this->rowData, $mlids);
327+
if ($rowData !== []) {
328+
$this->rowErrors = $this->validate($rowData, $mlids);
370329
if ($this->rowErrors !== []) {
371330
throw new ValidationException([$this->shortName => 'Multiline error']);
372331
}
373332
}
374333

375-
$rowsRaw = array_map(fn ($v) => $this->typecastContainedSaveRow($v), $this->rowData);
376-
377-
// mimic ContainsOne save format
378-
// https://github.com/atk4/data/blob/6.0.0/src/Reference/ContainsOne.php#L37
379-
// TODO replace with something like "schedule model save task" and then drop self::saveRows() method
380-
if ($rowsRaw === []) {
381-
$value = '';
382-
} else {
383-
foreach ($rowsRaw as $k => $rowRaw) {
384-
$idFieldRawName = $this->model->getIdField()->getPersistenceName();
385-
if ($rowRaw[$idFieldRawName] === null) {
386-
$refModel = $this->entityField->getField()->hasReference()
387-
? $this->entityField->getField()->getReference()->ref($this->entityField->getEntity())->getModel(true)
388-
: $this->model;
389-
390-
// TODO to pass Behat tests purely
391-
if (!$this->entityField->getField()->hasReference() && $this->entityField->getField()->type !== 'json' && !$refModel->getPersistence() instanceof Persistence\Array_) {
392-
continue;
393-
}
334+
$changes = new RefChanges();
335+
$model = $this->model;
394336

395-
$idField = $refModel->getIdField();
396-
$origIdFieldType = $idField->type;
397-
$idField->type = 'bigint';
398-
try {
399-
$rowsRaw[$k][$idFieldRawName] = Persistence\Array_::assertInstanceOf($refModel->getPersistence())->generateNewId($refModel);
400-
} finally {
401-
$idField->type = $origIdFieldType;
402-
}
403-
}
404-
}
337+
// TODO this is dangerous, deleted row IDs should be passed from UI
338+
$idsToDelete = array_filter(array_column($rowData, $model->idField), static fn ($v) => $v !== null);
339+
foreach ($model->createIteratorBy($model->idField, 'not in', $idsToDelete) as $entity) {
340+
$changes->deletes[] = [$model->idField => $entity->getId()];
341+
}
405342

406-
if ($this->isOneToOne()) {
407-
assert(count($rowsRaw) === 1);
408-
$rowsRaw = array_first($rowsRaw);
343+
foreach ($rowData as $row) {
344+
if ($row[$model->idField] === null) {
345+
$changes->inserts[] = $row;
346+
} else {
347+
$changes->updates[] = [[$model->idField => $row[$model->idField]], $row];
409348
}
410-
411-
$value = $this->getApp()->encodeJson($rowsRaw);
412349
}
413350

414-
$this->invokeWithContainsXxxNormalizeHookIgnored(function () use ($value) {
415-
parent::setInputValue($value);
416-
});
351+
$this->changes = $changes;
417352
}
418353

419354
/**
@@ -471,29 +406,7 @@ public function validate(array $rows, array $mlids): array
471406
*/
472407
public function saveRows(): self
473408
{
474-
$model = $this->model;
475-
476-
// delete removed rows
477-
// TODO this is dangerous, deleted row IDs should be passed from UI
478-
$idsToDelete = array_filter(array_column($this->rowData, $model->idField), static fn ($v) => $v !== null);
479-
foreach ($model->createIteratorBy($model->idField, 'not in', $idsToDelete) as $entity) {
480-
$entity->delete();
481-
}
482-
483-
foreach ($this->rowData as $row) {
484-
$entity = $row[$model->idField] !== null
485-
? $model->load($row[$model->idField])
486-
: $model->createEntity();
487-
foreach ($row as $fieldName => $value) {
488-
if ($model->getField($fieldName)->isEditable()) {
489-
$entity->set($fieldName, $value);
490-
}
491-
}
492-
493-
if (!$entity->isLoaded() || $entity->getDirtyRef() !== []) {
494-
$entity->save();
495-
}
496-
}
409+
$this->changes->saveTo($this->model);
497410

498411
return $this;
499412
}

src/Form/Control/RefChanges.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Atk4\Ui\Form\Control;
6+
7+
use Atk4\Data\Exception;
8+
use Atk4\Data\Model;
9+
10+
/**
11+
* TODO move to atk4/data and allow deep/nested changes.
12+
*/
13+
class RefChanges
14+
{
15+
/** @var list<array<string, mixed>> */
16+
public array $inserts = [];
17+
18+
/** @var list<array{array<string, mixed>, array<string, mixed>}> */
19+
public array $updates = [];
20+
21+
/** @var list<array<string, mixed>> */
22+
public array $deletes = [];
23+
24+
/**
25+
* @param array<string, mixed> $oldData
26+
*/
27+
protected function loadEntity(Model $ourModelOrEntity, array $oldData): Model
28+
{
29+
$entity = $ourModelOrEntity->isEntity()
30+
? $ourModelOrEntity
31+
: $ourModelOrEntity->load($oldData[$ourModelOrEntity->idField]);
32+
33+
foreach ($oldData as $k => $v) {
34+
if (!$entity->compare($k, $v)) {
35+
throw (new Exception('Field value does not match expected value'))
36+
->addMoreInfo('entity', $entity)
37+
->addMoreInfo('field', $k)
38+
->addMoreInfo('valueExpected', $v)
39+
->addMoreInfo('valueActual', $entity->get($k));
40+
}
41+
}
42+
43+
return $entity;
44+
}
45+
46+
public function saveTo(Model $ourModelOrEntity): void
47+
{
48+
$ourModelOrEntity->atomic(function () use ($ourModelOrEntity) {
49+
foreach ($this->deletes as $oldData) {
50+
$entity = $this->loadEntity($ourModelOrEntity, $oldData);
51+
52+
$entity->delete();
53+
}
54+
55+
foreach ($this->updates as [$oldData, $newData]) {
56+
$entity = $this->loadEntity($ourModelOrEntity, $oldData);
57+
58+
$entity->setMulti($newData);
59+
$entity->save();
60+
}
61+
62+
foreach ($this->inserts as $newData) {
63+
$entity = $ourModelOrEntity->isEntity()
64+
? $ourModelOrEntity
65+
: $ourModelOrEntity->createEntity();
66+
$this->loadEntity($entity, [$ourModelOrEntity->idField => null]);
67+
68+
if (($newData[$ourModelOrEntity->idField] ?? null) === null) {
69+
unset($newData[$ourModelOrEntity->idField]);
70+
}
71+
72+
$entity->setMulti($newData);
73+
$entity->save();
74+
}
75+
});
76+
}
77+
}

0 commit comments

Comments
 (0)