$when The rule's conditions. * @param array $facts The action's facts. * @param array> $catalog Facts active extensions declare. */ public static function holds( array $when, array $facts, array $catalog ): ?bool { $result = true; foreach ( $when as $condition ) { $one = is_array( $condition ) ? self::one( $condition, $facts, $catalog ) : null; if ( false === $one ) { return false; } if ( null === $one ) { $result = null; } } return $result; } /** * Whether a rule names a fact no active extension declares, so that it * cannot be evaluated on this site. * * @param array $rule A rule. * @param array> $catalog Facts active extensions declare. */ public static function dormant( array $rule, array $catalog ): bool { foreach ( self::facts_named( $rule ) as $fact ) { if ( ! isset( $catalog[ $fact ] ) ) { return true; } } return false; } /** * The fact names a rule's conditions and budget test, sorted and unique. * * @param array $rule A rule. * @return list */ public static function facts_named( array $rule ): array { $names = array(); $named = isset( $rule['when'] ) && is_array( $rule['when'] ) ? $rule['when'] : array(); $named[] = $rule['budget'] ?? null; foreach ( $named as $condition ) { if ( is_array( $condition ) && isset( $condition['fact'] ) && is_string( $condition['fact'] ) ) { $names[ $condition['fact'] ] = true; } } $names = array_map( 'strval', array_keys( $names ) ); sort( $names, SORT_STRING ); return $names; } /** * One condition's verdict. * * @param array $condition A condition. * @param array $facts The action's facts. * @param array> $catalog Facts active extensions declare. */ private static function one( array $condition, array $facts, array $catalog ): ?bool { $fact = $condition['fact'] ?? null; $op = $condition['op'] ?? null; if ( ! is_string( $fact ) || ! isset( $catalog[ $fact ] ) ) { return null; } if ( ! array_key_exists( $fact, $facts ) ) { return 'exists' === $op ? false : null; } // A value or operand not of the fact's declared type cannot be // compared - after an extension changed the type, say - so the // condition is unknown rather than a definite false. $type = (string) ( $catalog[ $fact ]['type'] ?? '' ); $actual = $facts[ $fact ]; $operand = $condition['value'] ?? null; if ( ! self::fits( $actual, $type ) || ( 'exists' !== $op && ! self::operand_fits( $operand, $type ) ) ) { return null; } switch ( $op ) { case 'exists': return true; case 'eq': return self::equal( $actual, $operand ); case 'ne': $equal = self::equal( $actual, $operand ); return null === $equal ? null : ! $equal; case 'in': return self::within( $actual, $operand ); case 'not_in': $within = self::within( $actual, $operand ); return null === $within ? null : ! $within; } return in_array( $op, self::ORDERING, true ) ? self::ordered( (string) $op, $actual, $operand ) : null; } /** * Whether a fact's value has its declared type: money an integer, a * number an int or finite float, a list a list of scalars. * * @param mixed $value The fact's value. * @param string $type Declared type. */ private static function fits( mixed $value, string $type ): bool { if ( 'list' === $type ) { return is_array( $value ) && array_is_list( $value ) && array() === array_filter( $value, static fn ( $item ): bool => ! is_scalar( $item ) ); } return self::scalar_fits( $value, $type ); } /** * Whether an operand suits a fact of this type: a scalar of its type, * or a list of them for in and not_in. A list fact's operand items may * be any scalar; each comparison judges itself. * * @param mixed $operand The rule's value. * @param string $type Declared type. */ private static function operand_fits( mixed $operand, string $type ): bool { $items = is_array( $operand ) ? $operand : array( $operand ); foreach ( $items as $item ) { if ( ! ( 'list' === $type ? is_scalar( $item ) : self::scalar_fits( $item, $type ) ) ) { return false; } } return true; } /** * Whether a scalar has a scalar type. * * @param mixed $value Candidate. * @param string $type Declared type. */ private static function scalar_fits( mixed $value, string $type ): bool { return match ( $type ) { 'money' => is_int( $value ), 'number' => self::numeric( $value ), 'string' => is_string( $value ), 'bool' => is_bool( $value ), default => false, }; } /** * Equality of two scalars: integers exactly, other numbers by value, * anything else strictly within one type. Null when they are of types * that cannot be compared, or either is not a scalar. * * @param mixed $actual The fact's value. * @param mixed $operand The rule's value. */ private static function equal( mixed $actual, mixed $operand ): ?bool { if ( ! is_scalar( $actual ) || ! is_scalar( $operand ) ) { return null; } if ( is_int( $actual ) && is_int( $operand ) ) { return $actual === $operand; } if ( self::numeric( $actual ) && self::numeric( $operand ) ) { return (float) $actual === (float) $operand; } return gettype( $actual ) === gettype( $operand ) ? $actual === $operand : null; } /** * Whether the fact - or, for a list fact, any of its items - is one of * the operand's items. One match settles it; otherwise an item that * could not be compared leaves it unknown. Null when the operand is not * a list. * * @param mixed $actual The fact's value. * @param mixed $operand The rule's list. */ private static function within( mixed $actual, mixed $operand ): ?bool { if ( ! is_array( $operand ) || ! array_is_list( $operand ) ) { return null; } $result = false; foreach ( is_array( $actual ) ? $actual : array( $actual ) as $item ) { foreach ( $operand as $candidate ) { $equal = self::equal( $item, $candidate ); if ( true === $equal ) { return true; } if ( null === $equal ) { $result = null; } } } return $result; } /** * An ordering comparison of two numbers; null unless both are. * * @param string $op gt, gte, lt or lte. * @param mixed $actual The fact's value. * @param mixed $operand The rule's value. */ private static function ordered( string $op, mixed $actual, mixed $operand ): ?bool { if ( ! self::numeric( $actual ) || ! self::numeric( $operand ) ) { return null; } $order = is_int( $actual ) && is_int( $operand ) ? $actual <=> $operand : (float) $actual <=> (float) $operand; return match ( $op ) { 'gt' => 1 === $order, 'gte' => $order >= 0, 'lt' => -1 === $order, default => $order <= 0, }; } /** * Whether a value is a number: an int or a finite float, never a bool * or a numeric string. * * @param mixed $value Candidate. */ private static function numeric( mixed $value ): bool { return is_int( $value ) || ( is_float( $value ) && is_finite( $value ) ); } /** * Validate a rule's `when` before it is saved. * * With a catalog, every fact must be in it and every operand must suit * the fact's type. Without one - network rules, which each site judges - * any well-formed name is accepted and only the shapes are checked. * * @param int $index Rule position, for messages. * @param mixed $when The rule's conditions. * @param array>|null $catalog Declared facts, or null for any. * @return list */ public static function validate( int $index, mixed $when, ?array $catalog ): array { if ( ! is_array( $when ) || array() === $when || ! array_is_list( $when ) ) { return array( sprintf( 'Rule %d: when must be a non-empty list of conditions.', $index ) ); } if ( count( $when ) > RuleSchema::MAX_CONDITIONS ) { return array( sprintf( 'Rule %d: at most %d conditions.', $index, RuleSchema::MAX_CONDITIONS ) ); } $errors = array(); foreach ( $when as $position => $condition ) { $error = self::invalid( $condition, $catalog ); if ( null !== $error ) { $errors[] = sprintf( 'Rule %d, condition %d: %s', $index, $position, $error ); } } return $errors; } /** * Why one condition is invalid, or null. * * @param mixed $condition A condition. * @param array>|null $catalog Declared facts, or null for any. */ private static function invalid( mixed $condition, ?array $catalog ): ?string { if ( ! is_array( $condition ) || array_is_list( $condition ) ) { return 'a condition is an object with fact, op and value.'; } if ( array() !== array_diff( array_keys( $condition ), array( 'fact', 'op', 'value' ) ) ) { return 'a condition may contain only fact, op and value.'; } $fact = $condition['fact'] ?? null; if ( ! is_string( $fact ) || 1 !== preg_match( Registry::FACT_PATTERN, $fact ) ) { return 'fact must be a name like namespace.name.'; } if ( null !== $catalog && ! isset( $catalog[ $fact ] ) ) { return sprintf( 'no active extension provides the fact "%s".', $fact ); } $op = $condition['op'] ?? null; if ( ! is_string( $op ) || ! in_array( $op, RuleSchema::CONDITION_OPS, true ) ) { return sprintf( 'op must be one of %s.', implode( ', ', RuleSchema::CONDITION_OPS ) ); } if ( 'exists' === $op ) { return array_key_exists( 'value', $condition ) ? 'exists takes no value.' : null; } if ( ! array_key_exists( 'value', $condition ) ) { return sprintf( '%s needs a value.', $op ); } $type = null === $catalog ? null : (string) $catalog[ $fact ]['type']; return self::invalid_operand( $op, $condition['value'], $type ); } /** * Why an operand does not suit its operator and the fact's type, or * null. A null type (no catalog) checks the operand's shape only. * * @param string $op Operator. * @param mixed $operand The rule's value. * @param string|null $type The fact's declared type, if known. */ private static function invalid_operand( string $op, mixed $operand, ?string $type ): ?string { if ( in_array( $op, array( 'in', 'not_in' ), true ) ) { if ( ! is_array( $operand ) || array() === $operand || ! array_is_list( $operand ) || count( $operand ) > Refiner::MAX_LIST ) { return sprintf( '%s needs a list of 1 to %d values.', $op, Refiner::MAX_LIST ); } foreach ( $operand as $item ) { $error = self::invalid_scalar( $item, 'list' === $type ? null : $type ); if ( null !== $error ) { return $error; } } return null; } if ( 'list' === $type ) { return 'a list fact is tested with in, not_in or exists.'; } if ( in_array( $op, self::ORDERING, true ) ) { if ( null !== $type && ! in_array( $type, array( 'number', 'money' ), true ) ) { return sprintf( '%s compares numbers, and this fact is a %s.', $op, $type ); } if ( ! self::numeric( $operand ) ) { return sprintf( '%s needs a number.', $op ); } } return self::invalid_scalar( $operand, $type ); } /** * Why a value is not a scalar of the fact's type, or null. * * @param mixed $value A value from the rule. * @param string|null $type The fact's declared type, if known. */ private static function invalid_scalar( mixed $value, ?string $type ): ?string { if ( ! is_scalar( $value ) || ( is_float( $value ) && ! is_finite( $value ) ) ) { return 'a value is a string, a finite number or true or false.'; } $fits = match ( $type ) { 'money' => is_int( $value ), 'number' => self::numeric( $value ), 'string' => is_string( $value ), 'bool' => is_bool( $value ), default => true, }; if ( $fits ) { return null; } return 'money' === $type ? 'a money value is a whole number of minor units, such as cents.' : sprintf( 'a value must be a %s, as the fact is.', (string) $type ); } }