From e8e15d0335c7252f78b71c59905cb82c8964e80a Mon Sep 17 00:00:00 2001 From: Benjamin Culkin Date: Sun, 7 Dec 2025 17:14:16 -0500 Subject: Add NamedPreparedStatement Adds a version of PreparedStatement that supports named parameter syntax. --- .../bjc/utils/misc/NamedPreparedStatement.java | 940 +++++++++++++++++++++ 1 file changed, 940 insertions(+) create mode 100644 base/src/main/java/bjc/utils/misc/NamedPreparedStatement.java (limited to 'base/src/main/java/bjc') diff --git a/base/src/main/java/bjc/utils/misc/NamedPreparedStatement.java b/base/src/main/java/bjc/utils/misc/NamedPreparedStatement.java new file mode 100644 index 0000000..b35029e --- /dev/null +++ b/base/src/main/java/bjc/utils/misc/NamedPreparedStatement.java @@ -0,0 +1,940 @@ +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 + } +} -- cgit v1.2.3