JAL-3020 small optimisation if max cache size reached
[jalview.git] / src / jalview / datamodel / features / FeatureAttributes.java
1 package jalview.datamodel.features;
2
3 import jalview.bin.Cache;
4
5 import java.util.ArrayList;
6 import java.util.Collections;
7 import java.util.Comparator;
8 import java.util.HashMap;
9 import java.util.HashSet;
10 import java.util.List;
11 import java.util.Map;
12 import java.util.Map.Entry;
13 import java.util.Set;
14 import java.util.StringTokenizer;
15 import java.util.TreeMap;
16 import java.util.regex.Pattern;
17 import java.util.regex.PatternSyntaxException;
18
19 /**
20  * A singleton class to hold the set of attributes known for each feature type
21  */
22 public class FeatureAttributes
23 {
24   public enum Datatype
25   {
26     Character, Number, Mixed
27   }
28
29   /*
30    * property key for lookup of a comma-separated list of regex patterns
31    * to match those attribute names for which distinct values should be cached
32    */
33   private static final String CACHED_ATTS_KEY = "CACHED_ATTRIBUTES";
34
35   /*
36    * default value if property is not specified
37    * (selected VCF/VEP terms which have 'categorical' value ranges)
38    */
39   private static final String CACHED_ATTS_DEFAULT = "AS_FilterStatus,clinical_significance,consequence_type,"
40           + "CSQ:Consequence,CSQ:CLIN_SIG,CSQ:DOMAIN,CSQ:IMPACT";
41
42   /*
43    * delimiters of terms in attribute values
44    */
45   private static final String TERM_DELIMITERS = ",&";
46
47   /*
48    * defensive limit to number of attribute values cached per attribute
49    */
50   private static final int MAX_ATT_VALS = 30;
51
52   private static FeatureAttributes instance = new FeatureAttributes();
53
54   /*
55    * map, by feature type, of a map, by attribute name, of
56    * attribute description and min-max range (if known)
57    */
58   private Map<String, Map<String[], AttributeData>> attributes;
59
60   /*
61    * attribute names that have been seen and 
62    * match the condition for caching distinct values
63    */
64   private Set<String> cachedAttributes;
65
66   /*
67    * attribute names that have been seen and do not 
68    * match the condition for caching distinct values
69    */
70   private Set<String> uncachedAttributes;
71
72   private List<Pattern> cacheableNamePatterns;
73
74   /*
75    * a case-insensitive comparator so that attributes are ordered e.g.
76    * AC
77    * af
78    * CSQ:AFR_MAF
79    * CSQ:Allele
80    */
81   private Comparator<String[]> comparator = new Comparator<String[]>()
82   {
83     @Override
84     public int compare(String[] o1, String[] o2)
85     {
86       int i = 0;
87       while (i < o1.length || i < o2.length)
88       {
89         if (o2.length <= i)
90         {
91           return o1.length <= i ? 0 : 1;
92         }
93         if (o1.length <= i)
94         {
95           return -1;
96         }
97         int comp = String.CASE_INSENSITIVE_ORDER.compare(o1[i], o2[i]);
98         if (comp != 0)
99         {
100           return comp;
101         }
102         i++;
103       }
104       return 0; // same length and all matched
105     }
106   };
107
108   private class AttributeData
109   {
110     /*
111      * description(s) for this attribute, if known
112      * (different feature source might have differing descriptions)
113      */
114     List<String> description;
115
116     /*
117      * minimum value (if only numeric values recorded)
118      */
119     float min = 0f;
120
121     /*
122      * maximum value (if only numeric values recorded)
123      */
124     float max = 0f;
125
126     /*
127      * flag is set true if only numeric values are detected for this attribute
128      */
129     boolean hasValue = false;
130
131     Datatype type;
132
133     /*
134      * (for selected attributes), a list of distinct terms found in values
135      */
136     Set<String> terms;
137
138     /**
139      * Note one instance of this attribute, recording unique, non-null
140      * descriptions, and the min/max of any numerical values.
141      * <p>
142      * Distinct value terms may also be recorded, if the feature type is one for
143      * which this is configured
144      * 
145      * @param attName
146      * @param desc
147      * @param value
148      */
149     void addInstance(String[] attName, String desc, String value)
150     {
151       addDescription(desc);
152
153       if (value != null)
154       {
155         value = value.trim();
156
157         String name = FeatureMatcher.toAttributeDisplayName(attName);
158         recordValue(name, value);
159
160         /*
161          * Parse numeric value unless we have previously
162          * seen text data for this attribute type
163          */
164         if (type == null || type == Datatype.Number)
165         {
166           try
167           {
168             float f = Float.valueOf(value);
169             min = hasValue ? Float.min(min, f) : f;
170             max = hasValue ? Float.max(max, f) : f;
171             hasValue = true;
172             type = (type == null || type == Datatype.Number)
173                     ? Datatype.Number
174                     : Datatype.Mixed;
175           } catch (NumberFormatException e)
176           {
177             /*
178              * non-numeric data: treat attribute as Character (or Mixed)
179              */
180             type = (type == null || type == Datatype.Character)
181                     ? Datatype.Character
182                     : Datatype.Mixed;
183             min = 0f;
184             max = 0f;
185             hasValue = false;
186           }
187         }
188       }
189     }
190
191     /**
192      * If attribute name is configured to cache distinct values, then parse out
193      * and store these
194      * 
195      * @param attName
196      * @param value
197      */
198     private void recordValue(String attName, String value)
199     {
200       /*
201        * quit if we've seen this attribute name before,
202        * and determined we are not caching its values
203        */
204       if (uncachedAttributes.contains(attName))
205       {
206         return;
207       }
208
209       /*
210        * if first time seen, check attribute name filters to
211        * see if we want to cache its value
212        */
213       if (!cachedAttributes.contains(attName))
214       {
215         if (!matches(attName, cacheableNamePatterns))
216         {
217           uncachedAttributes.add(attName);
218           return;
219         }
220         else
221         {
222           cachedAttributes.add(attName);
223         }
224       }
225
226       /*
227        * we want to cache distinct terms for this attribute;
228        * parse them out using comma or & delimiters
229        */
230       if (terms == null)
231       {
232         terms = new HashSet<>();
233       }
234       int count = terms.size();
235       if (count >= MAX_ATT_VALS)
236       {
237         return;
238       }
239       StringTokenizer st = new StringTokenizer(value, TERM_DELIMITERS);
240       while (st.hasMoreTokens() && count < MAX_ATT_VALS)
241       {
242         String term = st.nextToken().trim();
243         if (!terms.contains(term))
244         {
245           terms.add(term);
246           count++;
247         }
248       }
249     }
250
251     /**
252      * Answers true if any of the patterns matches the value, else false
253      * 
254      * @param value
255      * @param filters
256      * @return
257      */
258     private boolean matches(String value, List<Pattern> filters)
259     {
260       for (Pattern p : filters)
261       {
262         if (p.matcher(value).matches())
263         {
264           return true;
265         }
266       }
267       return false;
268     }
269
270     /**
271      * Answers the description of the attribute, if recorded and unique, or null
272      * if either no, or more than description is recorded
273      * 
274      * @return
275      */
276     public String getDescription()
277     {
278       if (description != null && description.size() == 1)
279       {
280         return description.get(0);
281       }
282       return null;
283     }
284
285     public Datatype getType()
286     {
287       return type;
288     }
289
290     /**
291      * Adds the given description to the list of known descriptions (without
292      * duplication)
293      * 
294      * @param desc
295      */
296     public void addDescription(String desc)
297     {
298       if (desc != null)
299       {
300         if (description == null)
301         {
302           description = new ArrayList<>();
303         }
304         if (!description.contains(desc))
305         {
306           description.add(desc);
307         }
308       }
309     }
310
311     /**
312      * Answers the distinct terms recorded for the attribute, or an empty set if
313      * it is not configured to cache values
314      * 
315      * @return
316      */
317     public Set<String> getDistinctTerms()
318     {
319       return terms == null ? Collections.<String> emptySet() : terms;
320     }
321   }
322
323   /**
324    * Answers the singleton instance of this class
325    * 
326    * @return
327    */
328   public static FeatureAttributes getInstance()
329   {
330     return instance;
331   }
332
333   /**
334    * Private constructor to enforce singleton pattern
335    */
336   private FeatureAttributes()
337   {
338     attributes = new HashMap<>();
339     cachedAttributes = new HashSet<>();
340     uncachedAttributes = new HashSet<>();
341     cacheableNamePatterns = getFieldMatchers(CACHED_ATTS_KEY,
342             CACHED_ATTS_DEFAULT);
343   }
344
345   /**
346    * Reads the Preference value for the given key, with default specified if no
347    * preference set. The value is interpreted as a comma-separated list of
348    * regular expressions, and converted into a list of compiled patterns ready
349    * for matching. Patterns are set to non-case-sensitive matching.
350    * <p>
351    * This supports user-defined filters for attributes of interest to capture
352    * distinct values for as instance are added.
353    * 
354    * @param key
355    * @param def
356    * @return
357    */
358   public static List<Pattern> getFieldMatchers(String key, String def)
359   {
360     String pref = def;
361     try
362     {
363       // temporary for applet: handle class loading errors...
364       pref = Cache.getDefault(key, def);
365     } catch (Throwable t)
366     {
367     }
368     List<Pattern> patterns = new ArrayList<>();
369     String[] tokens = pref.split(",");
370     for (String token : tokens)
371     {
372       try
373       {
374         patterns.add(Pattern.compile(token, Pattern.CASE_INSENSITIVE));
375       } catch (PatternSyntaxException e)
376       {
377         System.err.println("Invalid pattern ignored: " + token);
378       }
379     }
380     return patterns;
381   }
382
383   /**
384    * Answers the attribute names known for the given feature type, in
385    * alphabetical order (not case sensitive), or an empty set if no attributes
386    * are known. An attribute name is typically 'simple' e.g. "AC", but may be
387    * 'compound' e.g. {"CSQ", "Allele"} where a feature has map-valued attributes
388    * 
389    * @param featureType
390    * @return
391    */
392   public List<String[]> getAttributes(String featureType)
393   {
394     if (!attributes.containsKey(featureType))
395     {
396       return Collections.<String[]> emptyList();
397     }
398
399     return new ArrayList<>(attributes.get(featureType).keySet());
400   }
401
402   /**
403    * Answers the set of distinct terms recorded for the given feature type and
404    * attribute. Answers an empty set if values are not cached for this
405    * attribute.
406    * 
407    * @param featureType
408    * @param attName
409    * @return
410    */
411   public Set<String> getDistinctTerms(String featureType, String... attName)
412   {
413     if (!attributes.containsKey(featureType)
414             || !attributes.get(featureType).containsKey(attName))
415     {
416       return Collections.<String> emptySet();
417     }
418
419     return attributes.get(featureType).get(attName).getDistinctTerms();
420   }
421
422   /**
423    * Answers true if at least one attribute is known for the given feature type,
424    * else false
425    * 
426    * @param featureType
427    * @return
428    */
429   public boolean hasAttributes(String featureType)
430   {
431     if (attributes.containsKey(featureType))
432     {
433       if (!attributes.get(featureType).isEmpty())
434       {
435         return true;
436       }
437     }
438     return false;
439   }
440
441   /**
442    * Records the given attribute name and description for the given feature
443    * type, and updates the min-max for any numeric value
444    * 
445    * @param featureType
446    * @param description
447    * @param value
448    * @param attName
449    */
450   public void addAttribute(String featureType, String description,
451           Object value, String... attName)
452   {
453     if (featureType == null || attName == null)
454     {
455       return;
456     }
457
458     /*
459      * if attribute value is a map, drill down one more level to
460      * record its sub-fields
461      */
462     if (value instanceof Map<?, ?>)
463     {
464       for (Entry<?, ?> entry : ((Map<?, ?>) value).entrySet())
465       {
466         String[] attNames = new String[attName.length + 1];
467         System.arraycopy(attName, 0, attNames, 0, attName.length);
468         attNames[attName.length] = entry.getKey().toString();
469         addAttribute(featureType, description, entry.getValue(), attNames);
470       }
471       return;
472     }
473
474     String valueAsString = value.toString();
475     Map<String[], AttributeData> atts = attributes.get(featureType);
476     if (atts == null)
477     {
478       atts = new TreeMap<>(comparator);
479       attributes.put(featureType, atts);
480     }
481     AttributeData attData = atts.get(attName);
482     if (attData == null)
483     {
484       attData = new AttributeData();
485       atts.put(attName, attData);
486     }
487     attData.addInstance(attName, description, valueAsString);
488   }
489
490   /**
491    * Answers the description of the given attribute for the given feature type,
492    * if known and unique, else null
493    * 
494    * @param featureType
495    * @param attName
496    * @return
497    */
498   public String getDescription(String featureType, String... attName)
499   {
500     String desc = null;
501     Map<String[], AttributeData> atts = attributes.get(featureType);
502     if (atts != null)
503     {
504       AttributeData attData = atts.get(attName);
505       if (attData != null)
506       {
507         desc = attData.getDescription();
508       }
509     }
510     return desc;
511   }
512
513   /**
514    * Answers the [min, max] value range of the given attribute for the given
515    * feature type, if known, else null. Attributes with a mixture of text and
516    * numeric values are considered text (do not return a min-max range).
517    * 
518    * @param featureType
519    * @param attName
520    * @return
521    */
522   public float[] getMinMax(String featureType, String... attName)
523   {
524     Map<String[], AttributeData> atts = attributes.get(featureType);
525     if (atts != null)
526     {
527       AttributeData attData = atts.get(attName);
528       if (attData != null && attData.hasValue)
529       {
530         return new float[] { attData.min, attData.max };
531       }
532     }
533     return null;
534   }
535
536   /**
537    * Records the given attribute description for the given feature type
538    * 
539    * @param featureType
540    * @param attName
541    * @param description
542    */
543   public void addDescription(String featureType, String description,
544           String... attName)
545   {
546     if (featureType == null || attName == null)
547     {
548       return;
549     }
550   
551     Map<String[], AttributeData> atts = attributes.get(featureType);
552     if (atts == null)
553     {
554       atts = new TreeMap<>(comparator);
555       attributes.put(featureType, atts);
556     }
557     AttributeData attData = atts.get(attName);
558     if (attData == null)
559     {
560       attData = new AttributeData();
561       atts.put(attName, attData);
562     }
563     attData.addDescription(description);
564   }
565
566   /**
567    * Answers the datatype of the feature, which is one of Character, Number or
568    * Mixed (or null if not known), as discovered from values recorded.
569    * 
570    * @param featureType
571    * @param attName
572    * @return
573    */
574   public Datatype getDatatype(String featureType, String... attName)
575   {
576     Map<String[], AttributeData> atts = attributes.get(featureType);
577     if (atts != null)
578     {
579       AttributeData attData = atts.get(attName);
580       if (attData != null)
581       {
582         return attData.getType();
583       }
584     }
585     return null;
586   }
587 }