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.console.datatables.adapter; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.math.*; 022import java.time.*; 023import java.time.format.*; 024import java.util.*; 025 026import org.apache.juneau.commons.beanquery.*; 027import org.apache.juneau.console.datatables.*; 028 029/** 030 * Maps a DataTables {@link DataTablesRequest request bean} onto a {@link BeanQuery}, runs it against a 031 * {@link BeanQuerySession}, and copies the result onto a {@link DataTablesResults} response bean. 032 * 033 * <p> 034 * This adapter is the only place the two worlds meet: the DataTables JSON beans 035 * ({@code org.apache.juneau.console.datatables}) know nothing about the query engine, and the engine knows 036 * nothing about DataTables JSON. The mapping ({@link #toBeanQuery(DataTablesRequest, BeanQueryContext)}): 037 * <ul class='spaced-list'> 038 * <li>Each {@code columns[i].data} resolves against {@link BeanQueryContext#columns() context.columns()}: 039 * a declared name is used as is, an all-digits string is the n-th declared column, and {@code null}/{@code ""} 040 * is skipped — resolution only happens for a column something actually uses (a non-blank per-column 041 * search, an {@code order[]} entry, or global-search eligibility), so a render-only column with an unresolvable 042 * {@code data} never errors. 043 * <li>Each searchable column's non-blank {@code search.value} becomes one raw {@code column=expression} condition 044 * ({@link org.apache.juneau.commons.beanquery.BeanQuery.Builder#search(String, String) BeanQuery.Builder#search(String, String)}); two DataTables columns resolving to the same key merge 045 * into one {@code $and}. 046 * <li>A non-blank global {@code search.value} becomes a top-level {@code $or} of one leaf per eligible column 047 * (searchable, resolvable), the leaf chosen by that column's {@link SearchType}: 048 * {@code $contains} for {@link SearchType#TEXT}/{@link SearchType#ID}, {@code $prefix} for 049 * {@link SearchType#VERSION}, {@code $eqic} for {@link SearchType#ENUM}, and {@code $eq} for {@link 050 * SearchType#NUMERIC}/{@link SearchType#TIMESTAMP}/{@link SearchType#BOOLEAN} when {@code search.value} parses 051 * for that type — a parse failure excludes just that column from this request's {@code $or}, it never 052 * fails the request. Resolved keys are de-duplicated in column order; one eligible column collapses to a 053 * plain leaf (a single-item group is unwrapped); an eligible key that also has a per-column search merges into 054 * that column's {@code $and}. 055 * <li>Each {@code order[i]} resolves through {@code columns[i]} to a {@link org.apache.juneau.commons.beanquery.BeanQuery.Builder#sort(String) sort} / 056 * {@link org.apache.juneau.commons.beanquery.BeanQuery.Builder#sortDesc(String) sortDesc} entry; duplicate keys keep the first occurrence. 057 * <li>{@code start}/{@code length} become the {@link org.apache.juneau.commons.beanquery.BeanQuery.Builder#page(int,int) page} window 058 * ({@code length=-1} means all rows; a negative {@code start} is clamped to {@code 0}). 059 * <li>Counts are always requested ({@link CountRequest#BOTH}); the context's own {@code CountPolicy} still wins. 060 * </ul> 061 * 062 * <p> 063 * {@code columns.size()} and {@code order.size()} are capped by {@link BeanQueryContext#getMaxSearchClauses()} / 064 * {@link BeanQueryContext#getMaxSortKeys()} <em>before</em> any column is resolved. A caller mistake — an 065 * unknown column, too many search clauses, too many sort keys, a malformed expression, a disallowed operator, a 066 * restricted column — raises {@link BeanQuerySyntaxException}, which {@link #run(DataTablesRequest, 067 * BeanQuerySession) run} / {@link #runArrays(DataTablesRequest, BeanQuerySession) runArrays} do <b>not</b> catch: 068 * it propagates to {@code RestContext.convertThrowable}, which maps it to HTTP <b>400</b> with the server's message 069 * in the {@code X-BeanQuery-Error} response header ({@code BeanQueryRequest.ERROR_HEADER}) — the same contract as 070 * every other queryable endpoint. (It is not a 200 response carrying an {@code error} field.) 071 * The bundled {@code juneau-datatables.js} reads that header and shows the message through DataTables' {@code errMode} 072 * instead of a generic "Ajax error". A 073 * {@code BeanQueryExecutionException} (a genuine server fault) propagates unchanged to the REST layer's 500 mapping. 074 * 075 * <h5 class='section'>Example:</h5> 076 * <p class='bjava'> 077 * <jc>// The endpoint shape.</jc> 078 * <ja>@Rest</ja>(mixins=DataTablesMixin.<jk>class</jk>) 079 * <jk>public class</jk> ReleasesResource <jk>extends</jk> BasicRestServlet { 080 * 081 * <ja>@RestPost</ja>(path=<js>"/query"</js>, parsers=JsonParser.<jk>class</jk>) 082 * <jk>public</jk> DataTablesResults<Release> queryReleases(<ja>@Content</ja> DataTablesRequest <jv>request</jv>) { 083 * <jk>try</jk> (var <jv>session</jv> = CONTEXT.getSession(<jv>releases</jv>)) { 084 * <jk>return</jk> DataTablesQuery.<jsm>run</jsm>(<jv>request</jv>, <jv>session</jv>); 085 * } 086 * } 087 * } 088 * </p> 089 * 090 * <p> 091 * A caller that needs to add its own condition (e.g. a tenant guard) builds the query directly and merges it with 092 * {@link BeanQuery#copy()}: 093 * <p class='bjava'> 094 * BeanQuery <jv>query</jv> = DataTablesQuery.<jsm>toBeanQuery</jsm>(<jv>request</jv>, <jv>session</jv>.getContext()); 095 * BeanQuery <jv>guarded</jv> = <jv>query</jv>.copy().eq(<js>"tenant"</js>, <jv>tenantId</jv>).build(); 096 * var <jv>page</jv> = <jv>session</jv>.find(<jv>guarded</jv>); 097 * <jk>return</jk> DataTablesResults.<Release>create().setDraw(<jv>request</jv>.getDraw()).setData(<jv>page</jv>.rows()); 098 * </p> 099 * 100 * @since 10.0.0 101 */ 102@SuppressWarnings({ 103 "java:S3776" // Cognitive complexity acceptable: query translation walks each DataTables request section in one pass. 104}) 105public class DataTablesQuery { 106 107 private DataTablesQuery() {} 108 109 /** 110 * Runs the DataTables request against the session and builds the response envelope. 111 * 112 * <p> 113 * The caller owns the session (open it in a try-with-resources block); this method neither opens nor closes it, 114 * so the adapter works identically over an in-memory or a SQL engine. Rows come back as beans ({@code T}); use 115 * {@link #runArrays(DataTablesRequest, BeanQuerySession) runArrays} when {@code columns[i].data} are integers and 116 * the table expects array rows. 117 * 118 * @param <T> The row type. 119 * @param request The DataTables request bean. Must not be <jk>null</jk>. 120 * @param session The open bean-query session. Must not be <jk>null</jk>. 121 * @return The populated DataTables response envelope. 122 * @throws BeanQuerySyntaxException If {@code request} describes an invalid query. Mapped centrally to HTTP 400 123 * with an {@code X-BeanQuery-Error} header. 124 */ 125 public static <T> DataTablesResults<T> run(DataTablesRequest request, BeanQuerySession<T> session) { 126 var query = toBeanQuery(request, session.getContext()); 127 var page = session.find(query); 128 var r = DataTablesResults.<T>create().setDraw(request.getDraw()).setData(page.rows()); 129 page.total().ifPresentOrElse(r::setRecordsTotal, () -> page.matched().ifPresent(r::setRecordsTotal)); 130 page.matched().ifPresent(r::setRecordsFiltered); 131 return r; 132 } 133 134 /** 135 * Runs the DataTables request against the session and builds the response envelope with array rows. 136 * 137 * <p> 138 * For a table whose {@code columns[i].data} are integers (so there is no bean property to put named fields on): 139 * the view is set to every column in {@link BeanQueryContext#columns() context.columns()} order, rows come 140 * back from {@link BeanQuerySession#findValues(BeanQuery) findValues}, and each row {@code Map} is flattened to a 141 * {@code List} in that same order, so {@code data: n} and the n-th array cell refer to the same column. 142 * 143 * @param request The DataTables request bean. Must not be <jk>null</jk>. 144 * @param session The open bean-query session. Must not be <jk>null</jk>. 145 * @return The populated DataTables response envelope. 146 * @throws BeanQuerySyntaxException If {@code request} describes an invalid query (see {@link #run(DataTablesRequest, 147 * BeanQuerySession) run}). 148 */ 149 public static DataTablesResults<List<Object>> runArrays(DataTablesRequest request, BeanQuerySession<?> session) { 150 var context = session.getContext(); 151 var columns = context.columns(); 152 var query = toBeanQuery(request, context).copy().view(columns.toArray(new String[0])).build(); 153 var page = session.findValues(query); 154 var rows = page.rows().stream().map(row -> columns.stream().<Object>map(row::get).toList()).toList(); 155 var r = DataTablesResults.<List<Object>>create().setDraw(request.getDraw()).setData(rows); 156 page.total().ifPresentOrElse(r::setRecordsTotal, () -> page.matched().ifPresent(r::setRecordsTotal)); 157 page.matched().ifPresent(r::setRecordsFiltered); 158 return r; 159 } 160 161 /** 162 * Translates a DataTables request into an engine {@link BeanQuery} (search, sort, page window, counts) against 163 * the given context's declared columns. 164 * 165 * @param request The DataTables request bean. Must not be <jk>null</jk>. 166 * @param context The context the query will run against. Must not be <jk>null</jk>. Its {@link 167 * BeanQueryContext#columns() columns()} / {@link BeanQueryContext#getColumnType(String) getColumnType(String)} 168 * / {@link BeanQueryContext#getMaxSearchClauses() getMaxSearchClauses()} / {@link 169 * BeanQueryContext#getMaxSortKeys() getMaxSortKeys()} drive every resolution and cap below. A session that 170 * narrows its columns ({@code restrictColumns}) is not visible here — the query 171 * pipeline still rejects a restricted column, reported through the same {@link BeanQuerySyntaxException} path. 172 * @return The equivalent query. 173 * @throws BeanQuerySyntaxException If {@code columns}/{@code order} exceed the context's caps, or a search/sort 174 * target names an unresolvable column. 175 */ 176 public static BeanQuery toBeanQuery(DataTablesRequest request, BeanQueryContext<?> context) { 177 var columns = request.getColumns(); 178 var order = request.getOrder(); 179 var declared = context.columns(); 180 181 // Caps are checked before any column is resolved. 182 var columnCount = columns == null ? 0 : columns.size(); 183 var orderCount = order == null ? 0 : order.size(); 184 if (columnCount > context.getMaxSearchClauses()) 185 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.TOO_MANY_CLAUSES, "Too many columns (max %s).", context.getMaxSearchClauses()); 186 if (orderCount > context.getMaxSortKeys()) 187 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.TOO_MANY_SORT_KEYS, "Too many sort keys (max %s).", context.getMaxSortKeys()); 188 189 var b = BeanQuery.create(); 190 191 // Per-column search: a non-blank term on a searchable, resolved column becomes one raw condition. 192 if (columns != null) { 193 for (var c : columns) { 194 var value = c.getSearch() == null ? null : c.getSearch().getValue(); 195 if (c.isSearchable() && ! ib(value)) { 196 var key = resolveColumn(declared, c.getData(), true); 197 if (key != null) 198 b.search(key, value); 199 } 200 } 201 } 202 203 // Global search: one $or of a per-SearchType leaf over every eligible column 204 // ($contains for TEXT/ID, $prefix for VERSION, $eqic for ENUM, $eq for 205 // NUMERIC/TIMESTAMP/BOOLEAN). TEXT/ID/ENUM/VERSION are always eligible; a NUMERIC/TIMESTAMP/BOOLEAN column 206 // is eligible only when globalValue parses for that type (checked once per request, not per row) -- a 207 // parse failure excludes just that column, it never fails the request. Resolved keys are de-duplicated in 208 // column order first, same as the per-column search above. One eligible column becomes a plain top-level 209 // leaf and none eligible adds nothing, so "none eligible" naturally means no search string. If an eligible key 210 // also has a per-column search, the leaf merges into that column's $and. 211 var globalValue = request.getSearch() == null ? null : request.getSearch().getValue(); 212 if (inb(globalValue)) { 213 var keys = new LinkedHashSet<String>(); 214 if (columns != null) { 215 for (var c : columns) { 216 var key = c.isSearchable() ? resolveColumn(declared, c.getData(), false) : null; 217 if (key != null) 218 keys.add(key); 219 } 220 } 221 var numericOk = looksNumeric(globalValue); 222 var booleanOk = looksBoolean(globalValue); 223 var timestampOk = looksLikeInstant(globalValue); 224 var leaves = new ArrayList<String[]>(); // Each entry is a column key paired with its search operator. 225 for (var key : keys) { 226 switch (context.getColumnType(key)) { 227 case TEXT, ID -> leaves.add(new String[]{key, "$contains"}); 228 case VERSION -> leaves.add(new String[]{key, "$prefix"}); 229 case ENUM -> leaves.add(new String[]{key, "$eqic"}); 230 case NUMERIC -> { if (numericOk) leaves.add(new String[]{key, "$eq"}); } 231 case BOOLEAN -> { if (booleanOk) leaves.add(new String[]{key, "$eq"}); } 232 case TIMESTAMP -> { if (timestampOk) leaves.add(new String[]{key, "$eq"}); } 233 default -> { /* All SearchType values are handled above. */ } 234 } 235 } 236 // WORKAROUND (revert once BeanQuery is fixed): a single leaf is added straight to the top level instead of 237 // through b.or(...): the builder merges a top-level leaf with an earlier leaf on the same column into one 238 // $and, but an unwrapped single-item 239 // group bypasses that merge and would emit the same column twice (a DUPLICATE_COLUMN error). 240 if (leaves.size() == 1) 241 b.op(leaves.get(0)[0], leaves.get(0)[1], globalValue); 242 else if (leaves.size() > 1) 243 b.or(g -> leaves.forEach(l -> g.op(l[0], l[1], globalValue))); 244 } 245 246 // Ordering: order[i].column indexes columns[]; a non-orderable, blank-data, or duplicate key is skipped. 247 if (order != null && columns != null) { 248 var seen = new LinkedHashSet<String>(); 249 for (var o : order) { 250 var key = resolveOrderKey(o, columns, declared); 251 if (key != null && seen.add(key)) { 252 if (eqic(o.getDir(), "desc")) 253 b.sortDesc(key); 254 else 255 b.sort(key); 256 } 257 } 258 } 259 260 // Paging: length=-1 means all rows (passed through); a negative start is clamped rather than rejected. 261 b.page(Math.max(request.getStart(), 0), request.getLength()); 262 263 // Counts: always requested; the context's own CountPolicy (NONE/MATCHED) still wins. 264 b.counts(CountRequest.BOTH); 265 266 return b.build(); 267 } 268 269 /** Resolves an {@code order[]} entry to its column key, or <jk>null</jk> if the entry is out of range, non-orderable or blank. */ 270 private static String resolveOrderKey(DataTablesRequest.Order o, List<DataTablesRequest.Column> columns, List<String> declared) { 271 var ci = o.getColumn(); 272 if (ci < 0 || ci >= columns.size()) 273 return null; 274 var c = columns.get(ci); 275 return c.isOrderable() ? resolveColumn(declared, c.getData(), true) : null; 276 } 277 278 /** 279 * Resolves one {@code columns[i].data} string to a declared column key. 280 * 281 * @param declared The context's declared column names, in order. 282 * @param data The raw {@code data} string (may be <jk>null</jk>). 283 * @param used Whether this column is actually used (a non-blank search term or an order target). When 284 * <jk>false</jk>, an unresolvable name is excluded (returns <jk>null</jk>) instead of raising — this is 285 * what lets global-search eligibility skip a render-only column instead of failing the whole request. 286 * @return The resolved key, or <jk>null</jk> if {@code data} is blank or (when {@code used} is <jk>false</jk>) 287 * unresolvable. 288 * @throws BeanQuerySyntaxException If {@code used} is <jk>true</jk> and {@code data} does not resolve. 289 */ 290 private static String resolveColumn(List<String> declared, String data, boolean used) { 291 var trimmed = tr(data); 292 if (ib(trimmed)) 293 return null; 294 if (declared.contains(trimmed)) 295 return trimmed; 296 if (isAllDigits(trimmed)) { 297 try { 298 var i = Integer.parseInt(trimmed); 299 if (i < declared.size()) 300 return declared.get(i); 301 } catch (NumberFormatException e) { 302 // Too large for an int: cannot be a valid index, fall through to the unknown-column handling below. 303 } 304 } 305 if (used) 306 throw new BeanQuerySyntaxException(BeanQuerySyntaxException.Code.UNKNOWN_COLUMN, "Unknown column '%s'.", trimmed); 307 return null; 308 } 309 310 /** Returns whether {@code s} is non-empty and every character is an ASCII digit (so {@code "-1"} is not). */ 311 private static boolean isAllDigits(String s) { 312 return ! s.isEmpty() && s.chars().allMatch(ch -> ch >= '0' && ch <= '9'); 313 } 314 315 /** Whether a global-search term is eligible for the global-search {@code NUMERIC} leaf: only parseability 316 * gates eligibility — the leaf itself binds the raw, unparsed term. Uses {@link BigDecimal}, the same 317 * parse the engine applies to a {@code NUMERIC} value, so Java-only {@code Double} forms ({@code 1d}, 318 * {@code NaN}, {@code Infinity}, hex floats) are excluded instead of failing downstream. */ 319 private static boolean looksNumeric(String s) { 320 try { 321 // Constructed only for its NumberFormatException. 322 Objects.requireNonNull(new BigDecimal(s.strip())); 323 return true; 324 } catch (NumberFormatException e) { 325 return false; 326 } 327 } 328 329 /** Whether a global-search term is eligible for the global-search {@code BOOLEAN} leaf: {@code true}/ 330 * {@code false}, case-insensitively, nothing else. */ 331 private static boolean looksBoolean(String s) { 332 var t = s.strip(); 333 return eqic(t, "true") || eqic(t, "false"); 334 } 335 336 /** Whether a global-search term is eligible for the global-search {@code TIMESTAMP} leaf: the same 337 * ISO-8601 instant form the engine's own value formatting emits. */ 338 private static boolean looksLikeInstant(String s) { 339 try { 340 Instant.parse(s.strip()); 341 return true; 342 } catch (DateTimeParseException e) { 343 return false; 344 } 345 } 346}