PluginProbe
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant / 2.3.0
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant v2.3.0
2.3.2 2.3.1 2.3.0 2.2.8 2.2.7 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.11.0 1.11.1 1.11.2 1.6 1.7 1.8 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 1.9.10 1.9.11 All 60 releases
merchant / inc / abilities / class-merchant-field-validator.php

class-merchant-field-validator.php in Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant 2.3.0, at inc/abilities/class-merchant-field-validator.php

874 lines 26.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Merchant Field Validator.
4 *
5 * Validates and sanitizes field values submitted via the WP Abilities API.
6 * Implements a 4-stage pipeline: unknown key rejection, type coercion,
7 * constraint enforcement, and WordPress sanitization.
8 *
9 * @package Merchant
10 * @since 2.3.0
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Merchant_Field_Validator
19 *
20 * Validates input fields against a module's option group definitions.
21 * Produces valid values, errors, and warnings.
22 *
23 * @since 2.3.0
24 */
25 class Merchant_Field_Validator {
26
27 /**
28 * Schema generator for field lookup.
29 *
30 * @var Merchant_Schema_Generator
31 */
32 private $schema_generator;
33
34 /**
35 * Field registry used to delegate sanitization to the canonical field classes.
36 *
37 * @var Merchant_Field_Registry|null
38 */
39 private $field_registry;
40
41 /**
42 * Accumulated errors from fields_group recursive validation.
43 *
44 * @var array<int, array<string, mixed>>
45 */
46 private $fields_group_errors = array();
47
48 /**
49 * Constructor.
50 *
51 * @param Merchant_Schema_Generator $schema_generator Schema generator instance.
52 * @param Merchant_Field_Registry|null $field_registry Optional field registry; resolved lazily when null.
53 */
54 public function __construct( $schema_generator, $field_registry = null ) {
55 $this->schema_generator = $schema_generator;
56 $this->field_registry = $field_registry;
57 }
58
59 /**
60 * Resolve the field registry, lazily falling back to the singleton.
61 *
62 * @return Merchant_Field_Registry
63 */
64 private function registry() {
65 if ( null === $this->field_registry ) {
66 $this->field_registry = Merchant_Field_Registry::instance();
67 }
68
69 return $this->field_registry;
70 }
71
72 /**
73 * Validate and sanitize input fields against a module's definitions.
74 *
75 * @param string $module_id The module identifier.
76 * @param array<string, mixed> $input Key-value pairs of field updates.
77 * @param array<string, mixed> $context Optional context for conditional evaluation.
78 *
79 * @return array{valid: array<string, mixed>, errors: array<int, array<string, mixed>>, warnings: array<int, array<string, mixed>>}
80 */
81 public function validate_and_sanitize( $module_id, $input, $context = array() ) {
82 $valid = array();
83 $errors = array();
84 $warnings = array();
85
86 $this->fields_group_errors = array();
87
88 $known_fields = $this->schema_generator->get_field_ids( $module_id );
89
90 foreach ( $input as $field_id => $value ) {
91 // Stage 1: Unknown key rejection.
92 if ( ! in_array( $field_id, $known_fields, true ) ) {
93 $errors[] = array(
94 'code' => 'invalid_field_id',
95 'field' => $field_id,
96 'message' => sprintf( "Unknown field ID '%s'.", $field_id ),
97 'valid_fields' => $known_fields,
98 );
99 continue;
100 }
101
102 $field_def = $this->schema_generator->get_field_definition( $module_id, $field_id );
103
104 if ( null === $field_def ) {
105 continue;
106 }
107
108 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
109
110 // Stage 2: Type coercion & validation.
111 $type_result = $this->validate_type( $field_id, $value, $type, $field_def );
112 if ( null !== $type_result['error'] ) {
113 $errors[] = $type_result['error'];
114 continue;
115 }
116 $value = $type_result['value'];
117
118 // Stage 3: Constraint enforcement.
119 $constraint_error = $this->validate_constraints( $field_id, $value, $type, $field_def );
120 if ( null !== $constraint_error ) {
121 $errors[] = $constraint_error;
122 continue;
123 }
124
125 // Stage 4: WordPress sanitization.
126 $value = $this->sanitize_value( $value, $type, $field_def );
127
128 $valid[ $field_id ] = $value;
129 }
130
131 $errors = array_merge( $errors, $this->fields_group_errors );
132
133 return array(
134 'valid' => $valid,
135 'errors' => $errors,
136 'warnings' => $warnings,
137 );
138 }
139
140 /**
141 * Validate and sanitize campaign field updates.
142 *
143 * Same 4-stage pipeline as validate_and_sanitize() but resolves
144 * field definitions from inside a flexible_content campaign layout.
145 *
146 * @param string $module_id The module identifier.
147 * @param string $campaign_field_id The flexible_content field ID (e.g. 'campaigns').
148 * @param array<string, mixed> $input Key-value pairs of campaign field updates.
149 *
150 * @return array{valid: array<string, mixed>, errors: array<int, array<string, mixed>>, warnings: array<int, array<string, mixed>>}
151 */
152 public function validate_campaign_updates( $module_id, $campaign_field_id, $input ) {
153 $valid = array();
154 $errors = array();
155 $warnings = array();
156
157 $this->fields_group_errors = array();
158
159 $known_fields = $this->schema_generator->get_campaign_field_ids( $module_id, $campaign_field_id );
160
161 foreach ( $input as $field_id => $value ) {
162 // Stage 1: Unknown key rejection.
163 if ( ! in_array( $field_id, $known_fields, true ) ) {
164 $errors[] = array(
165 'code' => 'invalid_field_id',
166 'field' => $field_id,
167 'message' => sprintf( "Unknown campaign field ID '%s'.", $field_id ),
168 'valid_fields' => $known_fields,
169 );
170 continue;
171 }
172
173 $field_def = $this->schema_generator->get_campaign_field_definition(
174 $module_id,
175 $campaign_field_id,
176 $field_id
177 );
178
179 if ( null === $field_def ) {
180 continue;
181 }
182
183 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
184
185 // Stage 2: Type coercion & validation.
186 $type_result = $this->validate_type( $field_id, $value, $type, $field_def );
187 if ( null !== $type_result['error'] ) {
188 $errors[] = $type_result['error'];
189 continue;
190 }
191 $value = $type_result['value'];
192
193 // Stage 3: Constraint enforcement.
194 $constraint_error = $this->validate_constraints( $field_id, $value, $type, $field_def );
195 if ( null !== $constraint_error ) {
196 $errors[] = $constraint_error;
197 continue;
198 }
199
200 // Stage 4: WordPress sanitization.
201 $value = $this->sanitize_value( $value, $type, $field_def );
202
203 $valid[ $field_id ] = $value;
204 }
205
206 $errors = array_merge( $errors, $this->fields_group_errors );
207
208 return array(
209 'valid' => $valid,
210 'errors' => $errors,
211 'warnings' => $warnings,
212 );
213 }
214
215 /**
216 * Validate input against hand-authored field definitions.
217 *
218 * Unlike validate_and_sanitize(), the field defs here aren't looked up from a
219 * module's option groups — the caller supplies them directly (e.g. a bundle's
220 * per-item schema). Runs the same type-coercion and constraint stages, plus
221 * sanitization, so callers get back values ready to persist.
222 *
223 * @param array<string, array<string, mixed>> $field_defs Map of field_id => field definition.
224 * @param array<string, mixed> $input Key-value pairs to validate.
225 *
226 * @return array{valid: array<string, mixed>, errors: array<int, array<string, mixed>>}
227 */
228 public function validate_against_spec( array $field_defs, array $input ) {
229 $valid = array();
230 $errors = array();
231
232 $this->fields_group_errors = array();
233
234 foreach ( $input as $field_id => $value ) {
235 if ( ! array_key_exists( $field_id, $field_defs ) ) {
236 $errors[] = array(
237 'code' => 'unknown_field',
238 'field' => $field_id,
239 'message' => sprintf( "Unknown field '%s'.", $field_id ),
240 );
241 continue;
242 }
243
244 $field_def = $field_defs[ $field_id ];
245 $type = isset( $field_def['type'] ) ? $field_def['type'] : '';
246
247 $type_result = $this->validate_type( $field_id, $value, $type, $field_def );
248 if ( null !== $type_result['error'] ) {
249 $errors[] = $type_result['error'];
250 continue;
251 }
252 $value = $type_result['value'];
253
254 $constraint_error = $this->validate_constraints( $field_id, $value, $type, $field_def );
255 if ( null !== $constraint_error ) {
256 $errors[] = $constraint_error;
257 continue;
258 }
259
260 $valid[ $field_id ] = $this->sanitize_value( $value, $type, $field_def );
261 }
262
263 $errors = array_merge( $errors, $this->fields_group_errors );
264
265 return array(
266 'valid' => $valid,
267 'errors' => $errors,
268 );
269 }
270
271 /**
272 * Validate and coerce the value to the expected type.
273 *
274 * @param string $field_id Field identifier.
275 * @param mixed $value Input value.
276 * @param string $type Merchant field type.
277 * @param array<string, mixed> $field_def Full field definition.
278 *
279 * @return array{value: mixed, error: array<string, mixed>|null}
280 */
281 private function validate_type( $field_id, $value, $type, $field_def ) {
282 switch ( $type ) {
283 case 'text_readonly':
284 return array(
285 'value' => $value,
286 'error' => array(
287 'code' => 'readonly_field',
288 'field' => $field_id,
289 'message' => sprintf( "Field '%s' is read-only and cannot be updated.", $field_id ),
290 ),
291 );
292
293 case 'text':
294 case 'textarea':
295 case 'textarea_code':
296 case 'textarea_multiline':
297 case 'url':
298 case 'upload':
299 case 'select':
300 case 'radio':
301 case 'radio_alt':
302 case 'products_selector':
303 case 'gallery':
304 // gallery stores a comma-separated string of attachment/product IDs,
305 // like products_selector — mirrors Merchant_Field_Gallery::sanitize_value().
306 return $this->validate_string_type( $field_id, $value );
307
308 case 'number':
309 case 'range':
310 return $this->validate_numeric_type( $field_id, $value, $field_def );
311
312 case 'switcher':
313 case 'checkbox':
314 return $this->validate_boolean_type( $field_id, $value );
315
316 case 'color':
317 return $this->validate_color_type( $field_id, $value );
318
319 case 'date_time':
320 return $this->validate_date_time_type( $field_id, $value, $field_def );
321 }
322
323 if ( ! $this->registry()->has( $type ) ) {
324 return array(
325 'value' => $value,
326 'error' => array(
327 'code' => 'unsupported_field_type',
328 'field' => $field_id,
329 'message' => sprintf( "Field '%s' has unsupported type '%s'.", $field_id, $type ),
330 ),
331 );
332 }
333
334 $kind = $this->schema_generator->get_json_type( $type );
335
336 // Reject scalars for object kinds and for array types that would silently drop to []; others tolerate strings their field class decodes.
337 $needs_array = ( 'object' === $kind )
338 || in_array( $type, array( 'checkbox_multiple', 'flexible_content' ), true );
339
340 if ( $needs_array && ! is_array( $value ) ) {
341 return array(
342 'value' => $value,
343 'error' => array(
344 'code' => 'invalid_field_value',
345 'field' => $field_id,
346 'message' => sprintf(
347 "Field '%s' expects %s value.",
348 $field_id,
349 'object' === $kind ? 'an object' : 'an array'
350 ),
351 ),
352 );
353 }
354
355 return array( 'value' => $value, 'error' => null );
356 }
357
358 /**
359 * Validate that the value is a string.
360 *
361 * @param string $field_id Field identifier.
362 * @param mixed $value Input value.
363 *
364 * @return array{value: mixed, error: array<string, mixed>|null}
365 */
366 private function validate_string_type( $field_id, $value ) {
367 if ( ! is_string( $value ) ) {
368 return array(
369 'value' => $value,
370 'error' => $this->make_type_error( $field_id, 'string' ),
371 );
372 }
373
374 return array( 'value' => $value, 'error' => null );
375 }
376
377 /**
378 * Validate and coerce a numeric value.
379 *
380 * Casts to float when the step has a decimal point, otherwise int.
381 *
382 * @param string $field_id Field identifier.
383 * @param mixed $value Input value.
384 * @param array<string, mixed> $field_def Full field definition.
385 *
386 * @return array{value: mixed, error: array<string, mixed>|null}
387 */
388 private function validate_numeric_type( $field_id, $value, $field_def ) {
389 if ( ! is_numeric( $value ) ) {
390 return array(
391 'value' => $value,
392 'error' => $this->make_type_error( $field_id, 'number' ),
393 );
394 }
395
396 $step = isset( $field_def['step'] ) ? $field_def['step'] : 1;
397 if ( is_float( $step ) || ( is_string( $step ) && strpos( (string) $step, '.' ) !== false ) ) {
398 $value = (float) $value;
399 } else {
400 $value = (int) $value;
401 }
402
403 return array( 'value' => $value, 'error' => null );
404 }
405
406 /**
407 * Validate and coerce a boolean-like value (0/1/"0"/"1"/true/false → int).
408 *
409 * @param string $field_id Field identifier.
410 * @param mixed $value Input value.
411 *
412 * @return array{value: mixed, error: array<string, mixed>|null}
413 */
414 private function validate_boolean_type( $field_id, $value ) {
415 if ( ! in_array( $value, array( 0, 1, '0', '1', true, false ), true ) ) {
416 return array(
417 'value' => $value,
418 'error' => $this->make_type_error( $field_id, 'boolean (0 or 1)' ),
419 );
420 }
421
422 return array( 'value' => (int) (bool) $value, 'error' => null );
423 }
424
425 /**
426 * Validate a CSS color value.
427 *
428 * Accepts hex (#RGB, #RGBA, #RRGGBB, #RRGGBBAA), rgb(), rgba(), hsl(), hsla().
429 * Patterns match Merchant_Field_Color::sanitize_value() for consistency.
430 *
431 * @param string $field_id Field identifier.
432 * @param mixed $value Input value.
433 *
434 * @return array{value: mixed, error: array<string, mixed>|null}
435 */
436 private function validate_color_type( $field_id, $value ) {
437 if ( ! is_string( $value ) || ! $this->is_valid_css_color( $value ) ) {
438 return array(
439 'value' => $value,
440 'error' => array(
441 'code' => 'invalid_field_value',
442 'field' => $field_id,
443 'message' => sprintf(
444 "Value '%s' is not a valid CSS color. Accepted: hex (#RGB, #RRGGBB, #RRGGBBAA), rgb(), rgba(), hsl(), hsla().",
445 is_string( $value ) ? $value : gettype( $value )
446 ),
447 ),
448 );
449 }
450
451 return array( 'value' => $value, 'error' => null );
452 }
453
454 /**
455 * Check whether a string is a valid CSS color value.
456 *
457 * @param string $value The trimmed color string.
458 *
459 * @return bool
460 */
461 private function is_valid_css_color( $value ) {
462 // Hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA.
463 if ( preg_match( '/^#([A-Fa-f0-9]{3,4}){1,2}$/', $value ) ) {
464 return true;
465 }
466
467 // rgb(R, G, B) or rgba(R, G, B, A).
468 if ( preg_match( '/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)$/', $value ) ) {
469 return true;
470 }
471
472 // hsl(H, S%, L%) or hsla(H, S%, L%, A).
473 if ( preg_match( '/^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(,\s*(0|1|0?\.\d+)\s*)?\)$/', $value ) ) {
474 return true;
475 }
476
477 return false;
478 }
479
480 /**
481 * Validate a date/time string value.
482 *
483 * Accepts the native air-datepicker format (m-d-Y h:i A),
484 * ISO 8601 (Y-m-d, Y-m-dTH:i, Y-m-dTH:i:s), and m/d/y.
485 * Non-native formats are normalized to m-d-Y h:i A.
486 * Empty strings are accepted only when ai_meta.allow_empty is true.
487 *
488 * @param string $field_id Field identifier.
489 * @param mixed $value Input value.
490 * @param array<string, mixed> $field_def Full field definition.
491 *
492 * @return array{value: mixed, error: array<string, mixed>|null}
493 */
494 private function validate_date_time_type( $field_id, $value, $field_def ) {
495 if ( ! is_string( $value ) ) {
496 return array(
497 'value' => $value,
498 'error' => $this->make_type_error( $field_id, 'date/time string' ),
499 );
500 }
501
502 // Empty string gating via ai_meta.allow_empty.
503 if ( '' === $value ) {
504 $allow_empty = isset( $field_def['ai_meta']['allow_empty'] ) && $field_def['ai_meta']['allow_empty'];
505
506 if ( $allow_empty ) {
507 return array( 'value' => $value, 'error' => null );
508 }
509
510 return array(
511 'value' => $value,
512 'error' => array(
513 'code' => 'invalid_field_value',
514 'field' => $field_id,
515 'message' => sprintf( "Field '%s' requires a date/time value.", $field_id ),
516 ),
517 );
518 }
519
520 $native_format = 'm-d-Y h:i A';
521
522 // Try native format first (m-d-Y h:i A, e.g. 06-24-2026 03:30 PM).
523 if ( preg_match( '/^\d{2}-\d{2}-\d{4} \d{2}:\d{2} [AP]M$/', $value ) ) {
524 $d = DateTime::createFromFormat( $native_format, $value );
525
526 if ( $d && $d->format( $native_format ) === $value ) {
527 return array( 'value' => $value, 'error' => null );
528 }
529 }
530
531 // Try ISO 8601 with seconds (Y-m-dTH:i:s).
532 if ( preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/', $value ) ) {
533 $d = DateTime::createFromFormat( 'Y-m-d\TH:i:s', $value );
534
535 if ( $d && $d->format( 'Y-m-d\TH:i:s' ) === $value ) {
536 return array( 'value' => $d->format( $native_format ), 'error' => null );
537 }
538 }
539
540 // Try ISO 8601 without seconds (Y-m-dTH:i).
541 if ( preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/', $value ) ) {
542 $d = DateTime::createFromFormat( 'Y-m-d\TH:i', $value );
543
544 if ( $d && $d->format( 'Y-m-d\TH:i' ) === $value ) {
545 return array( 'value' => $d->format( $native_format ), 'error' => null );
546 }
547 }
548
549 // Try date-only ISO (Y-m-d).
550 if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
551 $d = DateTime::createFromFormat( 'Y-m-d', $value );
552
553 if ( $d && $d->format( 'Y-m-d' ) === $value ) {
554 $d->setTime( 0, 0, 0 );
555
556 return array( 'value' => $d->format( $native_format ), 'error' => null );
557 }
558 }
559
560 // Try slash format (m/d/y or m/d/yyyy).
561 if ( preg_match( '#^\d{1,2}/\d{1,2}/\d{2,4}$#', $value ) ) {
562 $parts = explode( '/', $value );
563 $format = strlen( $parts[2] ) <= 2 ? 'n/j/y' : 'n/j/Y';
564 $d = DateTime::createFromFormat( $format, $value );
565
566 if ( $d && $d->format( $format ) === $value ) {
567 $d->setTime( 0, 0, 0 );
568
569 return array( 'value' => $d->format( $native_format ), 'error' => null );
570 }
571 }
572
573 return array(
574 'value' => $value,
575 'error' => array(
576 'code' => 'invalid_field_value',
577 'field' => $field_id,
578 'message' => sprintf(
579 "Value '%s' is not a valid date/time. Accepted formats: m-d-Y h:i A (e.g. 06-24-2026 03:30 PM), Y-m-d, Y-m-dTH:i:s, m/d/y.",
580 $value
581 ),
582 ),
583 );
584 }
585
586 /**
587 * Validate constraints (enum, min/max, step).
588 *
589 * @param string $field_id Field identifier.
590 * @param mixed $value Coerced value.
591 * @param string $type Merchant field type.
592 * @param array<string, mixed> $field_def Full field definition.
593 *
594 * @return array<string, mixed>|null Error array, or null if valid.
595 */
596 private function validate_constraints( $field_id, $value, $type, $field_def ) {
597 // Enum check.
598 if ( in_array( $type, array( 'select', 'radio', 'radio_alt' ), true ) ) {
599 if ( isset( $field_def['options'] ) ) {
600 $allowed = array_keys( $field_def['options'] );
601 } elseif ( isset( $field_def['choices'] ) && is_array( $field_def['choices'] ) ) {
602 $allowed = $field_def['choices'];
603 } else {
604 $allowed = null;
605 }
606
607 if ( null !== $allowed && ! in_array( $value, $allowed, true ) ) {
608 return array(
609 'code' => 'invalid_field_value',
610 'field' => $field_id,
611 'message' => sprintf( "Value '%s' is not a valid option for '%s'.", $value, $field_id ),
612 'allowed_values' => $allowed,
613 );
614 }
615 }
616
617 // Min/Max check.
618 if ( in_array( $type, array( 'number', 'range' ), true ) ) {
619 if ( isset( $field_def['min'] ) && $value < $field_def['min'] ) {
620 return array(
621 'code' => 'invalid_field_value',
622 'field' => $field_id,
623 'message' => sprintf( "Value %s is below minimum %s for '%s'.", $value, $field_def['min'], $field_id ),
624 );
625 }
626 if ( isset( $field_def['max'] ) && $value > $field_def['max'] ) {
627 return array(
628 'code' => 'invalid_field_value',
629 'field' => $field_id,
630 'message' => sprintf( "Value %s exceeds maximum %s for '%s'.", $value, $field_def['max'], $field_id ),
631 );
632 }
633 }
634
635 return null;
636 }
637
638 /**
639 * Sanitize a value using WordPress sanitization functions.
640 *
641 * @param mixed $value The value to sanitize.
642 * @param string $type Merchant field type.
643 * @param array<string, mixed> $field_def Full field definition.
644 *
645 * @return mixed Sanitized value.
646 */
647 private function sanitize_value( $value, $type, $field_def ) {
648 switch ( $type ) {
649 case 'gallery':
650 // Not delegated: the field class only sanitize_text_field()s; the absint/CSV/cap hardening lives here.
651 $raw_ids = array_filter( explode( ',', (string) $value ) );
652 $ids = array_filter( array_map( 'absint', $raw_ids ) );
653 $ids = array_slice( array_values( $ids ), 0, $this->get_array_cap( $type ) );
654 return implode( ',', $ids );
655
656 case 'select_ajax':
657 return $this->sanitize_select_ajax( $value, $field_def );
658
659 case 'color':
660 case 'date_time':
661 return sanitize_text_field( $value );
662
663 case 'select':
664 case 'radio':
665 case 'radio_alt':
666 // Enum-constrained already; sanitize_text_field (not sanitize_key) preserves mixed-case keys like popFade.
667 return sanitize_text_field( $value );
668
669 case 'fields_group':
670 return $this->validate_fields_group( $field_def['id'], $value, $field_def );
671 }
672
673 $registry = $this->registry();
674
675 if ( ! $registry->has( $type ) ) {
676 return is_array( $value ) ? array() : sanitize_text_field( (string) $value );
677 }
678
679 /** @var Merchant_Field_Interface $field */
680 $field = $registry->create( $type, $field_def, $value );
681
682 // preprocess() only where the AI array shape matches the human $_POST shape; reviews_selector's would corrupt the array.
683 if ( in_array( $type, array( 'flexible_content', 'textarea_code', 'textarea_multiline' ), true ) ) {
684 $value = $field->preprocess( $value );
685 }
686
687 $value = $field->sanitize( $value );
688
689 // preserve_keys = true so an under-cap value matches the human save path; object kinds are never sliced (would drop named keys).
690 if ( is_array( $value ) && 'array' === $this->schema_generator->get_json_type( $type ) ) {
691 $value = array_slice( $value, 0, $this->get_array_cap( $type ), true );
692 }
693
694 return $value;
695 }
696
697 /**
698 * Return the maximum allowed items for an array field type.
699 *
700 * Defaults may be overridden via the merchant_ability_array_limits filter.
701 *
702 * @param string $type Field type key.
703 *
704 * @return int The cap for the given type, or PHP_INT_MAX if uncapped.
705 */
706 private function get_array_cap( $type ) {
707 $defaults = array(
708 'gallery' => 50,
709 'select_ajax' => 200,
710 'checkbox_multiple' => 200,
711 'choices' => 200,
712 'reviews_selector' => 200,
713 'sortable' => 200,
714 'sortable_repeater' => 200,
715 'sortable_repeater_icons' => 200,
716 // Shares the create-campaign cap filter key so both write paths use one ceiling.
717 'flexible_content' => 100,
718 );
719
720 /**
721 * Filter the maximum number of items allowed per array field type.
722 *
723 * @since 2.3.0
724 * @param array $defaults Map of field type => max items.
725 */
726 $limits = apply_filters( 'merchant_ability_array_limits', $defaults );
727
728 return isset( $limits[ $type ] ) ? (int) $limits[ $type ] : PHP_INT_MAX;
729 }
730
731 /**
732 * Validate and sanitize the sub-fields of a fields_group.
733 *
734 * Routes each sub-field through sanitize_value() rather than delegating the whole
735 * group, so the per-type preprocess decision (e.g. reviews_selector's wire-shape)
736 * and sub-field validation errors are preserved.
737 *
738 * @param string $parent_id The parent field_group ID (for error reporting).
739 * @param array<string, mixed> $value The submitted associative array of sub-field values.
740 * @param array<string, mixed> $field_def The fields_group field definition (contains 'fields').
741 *
742 * @return array<string, mixed> The validated and sanitized sub-field values.
743 */
744 private function validate_fields_group( $parent_id, $value, $field_def ) {
745 if ( ! isset( $field_def['fields'] ) || ! is_array( $field_def['fields'] ) ) {
746 return $value;
747 }
748
749 $sub_field_defs = array();
750 foreach ( $field_def['fields'] as $sub_field ) {
751 if ( isset( $sub_field['id'] ) ) {
752 $sub_field_defs[ $sub_field['id'] ] = $sub_field;
753 }
754 }
755
756 $validated = array();
757 foreach ( $value as $sub_key => $sub_value ) {
758 if ( ! isset( $sub_field_defs[ $sub_key ] ) ) {
759 continue;
760 }
761
762 $sub_def = $sub_field_defs[ $sub_key ];
763 $sub_type = isset( $sub_def['type'] ) ? $sub_def['type'] : '';
764
765 // Stage 2: Type coercion.
766 $type_result = $this->validate_type( $sub_key, $sub_value, $sub_type, $sub_def );
767 if ( null !== $type_result['error'] ) {
768 $type_result['error']['field'] = $parent_id . '.' . $type_result['error']['field'];
769 $this->fields_group_errors[] = $type_result['error'];
770 continue;
771 }
772 $sub_value = $type_result['value'];
773
774 // Stage 3: Constraint enforcement.
775 $constraint_error = $this->validate_constraints( $sub_key, $sub_value, $sub_type, $sub_def );
776 if ( null !== $constraint_error ) {
777 $constraint_error['field'] = $parent_id . '.' . $constraint_error['field'];
778 $this->fields_group_errors[] = $constraint_error;
779 continue;
780 }
781
782 // Stage 4: Sanitization.
783 $sub_value = $this->sanitize_value( $sub_value, $sub_type, $sub_def );
784
785 $validated[ $sub_key ] = $sub_value;
786 }
787
788 // Preserve the virtual display-status (active/inactive) control.
789 if (
790 Merchant_Schema_Generator::ensure_field_classes()
791 && Merchant_Field_Fields_Group::has_status_field( $field_def )
792 ) {
793 $status_key = $field_def['id'] . '_status';
794 if ( isset( $value[ $status_key ] ) ) {
795 $status_def = Merchant_Field_Fields_Group::get_status_field_definition( $field_def );
796 $allowed = array_keys( $status_def['options'] );
797 $raw = is_string( $value[ $status_key ] ) ? sanitize_text_field( $value[ $status_key ] ) : '';
798 if ( in_array( $raw, $allowed, true ) ) {
799 $validated[ $status_key ] = $raw;
800 } else {
801 $this->fields_group_errors[] = array(
802 'code' => 'invalid_field_value',
803 'field' => $parent_id . '.' . $status_key,
804 'message' => sprintf( "Field '%s' must be one of: %s.", $status_key, implode( ', ', $allowed ) ),
805 );
806 }
807 }
808 }
809
810 return $validated;
811 }
812
813 /**
814 * Sanitize a select_ajax value.
815 *
816 * When inline options are provided, values are kept verbatim if they match
817 * the allowlist — preserving percent-encoded taxonomy slugs (e.g. Arabic
818 * category slugs that sanitize_text_field would destroy).
819 * Mirrors Merchant_Field_Select_Ajax::sanitize_value().
820 *
821 * @param mixed $value Scalar or array of submitted values.
822 * @param array<string, mixed> $field_def Field definition (may include 'options').
823 *
824 * @return mixed Sanitized scalar or array.
825 */
826 private function sanitize_select_ajax( $value, $field_def ) {
827 $has_options = ! empty( $field_def['options'] ) && is_array( $field_def['options'] );
828
829 if ( $has_options ) {
830 $valid_ids = wp_list_pluck( $field_def['options'], 'id' );
831 $cap = $this->get_array_cap( 'select_ajax' );
832
833 if ( is_array( $value ) ) {
834 $filtered = array_values(
835 array_filter(
836 $value,
837 static function ( $v ) use ( $valid_ids ) {
838 return in_array( $v, $valid_ids, true );
839 }
840 )
841 );
842 return array_slice( $filtered, 0, $cap );
843 }
844
845 return in_array( $value, $valid_ids, true ) ? $value : '';
846 }
847
848 // AJAX-loaded source: sanitize normally.
849 if ( is_array( $value ) ) {
850 $cap = $this->get_array_cap( 'select_ajax' );
851 $sanitized = array_filter( array_map( 'sanitize_text_field', $value ) );
852 return array_slice( array_values( $sanitized ), 0, $cap );
853 }
854
855 return sanitize_text_field( $value );
856 }
857
858 /**
859 * Build a type mismatch error.
860 *
861 * @param string $field_id Field identifier.
862 * @param string $expected_type Expected type description.
863 *
864 * @return array<string, string> Error array.
865 */
866 private function make_type_error( $field_id, $expected_type ) {
867 return array(
868 'code' => 'invalid_field_value',
869 'field' => $field_id,
870 'message' => sprintf( "Field '%s' expects a %s value.", $field_id, $expected_type ),
871 );
872 }
873 }
874