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 clause grammar shared by the one {@link BeanQuery#getSearch() search} string and the one 026 * {@link BeanQuery#getOpts() opts} string (design §5.3). 027 * 028 * <p> 029 * A string is a list of {@code key=value} clauses: 030 * <ul> 031 * <li>Top-level commas (outside parentheses) separate clauses. A comma <b>inside</b> parentheses is protected (so an 032 * {@code $in(OPEN,CLOSED)} value survives intact). 033 * <li>The <b>first unescaped</b> {@code =} in a clause separates the key from the value; the value keeps any further 034 * {@code =} characters verbatim. 035 * <li>Backslash escapes decode to literals: {@code \,} → comma, {@code \=} → equals, {@code \\} → 036 * backslash, {@code \$} → dollar. Any other backslash is left as-is. 037 * </ul> 038 * 039 * <p> 040 * For {@code search}, the key is a column name and the value is a raw {@code $}-expression handed to 041 * {@link SearchExpressionParser}. For {@code opts}, the key is an option name and the value is a plain string. A 042 * session decodes these strings when the query runs; the {@link BeanQuery} bean itself never parses them. 043 * 044 * @since 10.0.0 045 */ 046public final class ClauseParser { 047 048 private ClauseParser() {} 049 050 /** 051 * Parses a search-or-opts string into an ordered {@code key}→{@code value} map. 052 * 053 * <p> 054 * Keys are stripped of surrounding whitespace; values are decoded but otherwise kept verbatim. A blank input, or a 055 * <jk>null</jk> input, yields an empty map. A later clause with a duplicate key overwrites an earlier one. 056 * 057 * @param s The raw string (a {@code search} or {@code opts} value). Can be <jk>null</jk>. 058 * @return A new, mutable, insertion-ordered map (never <jk>null</jk>). 059 */ 060 @SuppressWarnings({ 061 "java:S3776", // Cognitive complexity acceptable for parser state machine 062 "java:S6541" // Brain method acceptable for parser state machine 063 }) 064 public static Map<String,String> parse(String s) { 065 var m = new LinkedHashMap<String,String>(); 066 if (ib(s)) 067 return m; 068 069 // S1: Reading key (looking for unescaped '=' or top-level ',' or end). 070 // S2: Saw '\', next char is a key literal (\, / \= / \\ collapse; others keep the backslash). 071 // S3: Reading value (looking for top-level ',' or end); '(' / ')' adjust depth. 072 // S4: Saw '\', next char is a value literal. 073 074 var state = S1; 075 var key = new StringBuilder(); 076 var value = new StringBuilder(); 077 var depth = 0; 078 var len = s.length(); 079 080 for (var i = 0; i <= len; i++) { 081 var end = i == len; 082 var c = end ? ',' : s.charAt(i); // Synthetic comma flushes the final clause. 083 084 if (state == S1) { 085 if (end || (c == ',' && depth == 0)) { 086 putClause(m, key, value); 087 key.setLength(0); 088 value.setLength(0); 089 depth = 0; 090 } else if (c == '\\') { 091 state = S2; 092 } else if (c == '=') { 093 state = S3; 094 } else { 095 if (c == '(') 096 depth++; 097 else if (c == ')' && depth > 0) 098 depth--; 099 key.append(c); 100 } 101 } else if (state == S2) { 102 if (end) { 103 key.append('\\'); 104 putClause(m, key, value); 105 } else { 106 appendUnescaped(key, c); 107 state = S1; 108 } 109 } else if (state == S3) { // NOSONAR - State check necessary for state machine 110 if (end || (c == ',' && depth == 0)) { 111 putClause(m, key, value); 112 key.setLength(0); 113 value.setLength(0); 114 depth = 0; 115 state = S1; 116 } else if (c == '\\') { 117 state = S4; 118 } else { 119 if (c == '(') 120 depth++; 121 else if (c == ')' && depth > 0) 122 depth--; 123 value.append(c); 124 } 125 } else { // S4 — the only remaining state. 126 if (end) { 127 value.append('\\'); 128 putClause(m, key, value); 129 } else { 130 appendUnescaped(value, c); 131 state = S3; 132 } 133 } 134 } 135 136 return m; 137 } 138 139 /** 140 * Escapes a single key or value so it can be spliced into a clause string without changing its meaning. 141 * 142 * <p> 143 * Backslash, comma, and equals become {@code \\}, {@code \,}, and {@code \=} respectively. A <b>leading</b> 144 * {@code $} additionally becomes {@code \$}, so an escaped column name can never be mistaken for a 145 * {@code $or(}/{@code $and(}/{@code $not(} search-grammar group prefix (design §4). Note that a raw 146 * {@code $}-expression value must <b>not</b> be escaped — its commas live inside parentheses and its 147 * structure is the {@code $}-language's own, not this grammar's. 148 * 149 * @param s The raw key or value. Can be <jk>null</jk>. 150 * @return The escaped text, or <jk>null</jk> if {@code s} is <jk>null</jk>. 151 */ 152 public static String escape(String s) { 153 if (s == null) 154 return null; 155 var sb = new StringBuilder(s.length() + 4); 156 for (var i = 0; i < s.length(); i++) { 157 var c = s.charAt(i); 158 if ((i == 0 && c == '$') || c == '\\' || c == ',' || c == '=') 159 sb.append('\\'); 160 sb.append(c); 161 } 162 return sb.toString(); 163 } 164 165 /** Emits one decoded clause when the key is non-blank after strip. */ 166 private static void putClause(Map<String,String> m, StringBuilder key, StringBuilder value) { 167 var k = key.toString().strip(); 168 if (! k.isEmpty()) 169 m.put(k, value.toString()); 170 } 171 172 /** 173 * Appends one escape sequence into {@code buf}: {@code \,}/{@code \=}/{@code \\}/{@code \$} collapse to the 174 * literal; any other following character keeps the backslash. 175 */ 176 private static void appendUnescaped(StringBuilder buf, char c) { 177 if (c == ',' || c == '=' || c == '\\' || c == '$') 178 buf.append(c); 179 else 180 buf.append('\\').append(c); 181 } 182}