diff --git a/src/Filter.php b/src/Filter.php new file mode 100644 index 0000000..402400c --- /dev/null +++ b/src/Filter.php @@ -0,0 +1,474 @@ +performMatch($rule, $row); + } + + /** + * Create a rule that matches if **all** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function all(Rule ...$rules) + { + return new All(...$rules); + } + + /** + * Return whether the given rules all match the given item + * + * @param All $rules + * @param object $row + * + * @return bool + */ + protected function matchAll(All $rules, $row) + { + foreach ($rules as $rule) { + if (! $this->performMatch($rule, $row)) { + return false; + } + } + + return true; + } + + /** + * Create a rule that matches if **any** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function any(Rule ...$rules) + { + return new Any(...$rules); + } + + /** + * Return whether any of the given rules match the given item + * + * @param Any $rules + * @param object $row + * + * @return bool + */ + protected function matchAny(Any $rules, $row) + { + foreach ($rules as $rule) { + if ($this->performMatch($rule, $row)) { + return true; + } + } + + return false; + } + + /** + * Create a rule that matches if **none** of the given rules do + * + * @param Rule ...$rules + * + * @return Chain + */ + public static function none(Rule ...$rules) + { + return new None(...$rules); + } + + /** + * Return whether none of the given rules match the given item + * + * @param None $rules + * @param object $row + * + * @return bool + */ + protected function matchNone(None $rules, $row) + { + foreach ($rules as $rule) { + if ($this->performMatch($rule, $row)) { + return false; + } + } + + return true; + } + + /** + * Create a rule that matches rows with a column that **equals** the given value + * + * Performs a wildcard search if the value contains asterisks. + * + * @param string $column + * @param array|bool|float|int|string $value + * + * @return Condition + */ + public static function equal($column, $value) + { + return new Equal($column, $value); + } + + /** + * Return whether the given rule's value equals the given item's value + * + * @param Equal|Unequal $rule + * @param object $row + * + * @return bool + */ + protected function matchEqual($rule, $row) + { + if (! $rule instanceof Equal && ! $rule instanceof Unequal) { + throw new InvalidArgumentException(sprintf( + 'Rule must be of type %s or %s, got %s instead', + Equal::class, + Unequal::class, + get_php_type($rule) + )); + } + + $rowValue = $this->extractValue($rule->getColumn(), $row); + $value = $rule->getValue(); + $this->normalizeTypes($rowValue, $value); + + if (! is_array($rowValue)) { + $rowValue = [$rowValue]; + } + + foreach ($rowValue as $rowVal) { + if ($this->performEqualityMatch($value, $rowVal, $rule->ignoresCase())) { + return true; + } + } + + return false; + } + + /** + * Apply equality matching rules on the given row value + * + * @param mixed $value + * @param mixed $rowValue + * @param bool $ignoreCase + * + * @return bool + */ + protected function performEqualityMatch($value, $rowValue, $ignoreCase = false) + { + if ($ignoreCase && is_string($rowValue)) { + $rowValue = strtolower($rowValue); + $value = is_array($value) + ? array_map('strtolower', $value) + : strtolower($value); + } + + if (is_array($value)) { + return in_array($rowValue, $value, true); + } elseif (! is_string($value)) { + if (is_string($rowValue)) { + $value = (string) $value; + } else { + return $rowValue === $value; + } + } + + $wildcardSubSegments = preg_split('~\*~', $value); + if (count($wildcardSubSegments) === 1) { + return $rowValue === $value; + } + + $parts = []; + foreach ($wildcardSubSegments as $part) { + $parts[] = preg_quote($part, '~'); + } + + $pattern = '~^' . join('.*', $parts) . '$~'; + + return (bool) preg_match($pattern, $rowValue); + } + + /** + * Create a rule that matches rows with a column that is **unequal** with the given value + * + * Performs a wildcard search if the value contains asterisks. + * + * @param string $column + * @param array|bool|float|int|string $value + * + * @return Condition + */ + public static function unequal($column, $value) + { + return new Unequal($column, $value); + } + + /** + * Return whether the given rule's value does not equal the given item's value + * + * @param Unequal $rule + * @param object $row + * + * @return bool + */ + protected function matchUnequal(Unequal $rule, $row) + { + return ! $this->matchEqual($rule, $row); + } + + /** + * Create a rule that matches rows with a column that is **greater** than the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function greaterThan($column, $value) + { + return new GreaterThan($column, $value); + } + + /** + * Return whether the given rule's value is greater than the given item's value + * + * @param GreaterThan $rule + * @param object $row + * + * @return bool + */ + protected function matchGreaterThan(GreaterThan $rule, $row) + { + return $this->extractValue($rule->getColumn(), $row) > $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **less** than the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function lessThan($column, $value) + { + return new LessThan($column, $value); + } + + /** + * Return whether the given rule's value is less than the given item's value + * + * @param LessThan $rule + * @param object $row + * + * @return bool + */ + protected function matchLessThan(LessThan $rule, $row) + { + $rowValue = $this->extractValue($rule->getColumn(), $row); + if ($rowValue === null) { + return false; + } + + return $rowValue < $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **greater** than or **equal** to the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function greaterThanOrEqual($column, $value) + { + return new GreaterThanOrEqual($column, $value); + } + + /** + * Return whether the given rule's value is greater than or equals the given item's value + * + * @param GreaterThanOrEqual $rule + * @param object $row + * + * @return bool + */ + protected function matchGreaterThanOrEqual(GreaterThanOrEqual $rule, $row) + { + return $this->extractValue($rule->getColumn(), $row) >= $rule->getValue(); + } + + /** + * Create a rule that matches rows with a column that is **less** than or **equal** to the given value + * + * @param string $column + * @param float|int|string $value + * + * @return Condition + */ + public static function lessThanOrEqual($column, $value) + { + return new LessThanOrEqual($column, $value); + } + + /** + * Return whether the given rule's value is less than or equals the given item's value + * + * @param LessThanOrEqual $rule + * @param object $row + * + * @return bool + */ + protected function matchLessThanOrEqual(LessThanOrEqual $rule, $row) + { + $rowValue = $this->extractValue($rule->getColumn(), $row); + if ($rowValue === null) { + return false; + } + + return $rowValue <= $rule->getValue(); + } + + /** + * Perform the appropriate match for the given rule on the given item + * + * @param Rule $rule + * @param object $row + * + * @return bool + */ + protected function performMatch(Rule $rule, $row) + { + switch (true) { + case $rule instanceof All: + return $this->matchAll($rule, $row); + case $rule instanceof Any: + return $this->matchAny($rule, $row); + case $rule instanceof Equal: + return $this->matchEqual($rule, $row); + case $rule instanceof GreaterThan: + return $this->matchGreaterThan($rule, $row); + case $rule instanceof GreaterThanOrEqual: + return $this->matchGreaterThanOrEqual($rule, $row); + case $rule instanceof LessThan: + return $this->matchLessThan($rule, $row); + case $rule instanceof LessThanOrEqual: + return $this->matchLessThanOrEqual($rule, $row); + case $rule instanceof None: + return $this->matchNone($rule, $row); + case $rule instanceof Unequal: + return $this->matchUnequal($rule, $row); + default: + throw new InvalidArgumentException(sprintf( + 'Unable to match filter. Rule type %s is unknown', + get_class($rule) + )); + } + } + + /** + * Return a value from the given row suitable to work with + * + * @param string $column + * @param object $row + * + * @return mixed + */ + protected function extractValue($column, $row) + { + try { + return $row->{$column}; + } catch (Exception $_) { + return null; + } + } + + /** + * Normalize type of $value to the one of $rowValue + * + * For details on how this works please see the corresponding test + * {@see \ipl\Tests\Stdlib\FilterTest::testConditionsAreValueTypeAgnostic} + * + * @param mixed $rowValue + * @param mixed $value + * + * @return void + */ + protected function normalizeTypes($rowValue, &$value) + { + if ($rowValue === null || $value === null) { + return; + } + + if (is_array($rowValue)) { + if (empty($rowValue)) { + return; + } + + $rowValue = array_shift($rowValue); + } + + if (is_array($value)) { + if (is_bool($rowValue) && ! empty($value) && is_string(array_values($value)[0])) { + return; + } + + $rowValueType = gettype($rowValue); + foreach ($value as &$val) { + settype($val, $rowValueType); + } + } elseif (! is_bool($rowValue) || ! is_string($value)) { + settype($value, gettype($rowValue)); + } + } +} diff --git a/src/Filter/All.php b/src/Filter/All.php new file mode 100644 index 0000000..5b42bb1 --- /dev/null +++ b/src/Filter/All.php @@ -0,0 +1,8 @@ +add($rule); + } + } + + /** + * Clone this chain's rules + */ + public function __clone() + { + foreach ($this->rules as $i => $rule) { + $this->rules[$i] = clone $rule; + } + } + + /** + * Get an iterator this chain's rules + * + * @return ArrayIterator + */ + public function getIterator() + { + return new ArrayIterator($this->rules); + } + + /** + * Add a rule to this chain + * + * @param Rule $rule + * + * @return $this + */ + public function add(Rule $rule) + { + $this->rules[] = $rule; + + return $this; + } + + /** + * Get whether this chain contains the given rule + * + * @param Rule $rule + * + * @return bool + */ + public function has(Rule $rule) + { + return array_search($rule, $this->rules, true) !== false; + } + + /** + * Replace a rule with another one in this chain + * + * @param Rule $rule + * @param Rule $replacement + * + * @throws OutOfBoundsException In case no existing rule is found + * @return $this + */ + public function replace(Rule $rule, Rule $replacement) + { + $ruleAt = array_search($rule, $this->rules, true); + if ($ruleAt === false) { + throw new OutOfBoundsException('Rule to replace not found'); + } + + array_splice($this->rules, $ruleAt, 1, [$replacement]); + + return $this; + } + + /** + * Remove a rule from this chain + * + * @param Rule $rule + * + * @return $this + */ + public function remove(Rule $rule) + { + $ruleAt = array_search($rule, $this->rules, true); + if ($ruleAt !== false) { + array_splice($this->rules, $ruleAt, 1, []); + } + + return $this; + } + + /** + * Get whether this chain has any rules + * + * @return bool + */ + public function isEmpty() + { + return empty($this->rules); + } + + /** + * Count this chain's rules + * + * @return int + */ + public function count() + { + return count($this->rules); + } +} diff --git a/src/Filter/Condition.php b/src/Filter/Condition.php new file mode 100644 index 0000000..3e0e326 --- /dev/null +++ b/src/Filter/Condition.php @@ -0,0 +1,78 @@ +setColumn($column) + ->setValue($value); + } + + /** + * Set this condition's column + * + * @param string $column + * + * @return $this + */ + public function setColumn($column) + { + $this->column = $column; + + return $this; + } + + /** + * Get this condition's column + * + * @return string + */ + public function getColumn() + { + return $this->column; + } + + /** + * Set this condition's value + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Get this condition's value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } +} diff --git a/src/Filter/Equal.php b/src/Filter/Equal.php new file mode 100644 index 0000000..71da490 --- /dev/null +++ b/src/Filter/Equal.php @@ -0,0 +1,31 @@ +ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/src/Filter/GreaterThan.php b/src/Filter/GreaterThan.php new file mode 100644 index 0000000..8a05b26 --- /dev/null +++ b/src/Filter/GreaterThan.php @@ -0,0 +1,8 @@ +ignoreCase = true; + + return $this; + } + + /** + * Return whether this rule ignores case + * + * @return bool + */ + public function ignoresCase() + { + return $this->ignoreCase; + } +} diff --git a/tests/FilterTest.php b/tests/FilterTest.php new file mode 100644 index 0000000..d5a5d3e --- /dev/null +++ b/tests/FilterTest.php @@ -0,0 +1,421 @@ + 'localhost', + 'problem' => '1', + 'service' => 'ping', + 'state' => 2, + 'handled' => '1' + ], + [ + 'host' => 'localhost', + 'problem' => '1', + 'service' => 'www.icinga.com', + 'state' => 0, + 'handled' => '0' + ], + [ + 'host' => 'localhost', + 'problem' => '1', + 'service' => 'www.icinga.com', + 'state' => 1, + 'handled' => '0' + ] + ]; + + private function row($id) + { + return (object) $this->sampleData[$id]; + } + + public function testAllMatches() + { + $all = Filter::all( + Filter::equal('problem', '1'), + Filter::equal('handled', '1') + ); + + $this->assertTrue(Filter::match($all, $this->row(0))); + } + + public function testAllMismatches() + { + $all = Filter::all( + Filter::equal('problem', '1'), + Filter::equal('handled', '1') + ); + + $this->assertFalse(Filter::match($all, $this->row(1))); + } + + public function testAnyMatches() + { + $any = Filter::any( + Filter::equal('problem', '1'), + Filter::equal('handled', '1') + ); + + $this->assertTrue(Filter::match($any, $this->row(1))); + } + + public function testAnyMismatches() + { + $any = Filter::any( + Filter::equal('problem', '0'), + Filter::equal('handled', '1') + ); + + $this->assertFalse(Filter::match($any, $this->row(1))); + } + + public function testNoneMatches() + { + $none = Filter::none( + Filter::equal('problem', '0'), + Filter::equal('handled', '1') + ); + + $this->assertTrue(Filter::match($none, $this->row(2))); + } + + public function testNoneMismatches() + { + $none = Filter::none( + Filter::equal('problem', '1'), + Filter::equal('handled', '0') + ); + + $this->assertFalse(Filter::match($none, $this->row(2))); + } + + public function testEqualMatches() + { + $equal = Filter::equal('problem', '1'); + + $this->assertTrue(Filter::match($equal, $this->row(0))); + } + + public function testEqualMismatches() + { + $equal = Filter::equal('handled', '1'); + + $this->assertFalse(Filter::match($equal, $this->row(1))); + } + + public function testEqualIgnoresCase() + { + // single string + $equal = Filter::equal('host', 'LOCALHOST') + ->ignoreCase(); + + $this->assertTrue(Filter::match($equal, $this->row(0))); + + // string array + $equal->setValue(['LoCaLhOsT', '127.0.0.1']); + + $this->assertTrue(Filter::match($equal, $this->row(0))); + } + + public function testEqualMatchesMultiValuedColumns() + { + $this->assertTrue(Filter::match(Filter::equal('foo', 'bar'), [ + 'foo' => ['foo', 'bar'] + ])); + $this->assertTrue(Filter::match(Filter::equal('foo', 'ba*'), [ + 'foo' => ['foo', 'bar'] + ])); + $this->assertTrue(Filter::match(Filter::equal('foo', 'BAR')->ignoreCase(), [ + 'foo' => ['FoO', 'bAr'] + ])); + $this->assertTrue(Filter::match(Filter::equal('foo', ['bar', 'boar']), [ + 'foo' => ['foo', 'bar'] + ])); + } + + public function testUnequalMatches() + { + $unequal = Filter::unequal('problem', '0'); + + $this->assertTrue(Filter::match($unequal, $this->row(1))); + } + + public function testUnequalMismatches() + { + $unequal = Filter::unequal('problem', '1'); + + $this->assertFalse(Filter::match($unequal, $this->row(1))); + } + + public function testUnequalIgnoresCase() + { + // single string + $equal = Filter::unequal('host', 'LOCALHOST') + ->ignoreCase(); + + $this->assertFalse(Filter::match($equal, $this->row(0))); + + // string array + $equal->setValue(['LoCaLhOsT', '127.0.0.1']); + + $this->assertFalse(Filter::match($equal, $this->row(0))); + } + + public function testUnequalMatchesMultiValuedColumns() + { + $this->assertFalse(Filter::match(Filter::unequal('foo', 'bar'), [ + 'foo' => ['foo', 'bar'] + ])); + $this->assertFalse(Filter::match(Filter::unequal('foo', 'ba*'), [ + 'foo' => ['foo', 'bar'] + ])); + $this->assertFalse(Filter::match(Filter::unequal('foo', 'BAR')->ignoreCase(), [ + 'foo' => ['FoO', 'bAr'] + ])); + $this->assertFalse(Filter::match(Filter::unequal('foo', ['bar', 'boar']), [ + 'foo' => ['foo', 'bar'] + ])); + } + + public function testGreaterThanMatches() + { + $greaterThan = Filter::greaterThan('state', 1); + + $this->assertTrue(Filter::match($greaterThan, $this->row(0))); + } + + public function testGreaterThanMismatches() + { + $greaterThan = Filter::greaterThan('state', 1); + + $this->assertFalse(Filter::match($greaterThan, $this->row(2))); + } + + public function testGreaterThanOrEqualMatches() + { + $greaterThanOrEqual = Filter::greaterThanOrEqual('state', 1); + + $this->assertTrue(Filter::match($greaterThanOrEqual, $this->row(0))); + $this->assertTrue(Filter::match($greaterThanOrEqual, $this->row(2))); + } + + public function testGreaterThanOrEqualMismatches() + { + $greaterThanOrEqual = Filter::greaterThanOrEqual('state', 2); + + $this->assertFalse(Filter::match($greaterThanOrEqual, $this->row(1))); + $this->assertFalse(Filter::match($greaterThanOrEqual, $this->row(2))); + } + + public function testLessThanMatches() + { + $lessThan = Filter::lessThan('state', 1); + + $this->assertTrue(Filter::match($lessThan, $this->row(1))); + } + + public function testLessThanMismatches() + { + $lessThan = Filter::lessThan('state', 2); + + $this->assertFalse(Filter::match($lessThan, $this->row(0))); + } + + public function testLessThanOrEqualMatches() + { + $lessThanOrEqual = Filter::lessThanOrEqual('state', 1); + + $this->assertTrue(Filter::match($lessThanOrEqual, $this->row(1))); + $this->assertTrue(Filter::match($lessThanOrEqual, $this->row(2))); + } + + public function testLessThanOrEqualMismatches() + { + $lessThanOrEqual = Filter::lessThanOrEqual('state', 0); + + $this->assertFalse(Filter::match($lessThanOrEqual, $this->row(0))); + $this->assertFalse(Filter::match($lessThanOrEqual, $this->row(2))); + } + + public function testEqualWithWildcardMatches() + { + $equal = Filter::equal('service', '*icinga*'); + + $this->assertTrue(Filter::match($equal, $this->row(1))); + } + + public function testEqualWithWildcardMismatches() + { + $equal = Filter::equal('service', '*nagios*'); + + $this->assertFalse(Filter::match($equal, $this->row(1))); + } + + public function testUnequalWithWildcardMatches() + { + $unequal = Filter::unequal('service', '*nagios*'); + + $this->assertTrue(Filter::match($unequal, $this->row(1))); + } + + public function testUnequalWithWildcardMismatches() + { + $unequal = Filter::unequal('service', '*icinga*'); + + $this->assertFalse(Filter::match($unequal, $this->row(1))); + } + + public function testEqualWithArrayMatches() + { + $equal = Filter::equal('host', ['127.0.0.1', 'localhost']); + + $this->assertTrue(Filter::match($equal, $this->row(0))); + } + + public function testEqualWithArrayMismatches() + { + $equal = Filter::equal('host', ['10.0.10.20', '10.0.10.21']); + + $this->assertFalse(Filter::match($equal, $this->row(0))); + } + + public function testUnequalWithArrayMatches() + { + $unequal = Filter::unequal('host', ['10.0.20.10', '10.0.20.11']); + + $this->assertTrue(Filter::match($unequal, $this->row(0))); + } + + public function testUnequalWithArrayMismatches() + { + $unequal = Filter::unequal('host', ['127.0.0.1', 'localhost']); + + $this->assertFalse(Filter::match($unequal, $this->row(0))); + } + + public function testConditionsAreValueTypeAgnostic() + { + $this->assertTrue( + Filter::match(Filter::equal('name', ' foo '), ['name' => ' foo ']), + "Filter\Equal doesn't take strings with whitespace as-is" + ); + $this->assertTrue( + Filter::match(Filter::equal('length', '19'), ['length' => 19]), + "Filter\Equal fails to match strings with integers" + ); + $this->assertTrue( + Filter::match(Filter::equal('port', ['80', '8080']), ['port' => 8080]), + "Filter\Equal fails to match string[] with integers" + ); + $this->assertFalse( + Filter::match(Filter::equal('active', 'foo'), ['active' => true]), + "Filter\Equal doesn't differ between true strings and booleans" + ); + $this->assertFalse( + Filter::match(Filter::equal('active', ['foo', 'bar']), ['active' => true]), + "Filter\Equal doesn't differ between true string[] and booleans" + ); + $this->assertTrue( + Filter::match(Filter::equal('active', 0), ['active' => false]), + "Filter\Equal doesn't match false integers with booleans" + ); + $this->assertTrue( + Filter::match(Filter::equal('active', [true]), ['active' => 1]), + "Filter\Equal doesn't match boolean[] with true integers" + ); + $this->assertTrue( + Filter::match(Filter::equal('some_id', null), ['some_id' => null]), + "Filter\Equal fails to match NULL" + ); + $this->assertFalse( + Filter::match(Filter::equal('some_id', 0), ['some_id' => null]), + "Filter\Equal doesn't compare NULL strictly" + ); + $this->assertTrue( + Filter::match(Filter::greaterThan('length', '21'), ['length' => 22]), + "Filter\GreaterThan fails to match strings with integers" + ); + $this->assertTrue( + Filter::match(Filter::lessThan('length', '22'), ['length' => 21]), + "Filter\LessThan fails to match strings with integers" + ); + } + + public function testConditionsCanBeCloned() + { + $condition1 = Filter::equal('host', 'localhost'); + $condition2 = clone $condition1; + $condition2->setColumn('service'); + $condition2->setValue('ping'); + + $this->assertEquals('host', $condition1->getColumn()); + $this->assertEquals('localhost', $condition1->getValue()); + } + + public function testChainsCanBeCloned() + { + $chain1 = Filter::all( + Filter::equal('host', 'localhost'), + Filter::equal('problem', '1'), + Filter::all( + Filter::equal('handled', '0') + ) + ); + + $chain2 = clone $chain1; + foreach ($chain2 as $rule) { + if ($rule instanceof Filter\Chain) { + $rule->add(Filter::equal('state', 1)); + } + } + + $this->assertTrue(Filter::match($chain1, $this->row(1))); + $this->assertTrue(Filter::match($chain2, $this->row(2))); + } + + public function testChainsCanBeAdjusted() + { + $chain = Filter::any( + Filter::equal('service', 'ping') + ); + $this->assertFalse(Filter::match($chain, $this->row(1))); + + // add + $stateEqualsZero = Filter::equal('state', 0); + $chain->add($stateEqualsZero); + $this->assertTrue(Filter::match($chain, $this->row(1))); + $this->assertFalse(Filter::match($chain, $this->row(2))); + + // replace + $stateEqualsOne = Filter::equal('state', 1); + $chain->replace($stateEqualsZero, $stateEqualsOne); + $this->assertTrue(Filter::match($chain, $this->row(2))); + $this->assertFalse(Filter::match($chain, $this->row(1))); + + // remove + $chain->remove($stateEqualsOne); + $this->assertFalse(Filter::match($chain, $this->row(2))); + } + + public function testChainsCanBeEmpty() + { + $this->assertTrue(Filter::all()->isEmpty()); + $this->assertFalse(Filter::all(Filter::equal('a', 'b'))->isEmpty()); + } + + public function testConditionsHandleMissingColumnsProperly() + { + $this->assertFalse(Filter::match(Filter::equal('foo', 'bar'), [])); + $this->assertTrue(Filter::match(Filter::unequal('bar', 'foo'), [])); + $this->assertFalse(Filter::match(Filter::greaterThan('foo', 123), [])); + $this->assertFalse(Filter::match(Filter::lessThan('foo', 123), [])); + $this->assertFalse(Filter::match(Filter::lessThanOrEqual('foo', 123), [])); + $this->assertFalse(Filter::match(Filter::greaterThanOrEqual('foo', 123), [])); + } +}