From: gmungoc Date: Mon, 30 Oct 2017 16:51:40 +0000 (+0000) Subject: JAL-2808 classes to support filtering by attribute value X-Git-Tag: Release_2_11_0~174 X-Git-Url: http://source.jalview.org/gitweb/?a=commitdiff_plain;h=67dbd0072044a077ac43079ea12c8c4803636ee1;p=jalview.git JAL-2808 classes to support filtering by attribute value --- diff --git a/resources/lang/Messages.properties b/resources/lang/Messages.properties index 9ffe2ae..daca83b 100644 --- a/resources/lang/Messages.properties +++ b/resources/lang/Messages.properties @@ -1324,3 +1324,13 @@ label.overview = Overview label.reset_to_defaults = Reset to defaults label.oview_calc = Recalculating overview... label.feature_details = Feature details +label.matchCondition_contains = Contains +label.matchCondition_notcontains = Does not contain +label.matchCondition_matches = Matches +label.matchCondition_notmatches = Does not match +label.matchCondition_eq = Is equal to +label.matchCondition_ne = Is not equal to +label.matchCondition_lt = Is less than +label.matchCondition_le = Is less than or equal to +label.matchCondition_gt = Is greater than +label.matchCondition_ge = Is greater than or equal to diff --git a/src/jalview/util/matcher/Condition.java b/src/jalview/util/matcher/Condition.java new file mode 100644 index 0000000..455f805 --- /dev/null +++ b/src/jalview/util/matcher/Condition.java @@ -0,0 +1,57 @@ +package jalview.util.matcher; + +import jalview.util.MessageManager; + +import java.util.HashMap; +import java.util.Map; + +/** + * An enumeration for binary conditions that a user might choose from when + * setting filter or match conditions for values + */ +public enum Condition +{ + Contains(false), NotContains(false), Matches(false), NotMatches(false), + EQ(true), NE(true), LT(true), LE(true), GT(true), GE(true); + + private static Map displayNames = new HashMap<>(); + + private boolean numeric; + + Condition(boolean isNumeric) + { + numeric = isNumeric; + } + + /** + * Answers true if the condition does a numerical comparison, else false + * (string comparison) + * + * @return + */ + public boolean isNumeric() + { + return numeric; + } + + /** + * Answers a display name for the match condition, suitable for showing in + * drop-down menus. The value may be internationalized using the resource key + * "label.matchCondition_" with the enum name appended. + * + * @return + */ + @Override + public String toString() + { + String name = displayNames.get(this); + if (name != null) + { + return name; + } + name = MessageManager + .getStringOrReturn("label.matchCondition_", name()); + displayNames.put(this, name); + return name; + } +} diff --git a/src/jalview/util/matcher/KeyedMatcher.java b/src/jalview/util/matcher/KeyedMatcher.java new file mode 100644 index 0000000..5655118 --- /dev/null +++ b/src/jalview/util/matcher/KeyedMatcher.java @@ -0,0 +1,136 @@ +package jalview.util.matcher; + +import java.util.function.Function; + +public class KeyedMatcher implements KeyedMatcherI +{ + private String key; + + private MatcherI matcher; + + /* + * an optional second condition + */ + KeyedMatcherI combineWith; + + /* + * if true, any second condition is AND-ed with this one + * if false,any second condition is OR-ed with this one + */ + boolean combineAnd; + + /** + * Constructor given a match condition + * + * @param m + */ + public KeyedMatcher(String theKey, MatcherI m) + { + key = theKey; + matcher = m; + } + + @Override + public boolean matches(Function valueProvider) + { + String value = valueProvider.apply(key); + boolean matched = matcher.matches(value); + + /* + * apply a second condition if there is one, using + * lazy evalution of AND and OR combinations + */ + if (combineWith != null) + { + if (combineAnd && matched) + { + matched = combineWith.matches(valueProvider); + } + if (!combineAnd && !matched) + { + matched = combineWith.matches(valueProvider); + } + } + + return matched; + } + + @Override + public KeyedMatcherI and(String key2, MatcherI m) + { + return combineWith(key2, m, true); + } + + @Override + public KeyedMatcherI or(String key2, MatcherI m) + { + return combineWith(key2, m, false); + } + + /** + * Answers a Matcher that is the logical combination of this one with the + * given argument. The two matchers are AND-ed if and is true, else OR-ed. + * + * @param key2 + * @param condition2 + * @param and + * @return + */ + KeyedMatcher combineWith(String key2, MatcherI condition2, + boolean and) + { + if (condition2 == null) + { + return this; + } + + KeyedMatcher combined = new KeyedMatcher(key2, condition2); + combined.combineWith = this; + combined.combineAnd = and; + + return combined; + } + + @Override + public String getKey() + { + return key; + } + + @Override + public MatcherI getMatcher() + { + return matcher; + } + + @Override + public KeyedMatcherI getSecondMatcher() + { + return combineWith; + } + + @Override + public boolean isAnded() + { + return combineAnd; + } + + /** + * Answers a string description of this matcher, suitable for debugging or + * logging. The format may change in future. + */ + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(); + sb.append(key).append(" ").append(matcher.getCondition().name()) + .append(" ").append(matcher.getPattern()); + + if (combineWith != null) + { + sb.append(" ").append(combineAnd ? "AND (" : "OR (") + .append(combineWith.toString()).append(")"); + } + return sb.toString(); + } +} diff --git a/src/jalview/util/matcher/KeyedMatcherI.java b/src/jalview/util/matcher/KeyedMatcherI.java new file mode 100644 index 0000000..a746cd9 --- /dev/null +++ b/src/jalview/util/matcher/KeyedMatcherI.java @@ -0,0 +1,32 @@ +package jalview.util.matcher; + +import java.util.function.Function; + +public interface KeyedMatcherI +{ + boolean matches(Function valueProvider); + + /** + * Answers a new object that matches the logical AND of this and m + * + * @param m + * @return + */ + KeyedMatcherI and(String key, MatcherI m); + + /** + * Answers a new object that matches the logical OR of this and m + * + * @param m + * @return + */ + KeyedMatcherI or(String key, MatcherI m); + + String getKey(); + + MatcherI getMatcher(); + + KeyedMatcherI getSecondMatcher(); + + boolean isAnded(); +} diff --git a/src/jalview/util/matcher/Matcher.java b/src/jalview/util/matcher/Matcher.java new file mode 100644 index 0000000..b162d5d --- /dev/null +++ b/src/jalview/util/matcher/Matcher.java @@ -0,0 +1,205 @@ +package jalview.util.matcher; + +import java.util.regex.Pattern; + +/** + * A bean to describe one attribute-based filter + */ +public class Matcher implements MatcherI +{ + /* + * the comparison condition + */ + Condition condition; + + /* + * the string value (upper-cased), or the regex, to compare to + * also holds the string form of float value if a numeric condition + */ + String pattern; + + /* + * the compiled regex if using a pattern match condition + * (reserved for possible future enhancement) + */ + Pattern regexPattern; + + /* + * the value to compare to for a numerical condition + */ + float value; + + /** + * Constructor + * + * @param cond + * @param compareTo + * @return + * @throws NumberFormatException + * if a numerical condition is specified with a non-numeric + * comparision value + * @throws NullPointerException + * if a null comparison string is specified + */ + public Matcher(Condition cond, String compareTo) + { + condition = cond; + if (cond.isNumeric()) + { + value = Float.valueOf(compareTo); + } + // pattern matches will be non-case-sensitive + pattern = compareTo.toUpperCase(); + // if we add regex conditions (e.g. matchesPattern), then + // pattern should hold the raw regex, and + // regexPattern = Pattern.compile(compareTo); + } + + /** + * Constructor for a numerical match condition. Note that if a string + * comparison condition is specified, this will be converted to a comparison + * with the float value as string + * + * @param cond + * @param compareTo + */ + public Matcher(Condition cond, float compareTo) + { + condition = cond; + value = compareTo; + pattern = String.valueOf(compareTo); + } + + /** + * {@inheritDoc} + */ + @SuppressWarnings("incomplete-switch") + @Override + public boolean matches(String val) + { + if (condition.isNumeric()) + { + try + { + /* + * treat a null value (no such attribute) as + * failing any numerical filter condition + */ + return val == null ? false : matches(Float.valueOf(val)); + } catch (NumberFormatException e) + { + return false; + } + } + + /* + * a null value matches a negative condition, fails a positive test + */ + if (val == null) + { + return condition == Condition.NotContains + || condition == Condition.NotMatches; + } + + String upper = val.toUpperCase().trim(); + boolean matched = false; + switch(condition) { + case Matches: + matched = upper.equals(pattern); + break; + case NotMatches: + matched = !upper.equals(pattern); + break; + case Contains: + matched = upper.indexOf(pattern) > -1; + break; + case NotContains: + matched = upper.indexOf(pattern) == -1; + break; + } + return matched; + } + + /** + * Applies a numerical comparison match condition + * + * @param f + * @return + */ + @SuppressWarnings("incomplete-switch") + boolean matches(float f) + { + if (!condition.isNumeric()) + { + return matches(String.valueOf(f)); + } + + boolean matched = false; + switch (condition) { + case LT: + matched = f < value; + break; + case LE: + matched = f <= value; + break; + case EQ: + matched = f == value; + break; + case NE: + matched = f != value; + break; + case GT: + matched = f > value; + break; + case GE: + matched = f >= value; + break; + } + + return matched; + } + + /** + * A simple hash function that guarantees that when two objects are equal, + * they have the same hashcode + */ + @Override + public int hashCode() + { + return pattern.hashCode() + condition.hashCode() + (int) value; + } + + /** + * equals is overridden so that we can safely remove Matcher objects from + * collections (e.g. delete an attribut match condition for a feature colour) + */ + @Override + public boolean equals(Object obj) + { + if (obj == null || !(obj instanceof Matcher)) + { + return false; + } + Matcher m = (Matcher) obj; + return condition != m.condition || value != m.value + || !pattern.equals(m.pattern); + } + + @Override + public Condition getCondition() + { + return condition; + } + + @Override + public String getPattern() + { + return pattern; + } + + @Override + public float getFloatValue() + { + return value; + } +} diff --git a/src/jalview/util/matcher/MatcherI.java b/src/jalview/util/matcher/MatcherI.java new file mode 100644 index 0000000..ca6d44c --- /dev/null +++ b/src/jalview/util/matcher/MatcherI.java @@ -0,0 +1,18 @@ +package jalview.util.matcher; + +public interface MatcherI +{ + /** + * Answers true if the given value is matched, else false + * + * @param s + * @return + */ + boolean matches(String s); + + Condition getCondition(); + + String getPattern(); + + float getFloatValue(); +} diff --git a/test/jalview/util/matcher/KeyedMatcherTest.java b/test/jalview/util/matcher/KeyedMatcherTest.java new file mode 100644 index 0000000..ccf2ba2 --- /dev/null +++ b/test/jalview/util/matcher/KeyedMatcherTest.java @@ -0,0 +1,92 @@ +package jalview.util.matcher; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import java.util.function.Function; + +import org.testng.annotations.Test; + +public class KeyedMatcherTest +{ + @Test + public void testMatches() + { + /* + * a numeric matcher - MatcherTest covers more conditions + */ + MatcherI m1 = new Matcher(Condition.GE, -2f); + KeyedMatcherI km = new KeyedMatcher("AF", m1); + assertTrue(km.matches(key -> "-2")); + assertTrue(km.matches(key -> "-1")); + assertFalse(km.matches(key -> "-3")); + assertFalse(km.matches(key -> "")); + assertFalse(km.matches(key -> "junk")); + assertFalse(km.matches(key -> null)); + + /* + * a string pattern matcher + */ + MatcherI m2 = new Matcher(Condition.Contains, "Cat"); + km = new KeyedMatcher("AF", m2); + assertTrue(km.matches(key -> "AF".equals(key) ? "raining cats and dogs" + : "showers")); + } + + @Test + public void testAnd() + { + // condition1: AF value contains "dog" (matches) + KeyedMatcherI km1 = new KeyedMatcher("AF", new Matcher( + Condition.Contains, "dog")); + + Function vp = key -> "AF".equals(key) ? "raining cats and dogs" + : "showers"; + assertTrue(km1.matches(vp)); + + // condition 2: CSQ value does not contain "how" (does not match) + KeyedMatcherI km2 = km1.and("CSQ", new Matcher(Condition.NotContains, + "how")); + assertFalse(km2.matches(vp)); + } + + @Test + public void testToString() + { + KeyedMatcherI km = new KeyedMatcher("AF", + new Matcher(Condition.LT, 1.2f)); + assertEquals(km.toString(), "AF LT 1.2"); + + /* + * add an AND condition + */ + km = km.and("CLIN_SIG", new Matcher(Condition.NotContains, "path")); + assertEquals(km.toString(), "CLIN_SIG NotContains PATH AND (AF LT 1.2)"); + + /* + * add an OR condition + */ + km = km.or("CSQ", new Matcher(Condition.Contains, "benign")); + assertEquals(km.toString(), + "CSQ Contains BENIGN OR (CLIN_SIG NotContains PATH AND (AF LT 1.2))"); + } + + @Test + public void testOr() + { + // condition1: AF value contains "dog" (matches) + KeyedMatcherI km1 = new KeyedMatcher("AF", new Matcher( + Condition.Contains, "dog")); + + Function vp = key -> "AF".equals(key) ? "raining cats and dogs" + : "showers"; + assertTrue(km1.matches(vp)); + + // condition 2: CSQ value does not contain "how" (does not match) + // the OR combination still passes + KeyedMatcherI km2 = km1.or("CSQ", new Matcher(Condition.NotContains, + "how")); + assertTrue(km2.matches(vp)); + } +} diff --git a/test/jalview/util/matcher/MatcherTest.java b/test/jalview/util/matcher/MatcherTest.java new file mode 100644 index 0000000..ebfb5d2 --- /dev/null +++ b/test/jalview/util/matcher/MatcherTest.java @@ -0,0 +1,139 @@ +package jalview.util.matcher; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +import org.testng.annotations.Test; + +public class MatcherTest +{ + /** + * Tests for float comparison conditions + */ + @Test + public void testMatches_float() + { + /* + * EQUALS test + */ + MatcherI m = new Matcher(Condition.EQ, 2f); + assertTrue(m.matches("2")); + assertTrue(m.matches("2.0")); + assertFalse(m.matches("2.01")); + + /* + * NOT EQUALS test + */ + m = new Matcher(Condition.NE, 2f); + assertFalse(m.matches("2")); + assertFalse(m.matches("2.0")); + assertTrue(m.matches("2.01")); + + /* + * >= test + */ + m = new Matcher(Condition.GE, 2f); + assertTrue(m.matches("2")); + assertTrue(m.matches("2.1")); + assertFalse(m.matches("1.9")); + + /* + * > test + */ + m = new Matcher(Condition.GT, 2f); + assertFalse(m.matches("2")); + assertTrue(m.matches("2.1")); + assertFalse(m.matches("1.9")); + + /* + * <= test + */ + m = new Matcher(Condition.LE, 2f); + assertTrue(m.matches("2")); + assertFalse(m.matches("2.1")); + assertTrue(m.matches("1.9")); + + /* + * < test + */ + m = new Matcher(Condition.LT, 2f); + assertFalse(m.matches("2")); + assertFalse(m.matches("2.1")); + assertTrue(m.matches("1.9")); + } + + @Test + public void testMatches_floatNullOrInvalid() + { + for (Condition cond : Condition.values()) + { + if (cond.isNumeric()) + { + MatcherI m = new Matcher(cond, 2f); + assertFalse(m.matches(null)); + assertFalse(m.matches("")); + assertFalse(m.matches("two")); + } + } + } + + /** + * Tests for string comparison conditions + */ + @Test + public void testMatches_pattern() + { + /* + * Contains + */ + MatcherI m = new Matcher(Condition.Contains, "benign"); + assertTrue(m.matches("benign")); + assertTrue(m.matches("MOSTLY BENIGN OBSERVED")); // not case-sensitive + assertFalse(m.matches("pathogenic")); + assertFalse(m.matches(null)); + + /* + * does not contain + */ + m = new Matcher(Condition.NotContains, "benign"); + assertFalse(m.matches("benign")); + assertFalse(m.matches("MOSTLY BENIGN OBSERVED")); // not case-sensitive + assertTrue(m.matches("pathogenic")); + assertTrue(m.matches(null)); // null value passes this condition + + /* + * matches + */ + m = new Matcher(Condition.Matches, "benign"); + assertTrue(m.matches("benign")); + assertTrue(m.matches(" Benign ")); // trim before testing + assertFalse(m.matches("MOSTLY BENIGN")); + assertFalse(m.matches("pathogenic")); + assertFalse(m.matches(null)); + + /* + * does not match + */ + m = new Matcher(Condition.NotMatches, "benign"); + assertFalse(m.matches("benign")); + assertFalse(m.matches(" Benign ")); // trim before testing + assertTrue(m.matches("MOSTLY BENIGN")); + assertTrue(m.matches("pathogenic")); + assertTrue(m.matches(null)); + } + + /** + * If a float is passed with a string condition it gets converted to a string + */ + @Test + public void testMatches_floatWithStringCondition() + { + MatcherI m = new Matcher(Condition.Contains, 1.2e-6f); + assertTrue(m.matches("1.2e-6")); + + m = new Matcher(Condition.Contains, 0.0000001f); + assertTrue(m.matches("1.0e-7")); + assertTrue(m.matches("1.0E-7")); + assertFalse(m.matches("0.0000001f")); + } +}