*/ private array $members; /** * Constructor. * * @param string $relation How the members combine. * @param array $members Conditions and nested groups. */ public function __construct( string $relation = self::RELATION_AND, array $members = array() ) { $this->relation = self::RELATION_OR === $relation ? self::RELATION_OR : self::RELATION_AND; $this->members = array_values( array_filter( $members, static fn( $m ): bool => $m instanceof Evaluatable ) ); } /** * Builds a group from stored data. * * A member carrying its own `conditions` key is read as a nested group; one * carrying a `field` is read as a condition. * * @param array $data Stored payload. * @param int $depth Current nesting depth, used to stop runaway structures. */ public static function from_array( array $data, int $depth = 0 ): ConditionGroup { $relation = (string) ( $data['relation'] ?? self::RELATION_AND ); $members = array(); /* * A malformed or hostile payload must not be able to recurse without end. * * Past the cap the group is refused rather than emptied. An empty group * *holds* — that is deliberate, so a half-built rule does not hide a field * from visitors — which meant over-deep nesting degraded to "true", the one * answer a structure nobody could parse must not give. Refusing says "this * did not hold", which is what the rest of the cap is for. */ if ( $depth > 5 ) { return new self( $relation, array( new Condition() ) ); } foreach ( (array) ( $data['conditions'] ?? array() ) as $member ) { if ( ! is_array( $member ) ) { continue; } $members[] = isset( $member['conditions'] ) ? self::from_array( $member, $depth + 1 ) : Condition::from_array( $member ); } return new self( $relation, $members ); } /** * Determines whether the group holds. * * An empty group holds. A field whose logic has no conditions yet behaves as * though the logic were switched off, rather than disappearing while its * author is still building the rule. * * @param array $values Submitted values keyed by field key. * * @return bool True when the group holds. */ public function evaluate( array $values ): bool { if ( array() === $this->members ) { return true; } foreach ( $this->members as $member ) { $holds = $member->evaluate( $values ); if ( self::RELATION_AND === $this->relation && ! $holds ) { return false; } if ( self::RELATION_OR === $this->relation && $holds ) { return true; } } return self::RELATION_AND === $this->relation; } /** * Returns the keys of every field this group examines, nesting included. * * Used to work out which fields a dependent field listens to. * * @return array The field keys. */ public function fields(): array { $keys = array(); foreach ( $this->members as $member ) { if ( $member instanceof Condition ) { $keys[] = $member->field(); continue; } if ( $member instanceof ConditionGroup ) { $keys = array_merge( $keys, $member->fields() ); } } return array_values( array_unique( array_filter( $keys ) ) ); } /** * Determines whether the group holds any members. * * @return bool True when nothing was configured. */ public function is_empty(): bool { return array() === $this->members; } /** * Returns the group as storable data. * * @return array The payload. */ public function to_array(): array { return array( 'relation' => $this->relation, 'conditions' => array_map( static fn( Evaluatable $member ): array => $member->to_array(), $this->members ), ); } }