PluginProbe
Gutenberg / 23.7.2
Gutenberg v23.7.2
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / compat / wordpress-7.1 / class-gutenberg-view-config-data.php

class-gutenberg-view-config-data.php in Gutenberg 23.7.2, at lib/compat/wordpress-7.1/class-gutenberg-view-config-data.php

743 lines 28.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Gutenberg_View_Config_Data class
4 *
5 * @package gutenberg
6 */
7
8 /**
9 * Holds an entity's view configuration while it is being built.
10 *
11 * An instance of this class is what `get_entity_view_config_{$kind}_{$name}`
12 * filter callbacks receive: a callback changes the configuration by calling
13 * methods on the instance and returning it. The configuration has four
14 * top-level keys — `default_view`, `default_layouts`, `view_list`, and
15 * `form` — and there are three ways to contribute. They form a gradient of how
16 * deep the replacement reaches:
17 *
18 * - The `merge()` method merges partial changes (patches) into what is already
19 * there: `default_view`, `default_layouts`, and the `form` settings by key,
20 * and the `view_list` entries by view `slug` identity. This is what plugins
21 * should use: patches compose with core's configuration and with other
22 * plugins'.
23 * - `replace()` applies a patch the same way `merge()` does, with one
24 * difference: a list in the patch replaces the current list wholesale
25 * instead of merging into it by member identity. It shouldn't be the
26 * default choice — a callback that replaces a list stops inheriting core's
27 * future additions to it — but it's useful when a contributor needs to pin
28 * a list to an exact set of members.
29 * - `set()` goes one step further: it replaces each top-level key the patch
30 * names wholesale, dropping whatever that key held instead of merging into
31 * it. It's for a callback that owns a key outright and wants to pin it to an
32 * exact shape without the inherited default leaking through a key-by-key
33 * merge.
34 *
35 * All three touch only the top-level keys the patch names — an omitted key
36 * keeps whatever it had, and a top-level `null` value drops the key it names,
37 * which resets it to its default. They differ only in how deep the replacement
38 * reaches once a key is named: `merge()` and `replace()` merge the value in
39 * key by key (an associative array merges member by member, a nested `null`
40 * deletes just that leaf, a scalar replaces just that value), while `set()`
41 * swaps the whole value. A nested `null` deletes just the leaf it names in
42 * every case. A patch value whose shape does not match the current value —
43 * an associative array where a list lives, or the reverse — is rejected with
44 * a notice rather than merged, and an empty array under `merge()` is a
45 * no-op. Each patch also declares the configuration schema
46 * version it was written against (currently 1), so a future WordPress release
47 * that changes the configuration shape can migrate existing patches forward
48 * instead of breaking them.
49 *
50 * Where those three write values, `remove()` deletes them: it takes a spec of
51 * names — a list to delete entries at a level, or a nested map to reach deeper —
52 * and prunes just what it names, mirroring the configuration's shape all the way
53 * down to individual list members.
54 *
55 * @since 7.1.0
56 */
57 class Gutenberg_View_Config_Data {
58
59 /**
60 * The latest supported configuration schema version.
61 *
62 * @since 7.1.0
63 * @var int
64 */
65 const LATEST_VERSION = 1;
66
67 /**
68 * The documented top-level configuration keys.
69 *
70 * @since 7.1.0
71 * @var string[]
72 */
73 const CONFIG_KEYS = array( 'default_view', 'default_layouts', 'view_list', 'form' );
74
75 /**
76 * The configuration being contributed to.
77 *
78 * @since 7.1.0
79 * @var array
80 */
81 private $config;
82
83 /**
84 * The default configuration.
85 *
86 * @since 7.1.0
87 * @var array
88 */
89 private $defaults;
90
91 /**
92 * Constructor.
93 *
94 * @since 7.1.0
95 *
96 * @param array $config The base configuration to contribute to.
97 */
98 public function __construct( array $config ) {
99 $this->config = $config;
100 $this->defaults = $config;
101 }
102
103 /**
104 * Returns the current configuration array.
105 *
106 * Deliberately private: filter callbacks receive the container, not the
107 * materialized configuration, so they cannot read the built result and
108 * become coupled to a specific configuration shape or schema version. Only
109 * the class itself reconciles the container back into an array.
110 *
111 * @since 7.1.0
112 *
113 * @return array The configuration.
114 */
115 private function get_data() {
116 return $this->config;
117 }
118
119 /**
120 * Applies the entity view configuration filter and returns the result.
121 *
122 * Exposes the container through the dynamic
123 * `get_entity_view_config_{$kind}_{$name}` filter so that core and third
124 * parties can provide the configuration for a specific entity, then
125 * reconciles the filtered container back into a plain configuration array,
126 * limited to the documented configuration keys.
127 *
128 * @since 7.1.0
129 *
130 * @param string $kind The entity kind (e.g. `postType`).
131 * @param string $name The entity name (e.g. `page`).
132 * @return array The filtered configuration, limited to the documented keys.
133 */
134 public function apply_filters( $kind, $name ) {
135 /**
136 * Filters the view configuration for a given entity.
137 *
138 * The dynamic portions of the hook name, `$kind` and `$name`, refer to the
139 * entity kind (e.g. `postType`) and the entity name (e.g. `page`).
140 *
141 * Callbacks receive a Gutenberg_View_Config_Data object and change the
142 * configuration through its methods. Each write method takes the schema
143 * version the change was authored against as its second argument,
144 * and returns the object for chaining:
145 *
146 * - `merge( $patch, $version )` merges a partial change into the current
147 * configuration. It touches only the top-level keys the patch names, and
148 * merges each named value into the current one by shape: a scalar
149 * replaces, an associative array merges key by key, and a list merges by
150 * member identity (`id`, `slug`, or `field`). A `null` value drops the
151 * key it names, resetting it to its default.
152 * - `replace( $patch, $version )` applies a patch exactly like `merge()`,
153 * but swaps any list it names wholesale instead of merging that list by
154 * member identity.
155 * - `set( $patch, $version )` also touches only the keys the patch names,
156 * but swaps each named value in wholesale, dropping whatever the key held
157 * before — for a callback that owns those keys outright.
158 * - `remove( $spec, $version )` deletes named properties. The spec mirrors
159 * the configuration shape: a list of names deletes entries at that level,
160 * and a nested map recurses to prune from within a named value, down to
161 * individual list members.
162 *
163 * A change that declares an unsupported schema version is rejected and does
164 * not alter anything. As with any filter, each callback's return value is
165 * passed to the next callback as `$data`, so callbacks must return the
166 * container they received: a callback that returns nothing, or any other
167 * value, hands that result to every callback hooked at a later priority
168 * instead of the container. Since the write methods return the container,
169 * a callback can end with `return $data->merge( $patch, $version );`.
170 *
171 * @param Gutenberg_View_Config_Data $data The view configuration container
172 * for the entity, exposing the
173 * `default_view`, `default_layouts`,
174 * `view_list`, and `form` keys.
175 * @param array $entity {
176 * The entity the configuration is built for.
177 *
178 * @type string $kind The entity kind.
179 * @type string $name The entity name.
180 * }
181 */
182 apply_filters(
183 "get_entity_view_config_{$kind}_{$name}",
184 $this,
185 array(
186 'kind' => $kind,
187 'name' => $name,
188 )
189 );
190
191 // Discard any keys the filter introduced that are not part of the
192 // documented configuration shape.
193 return array_intersect_key( $this->get_data(), array_flip( self::CONFIG_KEYS ) );
194 }
195
196 /**
197 * Replaces whole top-level keys, leaving the rest of the configuration alone.
198 *
199 * Like merge() and replace(), set() applies a patch of top-level keys and
200 * touches only the keys the patch names: a key the patch omits keeps whatever
201 * it had, and a `null` value drops the key it names (which resets it to its
202 * default). The difference is depth — where merge() and replace() merge a
203 * named key's value into the current one key by key, set() swaps the whole
204 * value in wholesale, dropping whatever the key held before. A `null` nested
205 * within that value still drops the property it names, so set() honours
206 * nulls at every depth just as merge() and replace() do.
207 *
208 * Use it when a callback owns a key outright and wants to pin it to an exact
209 * shape, without the inherited default leaking through a key-by-key merge.
210 *
211 * A patch that declares an unsupported schema version is rejected and does
212 * not change anything.
213 *
214 * @since 7.1.0
215 *
216 * @param array $patch The partial configuration whose named keys to replace.
217 * @param int $version The schema version the patch was authored against.
218 * @return Gutenberg_View_Config_Data The instance, for chaining.
219 */
220 public function set( array $patch, int $version ) {
221 return $this->apply( $patch, $version, __METHOD__, 'set' );
222 }
223
224 /**
225 * Removes named properties from the configuration, leaving the rest alone.
226 *
227 * Where merge(), replace(), and set() take a patch of *values* to write,
228 * remove() takes a spec of *names* to delete, and its shape mirrors the
229 * configuration it prunes:
230 *
231 * - A list of names deletes each named entry from the value at that level: a
232 * key from an associative array, or the member with a matching identity
233 * (`id`, `slug`, `field`, or a bare scalar) from a list.
234 * - An associative array maps a name to a nested spec, recursing into that
235 * entry's value to delete from within it.
236 *
237 * Naming a top-level configuration key is the one exception: like a `null`
238 * value in a patch, it resets that key to its default rather than dropping it
239 * outright, so top-level removal and top-level `null` compose the same way.
240 *
241 * So `array( 'default_view' )` resets the whole `default_view` key to its
242 * default, `array( 'default_view' => array( 'sort' ) )` drops just its `sort`
243 * property, and `array( 'default_view' => array( 'fields' => array( 'f2' ) ) )`
244 * drops the `f2` member from its `fields` list. A name that is not present is
245 * ignored, and a list is renumbered after a member is removed.
246 *
247 * A spec that declares an unsupported schema version is rejected and does not
248 * change anything.
249 *
250 * @since 7.1.0
251 *
252 * @param array $spec The names to remove, keyed to match the configuration shape.
253 * @param int $version The schema version the spec was authored against.
254 * @return Gutenberg_View_Config_Data The instance, for chaining.
255 */
256 public function remove( array $spec, int $version ) {
257 if ( $version <= 0 || $version > self::LATEST_VERSION ) {
258 _doing_it_wrong(
259 __METHOD__,
260 esc_html__( 'A view configuration patch must declare a supported schema version.', 'gutenberg' ),
261 '7.1.0'
262 );
263
264 return $this;
265 }
266
267 // A flat list names top-level keys to reset; a map recurses into each
268 // named key to prune from within its value.
269 $spec_is_list = array_is_list( $spec );
270 foreach ( $spec as $spec_key => $spec_value ) {
271 $key = $spec_is_list ? $spec_value : $spec_key;
272
273 if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
274 _doing_it_wrong(
275 __METHOD__,
276 sprintf(
277 /* translators: %s: the configuration key. */
278 esc_html__( '"%s" is not a documented view configuration key.', 'gutenberg' ),
279 esc_html( $key )
280 ),
281 '7.1.0'
282 );
283 continue;
284 }
285
286 if ( $spec_is_list ) {
287 // Removing a top-level key resets it to its default, just as a
288 // null patch value does.
289 $this->config[ $key ] = $this->defaults[ $key ] ?? array();
290 } elseif ( array_key_exists( $key, $this->config ) ) {
291 $this->config[ $key ] = $this->remove_properties( $this->config[ $key ], $spec_value );
292 }
293 }
294
295 return $this;
296 }
297
298 /**
299 * Replaces list values while merging the rest of a partial configuration.
300 *
301 * Takes the same arguments as merge() and applies the patch the same way,
302 * with one difference: a list in the patch replaces the current list
303 * wholesale instead of merging into it by member identity. Associative
304 * arrays still merge key by key, `null` still drops what it names, and a
305 * scalar still replaces the current value.
306 *
307 * It shouldn't be the default choice — a callback that replaces a list
308 * stops inheriting core's future additions to it — but it's useful when a
309 * contributor needs to pin a list to an exact set of members.
310 *
311 * The shape rule applies here too: a patch value whose shape does not match
312 * the current value — an associative array where a list lives, or a
313 * non-empty list where an associative value lives — is rejected with a
314 * notice and leaves the current value unchanged. An empty array is exempt,
315 * so replacing a list with an empty list still clears it.
316 *
317 * A patch that declares an unsupported schema version is rejected and does
318 * not change anything.
319 *
320 * @since 7.1.0
321 *
322 * @param array $patch The partial configuration to apply.
323 * @param int $version The schema version the patch was authored against.
324 * @return Gutenberg_View_Config_Data The instance, for chaining.
325 */
326 public function replace( array $patch, int $version ) {
327 return $this->apply( $patch, $version, __METHOD__, 'replace' );
328 }
329
330 /**
331 * Merges a partial configuration into the existing one.
332 *
333 * Applies a patch of top-level keys and touches only the keys the patch
334 * names: a key the patch omits keeps whatever it had, and a `null` value
335 * drops the key it names (which resets it to its default). Each named key's
336 * value is then merged into the current one by value shape:
337 *
338 * - a scalar replaces the current value;
339 * - an associative array merges key by key, with a nested `null` deleting
340 * just the leaf it names;
341 * - a list merges into the current list by member identity.
342 *
343 * Identity is the member's value cast to a string: a bare scalar is its own
344 * identity, and a map is identified by the value of the first of the
345 * well-known identity keys (`id`, `slug`, `field`) it carries. A member
346 * whose identity matches one already present merges into it in place, keeping
347 * its position; a member with no identity is appended to the end of the list.
348 *
349 * For example, given this patch:
350 *
351 * ```php
352 * array(
353 * 'default_view' => array( 'search' => 'new search', 'fields' => array( 'newField' ) ),
354 * 'default_layouts' => array( 'grid' => array( 'layout' => array( 'badgeFields' => array( 'newField' ) ) ) ),
355 * 'view_list' => array( array( 'slug' => 'table', 'title' => 'New title' ) ),
356 * )
357 * ```
358 *
359 * - default_view will be updated so the search string is 'new search' and the newField is appended to the list of fields.
360 * - default_layouts will be updated so that newField is appended to the badgeFields.
361 * - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'.
362 *
363 * A patch value only merges into a current value of the same shape: an
364 * associative array where a list lives, or a non-empty list where an
365 * associative value lives, is rejected with a notice and leaves the current
366 * value unchanged. An empty array merges nothing and is a no-op — clear a
367 * list with replace() and an empty list, or reset a key to its default with
368 * a top-level `null`.
369 *
370 * A patch that declares an unsupported schema version is rejected and does
371 * not change anything.
372 *
373 * @since 7.1.0
374 *
375 * @param array $patch The partial configuration to merge.
376 * @param int $version The schema version the patch was authored against.
377 * @return Gutenberg_View_Config_Data The instance, for chaining.
378 */
379 public function merge( array $patch, int $version ) {
380 return $this->apply( $patch, $version, __METHOD__, 'merge' );
381 }
382
383 /**
384 * Applies a patch to the configuration, top-level key by top-level key.
385 *
386 * Shared by merge(), replace(), and set(); the three differ only in how the
387 * value of a named key is applied, which is carried by $mode:
388 *
389 * - `merge` merges the value into the current one, lists by member identity;
390 * - `replace` merges the value in the same way but swaps lists wholesale;
391 * - `set` swaps the whole value in wholesale, without merging.
392 *
393 * In every mode a top-level `null` resets the key it names to its default, a
394 * nested `null` drops the property it names, and an omitted key is left
395 * untouched, so all three treat nulls the same way at every depth.
396 *
397 * @since 7.1.0
398 *
399 * @param array $patch The partial configuration to apply.
400 * @param int $version The schema version the patch was authored against.
401 * @param string $method The public method the patch was passed to, for misuse reporting.
402 * @param string $mode How to apply each named key's value: `merge`, `replace`, or `set`.
403 * @return Gutenberg_View_Config_Data The instance, for chaining.
404 */
405 private function apply( array $patch, int $version, $method, $mode ) {
406 if ( $version <= 0 || $version > self::LATEST_VERSION ) {
407 _doing_it_wrong(
408 esc_html( $method ),
409 esc_html__( 'A view configuration patch must declare a supported schema version.', 'gutenberg' ),
410 '7.1.0'
411 );
412
413 return $this;
414 }
415
416 foreach ( $patch as $key => $value ) {
417 if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
418 _doing_it_wrong(
419 esc_html( $method ),
420 sprintf(
421 /* translators: %s: the configuration key. */
422 esc_html__( '"%s" is not a documented view configuration key.', 'gutenberg' ),
423 esc_html( $key )
424 ),
425 '7.1.0'
426 );
427 continue;
428 }
429
430 // A null patch value makes the top-level property reset to defaults.
431 if ( null === $value ) {
432 $this->config[ $key ] = $this->defaults[ $key ] ?? array();
433 continue;
434 }
435
436 // set() swaps the whole value in; merge()/replace() merge it into the
437 // current one, differing only in how they treat lists. In every mode a
438 // nested null still drops the property it names.
439 $this->config[ $key ] = 'set' === $mode
440 ? $this->strip_nulls( $value )
441 : $this->merge_properties( $this->config[ $key ] ?? array(), $value, 'replace' === $mode );
442 }
443
444 return $this;
445 }
446
447 /**
448 * Recursively drops every property whose value is `null` from a value.
449 *
450 * set() swaps a named key's value in wholesale rather than merging it into
451 * the current one, so it has no existing leaf for a nested `null` to delete
452 * the way merge() and replace() do. Stripping nulls here gives a nested
453 * `null` the same "drop the property it names" meaning under set() that it
454 * carries everywhere else. The same applies to a list replace() swaps in
455 * wholesale. A list is renumbered after a member is removed so removed
456 * entries do not leave gaps.
457 *
458 * @since 7.1.0
459 *
460 * @param mixed $value The value to strip nulls from.
461 * @return mixed The value with every `null` property removed, recursively.
462 */
463 private function strip_nulls( $value ) {
464 if ( ! is_array( $value ) ) {
465 return $value;
466 }
467
468 $result = array();
469 foreach ( $value as $key => $item ) {
470 // A null value drops the property it names.
471 if ( null === $item ) {
472 continue;
473 }
474
475 $result[ $key ] = $this->strip_nulls( $item );
476 }
477
478 // Renumber a list so a removed member does not leave a gap.
479 return array_is_list( $value ) ? array_values( $result ) : $result;
480 }
481
482 /**
483 * Merges an incoming value into the current one, recursing by value shape.
484 *
485 * This is the core of the merge algorithm and is applied at every nesting
486 * level: a scalar (or `null`) in $incoming replaces $current outright, an
487 * associative array merges key by key (recursing here for each key, with a
488 * `null` value deleting that key), and a list either replaces $current
489 * wholesale ($replace_lists) or merges into it by member identity. The
490 * $replace_lists flag is carried down through associative nesting so that,
491 * under replace(), every list reached along the way is swapped wholesale.
492 *
493 * An array in $incoming only merges into a current value of the same shape.
494 * A non-empty mismatch — an associative array where a list lives, or a
495 * non-empty list where an associative value lives — is reported with
496 * _doing_it_wrong() and leaves the current value unchanged, so a malformed
497 * patch cannot silently destroy configuration. An empty array is
498 * shape-ambiguous and merges nothing, so it is a no-op: clearing a list is
499 * spelled replace() with an empty list, and resetting a key is spelled
500 * `null`.
501 *
502 * @since 7.1.0
503 *
504 * @param mixed $current The current value.
505 * @param mixed $incoming The incoming value.
506 * @param bool $replace_lists Whether a list in $incoming replaces the current list
507 * wholesale instead of merging into it by member identity.
508 * @return mixed The merged value.
509 */
510 private function merge_properties( $current, $incoming, $replace_lists ) {
511 // Scalar properties are merged as-is.
512 if ( ! is_array( $incoming ) ) {
513 return $incoming;
514 }
515
516 // Numerical indexed arrays are expected to be lists (sequential integer keys starting at 0).
517 if ( array_is_list( $incoming ) ) {
518 // A non-empty list only lands where a list (or nothing) lives, under
519 // merge() and replace() alike. An empty array is shape-ambiguous and
520 // exempt, so replace() with an empty list can still clear a list.
521 if ( array() !== $incoming && is_array( $current ) && ! array_is_list( $current ) && array() !== $current ) {
522 _doing_it_wrong(
523 __METHOD__,
524 esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.', 'gutenberg' ),
525 '7.1.0'
526 );
527 return $current;
528 }
529
530 // replace() takes an incoming list as-is; merge() merges it by member identity.
531 if ( $replace_lists ) {
532 // As-is except for nulls: a list swapped in wholesale has no
533 // existing leaf for a null to delete (the same rationale as
534 // set()), so a null member is dropped rather than stored.
535 return $this->strip_nulls( $incoming );
536 }
537
538 // An empty list has no members to merge, and an empty array is
539 // shape-ambiguous, so merging one is a no-op rather than a reset.
540 if ( array() === $incoming ) {
541 return $current;
542 }
543
544 return $this->merge_list_by_identity(
545 is_array( $current ) && array_is_list( $current ) ? $current : array(),
546 $incoming
547 );
548 }
549
550 // Consider any other array as associative (keys are strings).
551 if ( is_array( $current ) && array_is_list( $current ) && array() !== $current ) {
552 _doing_it_wrong(
553 __METHOD__,
554 esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.', 'gutenberg' ),
555 '7.1.0'
556 );
557 return $current;
558 }
559
560 $result = is_array( $current ) && ! array_is_list( $current ) ? $current : array();
561 foreach ( $incoming as $key => $value ) {
562 // A null patch value deletes the property.
563 if ( null === $value ) {
564 unset( $result[ $key ] );
565 continue;
566 }
567
568 $result[ $key ] = $this->merge_properties(
569 array_key_exists( $key, $result ) ? $result[ $key ] : array(),
570 $value,
571 $replace_lists
572 );
573 }
574
575 return $result;
576 }
577
578 /**
579 * Removes the properties a spec names from the current value.
580 *
581 * The mirror of merge_properties(), applied at every nesting level: a list in
582 * $spec names entries to delete from $current — associative keys are unset,
583 * and list members are matched by identity (list_item_identity) and dropped —
584 * while an associative $spec recurses into each named entry to prune from
585 * within it. A name absent from $current is ignored, and a list is renumbered
586 * after members are removed so it keeps sequential keys.
587 *
588 * @since 7.1.0
589 *
590 * @param mixed $current The current value.
591 * @param mixed $spec The names to remove from it.
592 * @return mixed The pruned value.
593 */
594 private function remove_properties( $current, $spec ) {
595 if ( ! is_array( $current ) || ! is_array( $spec ) ) {
596 return $current;
597 }
598
599 $current_is_list = array_is_list( $current );
600
601 if ( array_is_list( $spec ) ) {
602 // Each entry names something to delete from the current value.
603 foreach ( $spec as $name ) {
604 if ( $current_is_list ) {
605 $current = $this->remove_list_member( $current, $name );
606 } else {
607 unset( $current[ $name ] );
608 }
609 }
610 } else {
611 // Each key names an entry to recurse into and prune from within.
612 foreach ( $spec as $name => $subspec ) {
613 if ( $current_is_list ) {
614 foreach ( $current as $index => $member ) {
615 if ( $this->list_item_identity( $member ) === (string) $name ) {
616 $current[ $index ] = $this->remove_properties( $member, $subspec );
617 break;
618 }
619 }
620 } elseif ( array_key_exists( $name, $current ) ) {
621 $current[ $name ] = $this->remove_properties( $current[ $name ], $subspec );
622 }
623 }
624 }
625
626 // Renumber so a list from which a member was removed keeps sequential keys.
627 return $current_is_list ? array_values( $current ) : $current;
628 }
629
630 /**
631 * Removes the first list member matching an identity, leaving the rest.
632 *
633 * @since 7.1.0
634 *
635 * @param array $members The current list.
636 * @param mixed $identity The identity of the member to remove.
637 * @return array The list with the matching member removed, if any.
638 */
639 private function remove_list_member( array $members, $identity ) {
640 foreach ( $members as $index => $member ) {
641 if ( $this->list_item_identity( $member ) === (string) $identity ) {
642 unset( $members[ $index ] );
643 break;
644 }
645 }
646
647 return $members;
648 }
649
650 /**
651 * Merges an incoming list into the current one by member identity.
652 *
653 * A member of the incoming list whose identity matches one already present
654 * merges into it in place, keeping its position; an unmatched member is
655 * appended to the end, except a literal `null`, which carries no identity
656 * and holds nothing to merge and so is dropped. An appended member has no
657 * existing leaf for a nested `null` to delete (the same rationale as set()),
658 * so its nulls are stripped rather than stored. A matched member's contents
659 * merge recursively with the same rules (merge_properties), so the
660 * identity-aware merge applies at
661 * any nesting level: each key named by the patch is substituted while the
662 * others are left intact, and a list nested inside a member merges by
663 * identity just like the list it lives in.
664 *
665 * @since 7.1.0
666 *
667 * @param array $current The current list.
668 * @param array $incoming The incoming list.
669 * @return array The merged list.
670 */
671 private function merge_list_by_identity( array $current, array $incoming ) {
672 $result = $current;
673 foreach ( $incoming as $item ) {
674 // A null member carries no identity and holds nothing to merge,
675 // so it is dropped rather than appended as a literal null.
676 if ( null === $item ) {
677 continue;
678 }
679
680 $identity = $this->list_item_identity( $item );
681
682 // Find the index of the existing member with the same identity, if any.
683 // If there's none, append the incoming member to the end of the list.
684 $index = null;
685 if ( null !== $identity ) {
686 foreach ( $result as $i => $existing ) {
687 if ( $this->list_item_identity( $existing ) === $identity ) {
688 $index = $i;
689 break;
690 }
691 }
692 }
693 if ( null === $index ) {
694 // An appended member has no existing leaf for a nested null to
695 // delete, so nulls are dropped rather than stored.
696 $result[] = $this->strip_nulls( $item );
697 continue;
698 }
699
700 // Otherwise, merge the incoming member into the existing one in place.
701 $result[ $index ] = $this->merge_properties( $result[ $index ], $item, false );
702 }
703
704 return $result;
705 }
706
707 /**
708 * Resolves the identity used to match a list member against another.
709 *
710 * The identity is simply the member's value cast to a string, regardless of
711 * which key carries it: a bare scalar is its own identity, and a map is
712 * identified by the value of the first of the well-known identity keys
713 * (`id`, `slug`, `field`) it carries. Because the key is not part of
714 * the identity, a bare field like `'f3'` matches any map carrying that
715 * value, whether it appears as `array( 'id' => 'f3' )`,
716 * `array( 'slug' => 'f3' )`, and so on — this lets the same shorthand target
717 * lists keyed by different fields. Casting to string keeps numeric
718 * identities matching whether they arrive as an int or a string. Anything
719 * else (e.g. a nested list) has no identity and never matches, so it is
720 * always appended.
721 *
722 * @since 7.1.0
723 *
724 * @param mixed $item The list member.
725 * @return string|null The identity, or null when the member has none.
726 */
727 private function list_item_identity( $item ) {
728 if ( is_scalar( $item ) ) {
729 return (string) $item;
730 }
731
732 if ( is_array( $item ) && ! array_is_list( $item ) ) {
733 foreach ( array( 'id', 'slug', 'field' ) as $key ) {
734 if ( isset( $item[ $key ] ) && is_scalar( $item[ $key ] ) ) {
735 return (string) $item[ $key ];
736 }
737 }
738 }
739
740 return null;
741 }
742 }
743