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