001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.commons.beanquery; 018 019import static org.apache.juneau.commons.lang.StateEnum.*; 020import static org.apache.juneau.commons.utils.Shorts.*; 021 022import java.util.*; 023 024/** 025 * The single public parser for the {@code $}-search language (design §5.1). Engines call it; a {@link BeanQuery} 026 * itself carries only the raw strings. 027 * 028 * <h5 class='section'>Grammar</h5> 029 * <p> 030 * A column's expression is one of: 031 * <ul> 032 * <li>A <b>pattern</b> — a bare token (case-insensitive substring / prefix / exact per the column type), 033 * optionally with {@code *} wildcards, or a value such as {@code $100} whose leading {@code $} is not a known 034 * operator invocation. Patterns are {@link SearchExpression#isLiteral() literal} nodes. This also covers a 035 * relative-duration token such as {@code -24h} or {@code +7d} ({@code [+-]<n>(ms|s|m|h|d)}) or a signed ISO-8601 duration such as {@code -PT24H}: the parser applies 036 * no special grammar to it, since it is already an ordinary pattern; a TIMESTAMP or duration column's typed 037 * conversion (not this parser) is what resolves it relative to request time. 038 * <li>An <b>operator</b> — {@code $name(arg,...)} where {@code $name} is in the supplied 039 * {@link SearchOperatorSet}. Leaf-operator arguments are literals; combinator ({@code $and}/{@code $or}/ 040 * {@code $not}) arguments are themselves expressions. Columns combine with {@code and}; {@code $or}/{@code $and}/ 041 * {@code $not} combine within one column. 042 * </ul> 043 * 044 * <h5 class='section'>Literal dollar</h5> 045 * <p> 046 * {@code $$} is a literal {@code $} everywhere. A token that starts with {@code $$} is always a pattern (never an 047 * operator), and every {@code $$} inside a literal decodes to a single {@code $}. So {@code $$eq(x)} is the literal 048 * text {@code $eq(x)}, while {@code $eq(x)} is the operator. 049 * 050 * <h5 class='section'>Quoting</h5> 051 * <p> 052 * A quoted literal argument (design §5.1) may contain its own delimiter by doubling it — {@code ""} inside a 053 * double-quoted value, or {@code ''} inside a single-quoted value, decodes to one literal quote character of that 054 * same kind. So {@code $eq("a""b")} is the literal text {@code a"b}. This doubling decode is independent of, and 055 * composes with, the {@code $$} → {@code $} decoding above. 056 * 057 * <h5 class='section'>Errors</h5> 058 * <p> 059 * An unknown {@code $name(...)}, a malformed expression (unclosed/unbalanced parentheses, junk after the close paren, 060 * a stray comma, a wrong argument count), or an empty expression throws {@link BeanQuerySyntaxException}. There is no 061 * "incomplete draft" state on the server: a request-facing adapter catches the exception and surfaces the message. 062 * 063 * <h5 class='section'>Example:</h5> 064 * <p class='bjava'> 065 * <jk>try</jk> { 066 * SearchExpression <jv>node</jv> = SearchExpressionParser.<jsm>parse</jsm>(<js>"$gt(10)"</js>, SearchOperatorSet.<jsm>standard</jsm>()); 067 * <jc>// node.isFunction() == true, node.name() == "$gt", node.literalArgs() == ["10"]</jc> 068 * } <jk>catch</jk> (BeanQuerySyntaxException <jv>e</jv>) { 069 * <jc>// e.code() names the specific grammar problem, e.g. Code.UNKNOWN_OPERATOR or Code.UNBALANCED</jc> 070 * } 071 * </p> 072 * 073 * @since 10.0.0 074 */ 075public final class SearchExpressionParser { 076 077 /** No instances. */ 078 private SearchExpressionParser() {} 079 080 /** 081 * Parses a raw column-search expression. 082 * 083 * @param raw The raw expression string (a pattern or {@code $name(...)}). Must not be <jk>null</jk> or blank. 084 * @param operators The operator set that defines which {@code $}-names are recognized. Must not be <jk>null</jk>. 085 * @return The parsed expression, never <jk>null</jk>. 086 * @throws BeanQuerySyntaxException If the expression is empty, names an unknown operator, or is malformed. 087 */ 088 public static SearchExpression parse(String raw, SearchOperatorSet operators) { 089 reqnn("operators", operators); 090 if (ib(raw)) 091 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.EMPTY_EXPRESSION, "Empty search expression."); 092 return parseExpr(raw.strip(), operators); 093 } 094 095 /** Parses one (already trimmed, non-empty) token into a node. */ 096 private static SearchExpression parseExpr(String s, SearchOperatorSet operators) { 097 if (! s.startsWith("$$") && s.startsWith("$") && s.length() >= 2 && Character.isLetter(s.charAt(1))) 098 return parseFunc(s, operators); 099 if (isUnterminatedQuote(s)) 100 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNTERMINATED_QUOTE, "Unterminated quoted value in search expression: '%s'", s); 101 return SearchExpression.literal(decodeLiteral(s), isQuotedToken(s)); 102 } 103 104 /** Whether {@code s} is delimited by a matching pair of double or single quotes. */ 105 private static boolean isQuotedToken(String s) { 106 if (s.length() < 2) 107 return false; 108 var first = s.charAt(0); 109 return (first == '"' || first == '\'') && s.charAt(s.length() - 1) == first; 110 } 111 112 /** Whether {@code s} opens a quote ({@code "} or {@code '} as its first character) that its own last character does not close. */ 113 private static boolean isUnterminatedQuote(String s) { 114 var first = s.charAt(0); 115 return (first == '"' || first == '\'') && ! isQuotedToken(s); 116 } 117 118 /** 119 * Parses a {@code $name(args...)} token that must span the entire string {@code s}. 120 * 121 * <p> 122 * After validating the operator name, a state machine walks the argument list once: top-level commas separate 123 * arguments, quotes protect commas/parens, and the matching {@code )} ends the call. Nested expressions are then 124 * parsed recursively (same shape as the JSON parser's nested object/array dispatch). 125 */ 126 @SuppressWarnings({ 127 "java:S3776", // Cognitive complexity acceptable for parser state machine 128 "java:S6541" // Brain method acceptable for parser state machine 129 }) 130 private static SearchExpression parseFunc(String s, SearchOperatorSet operators) { 131 var open = s.indexOf('('); 132 if (open < 0) 133 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.MALFORMED_OPERATOR, "Malformed search expression (operator name without arguments): '%s'", s); 134 var name = s.substring(0, open); 135 if (! isValidName(name)) 136 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.INVALID_OPERATOR_NAME, "Invalid operator name: '%s'", name); 137 var op = operators.get(name); 138 if (op == null) 139 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNKNOWN_OPERATOR, "Unknown search operator: '%s'", name); 140 141 // S1: Outside quotes, tracking paren depth (looking for top-level ',' or matching ')'). 142 // S2: Inside a double-quoted argument fragment. 143 // S3: Inside a single-quoted argument fragment. 144 145 var state = S1; 146 var depth = 0; 147 var argStart = open + 1; 148 var rawArgs = new ArrayList<String>(); 149 150 for (var i = open; i < s.length(); i++) { 151 var c = s.charAt(i); 152 if (state == S1) { 153 if (c == '"') { 154 state = S2; 155 } else if (c == '\'') { 156 state = S3; 157 } else if (c == '(') { 158 depth++; 159 } else if (c == ')' && --depth == 0) { 160 if (argStart != i || ! rawArgs.isEmpty()) // Otherwise $blank() — zero arguments. 161 addPart(s, rawArgs, s.substring(argStart, i)); 162 if (inb(s.substring(i + 1))) 163 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.TRAILING_TEXT, "Unexpected text after ')' in search expression: '%s'", s); 164 var args = rawArgs.stream().map(part -> parseExpr(part.strip(), operators)).toList(); 165 if (! op.acceptsArgCount(args.size())) 166 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.BAD_ARG_COUNT, 167 regexHint("Operator '%s' does not accept %s argument(s).", op.name()), op.name(), args.size()); 168 return SearchExpression.func(op, args); 169 } else if (c == ',' && depth == 1) { 170 addPart(s, rawArgs, s.substring(argStart, i)); 171 argStart = i + 1; 172 } 173 } else if (state == S2 && c == '"') { 174 state = S1; 175 } else if (state == S3 && c == '\'') { 176 state = S1; 177 } 178 } 179 if (state != S1) 180 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNTERMINATED_QUOTE, "Unterminated quoted value in search expression: '%s'", s); 181 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNBALANCED, regexHint("Unclosed parenthesis in search expression: '%s'", op.name()), s); 182 } 183 184 /** A valid operator name is {@code $} + a letter + zero or more letters/digits. */ 185 static boolean isValidName(String name) { 186 if (name.length() < 2 || name.charAt(0) != '$' || ! Character.isLetter(name.charAt(1))) 187 return false; 188 return name.substring(2).chars().allMatch(c -> Character.isLetterOrDigit((char)c)); 189 } 190 191 /** Appends the D5 quoting hint to {@code message} when {@code opName} is {@code $regex}; otherwise unchanged. */ 192 private static String regexHint(String message, String opName) { 193 return "$regex".equals(opName) ? message + " Quote regex patterns that contain commas or parentheses: $regex(\"…\")." : message; 194 } 195 196 /** Appends one split argument, rejecting a blank one (a stray/trailing comma). */ 197 private static void addPart(String whole, List<String> parts, String part) { 198 if (ib(part)) 199 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.EMPTY_ARGUMENT, "Empty argument in search expression: '%s'", whole); 200 parts.add(part); 201 } 202 203 /** 204 * Strips a matching pair of surrounding double or single quotes from a literal token, collapses a doubled 205 * instance of that same quote character to one literal quote (design D5 — {@code ""} or {@code ''} inside a 206 * quoted value is one literal quote character), then decodes every {@code $$} escape to a single {@code $}. 207 */ 208 private static String decodeLiteral(String s) { 209 var v = s; 210 if (v.length() >= 2) { 211 var first = v.charAt(0); 212 if ((first == '"' || first == '\'') && v.charAt(v.length() - 1) == first) { 213 v = v.substring(1, v.length() - 1); 214 var q = String.valueOf(first); 215 v = v.replace(q + q, q); 216 } 217 } 218 return v.replace("$$", "$"); 219 } 220}