100`, `>=100`, `<50`, `<=50`. * Pure PHP (no WordPress calls) so it can be unit-tested in isolation. * * @since 1.0.0 */ final class TotalQuery { /** * Smallest step used to make `>` / `<` exclusive. */ const EPSILON = 0.0001; /** * Largest total we will ever search for. */ const MAX = 999999999999.0; /** * Parse a term. * * @since 1.0.0 * @param string $term Raw search term. * @param float $tolerance ± tolerance applied to exact searches. * @param string $decimal Store decimal separator. * @return array{0: float, 1: float}|null Null when the term is not a total. Both ends are finite * and within [0, MAX]. */ public static function parse( $term, $tolerance = 0.0, $decimal = '.' ) { $range = self::parse_raw( $term, $tolerance, $decimal ); if ( null === $range ) { return null; } return array( self::clamp( $range[0] ), self::clamp( $range[1] ) ); } /** * A value inside [0, MAX] (a total column can't hold more, and %f of a huge float is a * 300-digit literal). * * @since 1.0.0 * @param float $value Value (finite). * @return float */ private static function clamp( $value ) { return min( self::MAX, max( 0.0, (float) $value ) ); } /** * Parse a term without clamping. * * @param string $term Raw search term. * @param float $tolerance ± tolerance applied to exact searches. * @param string $decimal Store decimal separator. * @return array{0: float, 1: float}|null */ private static function parse_raw( $term, $tolerance, $decimal ) { $term = trim( strtolower( (string) $term ) ); if ( '' === $term ) { return null; } // Comparisons. if ( preg_match( '/^(>=|<=|>|<)\s*(.+)$/', $term, $m ) ) { $value = self::number( $m[2], $decimal ); if ( null === $value ) { return null; } switch ( $m[1] ) { case '>': return array( $value + self::EPSILON, self::MAX ); case '>=': return array( $value, self::MAX ); case '<': return array( 0.0, max( 0.0, $value - self::EPSILON ) ); default: return array( 0.0, $value ); } } // Ranges such as 10-20, 10 - 20, 10..20 or 10 to 20. if ( preg_match( '/^(.+?)\s*(?:\.\.|\bto\b|\s-\s|-)\s*(.+)$/', $term, $m ) ) { $a = self::number( $m[1], $decimal ); $b = self::number( $m[2], $decimal ); if ( null !== $a && null !== $b ) { return array( min( $a, $b ), max( $a, $b ) ); } return null; } $value = self::number( $term, $decimal ); if ( null === $value ) { return null; } $tolerance = is_finite( (float) $tolerance ) ? max( 0.0, (float) $tolerance ) : 0.0; return array( max( 0.0, $value - $tolerance ), $value + $tolerance ); } /** * Parse a single amount, ignoring currency symbols and thousand separators. * * @since 1.0.0 * @param string $raw Raw amount. * @param string $decimal Decimal separator. * @return float|null Null when not an amount, or too large to be a number (INF). */ public static function number( $raw, $decimal = '.' ) { $raw = trim( (string) $raw ); // Reject anything containing letters (other than currency codes stripped below) to avoid matching IDs like "wc_123". $raw = preg_replace( '/^[a-z]{3}\s*|\s*[a-z]{3}$/i', '', $raw ); if ( preg_match( '/[a-z]/i', $raw ) ) { return null; } $raw = preg_replace( '/[^0-9.,]/', '', $raw ); if ( '' === $raw || ! preg_match( '/[0-9]/', $raw ) ) { return null; } if ( ',' === $decimal ) { $raw = str_replace( array( '.', ',' ), array( '', '.' ), $raw ); } else { $raw = str_replace( ',', '', $raw ); } if ( ! is_numeric( $raw ) ) { return null; } $value = (float) $raw; // 309+ digits overflow to INF, which prepare()'s %f prints as the bare word "INF". return is_finite( $value ) ? $value : null; } }