Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.64% covered (warning)
85.64%
161 / 188
46.15% covered (danger)
46.15%
6 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Conditional_Logic
85.64% covered (warning)
85.64%
161 / 188
46.15% covered (danger)
46.15%
6 / 13
171.28
0.00% covered (danger)
0.00%
0 / 1
 type_key_for_field_type
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 evaluate
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
24
 resolve_visibility
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
17
 evaluate_rule_value
93.33% covered (success)
93.33%
42 / 45
0.00% covered (danger)
0.00%
0 / 1
35.36
 to_selection_list
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 to_rating_value
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 to_numeric_pair
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 to_temporal_pair
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parse_temporal
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 parse_date
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 to_date_int
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 to_comparable_string
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
16.73
 is_empty_value
29.41% covered (danger)
29.41%
5 / 17
0.00% covered (danger)
0.00%
0 / 1
45.17
1<?php
2/**
3 * Conditional Logic evaluator for Jetpack form fields.
4 *
5 * Mirrors the JS implementation in
6 * src/blocks/shared/conditional-logic/util/evaluate.ts and MUST stay in sync.
7 *
8 * The browser decides what a visitor sees; this class decides what gets validated and
9 * stored. A disagreement between the two either discards a real answer or accepts one for
10 * a field that was never shown, so Conditional_Logic_Parity_Test pins the operator
11 * vocabulary and Conditional_Logic_Test mirrors the JS cases one for one.
12 *
13 * Targets PHP 7.4: no typed class constants, no match expressions.
14 *
15 * @package automattic/jetpack-forms
16 */
17
18namespace Automattic\Jetpack\Forms\ContactForm;
19
20/**
21 * Pure evaluator for field conditional-logic rules.
22 */
23class Conditional_Logic {
24
25    const OP_IS               = 'is';
26    const OP_IS_NOT           = 'is_not';
27    const OP_CONTAINS         = 'contains';
28    const OP_DOES_NOT_CONTAIN = 'does_not_contain';
29    const OP_IS_EMPTY         = 'is_empty';
30    const OP_IS_NOT_EMPTY     = 'is_not_empty';
31    const OP_EQUALS           = 'equals';
32    const OP_NOT_EQUALS       = 'not_equals';
33    const OP_GREATER_THAN     = 'greater_than';
34    const OP_LESS_THAN        = 'less_than';
35    const OP_GTE              = 'gte';
36    const OP_LTE              = 'lte';
37    const OP_BEFORE           = 'before';
38    const OP_AFTER            = 'after';
39    const OP_IS_CHECKED       = 'is_checked';
40    const OP_IS_NOT_CHECKED   = 'is_not_checked';
41
42    /**
43     * The rule type this release understands.
44     *
45     * Rules carry their own type so further condition kinds -- query string, user role, date
46     * and time -- become new rule types inside the existing groups rather than another
47     * reshape of the stored attribute.
48     */
49    const RULE_TYPE_FIELD_VALUE = 'fieldValue';
50
51    /**
52     * Shortcode field type to comparison behavior.
53     *
54     * Mirrors TYPE_KEY_BY_FIELD_TYPE in
55     * src/blocks/shared/conditional-logic/util/field-types.ts. `field-telephone` renders as
56     * either `telephone` or `phone` depending on its country-selector setting, so both appear.
57     *
58     * @var array
59     */
60    const TYPE_KEY_BY_FIELD_TYPE = array(
61        'text'              => 'string',
62        'name'              => 'string',
63        'email'             => 'string',
64        'url'               => 'string',
65        'textarea'          => 'string',
66        'telephone'         => 'string',
67        'phone'             => 'string',
68        'select'            => 'choice',
69        'radio'             => 'choice',
70        'image-select'      => 'choice',
71        'checkbox-multiple' => 'multichoice',
72        'number'            => 'number',
73        'slider'            => 'number',
74        'rating'            => 'rating',
75        'date'              => 'date',
76        'time'              => 'time',
77        'checkbox'          => 'boolean',
78        'consent'           => 'boolean',
79        'hidden'            => 'hidden',
80        'file'              => 'file',
81    );
82
83    /**
84     * Operators that compare against nothing, so `value` is ignored.
85     *
86     * @var array
87     */
88    const OPERATORS_WITHOUT_VALUE = array(
89        self::OP_IS_EMPTY,
90        self::OP_IS_NOT_EMPTY,
91        self::OP_IS_CHECKED,
92        self::OP_IS_NOT_CHECKED,
93    );
94
95    /**
96     * Resolve a shortcode field type to its comparison behavior.
97     *
98     * @param string $field_type The shortcode `type` attribute.
99     *
100     * @return string The type key; unknown types compare textually rather than being dropped.
101     */
102    public static function type_key_for_field_type( $field_type ): string {
103        if ( ! is_string( $field_type ) || '' === $field_type ) {
104            return 'string';
105        }
106
107        return self::TYPE_KEY_BY_FIELD_TYPE[ $field_type ] ?? 'string';
108    }
109
110    /**
111     * Evaluate a field's conditional logic.
112     *
113     * A rule whose subject field is absent from `$field_types` is ignored rather than
114     * compared against an empty value, so deleting an unrelated block cannot silently hide a
115     * field. When every rule is ignored the field stays visible.
116     *
117     * @param array|null $logic       The field's conditional-logic config.
118     * @param array      $field_types   Map of field id to shortcode type, for every form field.
119     * @param array      $form_values   Map of field id to submitted value.
120     * @param array      $field_formats Map of field id to date format, for date fields.
121     *
122     * @return bool True when the field should be visible.
123     */
124    public static function evaluate( $logic, array $field_types, array $form_values, array $field_formats = array() ): bool {
125        if ( ! is_array( $logic ) || empty( $logic['enabled'] ) ) {
126            return true;
127        }
128
129        $groups = isset( $logic['groups'] ) && is_array( $logic['groups'] ) ? $logic['groups'] : array();
130
131        if ( empty( $groups ) ) {
132            return true;
133        }
134
135        // Each group reduces its own rules with its own operator; the groups then reduce with
136        // the top-level one. With a single group -- all the V1 panel writes -- the outer
137        // reduction is a no-op, so this behaves exactly as a flat rule list until a second
138        // group exists.
139        $group_outcomes = array();
140
141        foreach ( $groups as $group ) {
142            $rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : array();
143
144            $outcomes = array();
145            foreach ( $rules as $rule ) {
146                if ( ! is_array( $rule ) || empty( $rule['field'] ) || empty( $rule['operator'] ) ) {
147                    continue;
148                }
149
150                // A condition kind this release does not know: ignore that rule, so a form
151                // saved by a newer editor degrades to its remaining conditions.
152                if ( ! empty( $rule['type'] ) && self::RULE_TYPE_FIELD_VALUE !== $rule['type'] ) {
153                    continue;
154                }
155
156                $field_id = (string) $rule['field'];
157                if ( ! array_key_exists( $field_id, $field_types ) ) {
158                    continue; // Subject field no longer exists: ignore this rule.
159                }
160
161                $type_key = self::type_key_for_field_type( $field_types[ $field_id ] );
162                $actual   = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
163                // A date field's value is written in its own format, so the comparison needs it.
164                $format  = isset( $field_formats[ $field_id ] ) ? (string) $field_formats[ $field_id ] : '';
165                $outcome = self::evaluate_rule_value( $rule, $type_key, $actual, $format );
166
167                if ( null !== $outcome ) {
168                    $outcomes[] = $outcome;
169                }
170            }
171
172            // A group with nothing evaluable is ignored, the same way a single unusable rule
173            // is, so deleting a subject field cannot silently hide the field referencing it.
174            if ( empty( $outcomes ) ) {
175                continue;
176            }
177
178            $group_operator   = $group['logicalOperator'] ?? 'any';
179            $group_outcomes[] = 'all' === $group_operator
180                ? ! in_array( false, $outcomes, true )
181                : in_array( true, $outcomes, true );
182        }
183
184        if ( empty( $group_outcomes ) ) {
185            return true;
186        }
187
188        $logical_operator = $logic['logicalOperator'] ?? 'any';
189
190        if ( 'all' === $logical_operator ) {
191            $matched = ! in_array( false, $group_outcomes, true );
192        } else {
193            $matched = in_array( true, $group_outcomes, true );
194        }
195
196        $action = $logic['action'] ?? 'show';
197
198        return 'hide' === $action ? ! $matched : $matched;
199    }
200
201    /**
202     * Resolve visibility for every field in a form at once.
203     *
204     * Runs to a fixed point so a hidden field's value reads as empty for everyone else: if the
205     * question was never asked, its answer must not satisfy another field's condition. On
206     * ambiguity â€” circular rules, or passes exhausted â€” the field is left visible, because a
207     * stray value in a response is recoverable and a silently discarded answer is not.
208     *
209     * @param array $fields      Map of field id to `array( 'logic' => array|null, 'type' => string )`.
210     * @param array $form_values Map of field id to submitted value.
211     *
212     * @return array Map of field id to bool visibility.
213     */
214    public static function resolve_visibility( array $fields, array $form_values ): array {
215        $visible = array();
216        foreach ( $fields as $field_id => $descriptor ) {
217            $visible[ $field_id ] = true;
218        }
219
220        $with_logic    = array();
221        $field_types   = array();
222        $field_formats = array();
223        foreach ( $fields as $field_id => $descriptor ) {
224            $field_types[ $field_id ] = $descriptor['type'] ?? 'text';
225            if ( isset( $descriptor['format'] ) ) {
226                $field_formats[ $field_id ] = (string) $descriptor['format'];
227            }
228            if ( isset( $descriptor['logic'] ) && is_array( $descriptor['logic'] ) && ! empty( $descriptor['logic']['enabled'] ) ) {
229                $with_logic[] = $field_id;
230            }
231        }
232
233        if ( empty( $with_logic ) ) {
234            return $visible;
235        }
236
237        // An acyclic dependency chain settles at least one more level per pass, so one pass
238        // per conditional field, plus one to confirm nothing moved, always reaches the fixed
239        // point unless the rules are circular.
240        //
241        // This used to be clamped to a constant 25, which made that claim false: a chain
242        // deeper than 24 ran out of passes, was read as circular, and failed open with
243        // fields left visible that should have been hidden. The bound is the field count
244        // because that is what actually guarantees convergence -- and it is self-limiting,
245        // since it can only be as large as the form.
246        $max_passes = count( $with_logic ) + 1;
247
248        // Fields that change after the opening pass are reacting to another field's change,
249        // which is the signature of an oscillation. Collected across every pass, because a
250        // participant in a cycle need not be the one that flipped on the final pass.
251        $unstable = array();
252
253        for ( $pass = 0; $pass < $max_passes; $pass++ ) {
254            $effective = array();
255            foreach ( $fields as $field_id => $descriptor ) {
256                $value                  = array_key_exists( $field_id, $form_values ) ? $form_values[ $field_id ] : '';
257                $effective[ $field_id ] = $visible[ $field_id ] ? $value : '';
258            }
259
260            $changed_count = 0;
261            foreach ( $with_logic as $field_id ) {
262                $next = self::evaluate( $fields[ $field_id ]['logic'], $field_types, $effective, $field_formats );
263                if ( $next !== $visible[ $field_id ] ) {
264                    $visible[ $field_id ] = $next;
265                    ++$changed_count;
266                    if ( $pass > 0 ) {
267                        $unstable[ $field_id ] = true;
268                    }
269                }
270            }
271
272            if ( 0 === $changed_count ) {
273                return $visible; // Fixed point.
274            }
275        }
276
277        // Passes exhausted, so the rules are circular. Fail open for everything in the cycle.
278        foreach ( array_keys( $unstable ) as $field_id ) {
279            $visible[ $field_id ] = true;
280        }
281
282        return $visible;
283    }
284
285    /**
286     * Evaluate a single rule against a submitted value.
287     *
288     * @param array  $rule     The rule.
289     * @param string $type_key Comparison behavior of the rule's subject field.
290     * @param mixed  $actual   The subject field's current value.
291     * @param string $format   Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
292     *
293     * @return bool|null True or false, or null when the rule must be ignored.
294     */
295    private static function evaluate_rule_value( array $rule, $type_key, $actual, $format = '' ) {
296        $operator    = (string) $rule['operator'];
297        $needs_value = ! in_array( $operator, self::OPERATORS_WITHOUT_VALUE, true );
298        $expected    = '';
299        if ( $needs_value && isset( $rule['value'] ) ) {
300            $expected = $rule['value'];
301        }
302
303        // An operator that compares against something, given nothing to compare against, cannot
304        // say anything -- so the rule is ignored rather than evaluated against an empty string.
305        // Evaluating it would be worse than useless: `does_not_contain ''` is true of every
306        // value, so a half-written rule would quietly force its field visible. The editor
307        // already tells the author this rule is inert; this is what makes that true.
308        if ( $needs_value && '' === trim( self::to_comparable_string( $expected ) ) ) {
309            return null;
310        }
311
312        switch ( $operator ) {
313            case self::OP_IS_EMPTY:
314                return self::is_empty_value( $actual );
315            case self::OP_IS_NOT_EMPTY:
316                return ! self::is_empty_value( $actual );
317            case self::OP_IS_CHECKED:
318                return ! self::is_empty_value( $actual );
319            case self::OP_IS_NOT_CHECKED:
320                return self::is_empty_value( $actual );
321        }
322
323        if ( 'multichoice' === $type_key ) {
324            $selection = self::to_selection_list( $actual );
325            $target    = trim( self::to_comparable_string( $expected ) );
326
327            switch ( $operator ) {
328                case self::OP_CONTAINS:
329                    return in_array( $target, $selection, true );
330                case self::OP_DOES_NOT_CONTAIN:
331                    return ! in_array( $target, $selection, true );
332            }
333
334            return null;
335        }
336
337        if ( 'number' === $type_key || 'rating' === $type_key ) {
338            // A rating submits `selected/max`, e.g. `4/5`. The rule stores the bare number, so
339            // only the submitted side needs unpacking -- without it is_numeric() sees `4/5`,
340            // every comparison returns false, and a rating rule can never match.
341            $submitted = 'rating' === $type_key ? self::to_rating_value( $actual ) : $actual;
342            $pair      = self::to_numeric_pair( $submitted, $expected );
343            if ( null === $pair ) {
344                return false;
345            }
346
347            switch ( $operator ) {
348                case self::OP_EQUALS:
349                    return $pair[0] === $pair[1];
350                case self::OP_NOT_EQUALS:
351                    return $pair[0] !== $pair[1];
352                case self::OP_GREATER_THAN:
353                    return $pair[0] > $pair[1];
354                case self::OP_LESS_THAN:
355                    return $pair[0] < $pair[1];
356                case self::OP_GTE:
357                    return $pair[0] >= $pair[1];
358                case self::OP_LTE:
359                    return $pair[0] <= $pair[1];
360            }
361
362            return null;
363        }
364
365        if ( 'date' === $type_key || 'time' === $type_key ) {
366            $pair = self::to_temporal_pair( $actual, $expected, $type_key, $format );
367            if ( null === $pair ) {
368                return false;
369            }
370
371            switch ( $operator ) {
372                case self::OP_IS:
373                    return $pair[0] === $pair[1];
374                case self::OP_IS_NOT:
375                    return $pair[0] !== $pair[1];
376                case self::OP_BEFORE:
377                    return $pair[0] < $pair[1];
378                case self::OP_AFTER:
379                    return $pair[0] > $pair[1];
380            }
381
382            return null;
383        }
384
385        // string, choice, hidden and file all compare textually.
386        $left  = self::to_comparable_string( $actual );
387        $right = self::to_comparable_string( $expected );
388
389        switch ( $operator ) {
390            case self::OP_IS:
391                return $left === $right;
392            case self::OP_IS_NOT:
393                return $left !== $right;
394            case self::OP_CONTAINS:
395                return '' !== $right && false !== strpos( $left, $right );
396            case self::OP_DOES_NOT_CONTAIN:
397                return '' === $right || false === strpos( $left, $right );
398        }
399
400        return null;
401    }
402
403    /**
404     * Normalize a multi-select value into a list of selected option strings.
405     *
406     * Membership comparison exists so `contains "Blue"` does not match an option named
407     * "Blueberry", and so an option containing a comma cannot corrupt the comparison.
408     *
409     * @param mixed $value The submitted value.
410     *
411     * @return array Selected options, trimmed, with blanks removed.
412     */
413    private static function to_selection_list( $value ): array {
414        $raw  = is_array( $value ) ? $value : array( $value );
415        $list = array();
416
417        foreach ( $raw as $item ) {
418            $text = trim( self::to_comparable_string( $item ) );
419            if ( '' !== $text ) {
420                $list[] = $text;
421            }
422        }
423
424        return $list;
425    }
426
427    /**
428     * The selected value of a rating, dropping the scale it was submitted with.
429     *
430     * @param mixed $value The submitted value, `selected/max` or a bare number.
431     *
432     * @return string The selected part, for numeric comparison.
433     */
434    private static function to_rating_value( $value ) {
435        $text  = trim( self::to_comparable_string( $value ) );
436        $slash = strpos( $text, '/' );
437
438        return false === $slash ? $text : substr( $text, 0, $slash );
439    }
440
441    /**
442     * Parse both sides of a numeric comparison.
443     *
444     * @param mixed $actual   The submitted value.
445     * @param mixed $expected The value configured on the rule.
446     *
447     * @return array|null Both sides as floats, or null when either side is not numeric.
448     */
449    private static function to_numeric_pair( $actual, $expected ) {
450        $left  = trim( self::to_comparable_string( $actual ) );
451        $right = trim( self::to_comparable_string( $expected ) );
452
453        if ( '' === $left || '' === $right || ! is_numeric( $left ) || ! is_numeric( $right ) ) {
454            return null;
455        }
456
457        return array( (float) $left, (float) $right );
458    }
459
460    /**
461     * Parse both sides of a date or time comparison into comparable numbers.
462     *
463     * Times become minutes since midnight, so a bare `HH:MM` needs no date context.
464     *
465     * @param mixed  $actual   The submitted value.
466     * @param mixed  $expected The value configured on the rule.
467     * @param string $type_key Either `date` or `time`.
468     * @param string $format   Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
469     *
470     * @return array|null Both sides as ints, or null when either side cannot be parsed.
471     */
472    private static function to_temporal_pair( $actual, $expected, $type_key, $format = '' ) {
473        // The submitted value is written in the field's format; a rule's value is always ISO.
474        $left  = self::parse_temporal( $actual, $type_key, $format );
475        $right = self::parse_temporal( $expected, $type_key, '' );
476
477        if ( null === $left || null === $right ) {
478            return null;
479        }
480
481        return array( $left, $right );
482    }
483
484    /**
485     * Parse one side of a temporal comparison.
486     *
487     * @param mixed  $value    The value to parse.
488     * @param string $type_key Either `date` or `time`.
489     * @param string $format   Subject field's date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
490     *
491     * @return int|null Comparable integer, or null when unparseable.
492     */
493    private static function parse_temporal( $value, $type_key, $format = '' ) {
494        $text = trim( self::to_comparable_string( $value ) );
495        if ( '' === $text ) {
496            return null;
497        }
498
499        if ( 'time' === $type_key ) {
500            if ( ! preg_match( '/^(\d{1,2}):(\d{2})/', $text, $matches ) ) {
501                return null;
502            }
503            return ( (int) $matches[1] ) * 60 + (int) $matches[2];
504        }
505
506        return self::parse_date( $text, $format );
507    }
508
509    /**
510     * Parse a date into a comparable YYYYMMDD integer.
511     *
512     * Deliberately not strtotime(). The browser has to reach the same answer, and its
513     * Date.parse() reads a bare `YYYY-MM-DD` as UTC while reading `mm/dd/yy` as local time --
514     * so for any visitor away from UTC the two engines disagreed by the offset, and `is` was
515     * false on one side while `after` was true on the other. A `dd/mm/yy` field was worse:
516     * neither engine could read `31/12/2026` at all, so a show-rule hid its field permanently
517     * and the answer was then dropped at storage.
518     *
519     * The field's own format decides how to read the value, the same way the datepicker
520     * writes it. A rule's value always arrives as ISO, since the rule builder uses a native
521     * date input, so ISO is accepted regardless of the field's format.
522     *
523     * @param string $text   Date text.
524     * @param string $format Field date format: `mm/dd/yy`, `dd/mm/yy` or `yy-mm-dd`.
525     *
526     * @return int|null YYYYMMDD, or null when the text does not match.
527     */
528    private static function parse_date( $text, $format = '' ) {
529        // ISO first: the rule side is always ISO, and it is the default field format.
530        if ( preg_match( '/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $text, $m ) ) {
531            return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
532        }
533
534        if ( ! preg_match( '/^(\d{1,4})[\/.-](\d{1,2})[\/.-](\d{1,4})$/', $text, $m ) ) {
535            return null;
536        }
537
538        // jQuery UI tokens, as used by the field: `yy` is the four-digit year.
539        switch ( $format ) {
540            case 'dd/mm/yy':
541                return self::to_date_int( (int) $m[3], (int) $m[2], (int) $m[1] );
542            case 'mm/dd/yy':
543                return self::to_date_int( (int) $m[3], (int) $m[1], (int) $m[2] );
544            default:
545                return self::to_date_int( (int) $m[1], (int) $m[2], (int) $m[3] );
546        }
547    }
548
549    /**
550     * Combine date parts, rejecting anything out of range.
551     *
552     * @param int $year  Four-digit year.
553     * @param int $month Month.
554     * @param int $day   Day.
555     *
556     * @return int|null YYYYMMDD, or null when the parts cannot be a date.
557     */
558    private static function to_date_int( $year, $month, $day ) {
559        if ( $month < 1 || $month > 12 || $day < 1 || $day > 31 ) {
560            return null;
561        }
562
563        return $year * 10000 + $month * 100 + $day;
564    }
565
566    /**
567     * Reduce any submitted value to a comparable string.
568     *
569     * @param mixed $value The submitted value.
570     *
571     * @return string Comparable string; empty for values with no sensible text form.
572     */
573    private static function to_comparable_string( $value ): string {
574        if ( null === $value ) {
575            return '';
576        }
577        if ( is_bool( $value ) ) {
578            return $value ? '1' : '';
579        }
580        if ( is_array( $value ) ) {
581            $parts = array();
582            foreach ( $value as $item ) {
583                $parts[] = self::to_comparable_string( $item );
584            }
585            return implode( ',', $parts );
586        }
587        if ( is_object( $value ) ) {
588            return '';
589        }
590
591        return (string) $value;
592    }
593
594    /**
595     * Whether a submitted value counts as unanswered.
596     *
597     * @param mixed $value The submitted value.
598     *
599     * @return bool True when the field has no answer.
600     */
601    private static function is_empty_value( $value ): bool {
602        if ( null === $value ) {
603            return true;
604        }
605        if ( is_string( $value ) ) {
606            return '' === trim( $value );
607        }
608        if ( is_bool( $value ) ) {
609            return ! $value;
610        }
611        if ( is_array( $value ) ) {
612            foreach ( $value as $item ) {
613                if ( ! self::is_empty_value( $item ) ) {
614                    return false;
615                }
616            }
617            return true;
618        }
619        if ( is_object( $value ) ) {
620            foreach ( get_object_vars( $value ) as $item ) {
621                if ( ! self::is_empty_value( $item ) ) {
622                    return false;
623                }
624            }
625            return true;
626        }
627
628        return false;
629    }
630}