PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 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 All 404 releases
gutenberg / lib / compat / wordpress-7.1 / class-gutenberg-view-config-data.php

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

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