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.beanquery.postgres;
018
019import static java.util.regex.Pattern.*;
020import static org.apache.juneau.commons.beanquery.SearchType.*;
021import static org.apache.juneau.commons.utils.Shorts.f;
022
023import java.util.*;
024import java.util.regex.*;
025
026import org.apache.juneau.commons.beanquery.*;
027import org.apache.juneau.beanquery.sql.*;
028
029/**
030 * The PostgreSQL {@link SqlDialect}: the one dialect Juneau ships for the shared JDBC {@link SqlBeanQueryContext}.
031 *
032 * <p>
033 * It renders the built-in {@code $}-operators (design §5.1) into Postgres predicates that mirror the in-memory
034 * semantics: exact, case-sensitive {@code $eq}/{@code $in}/{@code $ne} on text/id/enum columns, with the
035 * case-insensitive {@code $eqic} (and the case-insensitive {@code $contains}/{@code $prefix}) via {@code lower(...)}
036 * and {@code ILIKE}; full-string {@code $regex} via {@code ~}/{@code ~*} with the same {@code i}/{@code m}/{@code s}
037 * flag semantics as {@code java.util.regex}; dotted-tuple version comparison and {@code $prefix}; and typed binds for
038 * numeric/boolean/timestamp comparisons.  A bare pattern maps {@code *} to {@code %} and {@code ?} to {@code _} for
039 * {@code ILIKE}, unless the literal was quoted in the search string, in which case it is matched literally.
040 *
041 * <p>
042 * Values arrive already typed (design #4 §4: {@link SearchType#parse(String)} runs during query resolution, before
043 * this dialect ever sees a value), and are always bound as {@code ?} parameters, never concatenated into SQL text.
044 *
045 * <ul class='spaced-list'>
046 *    <li><b>ENUM columns</b> are compared as text: the column is cast ({@code col::text}) wherever this dialect applies a
047 *       text function or operator to it, so a native Postgres {@code enum} column behaves like a {@code text} one
048 *       (including bare {@code *}/{@code ?} wildcards, which match the whole constant name, case-insensitively).
049 *    <li><b>VERSION columns</b> are stored as dotted text (for example {@code 10.0.1}) and compared as a tuple of integers
050 *       ({@code 10.9} sorts before {@code 10.10}), like the in-memory engine.  A stored value
051 *       that is not a dotted sequence of integers never matches.  Known, deliberate differences from the in-memory
052 *       parser ({@code Integer.parseInt} over {@code split("\\.")}): each part is limited to 9 digits here (so it always
053 *       fits a Postgres {@code int}; longer parts never match, where in-memory accepts up to the {@code int} range), and a
054 *       trailing empty part ({@code 10.}) or a signed part ({@code +1}, {@code -1}) is rejected here but accepted in-memory.
055 *    <li><b>Blank</b> ({@code $blank}, and the blank-matching {@code $ne}) means {@code NULL} or only ASCII whitespace
056 *       (space, tab, line breaks, vertical tab, form feed and the C0 separators), matching {@code String.isBlank()} for
057 *       ASCII; non-ASCII Unicode spaces are not trimmed.
058 * </ul>
059 *
060 * <h5 class='section'>Example:</h5>
061 * <p class='bjava'>
062 *    <jc>// Register Postgres as the dialect for a JDBC-backed context.</jc>
063 *    SqlBeanQueryContext&lt;Task&gt; <jv>ctx</jv> = SqlBeanQueryContext.<jsm>create</jsm>(Task.<jk>class</jk>)
064 *       .dialect(PostgresDialect.<jsf>INSTANCE</jsf>)
065 *       .table(<js>"tasks"</js>)
066 *       .column(<js>"name"</js>, SearchType.<jsf>TEXT</jsf>)
067 *       .column(<js>"status"</js>, SearchType.<jsf>ENUM</jsf>)
068 *       .connectionSupplier(<jv>supplier</jv>)
069 *       .build();
070 *
071 *    <jc>// Render a bare value directly: the value is already typed, so a NUMERIC column gets a BigDecimal bind.</jc>
072 *    SqlFragment <jv>frag</jv> = PostgresDialect.<jsf>INSTANCE</jsf>.renderBare(<js>"\"age\""</js>, SearchType.<jsf>NUMERIC</jsf>, <jk>new</jk> BigDecimal(<js>"30"</js>), <jk>false</jk>);
073 *    <jc>// frag.sql() == "\"age\" = ?", frag.binds() == [30]</jc>
074 * </p>
075 *
076 * @since 10.0.0
077 */
078@SuppressWarnings({
079   "java:S1192" // Duplicated SQL fragments read more clearly inline than as constants.
080})
081public final class PostgresDialect implements SqlDialect {
082
083   /** The singleton instance. */
084   public static final PostgresDialect INSTANCE = new PostgresDialect();
085
086   private final Map<String,SearchSqlRenderer> builtins = new HashMap<>();
087
088   private PostgresDialect() {
089      builtins.put("$eq", (d, col, type, args) -> eq(col, type, args.get(0)));
090      builtins.put("$eqic", (d, col, type, args) -> eqicFrag(col, type, args.get(0)));
091      builtins.put("$ne", (d, col, type, args) -> ne(col, type, args));
092      builtins.put("$in", (d, col, type, args) -> in(col, type, args));
093      builtins.put("$contains", (d, col, type, args) -> frag(col + " ILIKE ?", like("%", text(args.get(0)), "%")));
094      builtins.put("$prefix", (d, col, type, args) -> prefix(col, type, args.get(0)));
095      builtins.put("$regex", (d, col, type, args) -> regex(col, args));
096      builtins.put("$blank", (d, col, type, args) -> SqlFragment.of(blank(col)));
097      builtins.put("$gt", (d, col, type, args) -> cmp(col, " > ", type, args.get(0)));
098      builtins.put("$gte", (d, col, type, args) -> cmp(col, " >= ", type, args.get(0)));
099      builtins.put("$lt", (d, col, type, args) -> cmp(col, " < ", type, args.get(0)));
100      builtins.put("$lte", (d, col, type, args) -> cmp(col, " <= ", type, args.get(0)));
101      builtins.put("$between", (d, col, type, args) -> between(col, type, args.get(0), args.get(1)));
102   }
103
104   @Override /* SqlDialect */
105   public String id() {
106      return "postgres";
107   }
108
109   /** Postgres compares versions as {@code int[]} tuples (see {@link #versionArray}), matching the in-memory order. */
110   @Override /* SqlDialect */
111   public boolean supportsVersionOrdering() {
112      return true;
113   }
114
115   @Override /* SqlDialect */
116   public String quote(String identifier) {
117      return '"' + identifier.replace("\"", "\"\"") + '"';
118   }
119
120   @Override /* SqlDialect */
121   public SearchSqlRenderer builtinRenderer(String operatorName) {
122      return builtins.get(operatorName);
123   }
124
125   @Override /* SqlDialect */
126   public String renderLimitOffset(Long limit, Long offset) {
127      var sb = new StringBuilder();
128      if (limit != null)
129         sb.append(" LIMIT ").append(limit.longValue());
130      if (offset != null && offset != 0L)
131         sb.append(" OFFSET ").append(offset.longValue());
132      return sb.toString();
133   }
134
135   @Override /* SqlDialect */
136   public SqlFragment renderBare(String columnSql, SearchType type, Object value, boolean quoted) {
137      switch (type) {
138         case TEXT, ID: {
139            var token = text(value);
140            if (quoted || ! hasWildcard(token))
141               return frag(columnSql + " ILIKE ?", like("%", token, "%"));
142            return frag(columnSql + " ILIKE ?", glob(token));
143         }
144         case ENUM: {
145            // Same rule as the in-memory engine: a wildcard pattern is a case-insensitive whole-string glob over the
146            // constant name; anything else (including a quoted literal) is case-insensitive equality.
147            var token = text(value);
148            if (quoted || ! hasWildcard(token))
149               return eqicFrag(columnSql, type, token);
150            return frag(textOf(columnSql, type) + " ILIKE ?", glob(token));
151         }
152         case VERSION:
153            return prefix(columnSql, type, value);
154         default:
155            return frag(columnSql + " = ?", value);  // NUMERIC / BOOLEAN / TIMESTAMP: typed-exact; quoting is a no-op.
156      }
157   }
158
159   // -----------------------------------------------------------------------------------------------------------------
160   // Built-in renderers
161   // -----------------------------------------------------------------------------------------------------------------
162
163   /** Exact, case-sensitive equality (typed bind); used by {@code $eq}. */
164   private static SqlFragment eq(String col, SearchType type, Object arg) {
165      if (type == VERSION)
166         return versionIn(col, List.of(arg));
167      return frag(textOf(col, type) + " = ?", bind(type, arg));
168   }
169
170   /**
171    * Case-insensitive equality; used by {@code $eqic} and the bare-token default for enum.
172    *
173    * <p>
174    * Named {@code eqicFrag} (rather than {@code eqic}) to make clear that it renders a SQL fragment, as opposed to
175    * evaluating a boolean comparison like the in-memory engine's {@code eqic} helper.
176    */
177   private static SqlFragment eqicFrag(String col, SearchType type, Object arg) {
178      return frag("lower(" + textOf(col, type) + ") = lower(?)", text(arg));
179   }
180
181   private static SqlFragment ne(String col, SearchType type, List<Object> args) {
182      if (type == VERSION) {
183         // Mirrors InMemoryMatch: a blank cell matches; otherwise no listed version may equal the (parseable) cell.
184         var in = versionIn(col, args);
185         var sb = new StringBuilder(blankOr(col, col));
186         if (in.binds().isEmpty())
187            return SqlFragment.of(sb.append(" OR 1 = 1)").toString());
188         sb.append(" OR NOT COALESCE(").append(in.sql()).append(", FALSE))");
189         return SqlFragment.of(sb.toString(), in.binds());
190      }
191      var c = textOf(col, type);
192      var sb = new StringBuilder(isCiText(type) ? blankOr(col, c) : "(" + col + " IS NULL");
193      sb.append(" OR ").append(c).append(" NOT IN (");
194      appendPlaceholders(sb, args.size());
195      sb.append("))");
196      return SqlFragment.of(sb.toString(), binds(type, args));
197   }
198
199   private static SqlFragment in(String col, SearchType type, List<Object> args) {
200      if (type == VERSION)
201         return versionIn(col, args);
202      var sb = new StringBuilder(textOf(col, type)).append(" IN (");
203      appendPlaceholders(sb, args.size());
204      sb.append(')');
205      return SqlFragment.of(sb.toString(), binds(type, args));
206   }
207
208   private static SqlFragment prefix(String col, SearchType type, Object arg) {
209      if (type == VERSION) {
210         var v = versionArg(arg);
211         if (v == null)
212            return NEVER;
213         var n = v.split("\\.").length;
214         // Array slice: a stored version with fewer parts than the prefix yields a shorter array, which never equals it.
215         return frag(versionArray(col) + "[1:" + n + "] = " + "string_to_array(?, '.')::int[]", v);
216      }
217      return frag(col + " ILIKE ?", like("", text(arg), "%"));
218   }
219
220   /**
221    * {@code $regex}: a full-string match with the flags of {@code InMemoryMatch}/{@code java.util.regex}.
222    *
223    * <p>
224    * Case-insensitive unless a {@code flags=} argument is given without {@code i} (then {@code ~} instead of {@code ~*}).
225    * Java's {@code m} (multiline) and {@code s} (dotall) are mapped onto Postgres's embedded regex options, which bundle
226    * newline handling differently: neither flag &rarr; {@code (?p)} ({@code .} does not match a newline, {@code ^}/{@code $}
227    * only at the string ends), {@code s} &rarr; the default, {@code m} &rarr; {@code (?n)} and both &rarr; {@code (?w)}.
228    * The pattern is anchored with {@code \A...\Z} (string boundaries, unaffected by those options) rather than
229    * {@code ^...$}, so {@code m} cannot turn the full-string match into a per-line one.
230    *
231    * <p>
232    * The pattern is first compiled with {@link java.util.regex.Pattern} using the mapped flags; a pattern that is not a valid
233    * Java regex never matches (renders as an always-false predicate), exactly like the in-memory engine, and can
234    * therefore never break out of the surrounding {@code \A(?:...)\Z} anchors (for example {@code x)|(y}).
235    *
236    * <p>
237    * <b>Flavor differences:</b> this uses PostgreSQL's POSIX {@code ~}/{@code ~*} operators (Advanced Regular
238    * Expressions), which are not Java regex.  Java and PostgreSQL (ARE) regex syntax are similar but not identical (for example Java
239    * possessive quantifiers and {@code \p{L}} classes are not supported by PostgreSQL, and a Java-valid pattern may be
240    * rejected by the server, failing the query).  A pathological pattern can also be slow on the server, so configure a
241    * {@code statement_timeout} for connections that run user-supplied patterns.  A pattern that uses the {@code (?x)}
242    * comment flag with a trailing {@code #} comment is passed through unchanged and will be rejected by the server.
243    */
244   private static SqlFragment regex(String col, List<Object> args) {
245      var ci = true;  // Case-insensitive by default (design §5.1).
246      var multiline = false;
247      var dotall = false;
248      for (var i = 1; i < args.size(); i++) {
249         var a = text(args.get(i)).strip();
250         if (a.startsWith("flags=")) {
251            var f = a.substring(6);  // The last flags= argument wins, as in-memory.
252            ci = f.indexOf('i') >= 0;
253            multiline = f.indexOf('m') >= 0;
254            dotall = f.indexOf('s') >= 0;
255         }
256      }
257      var pattern = text(args.get(0));
258      var javaFlags = 0;
259      if (ci)
260         javaFlags |= CASE_INSENSITIVE;
261      if (multiline)
262         javaFlags |= MULTILINE;
263      if (dotall)
264         javaFlags |= DOTALL;
265      try {
266         Pattern.compile(pattern, javaFlags);
267      } catch (PatternSyntaxException e) {
268         return NEVER;  // Invalid pattern = no match, like InMemoryMatch/RegexBudget; also keeps it from escaping the \A(?:...)\Z anchors.
269      }
270      String mods;
271      if (multiline)
272         mods = dotall ? "(?w)" : "(?n)";
273      else
274         mods = dotall ? "" : "(?p)";
275      return frag(col + (ci ? " ~* ?" : " ~ ?"), mods + "\\A(?:" + pattern + ")\\Z");
276   }
277
278   private static SqlFragment cmp(String col, String operator, SearchType type, Object arg) {
279      if (type == VERSION) {
280         var v = versionArg(arg);
281         return v == null ? NEVER : frag(versionArray(col) + operator + "string_to_array(?, '.')::int[]", v);
282      }
283      return frag(col + operator + "?", arg);
284   }
285
286   private static SqlFragment between(String col, SearchType type, Object lo, Object hi) {
287      if (type == VERSION) {
288         var l = versionArg(lo);
289         var h = versionArg(hi);
290         return (l == null || h == null) ? NEVER : frag(versionArray(col) + " BETWEEN " + "string_to_array(?, '.')::int[]" + " AND " + "string_to_array(?, '.')::int[]", l, h);
291      }
292      return frag(col + " BETWEEN ? AND ?", lo, hi);
293   }
294
295   // -----------------------------------------------------------------------------------------------------------------
296   // VERSION (stored as dotted text; compared as a tuple of integers, like InMemoryMatch.cmpVersion)
297   // -----------------------------------------------------------------------------------------------------------------
298
299   /** The predicate for a version that can never match (an unparseable argument, like in-memory). */
300   private static final SqlFragment NEVER = SqlFragment.of("(1 = 0)");
301
302   /**
303    * The column as an {@code int[]}, or {@code NULL} when the stored text is not a dotted sequence of integers.
304    * Postgres compares arrays element by element and then by length, which is exactly the in-memory dotted-tuple order.
305    *
306    * <p>
307    * The guard literal is an {@code E'...'} string with doubled backslashes, so its meaning does not depend on
308    * {@code standard_conforming_strings}, and it contains no {@code ?} that pgjdbc could mistake for a placeholder.
309    */
310   @SuppressWarnings({
311      "java:S6353" // Deliberately ASCII [0-9]: Postgres \\d may match non-ASCII digits in some locales, which would then fail the int cast.
312   })
313   private static String versionArray(String col) {
314      return "(CASE WHEN " + col + " ~ E'^\\\\s*[0-9]{1,9}(\\\\s*\\\\.\\\\s*[0-9]{1,9})*\\\\s*$' THEN string_to_array(" + col + ", '.')::int[] END)";
315   }
316
317   /** A version argument normalized for {@link #VERSION_BIND}, or <jk>null</jk> if it is not a dotted sequence of integers. */
318   private static String versionArg(Object arg) {
319      // Hand-rolled scan of  ws* d{1,9} (ws* '.' ws* d{1,9})* ws*  (ASCII digits/whitespace), equivalent to that regex but
320      // without a backtracking group.  At most 9 digits per part, so every part fits a Postgres int (no 22003 overflow).
321      var s = text(arg);
322      var n = s.length();
323      var parts = new ArrayList<String>();
324      var i = skipWs(s, 0);
325      while (true) {
326         var start = i;
327         while (i < n && s.charAt(i) >= '0' && s.charAt(i) <= '9')
328            i++;
329         if (i == start || i - start > 9)
330            return null;
331         parts.add(String.valueOf(Integer.parseInt(s.substring(start, i))));
332         i = skipWs(s, i);
333         if (i == n)
334            return String.join(".", parts);
335         if (s.charAt(i) != '.')
336            return null;
337         i = skipWs(s, i + 1);
338      }
339   }
340
341   /** Index of the first non-whitespace char at or after {@code i}; whitespace is the regex {@code \s} set (ASCII). */
342   private static int skipWs(String s, int i) {
343      while (i < s.length() && " \t\n\u000B\f\r".indexOf(s.charAt(i)) >= 0)
344         i++;
345      return i;
346   }
347
348   /** Equality against any of the (parseable) versions; renders as never-true when none parses. */
349   private static SqlFragment versionIn(String col, List<Object> args) {
350      var sb = new StringBuilder();
351      var binds = new ArrayList<>();
352      for (var a : args) {
353         var v = versionArg(a);
354         if (v == null)
355            continue;
356         sb.append(binds.isEmpty() ? "" : " OR ").append(versionArray(col)).append(" = ").append("string_to_array(?, '.')::int[]");
357         binds.add(v);
358      }
359      if (binds.isEmpty())
360         return NEVER;
361      return SqlFragment.of(binds.size() > 1 ? "(" + sb + ")" : sb.toString(), binds);
362   }
363
364   // -----------------------------------------------------------------------------------------------------------------
365   // Helpers
366   // -----------------------------------------------------------------------------------------------------------------
367
368   /** The cell is {@code NULL} or ASCII-whitespace only, like the in-memory blank test. */
369   private static String blank(String col) {
370      return blankOr(col, col) + ")";
371   }
372
373   /** Opens a parenthesized disjunction with the blank test for {@code textExpr}; the caller appends the rest and closes it. */
374   private static String blankOr(String col, String textExpr) {
375      return "(" + col + " IS NULL OR btrim(" + textExpr + ", " + "E' \\t\\n\\x0B\\f\\r\\x1C\\x1D\\x1E\\x1F'" + ") = ''";
376   }
377
378   private static boolean isCiText(SearchType type) {
379      return type == TEXT || type == ID || type == ENUM;
380   }
381
382   private static boolean hasWildcard(String token) {
383      return token.indexOf('*') >= 0 || token.indexOf('?') >= 0;
384   }
385
386   /** A bare glob ({@code *} any run, {@code ?} one character) as a LIKE pattern, with LIKE metacharacters escaped first. */
387   private static String glob(String token) {
388      return escapeLike(token).replace("*", "%").replace("?", "_");
389   }
390
391   /** The column as text: an ENUM column may be a native Postgres enum, which has no text operators of its own. */
392   private static String textOf(String col, SearchType type) {
393      return type == ENUM ? col + "::text" : col;
394   }
395
396   /** Text-like columns bind strings; everything else binds the already-typed value. */
397   private static Object bind(SearchType type, Object arg) {
398      return isCiText(type) ? text(arg) : arg;
399   }
400
401   private static List<Object> binds(SearchType type, List<Object> args) {
402      return args.stream().map(a -> bind(type, a)).toList();
403   }
404
405   private static void appendPlaceholders(StringBuilder sb, int count) {
406      for (var i = 0; i < count; i++) {
407         if (i > 0)
408            sb.append(", ");
409         sb.append('?');
410      }
411   }
412
413   /** Escapes LIKE/ILIKE metacharacters (backslash is the default Postgres LIKE escape). */
414   private static String escapeLike(String s) {
415      return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
416   }
417
418   private static String like(String pre, String s, String post) {
419      return pre + escapeLike(s) + post;
420   }
421
422   /**
423    * Asserts that a value on a text-only path is a {@link String}.
424    *
425    * <p>
426    * Values for TEXT / ID / ENUM / VERSION columns are always {@link String}s (design #4 §4: {@link SearchType#parse}
427    * leaves them unchanged), so a non-string here is an engine bug, not a user error.
428    *
429    * @param o The value.
430    * @return The value as a string.
431    * @throws IllegalStateException If the value is not a {@link String}.
432    */
433   private static String text(Object o) {
434      if (o instanceof String s)
435         return s;
436      throw new IllegalStateException(f("Expected a String value for a text comparison but got '%s'.", o == null ? null : o.getClass().getName()));  // I believe we have a short for getting class name?
437   }
438
439   private static SqlFragment frag(String sql, Object...binds) {
440      return SqlFragment.of(sql, binds);
441   }
442}