Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions demos/form-control/multiline-containsmany-single.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,10 @@
$form = Form::addTo($app);
$form->setEntity((new MultilineDelivery($app->db))->createEntity());
$form->onSubmit(static function (Form $form) {
// save ContainsXxx data to JSON
// https://github.com/atk4/data/blob/6.0.0/src/Reference/ContainsOne.php#L29-L40
$form->entity->save();
$form->entity->setNull($form->entity->idField);

return new JsToast($form->getApp()->encodeJson($form->entity->get()));
});
4 changes: 3 additions & 1 deletion demos/form-control/multiline.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@

$form->onSubmit(static function (Form $form) use ($multiline) {
$rows = $multiline->model->atomic(static function () use ($multiline) {
return $multiline->saveRows()->model->export();
$multiline->saveRows();

return $multiline->model->export();
});

$rows = array_map(static fn ($row) => $form->getApp()->uiPersistence->typecastSaveRow($multiline->model, $row), $rows);
Expand Down
23 changes: 23 additions & 0 deletions demos/init-db.php
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,29 @@ protected function init(): void

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

// workaround autoincrement for custom ID type for ContainsXxx save
// https://github.com/atk4/data/blob/6.0.0/src/Persistence/Array_.php#L324
$this->onHookShort(Model::HOOK_BEFORE_INSERT, function (&$data) {
$persistence = $this->getModel()->getPersistence();
if ($persistence instanceof Persistence\Array_) {
$idField = $this->getIdField();

if (isset($data[$idField->shortName])) {
return;
}

assert($this->getModel()->containedInEntity !== null);

$origIdFieldType = $idField->type;
$idField->type = 'bigint';
try {
$data[$idField->shortName] = new WrappedId($persistence->generateNewId($this->getModel()));
} finally {
$idField->type = $origIdFieldType;
}
}
});

$this->initPreventModification();

if ($this->getPersistence()->getDatabasePlatform() instanceof PostgreSQLPlatform || class_exists(CodeCoverage::class, false)) {
Expand Down
6 changes: 6 additions & 0 deletions demos/interactive/loader2.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Atk4\Ui\Columns;
use Atk4\Ui\Form;
use Atk4\Ui\Grid;
use Atk4\Ui\Js\JsToast;
use Atk4\Ui\Loader;
use Atk4\Ui\Text;
use Atk4\Ui\View;
Expand All @@ -35,4 +36,9 @@

$form = Form::addTo($p);
$form->setEntity($country->load($id));
$form->onSubmit(static function (Form $form) {
$message = $form->entity->getUserAction('edit')->execute();

return new JsToast($message);
});
});
2 changes: 1 addition & 1 deletion docs/multiline.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ $userForm->onSubmit(function (Form $form) use ($ml) {
// save emails record related to current user
$ml->saveRows();

return new JsToast(var_export($ml->model->export(), true));
return new JsToast(json_encode($ml->model->export()));
});
```

Expand Down
162 changes: 41 additions & 121 deletions src/Form/Control/Multiline.php
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ class Multiline extends Form\Control
/** @var list<string> The fields names used in each row. */
public $rowFields;

/** @var list<array<string, mixed>> The data sent for each row. */
protected $rowData;
/** The changes set by self::setInputValue(). */
public TheirChanges $changes;

/** @var int The max number of records (rows) that can be added to Multiline. 0 means no limit. */
public $rowLimit = 0;
Expand Down Expand Up @@ -276,17 +276,17 @@ private function isOneToOne(): bool
#[\Override]
public function getInputValue(): string
{
$refModelOrEntity = $this->entityField->getField()->hasReference()
$theirModelOrEntity = $this->entityField->getField()->hasReference()
? $this->entityField->getField()->getReference()->ref($this->entityField->getEntity())
: $this->model;
if ($refModelOrEntity->isEntity()) {
$refModelOrEntity = $refModelOrEntity->isLoaded()
? [$refModelOrEntity]
if ($theirModelOrEntity->isEntity()) {
$theirModelOrEntity = $theirModelOrEntity->isLoaded()
? [$theirModelOrEntity]
: [];
}

$rows = [];
foreach ($refModelOrEntity as $row) {
foreach ($theirModelOrEntity as $row) {
$rows[] = $row->get();
}

Expand All @@ -295,53 +295,6 @@ public function getInputValue(): string
return $this->getApp()->encodeJson($rowsUi);
}

/**
* Same as Persistence::typecastSaveRow() but allow null in not-nullable fields.
*
* @param array<string, mixed> $row
*
* @return array<string, scalar|null>
*/
private function typecastContainedSaveRow(array $row): array
{
$res = [];
foreach ($row as $fieldName => $value) {
$field = $this->model->getField($fieldName);

$res[$field->getPersistenceName()] = $value === null
? null
: $this->model->getPersistence()->typecastSaveField($field, $value);
}

return $res;
}

/**
* @param \Closure(): void $fx
*/
private function invokeWithContainsXxxNormalizeHookIgnored(\Closure $fx): void
{
// TOD hack to supress "Contained model data cannot be modified directly" exception
// https://github.com/atk4/data/blob/fca1cd55b0/src/Reference/ContainsBase.php#L58-L81
$ourModel = $this->entityField->getModel();
\Closure::bind(static function () use ($fx, $ourModel) {
$spot = Model::HOOK_NORMALIZE;
$priority = \PHP_INT_MIN;
$normalizeHooks = $ourModel->hooks[$spot][$priority] ?? [];
foreach (array_keys($normalizeHooks) as $hookIndex) {
$ourModel->hooks[$spot][$priority][$hookIndex][0] = static fn () => null;
}

try {
$fx();
} finally {
foreach (array_keys($normalizeHooks) as $hookIndex) {
$ourModel->hooks[$spot][$priority][$hookIndex][0] = $normalizeHooks[$hookIndex][0];
}
}
}, null, Model::class)();
}

/**
* @return array{list<array<string, mixed>>, list<string>}
*/
Expand All @@ -353,6 +306,13 @@ private function decodeInput(string $json): array
foreach ($rowDataWithMlid as $row) {
$mlids[] = $row['__atkml'];
unset($row['__atkml']);

foreach ($row as $k => $v) {
if ($this->model->getField($k)->readOnly) {
unset($row[$k]);
}
}

$rowData[] = $this->typecastUiLoadRow($row);
}

Expand All @@ -364,56 +324,41 @@ public function setInputValue(string $value): void
{
[$rowData, $mlids] = $this->decodeInput($value);

$this->rowData = $rowData;
if ($this->rowData !== []) {
$this->rowErrors = $this->validate($this->rowData, $mlids);
if ($rowData !== []) {
$this->rowErrors = $this->validate($rowData, $mlids);
if ($this->rowErrors !== []) {
throw new ValidationException([$this->shortName => 'Multiline error']);
}
}

$rowsRaw = array_map(fn ($v) => $this->typecastContainedSaveRow($v), $this->rowData);
$theirModelOrEntity = $this->entityField->getField()->hasReference()
? $this->entityField->getField()->getReference()->ref($this->entityField->getEntity())
: $this->model;

// mimic ContainsOne save format
// https://github.com/atk4/data/blob/6.0.0/src/Reference/ContainsOne.php#L37
// TODO replace with something like "schedule model save task" and then drop self::saveRows() method
if ($rowsRaw === []) {
$value = '';
} else {
foreach ($rowsRaw as $k => $rowRaw) {
$idFieldRawName = $this->model->getIdField()->getPersistenceName();
if ($rowRaw[$idFieldRawName] === null) {
$refModel = $this->entityField->getField()->hasReference()
? $this->entityField->getField()->getReference()->ref($this->entityField->getEntity())->getModel(true)
: $this->model;

// TODO to pass Behat tests purely
if (!$this->entityField->getField()->hasReference() && $this->entityField->getField()->type !== 'json' && !$refModel->getPersistence() instanceof Persistence\Array_) {
continue;
}
$changes = new TheirChanges();

$idField = $refModel->getIdField();
$origIdFieldType = $idField->type;
$idField->type = 'bigint';
try {
$rowsRaw[$k][$idFieldRawName] = Persistence\Array_::assertInstanceOf($refModel->getPersistence())->generateNewId($refModel);
} finally {
$idField->type = $origIdFieldType;
}
}
}
// TODO this is dangerous, deleted row IDs should be passed from UI
$idsToDelete = array_filter(array_column($rowData, $theirModelOrEntity->idField), static fn ($v) => $v !== null);
foreach ($theirModelOrEntity->getModel(true)->createIteratorBy($theirModelOrEntity->idField, 'not in', $idsToDelete) as $entity) {
$changes->deletes[] = [$theirModelOrEntity->idField => $entity->getId()];
}

if ($this->isOneToOne()) {
assert(count($rowsRaw) === 1);
$rowsRaw = array_first($rowsRaw);
foreach ($rowData as $row) {
if ($row[$theirModelOrEntity->idField] === null) {
$changes->inserts[] = $row;
} else {
$changes->updates[] = [[$theirModelOrEntity->idField => $row[$theirModelOrEntity->idField]], $row];
}

$value = $this->getApp()->encodeJson($rowsRaw);
}

$this->invokeWithContainsXxxNormalizeHookIgnored(function () use ($value) {
parent::setInputValue($value);
});
$this->changes = $changes;

if ($this->entityField->getField()->hasReference()) {
$changes->saveOnSave(
$this->entityField->getEntity(),
$this->entityField->getField()->getReference()
);
}
}

/**
Expand Down Expand Up @@ -466,36 +411,11 @@ public function validate(array $rows, array $mlids): array
return $rowErrors;
}

/**
* @return $this
*/
public function saveRows(): self
public function saveRows(): void
{
$model = $this->model;

// delete removed rows
// TODO this is dangerous, deleted row IDs should be passed from UI
$idsToDelete = array_filter(array_column($this->rowData, $model->idField), static fn ($v) => $v !== null);
foreach ($model->createIteratorBy($model->idField, 'not in', $idsToDelete) as $entity) {
$entity->delete();
}

foreach ($this->rowData as $row) {
$entity = $row[$model->idField] !== null
? $model->load($row[$model->idField])
: $model->createEntity();
foreach ($row as $fieldName => $value) {
if ($model->getField($fieldName)->isEditable()) {
$entity->set($fieldName, $value);
}
}

if (!$entity->isLoaded() || $entity->getDirtyRef() !== []) {
$entity->save();
}
}
assert(!$this->entityField->getField()->hasReference());

return $this;
$this->changes->saveTo($this->model);
}

/**
Expand Down
94 changes: 94 additions & 0 deletions src/Form/Control/TheirChanges.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace Atk4\Ui\Form\Control;

use Atk4\Data\Exception;
use Atk4\Data\Model;
use Atk4\Data\Reference;

/**
* TODO move to atk4/data and allow deep/nested changes.
*/
class TheirChanges
{
/** @var list<array<string, mixed>> */
public array $inserts = [];

/** @var list<array{array<string, mixed>, array<string, mixed>}> */
public array $updates = [];

/** @var list<array<string, mixed>> */
public array $deletes = [];

/**
* @param array<string, mixed> $oldData
*/
protected function loadEntity(Model $modelOrEntity, array $oldData): Model
{
$entity = $modelOrEntity->isEntity()
? $modelOrEntity
: $modelOrEntity->load($oldData[$modelOrEntity->idField]);

foreach ($oldData as $k => $v) {
if (!$entity->compare($k, $v)) {
throw (new Exception('Field value does not match expected value'))
->addMoreInfo('entity', $entity)
->addMoreInfo('field', $k)
->addMoreInfo('valueExpected', $v)
->addMoreInfo('valueActual', $entity->get($k));
}
}

return $entity;
}

public function saveTo(Model $theirModelOrEntity): void
{
$theirModelOrEntity->atomic(function () use ($theirModelOrEntity) {
foreach ($this->deletes as $oldData) {
$entity = $this->loadEntity($theirModelOrEntity, $oldData);

$entity->delete();
}

foreach ($this->updates as [$oldData, $newData]) {
$entity = $this->loadEntity($theirModelOrEntity, $oldData);

$entity->setMulti($newData);
$entity->save();
}

foreach ($this->inserts as $newData) {
$entity = $theirModelOrEntity->isEntity()
? $theirModelOrEntity
: $theirModelOrEntity->createEntity();
$this->loadEntity($entity, [$theirModelOrEntity->idField => null]);

if (($newData[$theirModelOrEntity->idField] ?? null) === null) {
unset($newData[$theirModelOrEntity->idField]);
}

$entity->setMulti($newData);
$entity->save();
}
});
}

public function saveOnSave(Model $ourEntity, Reference $theirReference): void
{
$ourEntity->assertIsEntity();
$theirReference->assertOurModelOrEntity($ourEntity);

$hookIndex = $ourEntity->onHook(Model::HOOK_AFTER_SAVE, function (Model $m) use ($ourEntity, $theirReference, &$hookIndex) {
assert($m === $ourEntity); // prevent cloning

$ourEntity->removeHook(Model::HOOK_AFTER_SAVE, $hookIndex, true);

$theirModelOrEntity = $theirReference->ref($m);

$this->saveTo($theirModelOrEntity);
});
}
}
Loading