001package org.cpsolver.ifs.util;
002
003import java.io.Serializable;
004import java.util.ArrayList;
005import java.util.Collection;
006import java.util.List;
007
008/**
009 * A representation of a boolean query. Besides of AND, OR, and NOT, the query
010 * may also contain terms (such as major:M1) that are evaluated by the {@link TermMatcher}. 
011 * 
012 * @version IFS 1.3 (Iterative Forward Search)<br>
013 *          Copyright (C) 2024 Tomáš Müller<br>
014 *          <a href="mailto:muller@unitime.org">muller@unitime.org</a><br>
015 *          <a href="http://muller.unitime.org">http://muller.unitime.org</a><br>
016 * <br>
017 *          This library is free software; you can redistribute it and/or modify
018 *          it under the terms of the GNU Lesser General Public License as
019 *          published by the Free Software Foundation; either version 3 of the
020 *          License, or (at your option) any later version. <br>
021 * <br>
022 *          This library is distributed in the hope that it will be useful, but
023 *          WITHOUT ANY WARRANTY; without even the implied warranty of
024 *          MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
025 *          Lesser General Public License for more details. <br>
026 * <br>
027 *          You should have received a copy of the GNU Lesser General Public
028 *          License along with this library; if not see
029 *          <a href='http://www.gnu.org/licenses/'>http://www.gnu.org/licenses/</a>.
030 */
031public class Query implements Serializable {
032    private static final long serialVersionUID = 1L;
033    
034    private Term iQuery = null;
035    
036    public Query(String query) {
037            iQuery = parse(query == null ? "" : query.trim());
038    }
039    
040    public Query(Term query) {
041            iQuery = query;
042    }
043    
044    public Term getQuery() { return iQuery; }
045    
046    /**
047     * Evaluate if the query using the provided term matcher.
048     */
049    public boolean match(TermMatcher m) {
050            return iQuery.match(m);
051    }
052
053    /**
054     * Evaluate if the query using the provided term matcher.
055     */
056
057    public boolean match(AmbigousTermMatcher m) {
058            Boolean ret = iQuery.match(m);
059            if (ret == null) return true;
060            return ret;
061    }
062    
063    @Override
064    public String toString() {
065            return iQuery.toString();
066    }
067    
068    public String toString(QueryFormatter f) {
069            return iQuery.toString(f);
070    }
071    
072    public boolean hasAttribute(String... attr) {
073            for (String a: attr)
074                    if (iQuery.hasAttribute(a)) return true;
075            return false;
076    }
077    
078    public boolean hasAttribute(Collection<String> attr) {
079            for (String a: attr)
080                    if (iQuery.hasAttribute(a)) return true;
081            return false;
082    }
083    
084    private static List<String> split(String query, String... splits) {
085            List<String> ret = new ArrayList<String>();
086            int bracket = 0;
087            boolean quot = false;
088            int last = 0;
089            boolean white = false;
090            loop: for (int i = 0; i < query.length(); i++) {
091                    if (query.charAt(i) == '"') {
092                            quot = !quot;
093                            white = !quot;
094                            continue;
095                    }
096                    if (!quot && query.charAt(i) == '(') { bracket ++; white = false; continue; }
097                    if (!quot && query.charAt(i) == ')') { bracket --; white = true; continue; }
098                    if (quot || bracket > 0 || (!white && query.charAt(i) != ' ')) {
099                            white = (query.charAt(i) == ' ');
100                            continue;
101                    }
102                    white = (query.charAt(i) == ' ');
103                    String q = query.substring(i).toLowerCase();
104                    for (String split: splits) {
105                            if (split.isEmpty() || q.startsWith(split + " ") || q.startsWith(split + "\"") || q.startsWith(split + "(")) {
106                                    String x = query.substring(last, i).trim();
107                                    if (split.isEmpty() && x.endsWith(":")) continue;
108                                    if (!x.isEmpty()) ret.add(x);
109                                    last = i + split.length();
110                                    if (!split.isEmpty())
111                                            i += split.length() - 1;
112                                    continue loop;
113                            }
114                    }
115            }
116            String x = query.substring(last).trim();
117            if (!x.isEmpty()) ret.add(x);
118            return ret;
119    }
120
121    private static Term parse(String query) {
122            List<String> splits;
123            splits = split(query, "and", "&&", "&");
124            if (splits.size() > 1) {
125                    CompositeTerm t = new AndTerm();
126                    for (String q: splits)
127                            t.add(parse(q));
128                    return t;
129            }
130            splits = split(query, "or", "||", "|");
131            if (splits.size() > 1) {
132                    CompositeTerm t = new OrTerm();
133                    for (String q: splits)
134                            t.add(parse(q));
135                    return t;
136            }
137            splits = split(query, "");
138            if (splits.size() > 1) {
139                    CompositeTerm and = new AndTerm();
140                    boolean not = false;
141                    splits: for (String q: splits) {
142                            if (q.equalsIgnoreCase("not") || q.equals("!")) { not = true; continue; }
143                            if (q.startsWith("!(")) {
144                                    q = q.substring(1); not = true;
145                            } else if (q.toLowerCase().startsWith("not(")) {
146                                    q = q.substring(3); not = true;
147                            }
148                            if (not) {
149                                    and.add(new NotTerm(parse(q)));
150                                    not = false;
151                            } else {
152                                    Term t = parse(q);
153                                    if (t instanceof AtomTerm) {
154                                            AtomTerm a = (AtomTerm)t;
155                                            for (Term x: and.terms()) {
156                                                    if (x instanceof AtomTerm && ((AtomTerm)x).sameAttribute(a)) {
157                                                            and.remove(x);
158                                                            OrTerm or = new OrTerm();
159                                                            or.add(x); or.add(a);
160                                                            and.add(or);
161                                                            continue splits;
162                                                    } else if (x instanceof OrTerm && ((OrTerm)x).terms().get(0) instanceof AtomTerm && ((AtomTerm)((OrTerm)x).terms().get(0)).sameAttribute(a)) {
163                                                            ((OrTerm)x).terms().add(a);
164                                                            continue splits;
165                                                    }
166                                            }
167                                    }
168                                    and.add(t);
169                            }
170                    }
171                    return and;
172            }
173            if (query.startsWith("(") && query.endsWith(")")) return parse(query.substring(1, query.length() - 1).trim());
174            if (query.startsWith("\"") && query.endsWith("\"") && query.length() >= 2) return new AtomTerm(null, query.substring(1, query.length() - 1).trim());
175            int idx = query.indexOf(':');
176            if (idx >= 0) {
177                    return new AtomTerm(query.substring(0, idx).trim().toLowerCase(), query.substring(idx + 1).trim());
178            } else {
179                    return new AtomTerm(null, query);
180            }
181    }
182    
183    /**
184     * Representation of a term in the query, that is attribute:value. Or a sub-query.
185     */
186    public static interface Term extends Serializable {
187        /**
188         * Does the term matches the provided object
189         */
190        public boolean match(TermMatcher m);
191        /**
192         * Print the term/sub-query
193         */
194        public String toString(QueryFormatter f);
195        /**
196         * Check if this term (or its sub-terms) contain a given attribute
197         */
198        public boolean hasAttribute(String attribute);
199        /**
200         * Does the term matches the provided object, returning null if does not apply (e.g., the object does not have the provided attribute)
201         */
202        public Boolean match(AmbigousTermMatcher m);
203    }
204
205    /**
206     * Composition of one or more terms in a query (e.g., A and B and C)
207     */
208    public static abstract class CompositeTerm implements Term {
209        private static final long serialVersionUID = 1L;
210        private List<Term> iTerms = new ArrayList<Term>();
211
212        public CompositeTerm() {}
213        
214        public CompositeTerm(Term... terms) {
215            for (Term t: terms) add(t);
216        }
217        
218        public CompositeTerm(Collection<Term> terms) {
219            for (Term t: terms) add(t);
220        }
221        
222        /**
223         * Add a term
224         */
225        public void add(Term t) { iTerms.add(t); }
226        
227        /**
228         * Remove a term
229         */
230        public void remove(Term t) { iTerms.remove(t); }
231        
232        /** List terms */
233        protected List<Term> terms() { return iTerms; }
234        
235        /** Operation over the terms, such as AND or OR */
236        public abstract String getOp();
237        
238        @Override
239        public boolean hasAttribute(String attribute) {
240            for (Term t: terms())
241                if (t.hasAttribute(attribute)) return true;
242            return false;
243        }
244        
245        @Override
246        public String toString() {
247            String ret = "";
248            for (Term t: terms()) {
249                if (!ret.isEmpty()) ret += " " + getOp() + " ";
250                ret += t;
251            }
252            return (terms().size() > 1 ? "(" + ret + ")" : ret);
253        }
254        
255        @Override
256        public String toString(QueryFormatter f) {
257            String ret = "";
258            for (Term t: terms()) {
259                if (!ret.isEmpty()) ret += " " + getOp() + " ";
260                ret += t.toString(f);
261            }
262            return (terms().size() > 1 ? "(" + ret + ")" : ret);
263        }
264    }
265    
266    /**
267     * Representation of an OR between two or more terms.
268     */    
269    public static class OrTerm extends CompositeTerm {
270        private static final long serialVersionUID = 1L;
271        public OrTerm() { super(); }
272        public OrTerm(Term... terms) { super(terms); }
273        public OrTerm(Collection<Term> terms) { super(terms); }
274        
275        @Override
276        public String getOp() { return "OR"; }
277        
278        /** A term matches, when at least one sub-term matches */
279        @Override
280        public boolean match(TermMatcher m) {
281            if (terms().isEmpty()) return true;
282            for (Term t: terms())
283                if (t.match(m)) return true;
284            return false;
285        }
286        
287        @Override
288        public Boolean match(AmbigousTermMatcher m) {
289            if (terms().isEmpty()) return true;
290            for (Term t: terms()) {
291                Boolean r = t.match(m);
292                if (r == null) return null;
293                if (r) return true;
294            }
295            return false;
296        }
297    }
298    
299    /**
300     * Representation of an AND between two or more terms.
301     */    
302    public static class AndTerm extends CompositeTerm {
303        private static final long serialVersionUID = 1L;
304        public AndTerm() { super(); }
305        public AndTerm(Term... terms) { super(terms); }
306        public AndTerm(Collection<Term> terms) { super(terms); }
307        
308        @Override
309        public String getOp() { return "AND"; }
310        
311        /** A term matches, when all sub-term match */
312        @Override
313        public boolean match(TermMatcher m) {
314            for (Term t: terms())
315                if (!t.match(m)) return false;
316            return true;
317        }
318        
319        @Override
320        public Boolean match(AmbigousTermMatcher m) {
321            for (Term t: terms()) {
322                Boolean r = t.match(m);
323                if (r == null) return null;
324                if (!r) return false;
325            }
326            return true;
327        }
328    }
329    
330    /**
331     * Representation of a NOT term
332     */
333    public static class NotTerm implements Term {
334        private static final long serialVersionUID = 1L;
335        private Term iTerm;
336        
337        public NotTerm(Term t) {
338                iTerm = t;
339        }
340        
341        /** A term matches, when sub-term does not match */
342        @Override
343        public boolean match(TermMatcher m) {
344            return !iTerm.match(m);
345        }
346        
347        @Override
348        public boolean hasAttribute(String attribute) {
349            return iTerm.hasAttribute(attribute);
350        }
351        
352        @Override
353        public Boolean match(AmbigousTermMatcher m) {
354            Boolean r = iTerm.match(m);
355            if (r == null) return r;
356            return !r;
357        }
358        
359        @Override
360        public String toString() { return "NOT " + iTerm.toString(); }
361        
362        @Override
363        public String toString(QueryFormatter f) { return "NOT " + iTerm.toString(f); }
364    }
365
366    /**
367     * Representation of one attribute:value term
368     */
369    public static class AtomTerm implements Term {
370        private static final long serialVersionUID = 1L;
371        private String iAttr, iBody;
372        
373        public AtomTerm(String attr, String body) {
374            if (body.startsWith("\"") && body.endsWith("\"") && body.length()>1)
375                body = body.substring(1, body.length() - 1);
376            iAttr = attr; iBody = body;
377        }
378        
379        /** A term matches, when {@link TermMatcher#match(String, String)} matches */
380        @Override
381        public boolean match(TermMatcher m) {
382            return m.match(iAttr, iBody);
383        }
384        
385        @Override
386        public boolean hasAttribute(String attribute) {
387            return attribute != null && attribute.equals(iAttr);
388        }
389        
390        public boolean sameAttribute(AtomTerm t) {
391            return t != null && hasAttribute(t.iAttr);
392        }
393        
394        @Override
395        public String toString() { return (iAttr == null ? "" : iAttr + ":") + (iBody.indexOf(' ') >= 0 ? "\"" + iBody + "\"" : iBody); }
396        
397        @Override
398        public String toString(QueryFormatter f) { return f.format(iAttr, iBody); }
399
400        @Override
401        public Boolean match(AmbigousTermMatcher m) {
402            return m.match(iAttr, iBody);
403        }
404    }
405    
406    /**
407     * Term matcher interface. Check representing an object to be matched with the query.
408     *
409     */
410    public static interface TermMatcher {
411        /**
412         * Does the object has the given attribute matching the given term? For example, for a student, major:M1
413         * would match a student that has major M1 (attribute is major, term is M1). 
414         */
415        public boolean match(String attr, String term);
416    }
417    
418    /**
419     * Ambigous term matcher. Returning yes, no, or does not apply (null).
420     */
421    public static interface AmbigousTermMatcher {
422        /**
423         * Does the object has the given attribute matching the given term?
424         * Returns null if does not apply.
425         **/
426        public Boolean match(String attr, String term);
427    }
428    
429    /**
430     * Query formatter class
431     */
432    public static interface QueryFormatter {
433            String format(String attr, String term);
434    }
435    
436    public static void main(String[] args) {
437            System.out.println(parse("(dept:1124 or dept:1125) and area:bio"));
438            System.out.println(parse("a \"b c\" or ddd f \"x:x\" x: s !(band or org) (a)or(b)"));
439            System.out.println(parse("! f (a)or(b) d !d not x s"));
440            System.out.println(parse(""));
441            System.out.println(split("(a \"b c\")  ddd f", ""));
442            System.out.println(split("a \"b c\" OR not ddd f", "or"));
443            System.out.println(split("a or((\"b c\" or dddor) f) q", "or"));
444            System.out.println(parse("false"));
445    }
446    
447    
448}