JAL-2808 classes to support filtering by attribute value
authorgmungoc <g.m.carstairs@dundee.ac.uk>
Mon, 30 Oct 2017 16:51:40 +0000 (16:51 +0000)
committergmungoc <g.m.carstairs@dundee.ac.uk>
Mon, 30 Oct 2017 16:51:40 +0000 (16:51 +0000)
resources/lang/Messages.properties
src/jalview/util/matcher/Condition.java [new file with mode: 0644]
src/jalview/util/matcher/KeyedMatcher.java [new file with mode: 0644]
src/jalview/util/matcher/KeyedMatcherI.java [new file with mode: 0644]
src/jalview/util/matcher/Matcher.java [new file with mode: 0644]
src/jalview/util/matcher/MatcherI.java [new file with mode: 0644]
test/jalview/util/matcher/KeyedMatcherTest.java [new file with mode: 0644]
test/jalview/util/matcher/MatcherTest.java [new file with mode: 0644]

index 9ffe2ae..daca83b 100644 (file)
@@ -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 (file)
index 0000000..455f805
--- /dev/null
@@ -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<Condition, String> 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 (file)
index 0000000..5655118
--- /dev/null
@@ -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<String, String> 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 (file)
index 0000000..a746cd9
--- /dev/null
@@ -0,0 +1,32 @@
+package jalview.util.matcher;
+
+import java.util.function.Function;
+
+public interface KeyedMatcherI
+{
+  boolean matches(Function<String, String> 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 (file)
index 0000000..b162d5d
--- /dev/null
@@ -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 (file)
index 0000000..ca6d44c
--- /dev/null
@@ -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 (file)
index 0000000..ccf2ba2
--- /dev/null
@@ -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<String, String> 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<String, String> 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 (file)
index 0000000..ebfb5d2
--- /dev/null
@@ -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"));
+  }
+}