Skip to content

Commit 71f5690

Browse files
authored
Save Multiline using structured changes (#2302)
1 parent e94a62e commit 71f5690

File tree

8 files changed

+181
-123
lines changed

8 files changed

+181
-123
lines changed

demos/form-control/multiline-containsmany-single.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@
1414
$form = Form::addTo($app);
1515
$form->setEntity((new MultilineDelivery($app->db))->createEntity());
1616
$form->onSubmit(static function (Form $form) {
17+
// save ContainsXxx data to JSON
18+
// https://github.com/atk4/data/blob/6.0.0/src/Reference/ContainsOne.php#L29-L40
19+
$form->entity->save();
20+
$form->entity->setNull($form->entity->idField);
21+
1722
return new JsToast($form->getApp()->encodeJson($form->entity->get()));
1823
});

demos/form-control/multiline.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@
5858

5959
$form->onSubmit(static function (Form $form) use ($multiline) {
6060
$rows = $multiline->model->atomic(static function () use ($multiline) {
61-
return $multiline->saveRows()->model->export();
61+
$multiline->saveRows();
62+
63+
return $multiline->model->export();
6264
});
6365

6466
$rows = array_map(static fn ($row) => $form->getApp()->uiPersistence->typecastSaveRow($multiline->model, $row), $rows);

demos/init-db.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,29 @@ protected function init(): void
284284

285285
$this->getIdField()->type = WrappedIdType::NAME;
286286

287+
// workaround autoincrement for custom ID type for ContainsXxx save
288+
// https://github.com/atk4/data/blob/6.0.0/src/Persistence/Array_.php#L324
289+
$this->onHookShort(Model::HOOK_BEFORE_INSERT, function (&$data) {
290+
$persistence = $this->getModel()->getPersistence();
291+
if ($persistence instanceof Persistence\Array_) {
292+
$idField = $this->getIdField();
293+
294+
if (isset($data[$idField->shortName])) {
295+
return;
296+
}
297+
298+
assert($this->getModel()->containedInEntity !== null);
299+
300+
$origIdFieldType = $idField->type;
301+
$idField->type = 'bigint';
302+
try {
303+
$data[$idField->shortName] = new WrappedId($persistence->generateNewId($this->getModel()));
304+
} finally {
305+
$idField->type = $origIdFieldType;
306+
}
307+
}
308+
});
309+
287310
$this->initPreventModification();
288311

289312
if ($this->getPersistence()->getDatabasePlatform() instanceof PostgreSQLPlatform || class_exists(CodeCoverage::class, false)) {

demos/interactive/loader2.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Atk4\Ui\Columns;
1010
use Atk4\Ui\Form;
1111
use Atk4\Ui\Grid;
12+
use Atk4\Ui\Js\JsToast;
1213
use Atk4\Ui\Loader;
1314
use Atk4\Ui\Text;
1415
use Atk4\Ui\View;
@@ -35,4 +36,9 @@
3536

3637
$form = Form::addTo($p);
3738
$form->setEntity($country->load($id));
39+
$form->onSubmit(static function (Form $form) {
40+
$message = $form->entity->getUserAction('edit')->execute();
41+
42+
return new JsToast($message);
43+
});
3844
});

docs/multiline.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ $userForm->onSubmit(function (Form $form) use ($ml) {
125125
// save emails record related to current user
126126
$ml->saveRows();
127127
128-
return new JsToast(var_export($ml->model->export(), true));
128+
return new JsToast(json_encode($ml->model->export()));
129129
});
130130
```
131131

src/Form/Control/Multiline.php

Lines changed: 41 additions & 121 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+
public TheirChanges $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;
@@ -276,17 +276,17 @@ private function isOneToOne(): bool
276276
#[\Override]
277277
public function getInputValue(): string
278278
{
279-
$refModelOrEntity = $this->entityField->getField()->hasReference()
279+
$theirModelOrEntity = $this->entityField->getField()->hasReference()
280280
? $this->entityField->getField()->getReference()->ref($this->entityField->getEntity())
281281
: $this->model;
282-
if ($refModelOrEntity->isEntity()) {
283-
$refModelOrEntity = $refModelOrEntity->isLoaded()
284-
? [$refModelOrEntity]
282+
if ($theirModelOrEntity->isEntity()) {
283+
$theirModelOrEntity = $theirModelOrEntity->isLoaded()
284+
? [$theirModelOrEntity]
285285
: [];
286286
}
287287

288288
$rows = [];
289-
foreach ($refModelOrEntity as $row) {
289+
foreach ($theirModelOrEntity as $row) {
290290
$rows[] = $row->get();
291291
}
292292

@@ -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,41 @@ 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);
334+
$theirModelOrEntity = $this->entityField->getField()->hasReference()
335+
? $this->entityField->getField()->getReference()->ref($this->entityField->getEntity())
336+
: $this->model;
376337

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-
}
338+
$changes = new TheirChanges();
394339

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-
}
340+
// TODO this is dangerous, deleted row IDs should be passed from UI
341+
$idsToDelete = array_filter(array_column($rowData, $theirModelOrEntity->idField), static fn ($v) => $v !== null);
342+
foreach ($theirModelOrEntity->getModel(true)->createIteratorBy($theirModelOrEntity->idField, 'not in', $idsToDelete) as $entity) {
343+
$changes->deletes[] = [$theirModelOrEntity->idField => $entity->getId()];
344+
}
405345

406-
if ($this->isOneToOne()) {
407-
assert(count($rowsRaw) === 1);
408-
$rowsRaw = array_first($rowsRaw);
346+
foreach ($rowData as $row) {
347+
if ($row[$theirModelOrEntity->idField] === null) {
348+
$changes->inserts[] = $row;
349+
} else {
350+
$changes->updates[] = [[$theirModelOrEntity->idField => $row[$theirModelOrEntity->idField]], $row];
409351
}
410-
411-
$value = $this->getApp()->encodeJson($rowsRaw);
412352
}
413353

414-
$this->invokeWithContainsXxxNormalizeHookIgnored(function () use ($value) {
415-
parent::setInputValue($value);
416-
});
354+
$this->changes = $changes;
355+
356+
if ($this->entityField->getField()->hasReference()) {
357+
$changes->saveOnSave(
358+
$this->entityField->getEntity(),
359+
$this->entityField->getField()->getReference()
360+
);
361+
}
417362
}
418363

419364
/**
@@ -466,36 +411,11 @@ public function validate(array $rows, array $mlids): array
466411
return $rowErrors;
467412
}
468413

469-
/**
470-
* @return $this
471-
*/
472-
public function saveRows(): self
414+
public function saveRows(): void
473415
{
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-
}
416+
assert(!$this->entityField->getField()->hasReference());
497417

498-
return $this;
418+
$this->changes->saveTo($this->model);
499419
}
500420

501421
/**

src/Form/Control/TheirChanges.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
use Atk4\Data\Reference;
10+
11+
/**
12+
* TODO move to atk4/data and allow deep/nested changes.
13+
*/
14+
class TheirChanges
15+
{
16+
/** @var list<array<string, mixed>> */
17+
public array $inserts = [];
18+
19+
/** @var list<array{array<string, mixed>, array<string, mixed>}> */
20+
public array $updates = [];
21+
22+
/** @var list<array<string, mixed>> */
23+
public array $deletes = [];
24+
25+
/**
26+
* @param array<string, mixed> $oldData
27+
*/
28+
protected function loadEntity(Model $modelOrEntity, array $oldData): Model
29+
{
30+
$entity = $modelOrEntity->isEntity()
31+
? $modelOrEntity
32+
: $modelOrEntity->load($oldData[$modelOrEntity->idField]);
33+
34+
foreach ($oldData as $k => $v) {
35+
if (!$entity->compare($k, $v)) {
36+
throw (new Exception('Field value does not match expected value'))
37+
->addMoreInfo('entity', $entity)
38+
->addMoreInfo('field', $k)
39+
->addMoreInfo('valueExpected', $v)
40+
->addMoreInfo('valueActual', $entity->get($k));
41+
}
42+
}
43+
44+
return $entity;
45+
}
46+
47+
public function saveTo(Model $theirModelOrEntity): void
48+
{
49+
$theirModelOrEntity->atomic(function () use ($theirModelOrEntity) {
50+
foreach ($this->deletes as $oldData) {
51+
$entity = $this->loadEntity($theirModelOrEntity, $oldData);
52+
53+
$entity->delete();
54+
}
55+
56+
foreach ($this->updates as [$oldData, $newData]) {
57+
$entity = $this->loadEntity($theirModelOrEntity, $oldData);
58+
59+
$entity->setMulti($newData);
60+
$entity->save();
61+
}
62+
63+
foreach ($this->inserts as $newData) {
64+
$entity = $theirModelOrEntity->isEntity()
65+
? $theirModelOrEntity
66+
: $theirModelOrEntity->createEntity();
67+
$this->loadEntity($entity, [$theirModelOrEntity->idField => null]);
68+
69+
if (($newData[$theirModelOrEntity->idField] ?? null) === null) {
70+
unset($newData[$theirModelOrEntity->idField]);
71+
}
72+
73+
$entity->setMulti($newData);
74+
$entity->save();
75+
}
76+
});
77+
}
78+
79+
public function saveOnSave(Model $ourEntity, Reference $theirReference): void
80+
{
81+
$ourEntity->assertIsEntity();
82+
$theirReference->assertOurModelOrEntity($ourEntity);
83+
84+
$hookIndex = $ourEntity->onHook(Model::HOOK_AFTER_SAVE, function (Model $m) use ($ourEntity, $theirReference, &$hookIndex) {
85+
assert($m === $ourEntity); // prevent cloning
86+
87+
$ourEntity->removeHook(Model::HOOK_AFTER_SAVE, $hookIndex, true);
88+
89+
$theirModelOrEntity = $theirReference->ref($m);
90+
91+
$this->saveTo($theirModelOrEntity);
92+
});
93+
}
94+
}

0 commit comments

Comments
 (0)