All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.juneau.utils.PojoQuery Maven / Gradle / Ivy

There is a newer version: 9.0.1
Show newest version
// ***************************************************************************************************************************
// * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.  See the NOTICE file *
// * distributed with this work for additional information regarding copyright ownership.  The ASF licenses this file        *
// * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance            *
// * with the License.  You may obtain a copy of the License at                                                              *
// *                                                                                                                         *
// *  http://www.apache.org/licenses/LICENSE-2.0                                                                             *
// *                                                                                                                         *
// * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an  *
// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the        *
// * specific language governing permissions and limitations under the License.                                              *
// ***************************************************************************************************************************
package org.apache.juneau.utils;

import static java.util.Calendar.*;
import static org.apache.juneau.internal.StringUtils.*;

import java.lang.reflect.*;
import java.text.*;
import java.util.*;
import java.util.regex.*;

import org.apache.juneau.*;
import org.apache.juneau.internal.*;

/**
 * Designed to provide search/view/sort/paging filtering on tabular in-memory POJO models.
 *
 * 

* It can also perform just view filtering on beans/maps. * *

* Examples of tabular POJO models: *

    *
  • Collection{@code } *
  • Collection{@code } *
  • Map[] *
  • Bean[] *
* *

* Tabular POJO models can be thought of as tables of data. For example, a list of the following beans... *

* public MyBean { * public int myInt; * public String myString; * public Date myDate; * } *

* ... can be thought of a table containing the following columns... *

*

* * * *
myIntmyStringmyDate
123'foobar'yyyy/MM/dd HH:mm:ss
...
* *

* From this table, you can perform the following functions: *

    *
  • * Search - Return only rows where a search pattern matches. *
  • * View - Return only the specified subset of columns in the specified order. *
  • * Sort - Sort the table by one or more columns. *
  • * Position/limit - Only return a subset of rows. *
* *
Search
* * The search capabilities allow you to filter based on query patterns against strings, dates, and numbers. * Queries take the form of a Map with column names as keys, and search patterns as values. *
Multiple search patterns are ANDed (i.e. all patterns must match for the row to be returned). * *
Example:
*
    *
  • * {myInt:'123'} - Return only rows where the myInt column is 123. *
  • * {myString:'foobar'} - Return only rows where the myString column is 'foobar'. *
  • * {myDate:'2001'} - Return only rows where the myDate column have dates in the year 2001. *
* *
String Patterns
* * Any objects can be queried against using string patterns. * If the objects being searched are not strings, then the patterns are matched against whatever is return by the * {@code Object#toString()} method. * *
Example string query patterns:
*
    *
  • foo - The string 'foo' *
  • foo bar - The string 'foo' or the string 'bar' *
  • 'foo bar' - The phrase 'foo bar' *
  • "foo bar" - The phrase 'foo bar' *
  • foo* - * matches zero-or-more characters. *
  • foo? - ? matches exactly one character *
* *
Notes:
*
    *
  • * Whitespace is ignored around search patterns. *
  • * Prepend + to tokens that must match. (e.g. +foo* +*bar) *
  • * Prepend - to tokens that must not match. (e.g. +foo* -*bar) *
* *
Numeric Patterns
* * Any object of type {@link Number} (or numeric primitives) can be searched using numeric patterns. * *
Example numeric query patterns:
*
    *
  • 123 - The single number 123 *
  • 1 2 3 - 1, 2, or 3 *
  • 1-100 - Between 1 and 100 *
  • 1 - 100 - Between 1 and 100 *
  • 1 - 100 200-300 - Between 1 and 100 or between 200 and 300 *
  • > 100 - Greater than 100 *
  • >= 100 - Greater than or equal to 100 *
  • !123 - Not 123 *
* *
Notes:
*
    *
  • * Whitespace is ignored in search patterns. *
  • * Negative numbers are supported. *
* *
Date Patterns
* * Any object of type {@link Date} or {@link Calendar} can be searched using date patterns. * *

* The default valid input timestamp formats (which can be overridden via the {@link #setValidTimestampFormats(String...)} * method are... * *

    *
  • yyyy.MM.dd.HH.mm.ss *
  • yyyy.MM.dd.HH.mm *
  • yyyy.MM.dd.HH *
  • yyyy.MM.dd *
  • yyyy.MM *
  • yyyy *
* *
Example date query patterns:
*
    *
  • 2001 - A specific year. *
  • 2001.01.01.10.50 - A specific time. *
  • >2001 - After a specific year. *
  • >=2001 - During or after a specific year. *
  • 2001 - 2003.06.30 - A date range. *
  • 2001 2003 2005 - Multiple date patterns are ORed. *
* *
Notes:
*
    *
  • * Whitespace is ignored in search patterns. *
* *
View
* * The view capability allows you to return only the specified subset of columns in the specified order. *
The view parameter is a list of either Strings or Maps. * *
Example view parameters:
*
    *
  • column1 - Return only column 'column1'. *
  • column2, column1 - Return only columns 'column2' and 'column1' in that order. *
* *
Sort
* * The sort capability allows you to sort values by the specified rows. *
The sort parameter is a list of strings with an optional '+' or '-' suffix representing * ascending and descending order accordingly. * *
Example sort parameters:
*
    *
  • column1 - Sort rows by column 'column1' ascending. *
  • column1+ - Sort rows by column 'column1' ascending. *
  • column1- - Sort rows by column 'column1' descending. *
  • column1, column2- - Sort rows by column 'column1' ascending, then 'column2' descending. *
* *
Paging
* * Use the position and limit parameters to specify a subset of rows to return. */ @SuppressWarnings({"unchecked","rawtypes"}) public final class PojoQuery { private Object input; private ClassMeta type; private BeanSession session; /** * Constructor. * * @param input The POJO we're going to be filtering. * @param session The bean session to use to create bean maps for beans. */ public PojoQuery(Object input, BeanSession session) { this.input = input; this.type = session.getClassMetaForObject(input); this.session = session; } /** * Filters the input object as a collection of maps. * * @param args The search arguments. * @return The filtered collection. * Returns the unaltered input if the input is not a collection or array of objects. */ public List filter(SearchArgs args) { if (input == null) return null; if (! type.isCollectionOrArray()) throw new FormattedRuntimeException("Cannot call filterCollection() on class type ''{0}''", type); // Create a new ObjectList ObjectList l = (ObjectList)replaceWithMutables(input); // Do the search CollectionFilter filter = new CollectionFilter(args.getSearch(), args.isIgnoreCase()); filter.doQuery(l); // If sort or view isn't empty, then we need to make sure that all entries in the // list are maps. Map sort = args.getSort(); List view = args.getView(); if ((! sort.isEmpty()) || (! view.isEmpty())) { if (! sort.isEmpty()) doSort(l, sort); if (! view.isEmpty()) doView(l, view); } // Do the paging. int pos = args.getPosition(); int limit = args.getLimit(); if (pos != 0 || limit != 0) { int end = (limit == 0 || limit+pos >= l.size()) ? l.size() : limit + pos; pos = Math.min(pos, l.size()); ObjectList l2 = new DelegateList(((DelegateList)l).getClassMeta()); l2.addAll(l.subList(pos, end)); l = l2; } return l; } /* * If there are any non-Maps in the specified list, replaces them with BeanMaps. */ private Object replaceWithMutables(Object o) { if (o == null) return null; ClassMeta cm = session.getClassMetaForObject(o); if (cm.isCollection()) { ObjectList l = new DelegateList(session.getClassMetaForObject(o)); for (Object o2 : (Collection)o) l.add(replaceWithMutables(o2)); return l; } if (cm.isMap() && o instanceof BeanMap) { BeanMap bm = (BeanMap)o; DelegateBeanMap dbm = new DelegateBeanMap(bm.getBean(), session); for (Object key : bm.keySet()) dbm.addKey(key.toString()); return dbm; } if (cm.isBean()) { BeanMap bm = session.toBeanMap(o); DelegateBeanMap dbm = new DelegateBeanMap(bm.getBean(), session); for (Object key : bm.keySet()) dbm.addKey(key.toString()); return dbm; } if (cm.isMap()) { Map m = (Map)o; DelegateMap dm = new DelegateMap(session.getClassMetaForObject(m)); for (Map.Entry e : (Set)m.entrySet()) dm.put(e.getKey().toString(), e.getValue()); return dm; } if (cm.isArray()) { return replaceWithMutables(Arrays.asList((Object[])o)); } return o; } /* * Sorts the specified list by the sort list. */ private static void doSort(List list, Map sortList) { // We reverse the list and sort last to first. List columns = new ArrayList<>(sortList.keySet()); Collections.reverse(columns); for (final String c : columns) { final boolean isDesc = sortList.get(c); Comparator comp = new Comparator() { @Override /* Comparator */ public int compare(Map m1, Map m2) { Comparable v1 = toComparable(m1.get(c)), v2 = toComparable(m2.get(c)); if (v1 == null && v2 == null) return 0; if (v1 == null) return (isDesc ? -1 : 1); if (v2 == null) return (isDesc ? 1 : -1); return (isDesc ? v2.compareTo(v1) : v1.compareTo(v2)); } }; Collections.sort(list, comp); } } static final Comparable toComparable(Object o) { if (o == null) return null; if (o instanceof Comparable) return (Comparable)o; if (o instanceof Map) return ((Map)o).size(); if (o.getClass().isArray()) return Array.getLength(o); return o.toString(); } /* * Filters all but the specified view columns on all entries in the specified list. */ private static void doView(List list, List view) { for (ListIterator i = list.listIterator(); i.hasNext();) { Object o = i.next(); Map m = (Map)o; doView(m, view); } } /* * Creates a new Map with only the entries specified in the view list. */ private static Map doView(Map m, List view) { if (m instanceof DelegateMap) ((DelegateMap)m).filterKeys(view); else ((DelegateBeanMap)m).filterKeys(view); return m; } //==================================================================================================== // CollectionFilter //==================================================================================================== private class CollectionFilter { IMatcher entryMatcher; public CollectionFilter(Map query, boolean ignoreCase) { if (query != null && ! query.isEmpty()) entryMatcher = new MapMatcher(query, ignoreCase); } public void doQuery(List in) { if (in == null || entryMatcher == null) return; for (Iterator i = in.iterator(); i.hasNext();) { Object o = i.next(); if (! entryMatcher.matches(o)) i.remove(); } } } //==================================================================================================== // IMatcher //==================================================================================================== private interface IMatcher { public boolean matches(E o); } //==================================================================================================== // MapMatcher //==================================================================================================== /* * Matches on a Map only if all specified entry matchers match. */ private class MapMatcher implements IMatcher { Map entryMatchers = new HashMap<>(); public MapMatcher(Map query, boolean ignoreCase) { for (Map.Entry e : (Set)query.entrySet()) if (e.getKey() != null && e.getValue() != null) entryMatchers.put(e.getKey().toString(), new ObjectMatcher(e.getValue().toString(), ignoreCase)); } @Override /* IMatcher */ public boolean matches(Map m) { if (m == null) return false; for (Map.Entry e : entryMatchers.entrySet()) { String key = e.getKey(); Object val = null; if (m instanceof BeanMap) { val = ((BeanMap)m).getRaw(key); } else { val = m.get(key); } if (! e.getValue().matches(val)) return false; } return true; } } //==================================================================================================== // ObjectMatcher //==================================================================================================== /* * Matcher that uses the correct matcher based on object type. * Used for objects when we can't determine the object type beforehand. */ private class ObjectMatcher implements IMatcher { String searchPattern; boolean ignoreCase; DateMatcher dateMatcher; NumberMatcher numberMatcher; StringMatcher stringMatcher; ObjectMatcher(String searchPattern, boolean ignoreCase) { this.searchPattern = searchPattern; this.ignoreCase = ignoreCase; } @Override /* IMatcher */ public boolean matches(Object o) { if (o instanceof Collection) { for (Object o2 : (Collection)o) if (matches(o2)) return true; return false; } if (o != null && o.getClass().isArray()) { for (int i = 0; i < Array.getLength(o); i++) if (matches(Array.get(o, i))) return true; return false; } if (o instanceof Map) { for (Object o2 : ((Map)o).values()) if (matches(o2)) return true; return false; } if (o instanceof Number) return getNumberMatcher().matches(o); if (o instanceof Date || o instanceof Calendar) return getDateMatcher().matches(o); return getStringMatcher().matches(o); } private IMatcher getNumberMatcher() { if (numberMatcher == null) numberMatcher = new NumberMatcher(searchPattern); return numberMatcher; } private IMatcher getStringMatcher() { if (stringMatcher == null) stringMatcher = new StringMatcher(searchPattern, ignoreCase); return stringMatcher; } private IMatcher getDateMatcher() { if (dateMatcher == null) dateMatcher = new DateMatcher(searchPattern); return dateMatcher; } } //==================================================================================================== // NumberMatcher //==================================================================================================== private static class NumberMatcher implements IMatcher { private NumberPattern[] numberPatterns; /** * Construct a number matcher for the given search pattern. * * @param searchPattern A date search paattern. See class usage for a description. */ public NumberMatcher(String searchPattern) { numberPatterns = new NumberPattern[1]; numberPatterns[0] = new NumberPattern(searchPattern); } /** * Returns 'true' if this integer matches the pattern(s). */ @Override /* IMatcher */ public boolean matches(Number in) { for (int i = 0; i < numberPatterns.length; i++) { if (! numberPatterns[i].matches(in)) return false; } return true; } } /** * A construct representing a single search pattern. */ private static class NumberPattern { NumberRange[] numberRanges; public NumberPattern(String searchPattern) { List l = new LinkedList<>(); for (String s : breakUpTokens(searchPattern)) { boolean isNot = (s.charAt(0) == '!'); String token = s.substring(1); Pattern p = Pattern.compile("(([<>]=?)?)(-?\\d+)(-?(-?\\d+)?)"); // Possible patterns: // 123, >123, <123, >=123, <=123, >-123, >=-123, 123-456, -123--456 // Regular expression used: (([<>]=?)?)(-?\d+)(-??(-?\d+)) Matcher m = p.matcher(token); // If a non-numeric value was passed in for a numeric value, just set the value to '0'. // (I think this might resolve a workaround in custom queries). if (! m.matches()) throw new FormattedRuntimeException("Numeric value didn't match pattern: ''{0}''", token); //m = numericPattern.matcher("0"); String arg1 = m.group(1); String start = m.group(3); String end = m.group(5); l.add(new NumberRange(arg1, start, end, isNot)); } numberRanges = l.toArray(new NumberRange[l.size()]); } private static List breakUpTokens(String s) { // Get rid of whitespace in "123 - 456" s = s.replaceAll("(-?\\d+)\\s*-\\s*(-?\\d+)", "$1-$2"); // Get rid of whitespace in ">= 123" s = s.replaceAll("([<>]=?)\\s+(-?\\d+)", "$1$2"); // Get rid of whitespace in "! 123" s = s.replaceAll("(!)\\s+(-?\\d+)", "$1$2"); // Replace all commas with whitespace // Allows for alternate notation of: 123,456... s = s.replaceAll(",", " "); String[] s2 = s.split("\\s+"); // Make all tokens 'ORed'. There is no way to AND numeric tokens. for (int i = 0; i < s2.length; i++) if (! startsWith(s2[i], '!')) s2[i] = "^"+s2[i]; List l = new LinkedList<>(); l.addAll(Arrays.asList(s2)); return l; } public boolean matches(Number number) { if (numberRanges.length == 0) return true; for (int i = 0; i < numberRanges.length; i++) if (numberRanges[i].matches(number)) return true; return false; } } /** * A construct representing a single search range in a single search pattern. * All possible forms of search patterns are boiled down to these number ranges. */ private static class NumberRange { int start; int end; boolean isNot; public NumberRange(String arg, String start, String end, boolean isNot) { this.isNot = isNot; // 123, >123, <123, >=123, <=123, >-123, >=-123, 123-456, -123--456 if (arg.equals("") && end == null) { // 123 this.start = Integer.parseInt(start); this.end = this.start; } else if (arg.equals(">")) { this.start = Integer.parseInt(start)+1; this.end = Integer.MAX_VALUE; } else if (arg.equals(">=")) { this.start = Integer.parseInt(start); this.end = Integer.MAX_VALUE; } else if (arg.equals("<")) { this.start = Integer.MIN_VALUE; this.end = Integer.parseInt(start)-1; } else if (arg.equals("<=")) { this.start = Integer.MIN_VALUE; this.end = Integer.parseInt(start); } else { this.start = Integer.parseInt(start); this.end = Integer.parseInt(end); } } public boolean matches(Number n) { long i = n.longValue(); boolean b = (i>=start && i<=end); if (isNot) b = !b; return b; } } //==================================================================================================== // DateMatcher //==================================================================================================== /** The list of all valid timestamp formats */ private SimpleDateFormat[] validTimestampFormats = new SimpleDateFormat[0]; { setValidTimestampFormats("yyyy.MM.dd.HH.mm.ss","yyyy.MM.dd.HH.mm","yyyy.MM.dd.HH","yyyy.MM.dd","yyyy.MM","yyyy"); } /** * Use this method to override the allowed search patterns when used in locales where time formats are different. * * @param s A comma-delimited list of valid time formats. */ public void setValidTimestampFormats(String...s) { validTimestampFormats = new SimpleDateFormat[s.length]; for (int i = 0; i < s.length; i++) validTimestampFormats[i] = new SimpleDateFormat(s[i]); } private class DateMatcher implements IMatcher { private TimestampPattern[] patterns; /** * Construct a timestamp matcher for the given search pattern. * * @param searchPattern The search pattern. */ DateMatcher(String searchPattern) { patterns = new TimestampPattern[1]; patterns[0] = new TimestampPattern(searchPattern); } /** * Returns true if the specified date matches the pattern passed in through the constructor. * *

*
The Object can be of type {@link Date} or {@link Calendar}. *
Always returns false on null input. */ @Override /* IMatcher */ public boolean matches(Object in) { if (in == null) return false; Calendar c = null; if (in instanceof Calendar) c = (Calendar)in; else if (in instanceof Date) { c = Calendar.getInstance(); c.setTime((Date)in); } else { return false; } for (int i = 0; i < patterns.length; i++) { if (! patterns[i].matches(c)) return false; } return true; } } /** * A construct representing a single search pattern. */ private class TimestampPattern { TimestampRange[] ranges; List l = new LinkedList<>(); public TimestampPattern(String s) { // Handle special case where timestamp is enclosed in quotes. // This can occur on hyperlinks created by group-by queries. // e.g. '2007/01/29 04:17:43 PM' if (s.charAt(0) == '\'' && s.charAt(s.length()-1) == '\'') s = s.substring(1, s.length()-1); // Pattern for finding <,>,<=,>= Pattern p1 = Pattern.compile("^\\s*([<>](?:=)?)\\s*(\\S+.*)$"); // Pattern for finding range dash (e.g. xxx - yyy) Pattern p2 = Pattern.compile("^(\\s*-\\s*)(\\S+.*)$"); // States are... // 1 - Looking for <,>,<=,>= // 2 - Looking for single date. // 3 - Looking for start date. // 4 - Looking for - // 5 - Looking for end date. int state = 1; String op = null; CalendarP startDate = null; ParsePosition pp = new ParsePosition(0); Matcher m = null; String seg = s; while (! seg.equals("") || state != 1) { if (state == 1) { m = p1.matcher(seg); if (m.matches()) { op = m.group(1); seg = m.group(2); state = 2; } else { state = 3; } } else if (state == 2) { l.add(new TimestampRange(op, parseDate(seg, pp))); //tokens.add("^"+op + parseTimestamp(seg, pp)); seg = seg.substring(pp.getIndex()).trim(); pp.setIndex(0); state = 1; } else if (state == 3) { startDate = parseDate(seg, pp); seg = seg.substring(pp.getIndex()).trim(); pp.setIndex(0); state = 4; } else if (state == 4) { // Look for '-' m = p2.matcher(seg); if (m.matches()) { state = 5; seg = m.group(2); } else { // This is a single date (e.g. 2002/01/01) l.add(new TimestampRange(startDate)); state = 1; } } else if (state == 5) { l.add(new TimestampRange(startDate, parseDate(seg, pp))); seg = seg.substring(pp.getIndex()).trim(); pp.setIndex(0); state = 1; } } ranges = l.toArray(new TimestampRange[l.size()]); } public boolean matches(Calendar c) { if (ranges.length == 0) return true; for (int i = 0; i < ranges.length; i++) if (ranges[i].matches(c)) return true; return false; } } /** * A construct representing a single search range in a single search pattern. * All possible forms of search patterns are boiled down to these timestamp ranges. */ private static class TimestampRange { Calendar start; Calendar end; public TimestampRange(CalendarP start, CalendarP end) { this.start = start.copy().roll(MILLISECOND, -1).getCalendar(); this.end = end.roll(1).getCalendar(); } public TimestampRange(CalendarP singleDate) { this.start = singleDate.copy().roll(MILLISECOND, -1).getCalendar(); this.end = singleDate.roll(1).getCalendar(); } public TimestampRange(String op, CalendarP singleDate) { if (op.equals(">")) { this.start = singleDate.roll(1).roll(MILLISECOND, -1).getCalendar(); this.end = new CalendarP(new Date(Long.MAX_VALUE), 0).getCalendar(); } else if (op.equals("<")) { this.start = new CalendarP(new Date(0), 0).getCalendar(); this.end = singleDate.getCalendar(); } else if (op.equals(">=")) { this.start = singleDate.roll(MILLISECOND, -1).getCalendar(); this.end = new CalendarP(new Date(Long.MAX_VALUE), 0).getCalendar(); } else if (op.equals("<=")) { this.start = new CalendarP(new Date(0), 0).getCalendar(); this.end = singleDate.roll(1).getCalendar(); } } public boolean matches(Calendar c) { boolean b = (c.after(start) && c.before(end)); return b; } } private static int getPrecisionField(String pattern) { if (pattern.indexOf('s') != -1) return SECOND; if (pattern.indexOf('m') != -1) return MINUTE; if (pattern.indexOf('H') != -1) return HOUR_OF_DAY; if (pattern.indexOf('d') != -1) return DAY_OF_MONTH; if (pattern.indexOf('M') != -1) return MONTH; if (pattern.indexOf('y') != -1) return YEAR; return Calendar.MILLISECOND; } /** * Parses a timestamp string off the beginning of the string segment 'seg'. * Goes through each possible valid timestamp format until it finds a match. * The position where the parsing left off is stored in pp. * * @param seg The string segment being parsed. * @param pp Where parsing last left off. * @return An object representing a timestamp. */ CalendarP parseDate(String seg, ParsePosition pp) { CalendarP cal = null; for (int i = 0; i < validTimestampFormats.length && cal == null; i++) { pp.setIndex(0); SimpleDateFormat f = validTimestampFormats[i]; Date d = f.parse(seg, pp); int idx = pp.getIndex(); if (idx != 0) { // it only counts if the next character is '-', 'space', or end-of-string. char c = (seg.length() == idx ? 0 : seg.charAt(idx)); if (c == 0 || c == '-' || Character.isWhitespace(c)) cal = new CalendarP(d, getPrecisionField(f.toPattern())); } } if (cal == null) throw new FormattedRuntimeException("Invalid date encountered: ''{0}''", seg); return cal; } /** * Combines a Calendar with a precision identifier. */ private static class CalendarP { public Calendar c; public int precision; public CalendarP(Date date, int precision) { c = Calendar.getInstance(); c.setTime(date); this.precision = precision; } public CalendarP copy() { return new CalendarP(c.getTime(), precision); } public CalendarP roll(int field, int amount) { c.add(field, amount); return this; } public CalendarP roll(int amount) { return roll(precision, amount); } public Calendar getCalendar() { return c; } } //==================================================================================================== // StringMatcher //==================================================================================================== private static class StringMatcher implements IMatcher { private SearchPattern[] searchPatterns; /** * Construct a string matcher for the given search pattern. * * @param searchPattern The search pattern. See class usage for details. * @param ignoreCase If true, use case-insensitive matching. */ public StringMatcher(String searchPattern, boolean ignoreCase) { this.searchPatterns = new SearchPattern[1]; this.searchPatterns[0] = new SearchPattern(searchPattern, ignoreCase); } /** * Returns 'true' if this string matches the pattern(s). * Always returns false on null input. */ @Override /* IMatcher */ public boolean matches(Object in) { if (in == null) return false; for (int i = 0; i < searchPatterns.length; i++) { if (! searchPatterns[i].matches(in.toString())) return false; } return true; } } /** * A construct representing a single search pattern. */ private static class SearchPattern { Pattern[] orPatterns, andPatterns, notPatterns; public SearchPattern(String searchPattern, boolean ignoreCase) { List ors = new LinkedList<>(); List ands = new LinkedList<>(); List nots = new LinkedList<>(); for (String arg : breakUpTokens(searchPattern)) { char prefix = arg.charAt(0); String token = arg.substring(1); token = token.replaceAll("([\\?\\*\\+\\\\\\[\\]\\{\\}\\(\\)\\^\\$\\.])", "\\\\$1"); token = token.replace("\u9997", ".*"); token = token.replace("\u9996", ".?"); if (! token.startsWith(".*")) token = "^" + token; if (! token.endsWith(".*")) token = token + "$"; int flags = Pattern.DOTALL; if (ignoreCase) flags |= Pattern.CASE_INSENSITIVE; Pattern p = Pattern.compile(token, flags); if (prefix == '^') ors.add(p); else if (prefix == '+') ands.add(p); else if (prefix == '-') nots.add(p); } orPatterns = ors.toArray(new Pattern[ors.size()]); andPatterns = ands.toArray(new Pattern[ands.size()]); notPatterns = nots.toArray(new Pattern[nots.size()]); } /** * Break up search pattern into separate tokens. */ private static List breakUpTokens(String s) { // If the string is null or all whitespace, return an empty vector. if (s == null || s.trim().length() == 0) return Collections.emptyList(); // Pad with spaces. s = " " + s + " "; // Replace instances of [+] and [-] inside single and double quotes with // \u2001 and \u2002 for later replacement. int escapeCount = 0; boolean inSingleQuote = false; boolean inDoubleQuote = false; char[] ca = s.toCharArray(); for (int i = 0; i < ca.length; i++) { if (ca[i] == '\\') escapeCount++; else if (escapeCount % 2 == 0) { if (ca[i] == '\'') inSingleQuote = ! inSingleQuote; else if (ca[i] == '"') inDoubleQuote = ! inDoubleQuote; else if (ca[i] == '+' && (inSingleQuote || inDoubleQuote)) ca[i] = '\u9999'; else if (ca[i] == '-' && (inSingleQuote || inDoubleQuote)) ca[i] = '\u9998'; } if (ca[i] != '\\') escapeCount = 0; } s = new String(ca); // Remove spaces between '+' or '-' and the keyword. //s = perl5Util.substitute("s/([\\+\\-])\\s+/$1/g", s); s = s.replaceAll("([\\+\\-])\\s+", "$1"); // Replace: [*]->[\u3001] as placeholder for '%', ignore escaped. s = replace(s, '*', '\u9997', true); // Replace: [?]->[\u3002] as placeholder for '_', ignore escaped. s = replace(s, '?', '\u9996', true); // Replace: [\*]->[*], [\?]->[?] s = unEscapeChars(s, new char[]{'*','?'}); // Remove spaces s = s.trim(); // Re-replace the [+] and [-] characters inside quotes. s = s.replace('\u9999', '+'); s = s.replace('\u9998', '-'); String[] sa = splitQuoted(s, ' '); List l = new ArrayList<>(sa.length); int numOrs = 0; for (int i = 0; i < sa.length; i++) { String token = sa[i]; int len = token.length(); if (len > 0) { char c = token.charAt(0); String s2 = null; if ((c == '+' || c == '-') && len > 1) s2 = token.substring(1); else { s2 = token; c = '^'; numOrs++; } // Trim off leading and trailing single and double quotes. if (s2.matches("\".*\"") || s2.matches("'.*'")) s2 = s2.substring(1, s2.length()-1); // Replace: [\"]->["] s2 = unEscapeChars(s2, new char[]{'"','\''}); // Un-escape remaining escaped backslashes. s2 = unEscapeChars(s2, new char[]{'\\'}); l.add(c + s2); } } // If there's a single OR clause, turn it into an AND clause (makes the SQL cleaner). if (numOrs == 1) { int ii = l.size(); for (int i = 0; i < ii; i++) { String x = l.get(i); if (x.charAt(0) == '^') l.set(i, '+'+x.substring(1)); } } return l; } public boolean matches(String input) { if (input == null) return false; for (int i = 0; i < andPatterns.length; i++) if (! andPatterns[i].matcher(input).matches()) return false; for (int i = 0; i < notPatterns.length; i++) if (notPatterns[i].matcher(input).matches()) return false; for (int i = 0; i < orPatterns.length; i++) if (orPatterns[i].matcher(input).matches()) return true; return orPatterns.length == 0; } } /* * Same as split(String, char), but does not split on characters inside * single quotes. * Does not split on escaped delimiters, and escaped quotes are also ignored. * Example: * split("a,b,c",',') -> {"a","b","c"} * split("a,'b,b,b',c",',') -> {"a","'b,b,b'","c"} */ static final String[] splitQuoted(String s, char c) { if (s == null || s.matches("\\s*")) return new String[0]; List l = new LinkedList<>(); char[] sArray = s.toCharArray(); int x1 = 0; int escapeCount = 0; boolean inSingleQuote = false; boolean inDoubleQuote = false; for (int i = 0; i < sArray.length; i++) { if (sArray[i] == '\\') escapeCount++; else if (escapeCount % 2 == 0) { if (sArray[i] == '\'' && ! inDoubleQuote) inSingleQuote = ! inSingleQuote; else if (sArray[i] == '"' && ! inSingleQuote) inDoubleQuote = ! inDoubleQuote; else if (sArray[i] == c && ! inSingleQuote && ! inDoubleQuote) { String s2 = new String(sArray, x1, i-x1).trim(); l.add(s2); x1 = i+1; } } if (sArray[i] != '\\') escapeCount = 0; } String s2 = new String(sArray, x1, sArray.length-x1).trim(); l.add(s2); return l.toArray(new String[l.size()]); } /** * Replaces tokens in a string with a different token. * *

* replace("A and B and C", "and", "or") -> "A or B or C" * replace("andandand", "and", "or") -> "ororor" * replace(null, "and", "or") -> null * replace("andandand", null, "or") -> "andandand" * replace("andandand", "", "or") -> "andandand" * replace("A and B and C", "and", null) -> "A B C" * @param ignoreEscapedChars Specify 'true' if escaped 'from' characters should be ignored. */ static String replace(String s, char from, char to, boolean ignoreEscapedChars) { if (s == null) return null; char[] sArray = s.toCharArray(); int escapeCount = 0; int singleQuoteCount = 0; int doubleQuoteCount = 0; for (int i = 0; i < sArray.length; i++) { char c = sArray[i]; if (c == '\\' && ignoreEscapedChars) escapeCount++; else if (escapeCount % 2 == 0) { if (c == from && singleQuoteCount % 2 == 0 && doubleQuoteCount % 2 == 0) sArray[i] = to; } if (sArray[i] != '\\') escapeCount = 0; } return new String(sArray); } /** * Removes escape characters (specified by escapeChar) from the specified characters. */ static String unEscapeChars(String s, char[] toEscape) { char escapeChar = '\\'; if (s == null) return null; if (s.length() == 0) return s; StringBuffer sb = new StringBuffer(s.length()); char[] sArray = s.toCharArray(); for (int i = 0; i < sArray.length; i++) { char c = sArray[i]; if (c == escapeChar) { if (i+1 != sArray.length) { char c2 = sArray[i+1]; boolean isOneOf = false; for (int j = 0; j < toEscape.length && ! isOneOf; j++) isOneOf = (c2 == toEscape[j]); if (isOneOf) { i++; } else if (c2 == escapeChar) { sb.append(escapeChar); i++; } } } sb.append(sArray[i]); } return sb.toString(); } }