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.utils.Shorts.*;
020
021import org.apache.juneau.commons.utils.StringUtils;
022
023import java.util.*;
024import java.util.function.*;
025import java.util.stream.*;
026
027/**
028 * A protocol-agnostic query request against a collection of beans: what to search, which columns to return, how to
029 * sort, and which page to return (design §5.1, §5.3).
030 *
031 * <p>
032 * Every field is a plain <b>raw value</b> &mdash; there are no HTTP-binding annotations, no {@link java.util.Optional},
033 * no search map, no page string, and no parsed operators on this bean.  A <jk>null</jk> field means <b>absent</b>.  The
034 * grammar for {@link #getSearch() search} and {@link #getOpts() opts} lives <b>inside</b> those single strings and is
035 * decoded by a {@link BeanQuerySession session} when the query runs, not by this bean.
036 *
037 * <h5 class='section'>Fields</h5>
038 * <ul>
039 *    <li><b>search</b> ({@link String}) &mdash; the one search string.  Top-level commas (outside parentheses) separate
040 *       clauses; the first unescaped {@code =} separates a column from its raw {@code $}-expression
041 *       ({@link SearchExpressionParser}); backslash escapes {@code \,} / {@code \=} / {@code \\} are literals.  Columns
042 *       combine with {@code and}.  See {@link ClauseParser}.
043 *    <li><b>view</b> ({@link String}) &mdash; a comma-separated list of column names, in output order.
044 *    <li><b>sort</b> ({@link String}) &mdash; a comma-separated list of {@code column} (ascending) or {@code column:desc}
045 *       tokens (a trailing {@code -}/{@code +} is also accepted).
046 *    <li><b>position</b> ({@link Integer}) &mdash; the 0-based first row of the page, or <jk>null</jk> for the start.
047 *    <li><b>limit</b> ({@link Integer}) &mdash; the page size, or <jk>null</jk> for all rows.
048 *    <li><b>opts</b> ({@link String}) &mdash; an open bag of {@code key=value} clauses (same grammar as {@code search}).
049 *       The session reads {@code counts} and ignores unknown keys.
050 * </ul>
051 *
052 * <p>
053 * Construct with {@link #create()}, which returns a {@link Builder} that assembles these raw strings fluently:
054 *
055 * <h5 class='section'>Example:</h5>
056 * <p class='bjava'>
057 *    BeanQuery <jv>q</jv> = BeanQuery.<jsm>create</jsm>()
058 *       .eq(<js>"status"</js>, <js>"active"</js>)
059 *       .or(<jv>g</jv> -&gt; <jv>g</jv>.contains(<js>"name"</js>, <js>"bob"</js>).contains(<js>"email"</js>, <js>"bob"</js>))
060 *       .sort(<js>"name"</js>)
061 *       .limit(50)
062 *       .build();
063 * </p>
064 *
065 * <p>
066 * The HTTP-binding {@code BeanQueryRequest} ({@code juneau-rest-common}) extends this class and adds the
067 * {@code @Query} bindings.
068 *
069 * @since 10.0.0
070 */
071@SuppressWarnings({
072   "java:S1192" // Duplicated argument-name literals read more clearly inline than as constants.
073})
074public class BeanQuery {
075
076   private String search;
077   private String view;
078   private String sort;
079   private Integer position;
080   private Integer limit;
081   private String opts;
082
083   /**
084    * Creates a new {@link Builder}.
085    *
086    * @return A new builder.
087    */
088   public static Builder create() {
089      return new Builder();
090   }
091
092   /**
093    * The one search string.
094    *
095    * @return The search string, or <jk>null</jk> if absent.
096    */
097   public String getSearch() {
098      return search;
099   }
100
101   /**
102    * Sets the one search string.
103    *
104    * @param value The search string.  Can be <jk>null</jk> to unset.
105    * @return This object.
106    */
107   public BeanQuery setSearch(String value) {
108      search = value;
109      return this;
110   }
111
112   /**
113    * The raw view (column-selection) directive.
114    *
115    * @return The view string, or <jk>null</jk> if absent.
116    */
117   public String getView() {
118      return view;
119   }
120
121   /**
122    * Sets the raw view (column-selection) directive.
123    *
124    * @param value The view string.  Can be <jk>null</jk> to unset.
125    * @return This object.
126    */
127   public BeanQuery setView(String value) {
128      view = value;
129      return this;
130   }
131
132   /**
133    * The raw sort directive.
134    *
135    * @return The sort string, or <jk>null</jk> if absent.
136    */
137   public String getSort() {
138      return sort;
139   }
140
141   /**
142    * Sets the raw sort directive.
143    *
144    * @param value The sort string.  Can be <jk>null</jk> to unset.
145    * @return This object.
146    */
147   public BeanQuery setSort(String value) {
148      sort = value;
149      return this;
150   }
151
152   /**
153    * The 0-based first row of the page.
154    *
155    * @return The position, or <jk>null</jk> for the start of the results.
156    */
157   public Integer getPosition() {
158      return position;
159   }
160
161   /**
162    * Sets the 0-based first row of the page.
163    *
164    * @param value The position.  Can be <jk>null</jk> to unset.
165    * @return This object.
166    */
167   public BeanQuery setPosition(Integer value) {
168      position = value;
169      return this;
170   }
171
172   /**
173    * The page size.
174    *
175    * @return The limit, or <jk>null</jk> for all rows.
176    */
177   public Integer getLimit() {
178      return limit;
179   }
180
181   /**
182    * Sets the page size.
183    *
184    * @param value The limit.  Can be <jk>null</jk> to unset (all rows).
185    * @return This object.
186    */
187   public BeanQuery setLimit(Integer value) {
188      limit = value;
189      return this;
190   }
191
192   /**
193    * The one opts string (an open bag of {@code key=value} clauses).
194    *
195    * @return The opts string, or <jk>null</jk> if absent.
196    */
197   public String getOpts() {
198      return opts;
199   }
200
201   /**
202    * Sets the one opts string.
203    *
204    * @param value The opts string.  Can be <jk>null</jk> to unset.
205    * @return This object.
206    */
207   public BeanQuery setOpts(String value) {
208      opts = value;
209      return this;
210   }
211
212   /**
213    * Creates a {@link Builder} pre-populated with this query's current state, so individual settings can be
214    * changed or added without disturbing the rest. {@link #getSearch()} is re-parsed into the builder's item
215    * tree, so a new condition on an existing top-level column merges through <c>$and(...)</c> rather than being
216    * dropped or duplicated.
217    *
218    * @return A new builder seeded from this query.
219    */
220   public Builder copy() {
221      var b = new Builder();
222      if (search != null)
223         b.group.items.seed(SearchParser.parse(search));
224      if (view != null)
225         b.view.addAll(splitNonBlank(view));
226      if (sort != null)
227         b.sort.addAll(splitNonBlank(sort));
228      b.position = position;
229      b.limit = limit;
230      if (opts != null)
231         b.opts.putAll(ClauseParser.parse(opts));
232      return b;
233   }
234
235   /** Splits a comma-delimited list, trimming each token and dropping blank ones. */
236   private static List<String> splitNonBlank(String s) {
237      var l = new ArrayList<>(StringUtils.split(s));
238      l.removeIf(String::isEmpty);
239      return l;
240   }
241
242   @Override
243   public boolean equals(Object o) {
244      if (this == o)
245         return true;
246      if (!(o instanceof BeanQuery other))
247         return false;
248      return eq(search, other.search) && eq(view, other.view)
249         && eq(sort, other.sort) && eq(position, other.position)
250         && eq(limit, other.limit) && eq(opts, other.opts);
251   }
252
253   @Override
254   public int hashCode() {
255      return h(search, view, sort, position, limit, opts);
256   }
257
258   @Override
259   public String toString() {
260      return "BeanQuery[search=" + search + ", view=" + view + ", sort=" + sort + ", position=" + position
261         + ", limit=" + limit + ", opts=" + opts + "]";
262   }
263
264   /**
265    * A group of cross-column conditions built with {@link BeanQuery.Builder#or(Consumer)},
266    * {@link BeanQuery.Builder#and(Consumer)}, or {@link BeanQuery.Builder#not(Consumer)}.
267    *
268    * <p>Exposes the same condition methods as {@link Builder}, minus paging/view/sort/opts, since a group only
269    * ever contributes conditions to its enclosing level.
270    *
271    * <h5 class='section'>Example:</h5>
272    * <p class='bjava'>
273    *    BeanQuery.<jsm>create</jsm>().or(<jv>g</jv> -&gt; <jv>g</jv>.eq(<js>"a"</js>, 1).eq(<js>"b"</js>, 2));
274    *    <jc>// -&gt; "$or(a=$eq(\"1\"),b=$eq(\"2\"))"</jc>
275    * </p>
276    *
277    * @since 10.0.0
278    */
279   public static final class GroupBuilder {
280
281      final SearchItemBuilder items = new SearchItemBuilder();
282
283      GroupBuilder() {}
284
285      /**
286       * Adds an equals condition.
287       *
288       * @param column The column name.
289       * @param value The value to compare against.
290       * @return This object.
291       */
292      public GroupBuilder eq(String column, Object value) {
293         return emit(column, "$eq", value);
294      }
295
296      /**
297       * Adds a not-equals condition, true if the column matches none of {@code values}.
298       *
299       * @param column The column name.
300       * @param values The values to compare against.
301       * @return This object.
302       */
303      public GroupBuilder ne(String column, Object...values) {
304         return emit(column, "$ne", values);
305      }
306
307      /**
308       * Adds an in-list condition, true if the column matches any of {@code values}.
309       *
310       * @param column The column name.
311       * @param values The values to compare against.
312       * @return This object.
313       */
314      public GroupBuilder in(String column, Object...values) {
315         return emit(column, "$in", values);
316      }
317
318      /**
319       * Adds a substring-match condition.
320       *
321       * @param column The column name.
322       * @param value The substring to search for.
323       * @return This object.
324       */
325      public GroupBuilder contains(String column, Object value) {
326         return emit(column, "$contains", value);
327      }
328
329      /**
330       * Adds a prefix-match condition.
331       *
332       * @param column The column name.
333       * @param value The prefix to match.
334       * @return This object.
335       */
336      public GroupBuilder prefix(String column, Object value) {
337         return emit(column, "$prefix", value);
338      }
339
340      /**
341       * Adds a blank-value condition.
342       *
343       * @param column The column name.
344       * @return This object.
345       */
346      public GroupBuilder blank(String column) {
347         reqnb("column", column);
348         items.addLeaf(column, "$blank()");
349         return this;
350      }
351
352      /**
353       * Adds a not-blank condition.
354       *
355       * @param column The column name.
356       * @return This object.
357       */
358      public GroupBuilder notBlank(String column) {
359         reqnb("column", column);
360         items.addLeaf(column, "$not($blank())");
361         return this;
362      }
363
364      /**
365       * Adds a greater-than condition.
366       *
367       * @param column The column name.
368       * @param value The value to compare against.
369       * @return This object.
370       */
371      public GroupBuilder gt(String column, Object value) {
372         return emit(column, "$gt", value);
373      }
374
375      /**
376       * Adds a greater-than-or-equal condition.
377       *
378       * @param column The column name.
379       * @param value The value to compare against.
380       * @return This object.
381       */
382      public GroupBuilder gte(String column, Object value) {
383         return emit(column, "$gte", value);
384      }
385
386      /**
387       * Adds a less-than condition.
388       *
389       * @param column The column name.
390       * @param value The value to compare against.
391       * @return This object.
392       */
393      public GroupBuilder lt(String column, Object value) {
394         return emit(column, "$lt", value);
395      }
396
397      /**
398       * Adds a less-than-or-equal condition.
399       *
400       * @param column The column name.
401       * @param value The value to compare against.
402       * @return This object.
403       */
404      public GroupBuilder lte(String column, Object value) {
405         return emit(column, "$lte", value);
406      }
407
408      /**
409       * Adds a between condition, inclusive of both bounds.
410       *
411       * @param column The column name.
412       * @param lower The lower bound.
413       * @param upper The upper bound.
414       * @return This object.
415       */
416      public GroupBuilder between(String column, Object lower, Object upper) {
417         return emit(column, "$between", lower, upper);
418      }
419
420      /**
421       * Adds a regex-match condition. The session still rejects this unless regex search is enabled
422       * (context/session design D7).
423       *
424       * @param column The column name.
425       * @param pattern The regular expression.
426       * @return This object.
427       */
428      public GroupBuilder regex(String column, String pattern) {
429         return emit(column, "$regex", pattern);
430      }
431
432      /**
433       * Adds a regex-match condition with flags. The session still rejects this unless regex search is enabled
434       * (context/session design D7).
435       *
436       * @param column The column name.
437       * @param pattern The regular expression.
438       * @param flags The regex flags (engine-specific, e.g. {@code "i"} for case-insensitive).
439       * @return This object.
440       */
441      public GroupBuilder regex(String column, String pattern, String flags) {
442         return emit(column, "$regex", pattern, flags);
443      }
444
445      /**
446       * Escape hatch for an operator not exposed by a dedicated method above. {@code name} must start with
447       * {@code "$"}.
448       *
449       * @param column The column name.
450       * @param name The operator name, starting with {@code "$"}.
451       * @param values The operator's arguments.
452       * @return This object.
453       */
454      public GroupBuilder op(String column, String name, Object...values) {
455         req(name != null && name.startsWith("$"),
456            "BeanQuery.GroupBuilder.op requires an operator name starting with '$': '%s'.", name);
457         return emit(column, name, values);
458      }
459
460      /**
461       * Escape hatch for a raw, already-formatted expression. Unlike every other condition method, the
462       * expression is used as-is and is not quoted.
463       *
464       * @param column The column name.
465       * @param expression The raw expression, e.g. {@code "$eq(\"bob\")"}.
466       * @return This object.
467       */
468      public GroupBuilder search(String column, String expression) {
469         reqnb("column", column);
470         reqnn("expression", expression);
471         items.addLeaf(column, expression);
472         return this;
473      }
474
475      /**
476       * Adds a cross-column <c>$or(...)</c> group. A block that adds no items adds nothing; a block that adds
477       * exactly one item emits that item unwrapped (no <c>$or(...)</c> wrapper).
478       *
479       * @param block Populates the group's conditions on a fresh {@link GroupBuilder}.
480       * @return This object.
481       */
482      public GroupBuilder or(Consumer<GroupBuilder> block) {
483         items.addGroup(group(block, SearchItem.Or::new));
484         return this;
485      }
486
487      /**
488       * Adds a cross-column <c>$and(...)</c> group. A block that adds no items adds nothing; a block that adds
489       * exactly one item emits that item unwrapped (no <c>$and(...)</c> wrapper).
490       *
491       * @param block Populates the group's conditions on a fresh {@link GroupBuilder}.
492       * @return This object.
493       */
494      public GroupBuilder and(Consumer<GroupBuilder> block) {
495         items.addGroup(group(block, SearchItem.And::new));
496         return this;
497      }
498
499      /**
500       * Adds a <c>$not(...)</c> group. The block must add exactly one item (a leaf or a nested group); any
501       * other count throws {@link IllegalArgumentException}.
502       *
503       * @param block Populates the single negated condition on a fresh {@link GroupBuilder}.
504       * @return This object.
505       * @throws IllegalArgumentException If the block adds a number of items other than exactly one.
506       */
507      public GroupBuilder not(Consumer<GroupBuilder> block) {
508         reqnn("block", block);
509         var inner = new GroupBuilder();
510         block.accept(inner);
511         var innerItems = inner.items.items();
512         req(innerItems.size() == 1,
513            "BeanQuery.GroupBuilder.not(...) requires exactly one condition, found %s.", innerItems.size());
514         items.addGroup(new SearchItem.Not(innerItems.get(0)));
515         return this;
516      }
517
518      private static SearchItem group(Consumer<GroupBuilder> block, Function<List<SearchItem>,SearchItem> wrap) {
519         reqnn("block", block);
520         var inner = new GroupBuilder();
521         block.accept(inner);
522         var innerItems = inner.items.items();
523         if (innerItems.isEmpty())
524            return null;
525         if (innerItems.size() == 1)
526            return innerItems.get(0);
527         return wrap.apply(innerItems);
528      }
529
530      private GroupBuilder emit(String column, String name, Object...values) {
531         reqnb("column", column);
532         req(values != null && values.length > 0,
533            "Operator '%s' on column '%s' requires at least one value.", name, column);
534         var body = Arrays.stream(values).map(GroupBuilder::quote).collect(Collectors.joining(","));
535         items.addLeaf(column, name + "(" + body + ")");
536         return this;
537      }
538
539      private static String quote(Object value) {
540         var s = ValueFormat.format(value);
541         // Escape a literal '$' by doubling it, in addition to (not instead of) the quote-escaping below:
542         // decodeLiteral unconditionally collapses $$ -> $ as its last decode step, so an un-escaped
543         // literal "$$" here would wrongly round-trip to "$". (The two replace() calls operate on
544         // disjoint alphabets, so the order between them doesn't matter - only that both happen.)
545         return "\"" + s.replace("$", "$$").replace("\"", "\"\"") + "\"";
546      }
547   }
548
549   /**
550    * Builds an immutable {@link BeanQuery}. Create with {@link BeanQuery#create()}; derive one from an existing
551    * query with {@link BeanQuery#copy()}.
552    *
553    * <p>Exposes every {@link GroupBuilder} condition method (they add top-level conditions here) plus
554    * view/sort/paging/opts/counts and {@link #build()}.
555    *
556    * <h5 class='section'>Example:</h5>
557    * <p class='bjava'>
558    *    BeanQuery <jv>q</jv> = BeanQuery.<jsm>create</jsm>().eq(<js>"status"</js>, <js>"active"</js>).build();
559    * </p>
560    *
561    * @since 10.0.0
562    */
563   public static final class Builder {
564
565      private final GroupBuilder group = new GroupBuilder();
566      private final List<String> view = new ArrayList<>();
567      private final List<String> sort = new ArrayList<>();
568      private final Map<String,String> opts = new LinkedHashMap<>();
569      private Integer position;
570      private Integer limit;
571
572      Builder() {}
573
574      /** Same as {@link GroupBuilder#eq(String,Object)}, adding a top-level condition. @param column The column name. @param value The value. @return This object. */
575      public Builder eq(String column, Object value) { group.eq(column, value); return this; }
576
577      /** Same as {@link GroupBuilder#ne(String,Object...)}, adding a top-level condition. @param column The column name. @param values The values. @return This object. */
578      public Builder ne(String column, Object...values) { group.ne(column, values); return this; }
579
580      /** Same as {@link GroupBuilder#in(String,Object...)}, adding a top-level condition. @param column The column name. @param values The values. @return This object. */
581      public Builder in(String column, Object...values) { group.in(column, values); return this; }
582
583      /** Same as {@link GroupBuilder#contains(String,Object)}, adding a top-level condition. @param column The column name. @param value The value. @return This object. */
584      public Builder contains(String column, Object value) { group.contains(column, value); return this; }
585
586      /** Same as {@link GroupBuilder#prefix(String,Object)}, adding a top-level condition. @param column The column name. @param value The value. @return This object. */
587      public Builder prefix(String column, Object value) { group.prefix(column, value); return this; }
588
589      /** Same as {@link GroupBuilder#blank(String)}, adding a top-level condition. @param column The column name. @return This object. */
590      public Builder blank(String column) { group.blank(column); return this; }
591
592      /** Same as {@link GroupBuilder#notBlank(String)}, adding a top-level condition. @param column The column name. @return This object. */
593      public Builder notBlank(String column) { group.notBlank(column); return this; }
594
595      /** Same as {@link GroupBuilder#gt(String,Object)}, adding a top-level condition. @param column The column name. @param value The value. @return This object. */
596      public Builder gt(String column, Object value) { group.gt(column, value); return this; }
597
598      /** Same as {@link GroupBuilder#gte(String,Object)}, adding a top-level condition. @param column The column name. @param value The value. @return This object. */
599      public Builder gte(String column, Object value) { group.gte(column, value); return this; }
600
601      /** Same as {@link GroupBuilder#lt(String,Object)}, adding a top-level condition. @param column The column name. @param value The value. @return This object. */
602      public Builder lt(String column, Object value) { group.lt(column, value); return this; }
603
604      /** Same as {@link GroupBuilder#lte(String,Object)}, adding a top-level condition. @param column The column name. @param value The value. @return This object. */
605      public Builder lte(String column, Object value) { group.lte(column, value); return this; }
606
607      /** Same as {@link GroupBuilder#between(String,Object,Object)}, adding a top-level condition. @param column The column name. @param lower The lower bound. @param upper The upper bound. @return This object. */
608      public Builder between(String column, Object lower, Object upper) { group.between(column, lower, upper); return this; }
609
610      /** Same as {@link GroupBuilder#regex(String,String)}, adding a top-level condition. @param column The column name. @param pattern The regular expression. @return This object. */
611      public Builder regex(String column, String pattern) { group.regex(column, pattern); return this; }
612
613      /** Same as {@link GroupBuilder#regex(String,String,String)}, adding a top-level condition. @param column The column name. @param pattern The regular expression. @param flags The regex flags. @return This object. */
614      public Builder regex(String column, String pattern, String flags) { group.regex(column, pattern, flags); return this; }
615
616      /** Same as {@link GroupBuilder#op(String,String,Object...)}, adding a top-level condition. @param column The column name. @param name The operator name, starting with {@code "$"}. @param values The operator's arguments. @return This object. */
617      public Builder op(String column, String name, Object...values) { group.op(column, name, values); return this; }
618
619      /** Same as {@link GroupBuilder#search(String,String)}, adding a top-level condition. @param column The column name. @param expression The raw expression. @return This object. */
620      public Builder search(String column, String expression) { group.search(column, expression); return this; }
621
622      /** Same as {@link GroupBuilder#or(Consumer)}, adding a top-level group. @param block Populates the group. @return This object. */
623      public Builder or(Consumer<GroupBuilder> block) { group.or(block); return this; }
624
625      /** Same as {@link GroupBuilder#and(Consumer)}, adding a top-level group. @param block Populates the group. @return This object. */
626      public Builder and(Consumer<GroupBuilder> block) { group.and(block); return this; }
627
628      /** Same as {@link GroupBuilder#not(Consumer)}, adding a top-level group. @param block Populates the negated condition. @return This object. @throws IllegalArgumentException If the block adds a number of items other than exactly one. */
629      public Builder not(Consumer<GroupBuilder> block) { group.not(block); return this; }
630
631      /**
632       * Appends columns to the view list; does not replace columns already added.
633       *
634       * @param columns The columns to append.  Must not be <jk>null</jk>, nor contain a <jk>null</jk> element.
635       * @return This object.
636       * @throws IllegalArgumentException If {@code columns} is <jk>null</jk> or contains a <jk>null</jk> element.
637       */
638      public Builder view(String...columns) {
639         reqnns("columns", columns);
640         Collections.addAll(view, columns);
641         return this;
642      }
643
644      /**
645       * Appends an ascending sort key; does not replace keys already added.
646       *
647       * @param column The column to sort by.
648       * @return This object.
649       */
650      public Builder sort(String column) {
651         reqnb("column", column);
652         sort.add(column);
653         return this;
654      }
655
656      /**
657       * Appends a descending sort key ({@code column + ":desc"}); does not replace keys already added.
658       *
659       * @param column The column to sort by, descending.
660       * @return This object.
661       */
662      public Builder sortDesc(String column) {
663         reqnb("column", column);
664         sort.add(column + ":desc");
665         return this;
666      }
667
668      /**
669       * Sets the zero-based row offset.
670       *
671       * @param position The row offset; must not be negative.
672       * @return This object.
673       * @throws IllegalArgumentException If {@code position} is negative.
674       */
675      public Builder position(int position) {
676         req(position >= 0, "BeanQuery.Builder.position must not be negative: %s.", position);
677         this.position = position;
678         return this;
679      }
680
681      /**
682       * Sets the maximum number of rows to return. A negative value is clamped to the literal {@code -1}
683       * (meaning "as many rows as the session allows"), never dropped to {@code null}.
684       *
685       * @param limit The row limit.
686       * @return This object.
687       */
688      public Builder limit(int limit) {
689         this.limit = limit < 0 ? -1 : limit;
690         return this;
691      }
692
693      /**
694       * Sets {@link #position(int)} and {@link #limit(int)} together.
695       *
696       * @param start The zero-based row offset; must not be negative.
697       * @param length The maximum number of rows to return; a negative value means "as many as allowed".
698       * @return This object.
699       * @throws IllegalArgumentException If {@code start} is negative.
700       */
701      public Builder page(int start, int length) {
702         position(start);
703         return limit(length);
704      }
705
706      /**
707       * Sets a free-form option, overwriting any earlier value for the same key.
708       *
709       * @param key The option name.  Must not be <jk>null</jk> or blank.
710       * @param value The option value.  Must not be <jk>null</jk>.
711       * @return This object.
712       * @throws IllegalArgumentException If {@code key} is <jk>null</jk> or blank, or {@code value} is <jk>null</jk>.
713       */
714      public Builder opt(String key, String value) {
715         reqnb("key", key);
716         reqnn("value", value);
717         opts.put(key, value);
718         return this;
719      }
720
721      /**
722       * Sets the {@code counts} option from a {@link CountRequest}. {@link CountRequest#NONE} removes the
723       * {@code counts} option entirely (rather than writing a {@code "none"} value).
724       *
725       * @param request The requested counts.
726       * @return This object.
727       */
728      public Builder counts(CountRequest request) {
729         reqnn("request", request);
730         if (request == CountRequest.NONE) {
731            opts.remove("counts");
732            return this;
733         }
734         return opt("counts", request.name().toLowerCase(Locale.ROOT));
735      }
736
737      /**
738       * Builds a new, immutable {@link BeanQuery} from this builder's current state. The builder remains usable
739       * afterward; later calls do not affect the {@link BeanQuery} already built.
740       *
741       * @return A new {@link BeanQuery}.
742       */
743      public BeanQuery build() {
744         var q = new BeanQuery();
745         var items = group.items.items();
746         if (!items.isEmpty())
747            q.setSearch(SearchParser.render(items));
748         if (!view.isEmpty())
749            q.setView(String.join(",", view));
750         if (!sort.isEmpty())
751            q.setSort(String.join(",", sort));
752         q.setPosition(position);
753         q.setLimit(limit);
754         if (!opts.isEmpty())
755            q.setOpts(opts.entrySet().stream()
756               .map(e -> ClauseParser.escape(e.getKey()) + "=" + ClauseParser.escape(e.getValue()))
757               .collect(Collectors.joining(",")));
758         return q;
759      }
760   }
761}