package bjc.utils.misc; import java.math.BigDecimal; import java.sql.*; import java.sql.Date; import java.util.*; /** * NamedPreparedStatement * * Features * - :name -> single parameter (bind once, all repeats covered) * - @name -> list parameter (expands to ?,?,? based on values you set; repeats ok) * - Empty-list toggle: * .emptyListAsNull() // IN (@ids) -> IN (NULL) [default] * .emptyListAsLiteral("YOUR_TEXT") // IN (@ids) -> IN (YOUR_TEXT) * - Skips strings ('...'/\"...\") and comments (-- ..., /* ... * /) * - Respects PostgreSQL type casts (::type) * - Lazy compilation (or call compile() to do it eagerly) * * Usage: * String sql = "SELECT * FROM t WHERE a = :a AND b IN (@bs)"; * try (NamedPreparedStatement nps = NamedPreparedStatement.prepare(conn, sql)) { * nps.setInt("a", 7); * nps.setList("bs", 1, 2, 3); * // optional toggles * nps.emptyListAsLiteral("SELECT NULL WHERE 1=0"); * // eager build if you need the PS now: * PreparedStatement ps = nps.compile(); // built & bound, not executed * try (ResultSet rs = ps.executeQuery()) { ... } * } */ public final class NamedPreparedStatement implements AutoCloseable { /* ---------- Construction ---------- */ private final Connection conn; private final List tokens; // parsed tokens from original SQL private final String originalSql; private final Integer autoGeneratedKeys; // null if not requested private final Integer resultSetType; // null unless custom rs type private final Integer resultSetConcurrency; // null unless custom rs type // current compiled statement private PreparedStatement currentPs; private String lastCompiledSql; // parameter values private final Map singles = new HashMap<>(); private final Map> lists = new HashMap<>(); // simple batch snapshots private final List batch = new ArrayList<>(); // empty-list behavior private EmptyListMode emptyListMode = EmptyListMode.AS_NULL; private String emptyListLiteral = "NULL"; private NamedPreparedStatement(Connection conn, String sql, Integer autoGeneratedKeys, Integer resultSetType, Integer resultSetConcurrency) { this.conn = Objects.requireNonNull(conn, "conn"); this.originalSql = Objects.requireNonNull(sql, "sql"); this.tokens = parse(sql); this.autoGeneratedKeys = autoGeneratedKeys; this.resultSetType = resultSetType; this.resultSetConcurrency = resultSetConcurrency; } /** * Default options. * * @param conn The DB connection to use * @param sql The SQL query to prepare * @return The prepared statement, bound to the given DB connection */ public static NamedPreparedStatement prepare(Connection conn, String sql) { return new NamedPreparedStatement(conn, sql, null, null, null); } /** * Request generated keys. * * @param conn The DB connection to use * @param sql The SQL query to prepare * @return The prepared statement, bound to the given DB connection and set to return generated keys. */ public static NamedPreparedStatement prepareWithKeys(Connection conn, String sql) { return new NamedPreparedStatement(conn, sql, Statement.RETURN_GENERATED_KEYS, null, null); } /** * Custom result set type/concurrency. * * @param conn The DB connection to use * @param sql The SQL query to prepare * @param resultSetType The result-set type to use * @param resultSetConcurrency The result-set concurrency to use * @return The prepared statement, configured as specified */ public static NamedPreparedStatement prepare(Connection conn, String sql, int resultSetType, int resultSetConcurrency) { return new NamedPreparedStatement(conn, sql, null, resultSetType, resultSetConcurrency); } /* ---------- Empty-list toggle ---------- */ /** * Expand empty @lists as NULL (IN (@x) -> IN (NULL)). * * @return The prepared statement */ public NamedPreparedStatement emptyListAsNull() { this.emptyListMode = EmptyListMode.AS_NULL; return this; } /** * Expand empty @lists as the provided literal (inserted verbatim). Example * (Postgres): emptyListAsLiteral("SELECT NULL WHERE 1=0") → IN (@x) becomes IN * (SELECT NULL WHERE 1=0) * * @param literal The literal to expand empty lists to * @return The prepared statement */ public NamedPreparedStatement emptyListAsLiteral(String literal) { if (literal == null || literal.isBlank()) throw new IllegalArgumentException("Empty-list literal must be non-blank."); this.emptyListMode = EmptyListMode.AS_CUSTOM_LITERAL; this.emptyListLiteral = literal; return this; } /* ---------- Eager compile ---------- */ /** * Build final SQL (expanding @lists), create PreparedStatement, and bind all * parameters; does NOT execute. Returns the prepared statement instance. * * @return The fully bound prepared statement * @throws SQLException If something went wrong preparing the statement */ public PreparedStatement compile() throws SQLException { buildAndBind(); return currentPs; } /* ---------- Execution ---------- */ /** * Execute this statement * @return The result of executing the query * @throws SQLException If something went wrong executing the query */ public ResultSet executeQuery() throws SQLException { buildAndBind(); return currentPs.executeQuery(); } /** * Execute this update. * * @return The number of rows updated by the update * @throws SQLException If something went wrong executing the update */ public int executeUpdate() throws SQLException { buildAndBind(); return currentPs.executeUpdate(); } /** * Execute the SQL statement * * @return The result of executing the SQL statement * @throws SQLException If something went wrong executing the statement */ public boolean execute() throws SQLException { buildAndBind(); return currentPs.execute(); } /** * Execute one or more batch of parameters against this SQL statement * * @return The number of rows updated by each batch * @throws SQLException If something went wrong executing the statement */ public int[] executeBatch() throws SQLException { if (batch.isEmpty()) { buildAndBind(); return currentPs.executeBatch(); } List counts = new ArrayList<>(batch.size()); List binders = buildPreparedStatement(); for (ParamSnapshot snap : batch) { // restore snapshot this.singles.clear(); this.lists.clear(); this.singles.putAll(snap.singles); this.lists.putAll(snap.lists); bindPreparedStatement(binders); counts.add(currentPs.executeUpdate()); } batch.clear(); return counts.stream().mapToInt(Integer::intValue).toArray(); } /** * Record the current set of parameters as a batch and start a new one. * * @return The prepared statement */ public NamedPreparedStatement addBatch() { batch.add(new ParamSnapshot(new HashMap<>(singles), deepCopyLists(lists))); return this; } /** * Clear the currently bound parameters. * * @return The prepared statement */ public NamedPreparedStatement clearParameters() { singles.clear(); lists.clear(); return this; } /** * Get the generated keys for this prepared statement. * * See {@link PreparedStatement#getGeneratedKeys()} * * @return The generated keys for this prepared statement * @throws SQLException If something went wrong getting the generated keys */ public ResultSet getGeneratedKeys() throws SQLException { if (currentPs == null) throw new IllegalStateException("No statement compiled/executed yet."); return currentPs.getGeneratedKeys(); } /** * Get the underlying prepared statement, or null if one hasn't been prepared yet * * TODO should this return a Optional? * * @return The underlying prepared statement, or null if there isn't one */ public PreparedStatement getPreparedStatement() { return currentPs; } /** * Get the parsed SQL for this statement. * * @return The parsed SQL for this statement */ public String getParsedSql() { return lastCompiledSql; } /** * Get the original SQL for this statement. * * @return The original SQL for this statement */ public String getOriginalSql() { return originalSql; } /** * Get the available parameter names. * * @return The names of the available parameters */ public Set getParameterNames() { LinkedHashSet names = new LinkedHashSet<>(); for (Token t : tokens) if (t instanceof ParamToken pt) names.add(pt.name); return Collections.unmodifiableSet(names); } @Override public void close() throws SQLException { if (currentPs != null) currentPs.close(); } @Override public String toString() { return "NamedPreparedStatement{sql='" + (lastCompiledSql != null ? lastCompiledSql : originalSql) + "'}"; } /* ---------- Binding (single) ---------- */ /** * Bind a object as a parameter * * @param name The name of the parameter * @param value The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setObject(String name, Object value) { singles.put(name, (ps, i) -> ps.setObject(i, value)); return this; } /** * Bind a object as a parameter * * @param name The name of the parameter * @param value The value for the parameter * @param sqlType The SQL type for the parameter (see {@link java.sql.Types}) * @return The prepared statement */ public NamedPreparedStatement setObject(String name, Object value, int sqlType) { singles.put(name, (ps, i) -> { if (value == null) ps.setNull(i, sqlType); else ps.setObject(i, value, sqlType); }); return this; } /** * Bind a null as a parameter * * @param name The name of the parameter * @param sqlType The SQL type for the parameter (see {@link java.sql.Types}) * @return The prepared statement */ public NamedPreparedStatement setNull(String name, int sqlType) { singles.put(name, (ps, i) -> ps.setNull(i, sqlType)); return this; } /** * Bind a boolean as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setBoolean(String n, boolean x) { singles.put(n, (ps, i) -> ps.setBoolean(i, x)); return this; } /** * Bind a byte as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setByte(String n, byte x) { singles.put(n, (ps, i) -> ps.setByte(i, x)); return this; } /** * Bind a short as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setShort(String n, short x) { singles.put(n, (ps, i) -> ps.setShort(i, x)); return this; } /** * Bind a int as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setInt(String n, int x) { singles.put(n, (ps, i) -> ps.setInt(i, x)); return this; } /** * Bind a long as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setLong(String n, long x) { singles.put(n, (ps, i) -> ps.setLong(i, x)); return this; } /** * Bind a float as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setFloat(String n, float x) { singles.put(n, (ps, i) -> ps.setFloat(i, x)); return this; } /** * Bind a double as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setDouble(String n, double x) { singles.put(n, (ps, i) -> ps.setDouble(i, x)); return this; } /** * Bind a {@link BigDecimal} as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setBigDecimal(String n, BigDecimal x) { singles.put(n, (ps, i) -> ps.setBigDecimal(i, x)); return this; } /** * Bind a string as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setString(String n, String x) { singles.put(n, (ps, i) -> ps.setString(i, x)); return this; } /** * Bind a byte-array as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setBytes(String n, byte[] x) { singles.put(n, (ps, i) -> ps.setBytes(i, x)); return this; } /** * Bind a date as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setDate(String n, Date x) { singles.put(n, (ps, i) -> ps.setDate(i, x)); return this; } /** * Bind a date as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @param c The calendar for the date * @return The prepared statement */ public NamedPreparedStatement setDate(String n, Date x, Calendar c) { singles.put(n, (ps, i) -> ps.setDate(i, x, c)); return this; } /** * Bind a time-value as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setTime(String n, Time x) { singles.put(n, (ps, i) -> ps.setTime(i, x)); return this; } /** * Bind a time-value as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @param c The calendar for the time * @return The prepared statement */ public NamedPreparedStatement setTime(String n, Time x, Calendar c) { singles.put(n, (ps, i) -> ps.setTime(i, x, c)); return this; } /** * Bind a timestamp as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setTimestamp(String n, Timestamp x) { singles.put(n, (ps, i) -> ps.setTimestamp(i, x)); return this; } /** * Bind a timestamp as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @param c The calendar for the timestamp * @return The prepared statement */ public NamedPreparedStatement setTimestamp(String n, Timestamp x, Calendar c) { singles.put(n, (ps, i) -> ps.setTimestamp(i, x, c)); return this; } /** * Bind a {@link Array} as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setArray(String n, Array x) { singles.put(n, (ps, i) -> ps.setArray(i, x)); return this; } /** * Bind a {@link Blob} as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setBlob(String n, Blob x) { singles.put(n, (ps, i) -> ps.setBlob(i, x)); return this; } /** * Bind a {@link Clob} as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setClob(String n, Clob x) { singles.put(n, (ps, i) -> ps.setClob(i, x)); return this; } /** * Bind a URL as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @return The prepared statement */ public NamedPreparedStatement setURL(String n, java.net.URL x) { singles.put(n, (ps, i) -> ps.setURL(i, x)); return this; } /** * Bind a ASCII stream as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @param len The length of the stream * @return The prepared statement */ public NamedPreparedStatement setAsciiStream(String n, java.io.InputStream x, int len) { singles.put(n, (ps, i) -> ps.setAsciiStream(i, x, len)); return this; } /** * Bind a binary stream as a parameter * * @param n The name of the parameter * @param x The value for the parameter * @param len The length of the stream * @return The prepared statement */ public NamedPreparedStatement setBinaryStream(String n, java.io.InputStream x, int len) { singles.put(n, (ps, i) -> ps.setBinaryStream(i, x, len)); return this; } /** * Bind a character stream as a parameter * * @param n The name of the parameter * @param r The value for the parameter * @param len The length of the stream * @return The prepared statement */ public NamedPreparedStatement setCharacterStream(String n, java.io.Reader r, int len) { singles.put(n, (ps, i) -> ps.setCharacterStream(i, r, len)); return this; } /* ---------- Binding (lists) ---------- */ /** * Bind a list parameter to the statement * * @param name The name of the list parameter * @param values The values for the list parameter * @return The prepared statement */ public NamedPreparedStatement setList(String name, Collection values) { lists.put(name, toBinders(values, Types.OTHER, false)); return this; } /** * Bind a list parameter to the statement * * @param name The name of the list parameter * @param values The values for the list parameter * @return The prepared statement */ public NamedPreparedStatement setList(String name, Object... values) { lists.put(name, toBinders(Arrays.asList(values), Types.OTHER, false)); return this; } /** * Explicit SQL type for all elements (useful if nulls might appear). * * @param name The name of the list parameter * @param sqlType The SQL type for the parameters * @param values The values for the list parameter * @return The prepared statement */ public NamedPreparedStatement setList(String name, int sqlType, Collection values) { lists.put(name, toBinders(values, sqlType, true)); return this; } /** * Explicit SQL type for all elements (useful if nulls might appear). * * @param name The name of the list parameter * @param sqlType The SQL type for the parameters * @param values The values for the list parameter * @return The prepared statement */ public NamedPreparedStatement setList(String name, int sqlType, Object... values) { lists.put(name, toBinders(Arrays.asList(values), sqlType, true)); return this; } /* ---------- Build & bind ---------- */ private void buildAndBind() throws SQLException { List bindersInOrder = buildPreparedStatement(); bindPreparedStatement(bindersInOrder); } private void bindPreparedStatement(List bindersInOrder) throws SQLException { int idx = 1; for (Binder b : bindersInOrder) b.bind(currentPs, idx++); } private List buildPreparedStatement() throws SQLException { StringBuilder sb = new StringBuilder(originalSql.length() + 32); List bindersInOrder = new ArrayList<>(32); for (Token t : tokens) { if (t instanceof TextToken tt) { sb.append(tt.text); } else { ParamToken pt = (ParamToken) t; if (!pt.list) { // :name Binder b = singles.get(pt.name); if (b == null) throw new IllegalArgumentException("Missing value for parameter :" + pt.name); sb.append('?'); bindersInOrder.add(b); } else { // @name List lst = lists.get(pt.name); if (lst == null) throw new IllegalArgumentException("Missing list for parameter @" + pt.name); if (lst.isEmpty()) { if (emptyListMode == EmptyListMode.AS_NULL) { sb.append("NULL"); } else { sb.append(emptyListLiteral); } } else { for (int i = 0; i < lst.size(); i++) { if (i > 0) sb.append(','); sb.append('?'); bindersInOrder.add(lst.get(i)); } } } } } this.lastCompiledSql = sb.toString(); if (currentPs != null) try { currentPs.close(); } catch (SQLException ignore) { } if (autoGeneratedKeys != null) { currentPs = conn.prepareStatement(lastCompiledSql, autoGeneratedKeys); } else if (resultSetType != null && resultSetConcurrency != null) { currentPs = conn.prepareStatement(lastCompiledSql, resultSetType, resultSetConcurrency); } else { currentPs = conn.prepareStatement(lastCompiledSql); } return bindersInOrder; } /* ---------- Parser ---------- */ private interface Token { } private static final class TextToken implements Token { final String text; TextToken(String s) { this.text = s; } } private static final class ParamToken implements Token { final String name; final boolean list; ParamToken(String n, boolean l) { this.name = n; this.list = l; } } private static List parse(String sql) { ArrayList out = new ArrayList<>(); StringBuilder buf = new StringBuilder(); boolean inSingle = false, inDouble = false, inLine = false, inBlock = false; int n = sql.length(); for (int i = 0; i < n; i++) { char c = sql.charAt(i); if (inLine) { buf.append(c); if (c == '\n' || c == '\r') inLine = false; continue; } if (inBlock) { buf.append(c); if (c == '*' && i + 1 < n && sql.charAt(i + 1) == '/') { buf.append('/'); i++; inBlock = false; } continue; } if (inSingle) { buf.append(c); if (c == '\'') { if (i + 1 < n && sql.charAt(i + 1) == '\'') { buf.append('\''); i++; } else inSingle = false; } continue; } if (inDouble) { buf.append(c); if (c == '"') { if (i + 1 < n && sql.charAt(i + 1) == '"') { buf.append('"'); i++; } else inDouble = false; } continue; } if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') { buf.append("--"); i++; inLine = true; continue; } if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') { buf.append("/*"); i++; inBlock = true; continue; } if (c == '\'') { buf.append(c); inSingle = true; continue; } if (c == '"') { buf.append(c); inDouble = true; continue; } if (c == ':' || c == '@') { char prev = (i > 0) ? sql.charAt(i - 1) : '\0'; if ((c == ':' && prev == ':') || (c == '@' && prev == '@')) { buf.append(c); continue; } // ::type or @@sys if (i + 1 < n && isIdentStart(sql.charAt(i + 1))) { if (buf.length() > 0) { out.add(new TextToken(buf.toString())); buf.setLength(0); } int j = i + 2; while (j < n && isIdentPart(sql.charAt(j))) j++; String name = sql.substring(i + 1, j); out.add(new ParamToken(name, c == '@')); i = j - 1; continue; } } buf.append(c); } if (buf.length() > 0) out.add(new TextToken(buf.toString())); return out; } private static boolean isIdentStart(char c) { return c == '_' || Character.isLetter(c); } private static boolean isIdentPart(char c) { return c == '_' || Character.isLetterOrDigit(c); } /* ---------- Helpers ---------- */ @FunctionalInterface private interface Binder { void bind(PreparedStatement ps, int index) throws SQLException; } private static List toBinders(Collection values, int sqlType, boolean forceType) { ArrayList list = new ArrayList<>(values.size()); for (Object v : values) { final Object val = v; if (forceType) { list.add((ps, i) -> { if (val == null) ps.setNull(i, sqlType); else ps.setObject(i, val, sqlType); }); } else { list.add((ps, i) -> ps.setObject(i, val)); } } return list; } private static Map> deepCopyLists(Map> src) { Map> m = new HashMap<>(src.size()); for (Map.Entry> e : src.entrySet()) { m.put(e.getKey(), new ArrayList<>(e.getValue())); } return m; } private static class ParamSnapshot { public Map singles; public Map> lists; public ParamSnapshot(Map singles, Map> lists) { super(); this.singles = singles; this.lists = lists; } @Override public int hashCode() { return Objects.hash(lists, singles); } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; ParamSnapshot other = (ParamSnapshot) obj; return Objects.equals(lists, other.lists) && Objects.equals(singles, other.singles); } } private enum EmptyListMode { AS_NULL, AS_CUSTOM_LITERAL } }