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 &mdash; 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 &mdash; 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 &mdash; an
065 * unknown column, too many search clauses, too many sort keys, a malformed expression, a disallowed operator, a
066 * restricted column &mdash; 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&nbsp;<b>400</b> with the server's message
069 * in the {@code X-BeanQuery-Error} response header ({@code BeanQueryRequest.ERROR_HEADER}) &mdash; 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&lt;Release&gt; 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.&lt;Release&gt;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 &mdash; 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 &mdash; 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 &mdash; 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}