diff --git a/src/main/java/org/duckdb/DuckDBConnection.java b/src/main/java/org/duckdb/DuckDBConnection.java index 4d39f8528..e29cc39f7 100644 --- a/src/main/java/org/duckdb/DuckDBConnection.java +++ b/src/main/java/org/duckdb/DuckDBConnection.java @@ -453,15 +453,31 @@ public CallableStatement prepareCall(String sql, int resultSetType, int resultSe } public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException { - throw new SQLFeatureNotSupportedException("prepareStatement"); + checkOpen(); + if (Statement.NO_GENERATED_KEYS == autoGeneratedKeys) { + return prepareStatement(sql); + } + if (Statement.RETURN_GENERATED_KEYS == autoGeneratedKeys) { + return new DuckDBPreparedStatement(this, sql, true, null, null); + } + throw new SQLFeatureNotSupportedException( + "prepareStatement(String sql, int autoGeneratedKeys=" + autoGeneratedKeys + ")"); } public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException { - throw new SQLFeatureNotSupportedException("prepareStatement"); + checkOpen(); + if (columnIndexes == null || columnIndexes.length == 0) { + return prepareStatement(sql); + } + return new DuckDBPreparedStatement(this, sql, true, columnIndexes, null); } public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException { - throw new SQLFeatureNotSupportedException("prepareStatement"); + checkOpen(); + if (columnNames == null || columnNames.length == 0) { + return prepareStatement(sql); + } + return new DuckDBPreparedStatement(this, sql, true, null, columnNames); } public NClob createNClob() throws SQLException { diff --git a/src/main/java/org/duckdb/DuckDBGeneratedKeysResultSet.java b/src/main/java/org/duckdb/DuckDBGeneratedKeysResultSet.java new file mode 100644 index 000000000..39e8e3e08 --- /dev/null +++ b/src/main/java/org/duckdb/DuckDBGeneratedKeysResultSet.java @@ -0,0 +1,1160 @@ +package org.duckdb; + +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.sql.*; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.util.Calendar; +import java.util.Map; + +/** + * An in-memory, read-only {@link ResultSet} that holds the rows returned by a rewritten + * {@code INSERT/UPDATE/DELETE ... RETURNING} statement. It is used to expose the generated keys + * from {@link DuckDBPreparedStatement#getGeneratedKeys()} after execution. + */ +public class DuckDBGeneratedKeysResultSet implements ResultSet { + + private final DuckDBResultSetMetaData meta; + private final String[] columnNames; + private final Object[][] rows; + private int currentRow = -1; + private boolean wasNull = false; + private boolean closed = false; + + public DuckDBGeneratedKeysResultSet(DuckDBResultSetMetaData meta, Object[][] rows) { + this.meta = meta; + this.columnNames = meta.column_names; + this.rows = rows; + } + + @Override + public boolean next() throws SQLException { + checkOpen(); + if (currentRow >= rows.length - 1) { + currentRow = rows.length; + return false; + } + currentRow++; + return true; + } + + @Override + public void close() { + closed = true; + } + + @Override + public boolean wasNull() throws SQLException { + checkOpen(); + return wasNull; + } + + private void checkOpen() throws SQLException { + if (closed) { + throw new SQLException("ResultSet was closed"); + } + } + + private void checkRow() throws SQLException { + checkOpen(); + if (currentRow < 0 || currentRow >= rows.length) { + throw new SQLException("No row in context"); + } + } + + private Object getRaw(int columnIndex) throws SQLException { + checkRow(); + if (columnIndex < 1 || columnIndex > columnNames.length) { + throw new SQLException("Column index out of bounds"); + } + Object value = rows[currentRow][columnIndex - 1]; + wasNull = value == null; + return value; + } + + private int findColumnIndex(String columnLabel) throws SQLException { + for (int i = 0; i < columnNames.length; i++) { + if (columnNames[i].equals(columnLabel)) { + return i + 1; + } + } + throw new SQLException("Could not find column with label " + columnLabel); + } + + @Override + public String getString(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + return value == null ? null : value.toString(); + } + + @Override + public boolean getBoolean(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).byteValue() != 0; + } + return Boolean.parseBoolean(value.toString()); + } + + @Override + public byte getByte(int columnIndex) throws SQLException { + return numberValue(columnIndex).byteValue(); + } + + @Override + public short getShort(int columnIndex) throws SQLException { + return numberValue(columnIndex).shortValue(); + } + + @Override + public int getInt(int columnIndex) throws SQLException { + return numberValue(columnIndex).intValue(); + } + + @Override + public long getLong(int columnIndex) throws SQLException { + return numberValue(columnIndex).longValue(); + } + + @Override + public float getFloat(int columnIndex) throws SQLException { + return numberValue(columnIndex).floatValue(); + } + + @Override + public double getDouble(int columnIndex) throws SQLException { + return numberValue(columnIndex).doubleValue(); + } + + @Override + public BigDecimal getBigDecimal(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + if (value == null) { + return null; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return BigDecimal.valueOf(((Number) value).doubleValue()); + } + return new BigDecimal(value.toString()); + } + + private Number numberValue(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + if (value == null) { + return 0; + } + if (value instanceof Number) { + return (Number) value; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + throw new SQLException("Can't convert value to number " + value.getClass().toString()); + } + + @Override + @SuppressWarnings("deprecation") + public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException { + return getBigDecimal(columnIndex); + } + + @Override + public byte[] getBytes(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + if (value == null) { + return null; + } + if (value instanceof byte[]) { + return (byte[]) value; + } + return value.toString().getBytes(); + } + + @Override + @SuppressWarnings("deprecation") + public Date getDate(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + if (value == null) { + return null; + } + if (value instanceof Date) { + return (Date) value; + } + if (value instanceof java.time.LocalDate) { + return Date.valueOf((LocalDate) value); + } + if (value instanceof Timestamp) { + return new Date(((Timestamp) value).getTime()); + } + throw new SQLException("Can't convert value to date " + value.getClass().toString()); + } + + @Override + public Time getTime(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + if (value == null) { + return null; + } + if (value instanceof Time) { + return (Time) value; + } + if (value instanceof LocalTime) { + return Time.valueOf((LocalTime) value); + } + if (value instanceof Timestamp) { + return new Time(((Timestamp) value).getTime()); + } + throw new SQLException("Can't convert value to time " + value.getClass().toString()); + } + + @Override + public Timestamp getTimestamp(int columnIndex) throws SQLException { + Object value = getRaw(columnIndex); + if (value == null) { + return null; + } + if (value instanceof Timestamp) { + return (Timestamp) value; + } + if (value instanceof java.time.LocalDateTime) { + return Timestamp.valueOf((java.time.LocalDateTime) value); + } + if (value instanceof OffsetDateTime) { + return Timestamp.from(((OffsetDateTime) value).toInstant()); + } + if (value instanceof Date) { + return new Timestamp(((Date) value).getTime()); + } + throw new SQLException("Can't convert value to timestamp " + value.getClass().toString()); + } + + @Override + public InputStream getAsciiStream(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getAsciiStream"); + } + + @Override + @SuppressWarnings("deprecation") + public InputStream getUnicodeStream(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getUnicodeStream"); + } + + @Override + public InputStream getBinaryStream(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getBinaryStream"); + } + + @Override + public String getString(String columnLabel) throws SQLException { + return getString(findColumnIndex(columnLabel)); + } + + @Override + public boolean getBoolean(String columnLabel) throws SQLException { + return getBoolean(findColumnIndex(columnLabel)); + } + + @Override + public byte getByte(String columnLabel) throws SQLException { + return getByte(findColumnIndex(columnLabel)); + } + + @Override + public short getShort(String columnLabel) throws SQLException { + return getShort(findColumnIndex(columnLabel)); + } + + @Override + public int getInt(String columnLabel) throws SQLException { + return getInt(findColumnIndex(columnLabel)); + } + + @Override + public long getLong(String columnLabel) throws SQLException { + return getLong(findColumnIndex(columnLabel)); + } + + @Override + public float getFloat(String columnLabel) throws SQLException { + return getFloat(findColumnIndex(columnLabel)); + } + + @Override + public double getDouble(String columnLabel) throws SQLException { + return getDouble(findColumnIndex(columnLabel)); + } + + @Override + @SuppressWarnings("deprecation") + public BigDecimal getBigDecimal(String columnLabel, int scale) throws SQLException { + return getBigDecimal(findColumnIndex(columnLabel)); + } + + @Override + public byte[] getBytes(String columnLabel) throws SQLException { + return getBytes(findColumnIndex(columnLabel)); + } + + @Override + @SuppressWarnings("deprecation") + public Date getDate(String columnLabel) throws SQLException { + return getDate(findColumnIndex(columnLabel)); + } + + @Override + public Time getTime(String columnLabel) throws SQLException { + return getTime(findColumnIndex(columnLabel)); + } + + @Override + public Timestamp getTimestamp(String columnLabel) throws SQLException { + return getTimestamp(findColumnIndex(columnLabel)); + } + + @Override + public InputStream getAsciiStream(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getAsciiStream"); + } + + @Override + @SuppressWarnings("deprecation") + public InputStream getUnicodeStream(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getUnicodeStream"); + } + + @Override + public InputStream getBinaryStream(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getBinaryStream"); + } + + @Override + public SQLWarning getWarnings() throws SQLException { + return null; + } + + @Override + public void clearWarnings() throws SQLException { + // no-op + } + + @Override + public String getCursorName() throws SQLException { + throw new SQLFeatureNotSupportedException("getCursorName"); + } + + @Override + public ResultSetMetaData getMetaData() throws SQLException { + checkOpen(); + return meta; + } + + @Override + public Object getObject(int columnIndex) throws SQLException { + return getRaw(columnIndex); + } + + @Override + public Object getObject(String columnLabel) throws SQLException { + return getObject(findColumnIndex(columnLabel)); + } + + @Override + public int findColumn(String columnLabel) throws SQLException { + return findColumnIndex(columnLabel); + } + + @Override + public Reader getCharacterStream(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getCharacterStream"); + } + + @Override + public Reader getCharacterStream(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getCharacterStream"); + } + + @Override + public BigDecimal getBigDecimal(String columnLabel) throws SQLException { + return getBigDecimal(findColumnIndex(columnLabel)); + } + + @Override + public boolean isBeforeFirst() throws SQLException { + return currentRow < 0; + } + + @Override + public boolean isAfterLast() throws SQLException { + return currentRow >= rows.length; + } + + @Override + public boolean isFirst() throws SQLException { + return currentRow == 0; + } + + @Override + public boolean isLast() throws SQLException { + return currentRow == rows.length - 1; + } + + @Override + public void beforeFirst() throws SQLException { + checkOpen(); + currentRow = -1; + } + + @Override + public void afterLast() throws SQLException { + checkOpen(); + currentRow = rows.length; + } + + @Override + public boolean first() throws SQLException { + checkOpen(); + if (rows.length > 0) { + currentRow = 0; + return true; + } + return false; + } + + @Override + public boolean last() throws SQLException { + checkOpen(); + if (rows.length > 0) { + currentRow = rows.length - 1; + return true; + } + return false; + } + + @Override + public int getRow() throws SQLException { + checkOpen(); + return currentRow + 1; + } + + @Override + public boolean absolute(int row) throws SQLException { + checkOpen(); + if (row >= 0) { + currentRow = row - 1; + } else { + currentRow = rows.length + row; + } + if (currentRow < -1) { + currentRow = -1; + return false; + } + if (currentRow > rows.length) { + currentRow = rows.length; + return false; + } + return currentRow >= 0 && currentRow < rows.length; + } + + @Override + public boolean relative(int rowsOffset) throws SQLException { + checkOpen(); + currentRow += rowsOffset; + return absolute(currentRow + 1); + } + + @Override + public boolean previous() throws SQLException { + checkOpen(); + if (currentRow <= -1) { + currentRow = -1; + return false; + } + currentRow--; + return currentRow >= 0; + } + + @Override + public void setFetchDirection(int direction) throws SQLException { + // no-op + } + + @Override + public int getFetchDirection() throws SQLException { + return ResultSet.FETCH_FORWARD; + } + + @Override + public void setFetchSize(int rowsFetch) throws SQLException { + // no-op + } + + @Override + public int getFetchSize() throws SQLException { + return 0; + } + + @Override + public int getType() throws SQLException { + return ResultSet.TYPE_SCROLL_INSENSITIVE; + } + + @Override + public int getConcurrency() throws SQLException { + return ResultSet.CONCUR_READ_ONLY; + } + + @Override + public boolean rowUpdated() throws SQLException { + return false; + } + + @Override + public boolean rowInserted() throws SQLException { + return false; + } + + @Override + public boolean rowDeleted() throws SQLException { + return false; + } + + @Override + public void updateNull(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNull"); + } + + @Override + public void updateBoolean(int columnIndex, boolean x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBoolean"); + } + + @Override + public void updateByte(int columnIndex, byte x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateByte"); + } + + @Override + public void updateShort(int columnIndex, short x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateShort"); + } + + @Override + public void updateInt(int columnIndex, int x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateInt"); + } + + @Override + public void updateLong(int columnIndex, long x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateLong"); + } + + @Override + public void updateFloat(int columnIndex, float x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateFloat"); + } + + @Override + public void updateDouble(int columnIndex, double x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateDouble"); + } + + @Override + public void updateBigDecimal(int columnIndex, BigDecimal x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBigDecimal"); + } + + @Override + public void updateString(int columnIndex, String x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateString"); + } + + @Override + public void updateBytes(int columnIndex, byte[] x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBytes"); + } + + @Override + public void updateDate(int columnIndex, Date x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateDate"); + } + + @Override + public void updateTime(int columnIndex, Time x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateTime"); + } + + @Override + public void updateTimestamp(int columnIndex, Timestamp x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateTimestamp"); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateAsciiStream"); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBinaryStream"); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateCharacterStream"); + } + + @Override + public void updateObject(int columnIndex, Object x, int scaleOrLength) throws SQLException { + throw new SQLFeatureNotSupportedException("updateObject"); + } + + @Override + public void updateObject(int columnIndex, Object x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateObject"); + } + + @Override + public void updateNull(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNull"); + } + + @Override + public void updateBoolean(String columnLabel, boolean x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBoolean"); + } + + @Override + public void updateByte(String columnLabel, byte x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateByte"); + } + + @Override + public void updateShort(String columnLabel, short x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateShort"); + } + + @Override + public void updateInt(String columnLabel, int x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateInt"); + } + + @Override + public void updateLong(String columnLabel, long x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateLong"); + } + + @Override + public void updateFloat(String columnLabel, float x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateFloat"); + } + + @Override + public void updateDouble(String columnLabel, double x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateDouble"); + } + + @Override + public void updateBigDecimal(String columnLabel, BigDecimal x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBigDecimal"); + } + + @Override + public void updateString(String columnLabel, String x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateString"); + } + + @Override + public void updateBytes(String columnLabel, byte[] x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBytes"); + } + + @Override + public void updateDate(String columnLabel, Date x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateDate"); + } + + @Override + public void updateTime(String columnLabel, Time x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateTime"); + } + + @Override + public void updateTimestamp(String columnLabel, Timestamp x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateTimestamp"); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateAsciiStream"); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBinaryStream"); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, int length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateCharacterStream"); + } + + @Override + public void updateObject(String columnLabel, Object x, int scaleOrLength) throws SQLException { + throw new SQLFeatureNotSupportedException("updateObject"); + } + + @Override + public void updateObject(String columnLabel, Object x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateObject"); + } + + @Override + public void insertRow() throws SQLException { + throw new SQLFeatureNotSupportedException("insertRow"); + } + + @Override + public void updateRow() throws SQLException { + throw new SQLFeatureNotSupportedException("updateRow"); + } + + @Override + public void deleteRow() throws SQLException { + throw new SQLFeatureNotSupportedException("deleteRow"); + } + + @Override + public void refreshRow() throws SQLException { + throw new SQLFeatureNotSupportedException("refreshRow"); + } + + @Override + public void cancelRowUpdates() throws SQLException { + throw new SQLFeatureNotSupportedException("cancelRowUpdates"); + } + + @Override + public void moveToInsertRow() throws SQLException { + throw new SQLFeatureNotSupportedException("moveToInsertRow"); + } + + @Override + public void moveToCurrentRow() throws SQLException { + throw new SQLFeatureNotSupportedException("moveToCurrentRow"); + } + + @Override + public Statement getStatement() throws SQLException { + throw new SQLFeatureNotSupportedException("getStatement"); + } + + @Override + public Object getObject(int columnIndex, Map> map) throws SQLException { + throw new SQLFeatureNotSupportedException("getObject"); + } + + @Override + public Ref getRef(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getRef"); + } + + @Override + public Blob getBlob(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getBlob"); + } + + @Override + public Clob getClob(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getClob"); + } + + @Override + public Array getArray(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getArray"); + } + + @Override + public Object getObject(String columnLabel, Map> map) throws SQLException { + throw new SQLFeatureNotSupportedException("getObject"); + } + + @Override + public Ref getRef(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getRef"); + } + + @Override + public Blob getBlob(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getBlob"); + } + + @Override + public Clob getClob(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getClob"); + } + + @Override + public Array getArray(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getArray"); + } + + @Override + public Date getDate(int columnIndex, Calendar cal) throws SQLException { + return getDate(columnIndex); + } + + @Override + public Date getDate(String columnLabel, Calendar cal) throws SQLException { + return getDate(columnLabel); + } + + @Override + public Time getTime(int columnIndex, Calendar cal) throws SQLException { + return getTime(columnIndex); + } + + @Override + public Time getTime(String columnLabel, Calendar cal) throws SQLException { + return getTime(columnLabel); + } + + @Override + public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { + return getTimestamp(columnIndex); + } + + @Override + public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { + return getTimestamp(columnLabel); + } + + @Override + public URL getURL(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getURL"); + } + + @Override + public URL getURL(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getURL"); + } + + @Override + public void updateRef(int columnIndex, Ref x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateRef"); + } + + @Override + public void updateRef(String columnLabel, Ref x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateRef"); + } + + @Override + public void updateBlob(int columnIndex, Blob x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBlob"); + } + + @Override + public void updateBlob(String columnLabel, Blob x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBlob"); + } + + @Override + public void updateClob(int columnIndex, Clob x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateClob"); + } + + @Override + public void updateClob(String columnLabel, Clob x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateClob"); + } + + @Override + public void updateArray(int columnIndex, Array x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateArray"); + } + + @Override + public void updateArray(String columnLabel, Array x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateArray"); + } + + @Override + public RowId getRowId(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getRowId"); + } + + @Override + public RowId getRowId(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getRowId"); + } + + @Override + public void updateRowId(int columnIndex, RowId x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateRowId"); + } + + @Override + public void updateRowId(String columnLabel, RowId x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateRowId"); + } + + @Override + public int getHoldability() throws SQLException { + return ResultSet.HOLD_CURSORS_OVER_COMMIT; + } + + @Override + public boolean isClosed() throws SQLException { + return closed; + } + + @Override + public void updateNString(int columnIndex, String nString) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNString"); + } + + @Override + public void updateNString(String columnLabel, String nString) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNString"); + } + + @Override + public void updateNClob(int columnIndex, NClob nClob) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNClob"); + } + + @Override + public void updateNClob(String columnLabel, NClob nClob) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNClob"); + } + + @Override + public NClob getNClob(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getNClob"); + } + + @Override + public NClob getNClob(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getNClob"); + } + + @Override + public SQLXML getSQLXML(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getSQLXML"); + } + + @Override + public SQLXML getSQLXML(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getSQLXML"); + } + + @Override + public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException { + throw new SQLFeatureNotSupportedException("updateSQLXML"); + } + + @Override + public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException { + throw new SQLFeatureNotSupportedException("updateSQLXML"); + } + + @Override + public String getNString(int columnIndex) throws SQLException { + return getString(columnIndex); + } + + @Override + public String getNString(String columnLabel) throws SQLException { + return getString(columnLabel); + } + + @Override + public Reader getNCharacterStream(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getNCharacterStream"); + } + + @Override + public Reader getNCharacterStream(String columnLabel) throws SQLException { + throw new SQLFeatureNotSupportedException("getNCharacterStream"); + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNCharacterStream"); + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNCharacterStream"); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateAsciiStream"); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBinaryStream"); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateCharacterStream"); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateAsciiStream"); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBinaryStream"); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateCharacterStream"); + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBlob"); + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBlob"); + } + + @Override + public void updateClob(int columnIndex, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateClob"); + } + + @Override + public void updateClob(String columnLabel, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateClob"); + } + + @Override + public void updateNClob(int columnIndex, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNClob"); + } + + @Override + public void updateNClob(String columnLabel, Reader reader, long length) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNClob"); + } + + @Override + public void updateNCharacterStream(int columnIndex, Reader x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNCharacterStream"); + } + + @Override + public void updateNCharacterStream(String columnLabel, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNCharacterStream"); + } + + @Override + public void updateAsciiStream(int columnIndex, InputStream x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateAsciiStream"); + } + + @Override + public void updateBinaryStream(int columnIndex, InputStream x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBinaryStream"); + } + + @Override + public void updateCharacterStream(int columnIndex, Reader x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateCharacterStream"); + } + + @Override + public void updateAsciiStream(String columnLabel, InputStream x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateAsciiStream"); + } + + @Override + public void updateBinaryStream(String columnLabel, InputStream x) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBinaryStream"); + } + + @Override + public void updateCharacterStream(String columnLabel, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("updateCharacterStream"); + } + + @Override + public void updateBlob(int columnIndex, InputStream inputStream) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBlob"); + } + + @Override + public void updateBlob(String columnLabel, InputStream inputStream) throws SQLException { + throw new SQLFeatureNotSupportedException("updateBlob"); + } + + @Override + public void updateClob(int columnIndex, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("updateClob"); + } + + @Override + public void updateClob(String columnLabel, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("updateClob"); + } + + @Override + public void updateNClob(int columnIndex, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNClob"); + } + + @Override + public void updateNClob(String columnLabel, Reader reader) throws SQLException { + throw new SQLFeatureNotSupportedException("updateNClob"); + } + + @Override + public T getObject(int columnIndex, Class type) throws SQLException { + if (type == null) { + throw new SQLException("Type argument cannot be null"); + } + Object value = getRaw(columnIndex); + if (value == null) { + return null; + } + return type.cast(value); + } + + @Override + public T getObject(String columnLabel, Class type) throws SQLException { + return getObject(findColumnIndex(columnLabel), type); + } + + @Override + public T unwrap(Class iface) throws SQLException { + return JdbcUtils.unwrap(this, iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this); + } +} \ No newline at end of file diff --git a/src/main/java/org/duckdb/DuckDBPreparedStatement.java b/src/main/java/org/duckdb/DuckDBPreparedStatement.java index c62f12973..8c9454fb8 100644 --- a/src/main/java/org/duckdb/DuckDBPreparedStatement.java +++ b/src/main/java/org/duckdb/DuckDBPreparedStatement.java @@ -61,6 +61,11 @@ public class DuckDBPreparedStatement implements PreparedStatement { private boolean returnsChangedRows = false; private boolean returnsNothing = false; private boolean returnsResultSet = false; + private boolean returningGeneratedKeys = false; + private boolean dmlReturningApplied = false; + private int[] generatedKeyColumnIndexes = null; + private String[] generatedKeyColumnNames = null; + private DuckDBGeneratedKeysResultSet generatedKeysResult = null; private Object[] params = new Object[0]; private DuckDBResultSetMetaData meta = null; private final List batchedParams = new ArrayList<>(); @@ -79,6 +84,11 @@ public DuckDBPreparedStatement(DuckDBConnection conn) throws SQLException { } public DuckDBPreparedStatement(DuckDBConnection conn, String sql) throws SQLException { + this(conn, sql, false, null, null); + } + + DuckDBPreparedStatement(DuckDBConnection conn, String sql, boolean returningGeneratedKeys, int[] columnIndexes, + String[] columnNames) throws SQLException { if (conn == null) { throw new SQLException("connection parameter cannot be null"); } @@ -87,6 +97,9 @@ public DuckDBPreparedStatement(DuckDBConnection conn, String sql) throws SQLExce } this.conn = conn; this.isPreparedStatement = true; + this.returningGeneratedKeys = returningGeneratedKeys; + this.generatedKeyColumnIndexes = columnIndexes; + this.generatedKeyColumnNames = columnNames; prepare(sql); } @@ -127,6 +140,8 @@ private void prepare(String sql) throws SQLException { return; } + String preparedSql = this.returningGeneratedKeys ? rewriteForReturning(sql) : sql; + stmtRefLock.lock(); try { checkOpen(); @@ -152,7 +167,7 @@ private void prepare(String sql) throws SQLException { startTransaction(); } - stmtRef = DuckDBNative.duckdb_jdbc_prepare(conn.connRef, sql.getBytes(UTF_8)); + stmtRef = DuckDBNative.duckdb_jdbc_prepare(conn.connRef, preparedSql.getBytes(UTF_8)); // Track prepared statement inside the parent connection conn.preparedStatements.add(this); } finally { @@ -261,11 +276,34 @@ public boolean execute() throws SQLException { updateResult = selectResult.getLong(1); } selectResult.close(); + } else if (returningGeneratedKeys && returnsResultSet && dmlReturningApplied) { + materializeGeneratedKeys(); } return returnsResultSet; } + /** + * Buffers the rows returned by a rewritten {@code ... RETURNING} statement into an in-memory + * result set (exposed through {@link #getGeneratedKeys()}) and records the row count as the + * update count. + */ + private void materializeGeneratedKeys() throws SQLException { + DuckDBResultSetMetaData resultMeta = (DuckDBResultSetMetaData) selectResult.getMetaData(); + int columnCount = resultMeta.getColumnCount(); + List rows = new ArrayList<>(); + while (selectResult.next()) { + Object[] row = new Object[columnCount]; + for (int c = 1; c <= columnCount; c++) { + row[c - 1] = selectResult.getObject(c); + } + rows.add(row); + } + selectResult.close(); + generatedKeysResult = new DuckDBGeneratedKeysResultSet(resultMeta, rows.toArray(new Object[0][])); + updateResult = rows.size(); + } + public DuckDBChunkedResult query() throws SQLException { checkOpen(); if (!isPreparedStatement) { @@ -331,7 +369,8 @@ public int executeUpdate() throws SQLException { public long executeLargeUpdate() throws SQLException { requireNonBatch(); execute(); - if (!(returnsChangedRows || returnsNothing)) { + if (!(returnsChangedRows || (returningGeneratedKeys && dmlReturningApplied && returnsResultSet) || + returnsNothing)) { throw new SQLException( "executeUpdate() can only be used with queries that return nothing (eg, a DDL statement), or update rows"); } @@ -628,6 +667,11 @@ private long getUpdateCountInternal() throws SQLException { return -1; } + if (returningGeneratedKeys && dmlReturningApplied && returnsResultSet) { + // A rewritten ... RETURNING statement reports the number of returned rows as the update count + return updateResult; + } + if (returnsResultSet || returnsNothing || selectResult.isFinished()) { return -1; } @@ -806,7 +850,19 @@ public boolean getMoreResults(int current) throws SQLException { @Override public ResultSet getGeneratedKeys() throws SQLException { checkOpen(); - throw new SQLFeatureNotSupportedException("getGeneratedKeys"); + if (generatedKeysResult == null) { + return new DuckDBGeneratedKeysResultSet(newEmptyMeta(), new Object[0][]); + } + generatedKeysResult.beforeFirst(); + return generatedKeysResult; + } + + /** + * Builds an empty metadata object (zero columns) used for an empty generated-keys result set. + */ + private DuckDBResultSetMetaData newEmptyMeta() { + return new DuckDBResultSetMetaData(0, 0, new String[0], new String[0], new String[0], "NOTHING", new String[0], + new String[0]); } @Override @@ -820,7 +876,11 @@ public long executeLargeUpdate(String sql, int autoGeneratedKeys) throws SQLExce if (NO_GENERATED_KEYS == autoGeneratedKeys) { return executeLargeUpdate(sql); } - throw new SQLFeatureNotSupportedException("executeUpdate(String sql, int autoGeneratedKeys)"); + if (RETURN_GENERATED_KEYS == autoGeneratedKeys) { + return executeLargeUpdateWithKeys(sql, null, null); + } + throw new SQLFeatureNotSupportedException( + "executeUpdate(String sql, int autoGeneratedKeys=" + autoGeneratedKeys + ")"); } @Override @@ -834,7 +894,7 @@ public long executeLargeUpdate(String sql, int[] columnIndexes) throws SQLExcept if (columnIndexes == null || columnIndexes.length == 0) { return executeLargeUpdate(sql); } - throw new SQLFeatureNotSupportedException("executeUpdate(String sql, int[] columnIndexes)"); + return executeLargeUpdateWithKeys(sql, columnIndexes, null); } @Override @@ -848,7 +908,7 @@ public long executeLargeUpdate(String sql, String[] columnNames) throws SQLExcep if (columnNames == null || columnNames.length == 0) { return executeUpdate(sql); } - throw new SQLFeatureNotSupportedException("executeUpdate(String sql, String[] columnNames)"); + return executeLargeUpdateWithKeys(sql, null, columnNames); } @Override @@ -856,7 +916,11 @@ public boolean execute(String sql, int autoGeneratedKeys) throws SQLException { if (NO_GENERATED_KEYS == autoGeneratedKeys) { return execute(sql); } - throw new SQLFeatureNotSupportedException("execute(String sql, int autoGeneratedKeys)"); + if (RETURN_GENERATED_KEYS == autoGeneratedKeys) { + return executeWithKeys(sql, null, null); + } + throw new SQLFeatureNotSupportedException("execute(String sql, int autoGeneratedKeys=" + autoGeneratedKeys + + ")"); } @Override @@ -864,7 +928,7 @@ public boolean execute(String sql, int[] columnIndexes) throws SQLException { if (columnIndexes == null || columnIndexes.length == 0) { return execute(sql); } - throw new SQLFeatureNotSupportedException("execute(String sql, int[] columnIndexes)"); + return executeWithKeys(sql, columnIndexes, null); } @Override @@ -872,7 +936,30 @@ public boolean execute(String sql, String[] columnNames) throws SQLException { if (columnNames == null || columnNames.length == 0) { return execute(sql); } - throw new SQLFeatureNotSupportedException("execute(String sql, String[] columnNames)"); + return executeWithKeys(sql, null, columnNames); + } + + private boolean executeWithKeys(String sql, int[] columnIndexes, String[] columnNames) throws SQLException { + requireNonBatch(); + this.returningGeneratedKeys = true; + this.generatedKeyColumnIndexes = columnIndexes; + this.generatedKeyColumnNames = columnNames; + if (isPreparedStatement) { + prepare(sql); + } else { + prepare(rewriteForReturning(sql)); + } + return execute(); + } + + private long executeLargeUpdateWithKeys(String sql, int[] columnIndexes, String[] columnNames) throws SQLException { + executeWithKeys(sql, columnIndexes, columnNames); + if (!(returnsChangedRows || (returningGeneratedKeys && dmlReturningApplied && returnsResultSet) || + returnsNothing)) { + throw new SQLException( + "executeUpdate() can only be used with queries that return nothing (eg, a DDL statement), or update rows"); + } + return getUpdateCountInternal(); } @Override @@ -1381,6 +1468,7 @@ private void clearResults() throws SQLException { chunkedResult.close(); chunkedResult = null; } + generatedKeysResult = null; } private void cleanupCancelQueryTask() { @@ -1426,4 +1514,259 @@ private DirectQueryResult(ByteBuffer resultRef, DuckDBPendingQuery pendingQuery) this.pendingQuery = pendingQuery; } } + + /** + * Rewrites a DML statement ({@code INSERT}/{@code UPDATE}/{@code DELETE}) to append a + * {@code RETURNING } clause so that generated keys can be captured. If the statement + * cannot be reliably rewritten, or it already has a {@code RETURNING} clause, the original SQL is + * returned unchanged. + */ + private String rewriteForReturning(String sql) throws SQLException { + this.dmlReturningApplied = false; + String trimmed = sql.trim(); + if (trimmed.endsWith(";")) { + trimmed = trimmed.substring(0, trimmed.length() - 1).trim(); + } + if (hasReturningClause(trimmed)) { + return sql; + } + if (!trimmed.regionMatches(true, 0, "INSERT", 0, 6) && !trimmed.regionMatches(true, 0, "UPDATE", 0, 6) && + !trimmed.regionMatches(true, 0, "DELETE", 0, 6)) { + // Only DML statements support RETURNING; other statements cannot produce generated keys + return sql; + } + + String columns = delegatingReturningColumns(trimmed); + if (columns == null) { + // Unable to determine the target table or generated columns; fall back to the original SQL + return sql; + } + this.dmlReturningApplied = true; + return trimmed + " RETURNING " + columns; + } + + private static boolean hasReturningClause(String sql) { + int depth = 0; + boolean inString = false; + char quoteChar = 0; + for (int i = 0; i < sql.length(); i++) { + char c = sql.charAt(i); + if (inString) { + if (c == quoteChar) { + inString = false; + } + continue; + } + if (c == '\'' || c == '"') { + inString = true; + quoteChar = c; + } else if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (depth == 0 && (c == 'R' || c == 'r') && i + "RETURNING".length() <= sql.length() && + sql.regionMatches(true, i, "RETURNING", 0, "RETURNING".length())) { + return true; + } + } + return false; + } + + /** + * Determines the {@code RETURNING} column list. Returns {@code null} if the target table cannot be + * identified or no generated columns could be found. + */ + private String delegatingReturningColumns(String sql) throws SQLException { + String table = extractTargetTable(sql); + if (table == null) { + return null; + } + + if (this.generatedKeyColumnNames != null && this.generatedKeyColumnNames.length > 0) { + StringBuilder sb = new StringBuilder(); + for (String name : this.generatedKeyColumnNames) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(quoteIdentifier(name)); + } + return sb.length() == 0 ? null : sb.toString(); + } + + if (this.generatedKeyColumnIndexes != null && this.generatedKeyColumnIndexes.length > 0) { + return columnsByIndex(table, this.generatedKeyColumnIndexes); + } + + return autoDetectGeneratedColumns(table); + } + + /** + * Extracts the (schema-qualified) target table name from a DML statement, quoted/unquoted. + */ + private static String extractTargetTable(String sql) { + if (sql.regionMatches(true, 0, "INSERT", 0, 6)) { + return parseAfterKeyword(sql, "INTO"); + } + if (sql.regionMatches(true, 0, "DELETE", 0, 6)) { + String from = parseAfterKeyword(sql, "FROM"); + return from == null ? parseAfterKeyword(sql, "DELETE") : from; + } + // UPDATE table SET... + return parseAfterKeyword(sql, "UPDATE"); + } + + private static String parseAfterKeyword(String sql, String keyword) { + int idx = indexOfKeyword(sql, keyword, 0); + if (idx < 0) { + return null; + } + int start = idx + keyword.length(); + while (start < sql.length() && Character.isWhitespace(sql.charAt(start))) { + start++; + } + StringBuilder table = new StringBuilder(); + int i = start; + while (i < sql.length()) { + char c = sql.charAt(i); + if (c == '(' || Character.isWhitespace(c)) { + break; + } + table.append(c); + i++; + } + return table.length() == 0 || !isValidIdentifier(table.toString()) ? null : table.toString(); + } + + private static int indexOfKeyword(String sql, String keyword, int from) { + int i = from; + while (i <= sql.length() - keyword.length()) { + int idx = sql.regionMatches(true, i, keyword, 0, keyword.length()) ? i : -1; + if (idx >= 0) { + boolean prevOk = idx == 0 || !Character.isJavaIdentifierPart(sql.charAt(idx - 1)); + boolean nextOk = idx + keyword.length() == sql.length() || + !Character.isJavaIdentifierPart(sql.charAt(idx + keyword.length())); + if (prevOk && nextOk) { + return idx; + } + } + i++; + } + return -1; + } + + private static boolean isValidIdentifier(String name) { + if (name.isEmpty()) { + return false; + } + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (!(Character.isJavaIdentifierPart(c) || c == '.' || c == '"')) { + return false; + } + } + return true; + } + + private static String quoteIdentifier(String name) { + String trimmed = name.trim(); + if (trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed; + } + return "\"" + trimmed.replace("\"", "\"\"") + "\""; + } + + /** + * Resolves the column names for the given (1-based) column indexes by querying the table metadata. + */ + private String columnsByIndex(String table, int[] columnIndexes) throws SQLException { + List names = queryColumnNames(table); + StringBuilder sb = new StringBuilder(); + for (int index : columnIndexes) { + if (index < 1 || index > names.size()) { + continue; + } + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(quoteIdentifier(names.get(index - 1))); + } + return sb.length() == 0 ? null : sb.toString(); + } + + /** + * Auto-detects the generated columns (those with a non-null default, e.g. sequence-backed or + * derived defaults) of the target table and returns them as a {@code RETURNING} column list. + * Returns {@code null} if no such columns exist. + */ + private String autoDetectGeneratedColumns(String table) throws SQLException { + List names = queryGeneratedColumnNames(table); + if (names.isEmpty()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (String name : names) { + if (sb.length() > 0) { + sb.append(", "); + } + sb.append(quoteIdentifier(name)); + } + return sb.toString(); + } + + /** + * Returns the names of columns of the given table whose {@code column_default} is not null, + * ordered by their column index. + */ + private List queryGeneratedColumnNames(String table) throws SQLException { + StringBuilder query = new StringBuilder(); + query.append("SELECT column_name AS col FROM duckdb_columns() WHERE column_default IS NOT NULL"); + appendTableFilter(query, table); + query.append(" ORDER BY column_index"); + return runForColumnNames(query.toString()); + } + + private List queryColumnNames(String table) throws SQLException { + StringBuilder query = new StringBuilder(); + query.append("SELECT column_name AS col FROM duckdb_columns() WHERE TRUE"); + appendTableFilter(query, table); + query.append(" ORDER BY column_index"); + return runForColumnNames(query.toString()); + } + + private static void appendTableFilter(StringBuilder query, String table) { + int dot = table.indexOf('.'); + if (dot > 0) { + String schema = unquoteIdentifier(table.substring(0, dot)); + String name = unquoteIdentifier(table.substring(dot + 1)); + query.append(" AND schema_name = ").append(quoteStringLiteral(schema)); + query.append(" AND table_name = ").append(quoteStringLiteral(name)); + } else { + query.append(" AND table_name = ").append(quoteStringLiteral(unquoteIdentifier(table))); + } + } + + private static String unquoteIdentifier(String identifier) { + String trimmed = identifier == null ? null : identifier.trim(); + if (trimmed != null && trimmed.length() >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + trimmed = trimmed.substring(1, trimmed.length() - 1).replace("\"\"", "\""); + } + return trimmed; + } + + private static String quoteStringLiteral(String value) { + return "'" + value.replace("'", "''") + "'"; + } + + private List runForColumnNames(String query) throws SQLException { + List result = new ArrayList<>(); + try (DuckDBPreparedStatement ps = new DuckDBPreparedStatement(conn)) { + ps.query = query; + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(rs.getString(1)); + } + } + } + return result; + } } diff --git a/src/test/java/org/duckdb/TestDuckDBJDBC.java b/src/test/java/org/duckdb/TestDuckDBJDBC.java index 7fa6db2bb..c7ee67d88 100644 --- a/src/test/java/org/duckdb/TestDuckDBJDBC.java +++ b/src/test/java/org/duckdb/TestDuckDBJDBC.java @@ -2330,9 +2330,10 @@ public static void main(String[] args) throws Exception { runTests(args, TestDuckDBJDBC.class, TestAppender.class, TestAppenderCollection.class, TestAppenderCollection2D.class, TestAppenderComposite.class, TestSingleValueAppender.class, TestBatch.class, TestBindings.class, TestChunkedResult.class, TestClosure.class, - TestJfrEvents.class, TestMetadata.class, TestNoLib.class, TestSpatial.class, - TestParameterMetadata.class, TestPrepare.class, TestResults.class, TestScalarFunctions.class, - TestSessionInit.class, TestTableFunctions.class, TestTimestamp.class, TestVariant.class); + TestGeneratedKeysResultSet.class, TestJfrEvents.class, TestMetadata.class, TestNoLib.class, + TestSpatial.class, TestParameterMetadata.class, TestPrepare.class, TestResults.class, + TestScalarFunctions.class, TestSessionInit.class, TestTableFunctions.class, + TestTimestamp.class, TestVariant.class); } System.exit(statusCode); } diff --git a/src/test/java/org/duckdb/TestGeneratedKeysResultSet.java b/src/test/java/org/duckdb/TestGeneratedKeysResultSet.java new file mode 100644 index 000000000..9beedb643 --- /dev/null +++ b/src/test/java/org/duckdb/TestGeneratedKeysResultSet.java @@ -0,0 +1,196 @@ +package org.duckdb; + +import static org.duckdb.test.Assertions.*; + +import java.math.BigDecimal; +import java.sql.SQLException; + +/** + * Isolated unit tests for the in-memory {@link DuckDBGeneratedKeysResultSet}. + */ +public class TestGeneratedKeysResultSet { + + private static DuckDBResultSetMetaData meta(String... columnNames) { + String[] columnTypes = new String[columnNames.length]; + for (int i = 0; i < columnNames.length; i++) { + columnTypes[i] = "VARCHAR"; + } + return new DuckDBResultSetMetaData(0, columnNames.length, columnNames, columnTypes, columnTypes, "NOTHING", + new String[0], new String[0]); + } + + public static void test_generated_keys_resultset_navigation() throws Exception { + DuckDBGeneratedKeysResultSet rs = + new DuckDBGeneratedKeysResultSet(meta("id"), new Object[][] {{1L}, {2L}, {3L}}); + + assertTrue(rs.isBeforeFirst()); + assertFalse(rs.isFirst()); + assertFalse(rs.isLast()); + assertFalse(rs.isAfterLast()); + + assertTrue(rs.next()); + assertTrue(rs.isFirst()); + assertFalse(rs.isBeforeFirst()); + assertFalse(rs.isLast()); + assertEquals(rs.getRow(), 1); + assertEquals(rs.getLong("id"), 1L); + + assertTrue(rs.next()); + assertFalse(rs.isFirst()); + assertFalse(rs.isLast()); + assertEquals(rs.getRow(), 2); + assertEquals(rs.getLong("id"), 2L); + + assertTrue(rs.next()); + assertFalse(rs.isFirst()); + assertTrue(rs.isLast()); + assertEquals(rs.getRow(), 3); + + assertFalse(rs.next()); + assertTrue(rs.isAfterLast()); + + assertTrue(rs.first()); + assertEquals(rs.getLong("id"), 1L); + assertTrue(rs.last()); + assertEquals(rs.getLong("id"), 3L); + assertTrue(rs.previous()); + assertEquals(rs.getLong("id"), 2L); + + rs.beforeFirst(); + assertTrue(rs.isBeforeFirst()); + assertTrue(rs.next()); + assertEquals(rs.getLong("id"), 1L); + rs.close(); + } + + public static void test_generated_keys_resultset_empty() throws Exception { + DuckDBGeneratedKeysResultSet rs = new DuckDBGeneratedKeysResultSet(meta("id"), new Object[0][]); + assertFalse(rs.next()); + assertFalse(rs.first()); + assertFalse(rs.last()); + assertTrue(rs.isAfterLast()); + rs.close(); + } + + public static void test_generated_keys_resultset_absolute_relative() throws Exception { + DuckDBGeneratedKeysResultSet rs = + new DuckDBGeneratedKeysResultSet(meta("id"), new Object[][] {{10L}, {20L}, {30L}}); + + assertTrue(rs.absolute(2)); + assertEquals(rs.getLong(1), 20L); + assertFalse(rs.absolute(0)); + assertTrue(rs.absolute(-1)); + assertEquals(rs.getLong(1), 30L); + assertTrue(rs.absolute(-2)); + assertEquals(rs.getLong(1), 20L); + assertFalse(rs.absolute(100)); + assertTrue(rs.absolute(1)); + assertTrue(rs.relative(1)); + assertEquals(rs.getLong(1), 20L); + assertTrue(rs.relative(-1)); + assertEquals(rs.getLong(1), 10L); + rs.close(); + } + + public static void test_generated_keys_resultset_typed_getters() throws Exception { + Object[][] rows = {{1L, "duck", true, 4.5d, BigDecimal.valueOf(12, 1)}, + {2L, "goose", false, 9.5d, BigDecimal.valueOf(34, 1)}}; + DuckDBGeneratedKeysResultSet rs = + new DuckDBGeneratedKeysResultSet(meta("id", "name", "flag", "ratio", "dec"), rows); + + assertTrue(rs.next()); + assertEquals(rs.getLong(1), 1L); + assertEquals(rs.getLong("id"), 1L); + assertEquals(rs.getString(2), "duck"); + assertEquals(rs.getString("name"), "duck"); + assertEquals(rs.getBoolean(3), true); + assertEquals(rs.getBoolean("flag"), true); + assertEquals(rs.getDouble(4), 4.5, 0.0001); + assertEquals(rs.getDouble("ratio"), 4.5, 0.0001); + assertEquals(rs.getBigDecimal(5), new BigDecimal("1.2")); + assertEquals(rs.getBigDecimal(5, 2), new BigDecimal("1.2")); + assertEquals(rs.getObject(1), 1L); + assertEquals(rs.getObject("name"), "duck"); + assertEquals(rs.getBytes(2), "duck".getBytes()); + assertEquals(rs.getNString(2), "duck"); + + assertTrue(rs.next()); + assertEquals(rs.getLong(1), 2L); + assertEquals(rs.getBoolean(3), false); + + assertFalse(rs.next()); + rs.close(); + } + + public static void test_generated_keys_resultset_null_handling() throws Exception { + DuckDBGeneratedKeysResultSet rs = + new DuckDBGeneratedKeysResultSet(meta("id", "name"), new Object[][] {new Object[] {null, "duck"}}); + + assertTrue(rs.next()); + assertNull(rs.getObject(1)); + assertTrue(rs.wasNull()); + assertNull(rs.getString(1)); + assertEquals(rs.getLong(1), 0L); + assertEquals(rs.getBoolean(1), false); + assertEquals(rs.getString(2), "duck"); + assertFalse(rs.wasNull()); + rs.close(); + } + + public static void test_generated_keys_resultset_coercion() throws Exception { + DuckDBGeneratedKeysResultSet rs = new DuckDBGeneratedKeysResultSet( + meta("a", "b", "c", "d"), new Object[][] {new Object[] {1.0d, 2.0f, 3, "true"}}); + + assertTrue(rs.next()); + // getBoolean from a Number + assertEquals(rs.getBoolean(1), true); + // getBoolean from a String + assertEquals(rs.getBoolean(4), true); + // getBigDecimal from a Number (via double) produces BigDecimal.valueOf(double) + assertEquals(rs.getBigDecimal(3), new BigDecimal("3.0")); + // non-numeric value fails numeric getters + assertThrows(() -> rs.getLong(4), SQLException.class); + rs.close(); + } + + public static void test_generated_keys_resultset_metadata_and_findcolumn() throws Exception { + DuckDBGeneratedKeysResultSet rs = + new DuckDBGeneratedKeysResultSet(meta("id", "name"), new Object[][] {{1L, "duck"}}); + + assertEquals(rs.getMetaData().getColumnCount(), 2); + assertEquals(rs.getMetaData().getColumnName(1), "id"); + assertEquals(rs.getMetaData().getColumnName(2), "name"); + assertEquals(rs.findColumn("name"), 2); + assertThrows(() -> rs.findColumn("nope"), SQLException.class); + assertTrue(rs.next()); + assertEquals(rs.getLong("id"), 1L); + rs.close(); + } + + public static void test_generated_keys_resultset_errors() throws Exception { + DuckDBGeneratedKeysResultSet rs = new DuckDBGeneratedKeysResultSet(meta("id"), new Object[][] {{1L}}); + + // getter before next() + assertThrows(() -> rs.getLong(1), SQLException.class); + assertThrows(() -> rs.getObject(1), SQLException.class); + + rs.next(); + // out of bounds column index + assertThrows(() -> rs.getLong(0), SQLException.class); + assertThrows(() -> rs.getObject(2), SQLException.class); + + rs.close(); + // operations after close + assertThrows(() -> rs.next(), SQLException.class); + assertThrows(() -> rs.getMetaData(), SQLException.class); + assertThrows(() -> rs.getLong(1), SQLException.class); + } + + public static void test_generated_keys_resultset_unwrap() throws Exception { + DuckDBGeneratedKeysResultSet rs = new DuckDBGeneratedKeysResultSet(meta("id"), new Object[][] {{1L}}); + assertTrue(rs.isWrapperFor(DuckDBGeneratedKeysResultSet.class)); + assertTrue(rs.isWrapperFor(java.sql.ResultSet.class)); + assertEquals(rs.unwrap(DuckDBGeneratedKeysResultSet.class), rs); + rs.close(); + } +} \ No newline at end of file diff --git a/src/test/java/org/duckdb/TestPrepare.java b/src/test/java/org/duckdb/TestPrepare.java index f6feeb1e4..f216da66d 100644 --- a/src/test/java/org/duckdb/TestPrepare.java +++ b/src/test/java/org/duckdb/TestPrepare.java @@ -338,6 +338,298 @@ public static void test_execute_autogen_keys() throws Exception { } } + public static void test_prepare_autogen_keys() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE tab2 (col1 INT)"); + + String sql = "INSERT INTO tab2 VALUES (42)"; + + // NO_GENERATED_KEYS should transparently fall through to the plain overload + try (PreparedStatement ps = conn.prepareStatement(sql, Statement.NO_GENERATED_KEYS)) { + assertEquals(ps.executeUpdate(), 1); + assertEquals(ps.executeLargeUpdate(), 1L); + } + + // RETURN_GENERATED_KEYS on a table without generated columns yields an empty result set + try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertFalse(keys.next()); + } + } + } + } + + public static void test_prepare_return_generated_keys() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + // A table with a sequence-backed auto-increment identifier + stmt.execute("CREATE SEQUENCE seq_return_tab START 5"); + stmt.execute( + "CREATE TABLE return_tab (id BIGINT DEFAULT nextval('seq_return_tab') PRIMARY KEY, name VARCHAR)"); + + String sql = "INSERT INTO return_tab (name) VALUES ('duck')"; + + // prepareStatement(sql, RETURN_GENERATED_KEYS) + executeUpdate + getGeneratedKeys + try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 5L); + assertFalse(keys.next()); + } + } + + // execute(sql, RETURN_GENERATED_KEYS) + try (Statement s = conn.createStatement()) { + assertTrue( + s.execute("INSERT INTO return_tab (name) VALUES ('goose')", Statement.RETURN_GENERATED_KEYS)); + try (ResultSet keys = s.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 6L); + } + } + } + } + + public static void test_prepare_return_generated_keys_cols() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE SEQUENCE seq_generated_cols START WITH 10"); + stmt.execute( + "CREATE TABLE return_cols (id BIGINT DEFAULT nextval('seq_generated_cols'), name VARCHAR, val INT)"); + + String sql = "INSERT INTO return_cols (name, val) VALUES ('duck', 42)"; + + // column names overload + try (PreparedStatement ps = conn.prepareStatement(sql, new String[] {"id"})) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 10L); + assertFalse(keys.next()); + } + } + + // column indexes overload (id is column 1) + try (PreparedStatement ps = conn.prepareStatement(sql, new int[] {1})) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 11L); + assertFalse(keys.next()); + } + } + } + } + + public static void test_execute_update_generated_keys() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE SEQUENCE seq_update_gen START WITH 1"); + stmt.execute("CREATE TABLE update_gen (id BIGINT DEFAULT nextval('seq_update_gen'), payload VARCHAR)"); + + String sql = "INSERT INTO update_gen (payload) VALUES ('x')"; + + // executeLargeUpdate(sql, RETURN_GENERATED_KEYS) + try (Statement s = conn.createStatement()) { + assertEquals(s.executeLargeUpdate(sql, Statement.RETURN_GENERATED_KEYS), 1L); + try (ResultSet keys = s.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 1L); + } + } + + // executeUpdate(sql, int[]) and executeUpdate(sql, String[]) + try (Statement s = conn.createStatement()) { + assertEquals(s.executeUpdate(sql, new int[] {1}), 1); + try (ResultSet keys = s.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 2L); + } + assertEquals(s.executeUpdate(sql, new String[] {"id"}), 1); + try (ResultSet keys = s.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 3L); + } + } + } + } + + public static void test_generated_keys_multiple_rows() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE SEQUENCE seq_multiple START WITH 1"); + stmt.execute("CREATE TABLE multiple_gen (id BIGINT DEFAULT nextval('seq_multiple'), payload VARCHAR)"); + + String sql = "INSERT INTO multiple_gen (payload) SELECT 'a' UNION ALL SELECT 'b' UNION ALL SELECT 'c'"; + + try (Statement s = conn.createStatement()) { + assertEquals(s.executeLargeUpdate(sql, Statement.RETURN_GENERATED_KEYS), 3L); + try (ResultSet keys = s.getGeneratedKeys()) { + for (long i = 1; i <= 3; i++) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), i); + } + assertFalse(keys.next()); + } + } + } + } + + public static void test_generated_keys_update_and_delete() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE upd_del (id BIGINT, payload VARCHAR, flag INT)"); + stmt.execute("INSERT INTO upd_del VALUES (1, 'a', 0), (2, 'b', 0), (3, 'c', 1)"); + + // UPDATE ... RETURNING id uses an explicit column selector + try (Statement s = conn.createStatement()) { + String sql = "UPDATE upd_del SET flag = 1 WHERE id = 1"; + assertEquals(s.executeUpdate(sql, new String[] {"id"}), 1); + try (ResultSet keys = s.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 1L); + assertFalse(keys.next()); + } + } + + // DELETE ... RETURNING id uses an explicit column selector + try (Statement s = conn.createStatement()) { + String sql = "DELETE FROM upd_del WHERE id = 2"; + assertEquals(s.executeLargeUpdate(sql, new int[] {1}), 1L); + try (ResultSet keys = s.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 2L); + assertFalse(keys.next()); + } + } + } + } + + public static void test_generated_keys_non_dml() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + // SELECT with RETURN_GENERATED_KEYS must not be rewritten and must yield empty keys + assertTrue(stmt.execute("SELECT 1", Statement.RETURN_GENERATED_KEYS)); + try (ResultSet keys = stmt.getGeneratedKeys()) { + assertFalse(keys.next()); + } + + // DDL with RETURN_GENERATED_KEYS must not fail and must yield empty keys + stmt.execute("CREATE TABLE non_dml (id INT)", Statement.RETURN_GENERATED_KEYS); + try (ResultSet keys = stmt.getGeneratedKeys()) { + assertFalse(keys.next()); + } + } + } + + public static void test_generated_keys_schema_qualified() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE SEQUENCE seq_schema START WITH 7"); + stmt.execute("CREATE TABLE \"QualifiedTable\" (\"Id\" BIGINT DEFAULT nextval('seq_schema'), name VARCHAR)"); + + String sql = "INSERT INTO main.\"QualifiedTable\" (name) VALUES ('duck')"; + + // auto-detect on a schema-qualified, quoted identifier + try (PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 7L); + assertFalse(keys.next()); + } + } + + // quoted column name selector + try (PreparedStatement ps = conn.prepareStatement(sql, new String[] {"id"})) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getLong(1), 8L); + assertFalse(keys.next()); + } + } + } + } + + public static void test_generated_keys_int_array_multi() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE multi_idx (a INT, b INT, c INT)"); + + String sql = "INSERT INTO multi_idx VALUES (1, 2, 3)"; + + // two columns, plus an out-of-range index that should be skipped + try (PreparedStatement ps = conn.prepareStatement(sql, new int[] {2, 99, 1})) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getInt(1), 2); + assertEquals(keys.getInt(2), 1); + assertFalse(keys.next()); + } + } + } + } + + public static void test_generated_keys_string_array_missing() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE missing_str (id INT, name VARCHAR)"); + + String sql = "INSERT INTO missing_str VALUES (1, 'x')"; + + // requesting a non-existent column from RETURNING is rejected by the engine during prepare + assertThrows(() -> conn.prepareStatement(sql, new String[] {"does_not_exist"}), SQLException.class); + } + } + + public static void test_generated_keys_before_execute() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE before_exec (id INT)"); + + try (PreparedStatement ps = conn.prepareStatement("INSERT INTO before_exec VALUES (1)")) { + // getGeneratedKeys before any execution returns an empty result set + try (ResultSet keys = ps.getGeneratedKeys()) { + assertFalse(keys.next()); + } + } + } + } + + public static void test_generated_keys_typed_values() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL); Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE typed_keys (id BIGINT, ratio DOUBLE, label VARCHAR)"); + + String sql = "INSERT INTO typed_keys VALUES (1, 3.5, 'duck')"; + + try (PreparedStatement ps = conn.prepareStatement(sql, new String[] {"ratio", "label"})) { + assertEquals(ps.executeUpdate(), 1); + try (ResultSet keys = ps.getGeneratedKeys()) { + assertTrue(keys.next()); + assertEquals(keys.getDouble(1), 3.5, 0.0001); + assertEquals(keys.getString(2), "duck"); + assertFalse(keys.next()); + } + } + } + } + + public static void test_generated_keys_invalid_flag_value() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL)) { + assertThrows( + () -> conn.prepareStatement("INSERT INTO t VALUES (1)", 42), SQLFeatureNotSupportedException.class); + + try (Statement s = conn.createStatement()) { + assertThrows(() -> s.execute("INSERT INTO t VALUES (1)", 42), SQLFeatureNotSupportedException.class); + + assertThrows( + () -> s.executeUpdate("INSERT INTO t VALUES (1)", 42), SQLFeatureNotSupportedException.class); + } + } + } + + public static void test_generated_keys_invalid_flag_value_message() throws Exception { + try (Connection conn = DriverManager.getConnection(JDBC_URL)) { + String msg = assertThrows( + () -> conn.prepareStatement("INSERT INTO t VALUES (1)", 42), SQLFeatureNotSupportedException.class); + assertTrue(msg.contains("autoGeneratedKeys="), "message should report the value but was: " + msg); + } + } + public static void test_max_rows() throws Exception { try (Connection connection = DriverManager.getConnection(JDBC_URL); Statement stmt = connection.createStatement()) {