diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/JdbcValueReader.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/JdbcValueReader.java new file mode 100644 index 0000000..af9e2c8 --- /dev/null +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/JdbcValueReader.java @@ -0,0 +1,472 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.hrana.codec.Value; + +import java.math.BigDecimal; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.NClob; +import java.sql.RowId; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLXML; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; + +/** + * Converts a Hrana {@link Value} to the Java value a result-set getter returns. + * + *

This is the read-side counterpart of {@link JdbcValueConverter}. Every method is a pure + * function of the stored value, the declared type where one applies, and the caller's calendar + * zone. It holds no result-set state. A declared column parses numeric text strictly, while an + * expression column keeps SQLite's permissive storage coercion; the caller passes that distinction + * in. Exceptions are {@link SQLException} with the sqlite-jdbc 3.53.4.0 messages. + */ +final class JdbcValueReader { + + private JdbcValueReader() {} + + static String stringValue(Value val) { + return switch (val) { + case Value.Null n -> null; + case Value.Integer i -> Long.toString(i.value()); + case Value.Float f -> SqliteRealText.format(f.value()); + case Value.Text t -> t.value(); + case Value.Blob b -> new String(b.bytes(), java.nio.charset.StandardCharsets.UTF_8); + }; + } + + static boolean booleanValue(Value value) { + if (value instanceof Value.Text text) { + String string = text.value().trim(); + if ("true".equalsIgnoreCase(string)) { + return true; + } + if ("false".equalsIgnoreCase(string)) { + return false; + } + } + return (int) toLong(value) != 0; + } + + /** + * Reads a declared column strictly: a text or blob value that is not a number is an error + * rather than SQLite's zero coercion. An expression column has no declared type and keeps the + * permissive storage fallback that xerial users rely on. + */ + static long longValue(Value value, boolean declared) throws SQLException { + if (!declared) { + return toLong(value); + } + return switch (value) { + case Value.Text text -> strictLong(text.value()); + case Value.Blob blob -> strictLong(SqliteNumericText.blobAsText(blob.bytes())); + default -> toLong(value); + }; + } + + static double doubleValue(Value value, boolean declared) throws SQLException { + if (!declared) { + return toDouble(value); + } + return switch (value) { + case Value.Text text -> strictDouble(text.value()); + case Value.Blob blob -> strictDouble(SqliteNumericText.blobAsText(blob.bytes())); + default -> toDouble(value); + }; + } + + private static long strictLong(String text) throws SQLException { + try { + return new BigDecimal(text.trim()).longValue(); + } catch (NumberFormatException e) { + throw new SQLException("Bad value for type numeric: " + text, e); + } + } + + private static double strictDouble(String text) throws SQLException { + try { + return Double.parseDouble(text.trim()); + } catch (NumberFormatException e) { + throw new SQLException("Bad value for type numeric: " + text, e); + } + } + + static long toLong(Value val) { + return switch (val) { + case Value.Null n -> 0L; + case Value.Integer i -> i.value(); + case Value.Float f -> (long) f.value(); + case Value.Text t -> SqliteNumericText.parseLong(t.value()); + case Value.Blob b -> + SqliteNumericText.parseLong(SqliteNumericText.blobAsText(b.bytes())); + }; + } + + static double toDouble(Value val) { + return switch (val) { + case Value.Null n -> 0.0; + case Value.Integer i -> (double) i.value(); + case Value.Float f -> f.value(); + case Value.Text t -> SqliteNumericText.parseDouble(t.value()); + case Value.Blob b -> + SqliteNumericText.parseDouble(SqliteNumericText.blobAsText(b.bytes())); + }; + } + + static byte[] bytesValue(Value val) { + return switch (val) { + case Value.Null n -> null; + case Value.Blob b -> b.bytes(); + case Value.Text t -> t.value().getBytes(java.nio.charset.StandardCharsets.UTF_8); + case Value.Integer i -> + Long.toString(i.value()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + case Value.Float f -> + SqliteRealText.format(f.value()) + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + }; + } + + static Date dateValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Date"); + return dateTime == null ? null : Date.valueOf(dateTime.toLocalDate()); + } + + static Date dateValue(Value value, ZoneId zone) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Date", zone); + if (dateTime == null) { + return null; + } + return new Date(dateTime.toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()); + } + + static Time timeValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Time"); + return dateTime == null ? null : Time.valueOf(dateTime.toLocalTime()); + } + + static Time timeValue(Value value, ZoneId zone) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Time", zone); + if (dateTime == null) { + return null; + } + return new Time( + LocalDate.of(1970, 1, 1) + .atTime(dateTime.toLocalTime()) + .atZone(zone) + .toInstant() + .toEpochMilli()); + } + + static Timestamp timestampValue(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Timestamp"); + return dateTime == null ? null : Timestamp.valueOf(dateTime); + } + + static Timestamp timestampValue(Value value, ZoneId zone) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "Timestamp", zone); + return dateTime == null ? null : Timestamp.from(dateTime.atZone(zone).toInstant()); + } + + static OffsetTime offsetTimeValue(Value value) throws SQLException { + String text = stringValue(value); + if (text == null) { + return null; + } + try { + return OffsetTime.parse(text); + } catch (DateTimeParseException e) { + throw new SQLException("Bad value for type OffsetTime: " + text, e); + } + } + + static OffsetDateTime offsetDateTimeValue(Value value) throws SQLException { + String text = stringValue(value); + if (text == null) { + return null; + } + try { + return OffsetDateTime.parse(sqlTimestampSeparator(text)); + } catch (DateTimeParseException e) { + throw new SQLException("Bad value for type OffsetDateTime: " + text, e); + } + } + + /** + * Accepts the SQLite text form with a space between the date and the time as well as the + * ISO-8601 form with {@code T}. {@link OffsetDateTime#parse(CharSequence)} accepts only the + * latter, and SQLite columns are commonly written with the former. + */ + private static String sqlTimestampSeparator(String text) { + return text.length() > 10 && text.charAt(10) == ' ' + ? text.substring(0, 10) + 'T' + text.substring(11) + : text; + } + + static BigDecimal bigDecimalValue(Value value) throws SQLException { + String s = stringValue(value); + if (s == null) { + return null; + } + try { + return new BigDecimal(s); + } catch (NumberFormatException e) { + throw new SQLException("Bad value for type BigDecimal: " + s, e); + } + } + + static Object objectValue(Value value, JdbcTypeMapping.JdbcType type) throws SQLException { + Class objectClass = type.objectClass(); + if (objectClass == Integer.class) { + return checkedInt(value, type.sqlType()); + } + if (objectClass == Long.class) { + return toLong(value); + } + if (objectClass == Double.class) { + return toDouble(value); + } + if (objectClass == Float.class) { + return (float) toDouble(value); + } + if (objectClass == String.class) { + return stringValue(value); + } + return convertedObject(value, type); + } + + /** + * Converts every declared type outside the numeric and text fast path. It stays out of the + * result-set getter so the common reads stay small enough for the JIT to inline and + * scalar-replace their boxes. + */ + private static Object convertedObject(Value value, JdbcTypeMapping.JdbcType type) + throws SQLException { + Class objectClass = type.objectClass(); + if (objectClass == BigDecimal.class) { + return bigDecimalValue(value); + } + if (objectClass == Boolean.class) { + return booleanValue(value); + } + if (objectClass == byte[].class) { + return bytesValue(value); + } + if (objectClass == Date.class) { + return dateValue(value); + } + if (objectClass == Time.class) { + return timeValue(value); + } + if (objectClass == Timestamp.class) { + return timestampValue(value); + } + if (objectClass == OffsetTime.class) { + return offsetTimeValue(value); + } + if (objectClass == OffsetDateTime.class) { + return offsetDateTimeValue(value); + } + if (objectClass == Clob.class) { + return clobValue(value); + } + if (objectClass == NClob.class) { + return nclobValue(value); + } + if (objectClass == Blob.class) { + return blobValue(value); + } + if (objectClass == RowId.class) { + return rowIdValue(value); + } + if (objectClass == SQLXML.class) { + return sqlxmlValue(value); + } + if (objectClass == URL.class) { + return urlValue(value); + } + return storedObject(value); + } + + private static Object storedObject(Value value) { + return switch (value) { + case Value.Null n -> null; + case Value.Integer i -> + i.value() >= Integer.MIN_VALUE && i.value() <= Integer.MAX_VALUE + ? (Object) (int) i.value() + : (Object) i.value(); + case Value.Float f -> f.value(); + case Value.Text t -> t.value(); + case Value.Blob b -> b.bytes(); + }; + } + + static Integer checkedInt(Value value, int sqlType) throws SQLException { + return checkedInt(toLong(value), sqlType); + } + + static Integer checkedInt(long number, int sqlType) throws SQLException { + if (number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) { + throw new SQLException( + "Value " + + number + + " does not fit the declared " + + sqlTypeName(sqlType) + + " type"); + } + return (int) number; + } + + private static String sqlTypeName(int sqlType) { + return switch (sqlType) { + case java.sql.Types.TINYINT -> "TINYINT"; + case java.sql.Types.SMALLINT -> "SMALLINT"; + case java.sql.Types.INTEGER -> "INTEGER"; + default -> "numeric"; + }; + } + + static Blob blobValue(Value value) { + byte[] bytes = bytesValue(value); + return bytes == null ? null : new LibsqlBlob(bytes); + } + + static Clob clobValue(Value value) { + String string = stringValue(value); + return string == null ? null : new LibsqlClob(string); + } + + static URL urlValue(Value value) throws SQLException { + String text = stringValue(value); + if (text == null) { + return null; + } + try { + // URI.toURL replaces the deprecated URL(String) constructor and rejects the same text. + return URI.create(text).toURL(); + } catch (IllegalArgumentException | MalformedURLException e) { + throw new SQLException("Bad value for type URL: " + text, e); + } + } + + static RowId rowIdValue(Value value) { + byte[] bytes = bytesValue(value); + return bytes == null ? null : new LibsqlRowId(bytes); + } + + static NClob nclobValue(Value value) { + String text = stringValue(value); + return text == null ? null : new LibsqlNClob(text); + } + + static SQLXML sqlxmlValue(Value value) { + String text = stringValue(value); + return text == null ? null : new LibsqlSQLXML(text); + } + + /** + * Returns a value converted to the requested Java type. Numeric wrapper targets are gated on + * the storage class and read strictly when the column is declared, matching the individual + * getters. The result-set override owns the null-type check and the declared-type rejection. + */ + static T typedObject(Value stored, Class type) throws SQLException { + if (type == String.class) { + return type.cast(stringValue(stored)); + } + if (type == Boolean.class) { + return type.cast(booleanValue(stored)); + } + if (type == BigDecimal.class) { + return type.cast(bigDecimalValue(stored)); + } + if (type == byte[].class) { + return type.cast(bytesValue(stored)); + } + if (type == Date.class) { + return type.cast(dateValue(stored)); + } + if (type == Time.class) { + return type.cast(timeValue(stored)); + } + if (type == Timestamp.class) { + return type.cast(timestampValue(stored)); + } + if (type == LocalDate.class) { + return type.cast(localDate(stored)); + } + if (type == LocalTime.class) { + return type.cast(localTime(stored)); + } + if (type == LocalDateTime.class) { + return type.cast(dateTimeValue(stored, "LocalDateTime")); + } + if (type == OffsetTime.class) { + return type.cast(offsetTimeValue(stored)); + } + if (type == OffsetDateTime.class) { + return type.cast(offsetDateTimeValue(stored)); + } + + boolean numeric = stored instanceof Value.Integer || stored instanceof Value.Float; + if (type == Double.class) { + if (numeric) { + return type.cast(toDouble(stored)); + } + throw new SQLException("Bad value for type Double"); + } + if (type == Long.class) { + if (numeric) { + return type.cast(toLong(stored)); + } + throw new SQLException("Bad value for type Long"); + } + if (type == Float.class) { + if (numeric) { + return type.cast((float) toDouble(stored)); + } + throw new SQLException("Bad value for type Float"); + } + if (type == Integer.class) { + if (numeric) { + return type.cast((int) toLong(stored)); + } + throw new SQLException("Bad value for type Integer"); + } + + throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); + } + + private static LocalDate localDate(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "LocalDate"); + return dateTime == null ? null : dateTime.toLocalDate(); + } + + private static LocalTime localTime(Value value) throws SQLException { + LocalDateTime dateTime = dateTimeValue(value, "LocalTime"); + return dateTime == null ? null : dateTime.toLocalTime(); + } + + /** + * Reads a value as a local date and time using SQLite storage-class conventions: INTEGER is + * epoch milliseconds, FLOAT is a Julian day, and TEXT is a SQLite date/time string. + */ + static LocalDateTime dateTimeValue(Value value, String targetType) throws SQLException { + return SqliteDateTimeValues.from(value, targetType); + } + + static LocalDateTime dateTimeValue(Value value, String targetType, ZoneId zone) + throws SQLException { + return SqliteDateTimeValues.from(value, targetType, zone); + } +} diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java index d7d15d4..c4726f4 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -10,8 +10,6 @@ import java.io.Reader; import java.io.StringReader; import java.math.BigDecimal; -import java.net.MalformedURLException; -import java.net.URI; import java.net.URL; import java.sql.Array; import java.sql.Blob; @@ -29,13 +27,9 @@ import java.sql.Statement; import java.sql.Time; import java.sql.Timestamp; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; import java.time.OffsetDateTime; import java.time.OffsetTime; import java.time.ZoneId; -import java.time.format.DateTimeParseException; import java.util.Calendar; import java.util.HashMap; import java.util.List; @@ -214,17 +208,7 @@ public boolean wasNull() throws SQLException { @Override public String getString(int columnIndex) throws SQLException { checkReadable(columnIndex); - return stringValue(getCurrentValue(columnIndex)); - } - - private static String stringValue(Value val) { - return switch (val) { - case Value.Null n -> null; - case Value.Integer i -> Long.toString(i.value()); - case Value.Float f -> SqliteRealText.format(f.value()); - case Value.Text t -> t.value(); - case Value.Blob b -> new String(b.bytes(), java.nio.charset.StandardCharsets.UTF_8); - }; + return JdbcValueReader.stringValue(getCurrentValue(columnIndex)); } /** @@ -237,20 +221,7 @@ private static String stringValue(Value val) { @Override public boolean getBoolean(int columnIndex) throws SQLException { checkReadable(columnIndex); - return booleanValue(getCurrentValue(columnIndex)); - } - - private static boolean booleanValue(Value value) { - if (value instanceof Value.Text text) { - String string = text.value().trim(); - if ("true".equalsIgnoreCase(string)) { - return true; - } - if ("false".equalsIgnoreCase(string)) { - return false; - } - } - return (int) toLong(value) != 0; + return JdbcValueReader.booleanValue(getCurrentValue(columnIndex)); } @Override @@ -295,65 +266,13 @@ public double getDouble(int columnIndex) throws SQLException { * permissive storage fallback that xerial users rely on. */ private long longValue(int columnIndex) throws SQLException { - Value value = getCurrentValue(columnIndex); - if (declaredTypes[columnIndex - 1] == null) { - return toLong(value); - } - return switch (value) { - case Value.Text text -> strictLong(text.value()); - case Value.Blob blob -> strictLong(SqliteNumericText.blobAsText(blob.bytes())); - default -> toLong(value); - }; + return JdbcValueReader.longValue( + getCurrentValue(columnIndex), declaredTypes[columnIndex - 1] != null); } private double doubleValue(int columnIndex) throws SQLException { - Value value = getCurrentValue(columnIndex); - if (declaredTypes[columnIndex - 1] == null) { - return toDouble(value); - } - return switch (value) { - case Value.Text text -> strictDouble(text.value()); - case Value.Blob blob -> strictDouble(SqliteNumericText.blobAsText(blob.bytes())); - default -> toDouble(value); - }; - } - - private static long strictLong(String text) throws SQLException { - try { - return new BigDecimal(text.trim()).longValue(); - } catch (NumberFormatException e) { - throw new SQLException("Bad value for type numeric: " + text, e); - } - } - - private static double strictDouble(String text) throws SQLException { - try { - return Double.parseDouble(text.trim()); - } catch (NumberFormatException e) { - throw new SQLException("Bad value for type numeric: " + text, e); - } - } - - private static long toLong(Value val) { - return switch (val) { - case Value.Null n -> 0L; - case Value.Integer i -> i.value(); - case Value.Float f -> (long) f.value(); - case Value.Text t -> SqliteNumericText.parseLong(t.value()); - case Value.Blob b -> - SqliteNumericText.parseLong(SqliteNumericText.blobAsText(b.bytes())); - }; - } - - private static double toDouble(Value val) { - return switch (val) { - case Value.Null n -> 0.0; - case Value.Integer i -> (double) i.value(); - case Value.Float f -> f.value(); - case Value.Text t -> SqliteNumericText.parseDouble(t.value()); - case Value.Blob b -> - SqliteNumericText.parseDouble(SqliteNumericText.blobAsText(b.bytes())); - }; + return JdbcValueReader.doubleValue( + getCurrentValue(columnIndex), declaredTypes[columnIndex - 1] != null); } @Override @@ -366,128 +285,31 @@ public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException @Override public byte[] getBytes(int columnIndex) throws SQLException { checkReadable(columnIndex); - return bytesValue(getCurrentValue(columnIndex)); - } - - private static byte[] bytesValue(Value val) { - return switch (val) { - case Value.Null n -> null; - case Value.Blob b -> b.bytes(); - case Value.Text t -> t.value().getBytes(java.nio.charset.StandardCharsets.UTF_8); - case Value.Integer i -> Long.toString(i.value()).getBytes(java.nio.charset.StandardCharsets.UTF_8); - case Value.Float f -> SqliteRealText.format(f.value()).getBytes(java.nio.charset.StandardCharsets.UTF_8); - }; + return JdbcValueReader.bytesValue(getCurrentValue(columnIndex)); } @Override public Date getDate(int columnIndex) throws SQLException { checkReadable(columnIndex); - return dateValue(getCurrentValue(columnIndex)); - } - - private static Date dateValue(Value value) throws SQLException { - LocalDateTime dateTime = dateTimeValue(value, "Date"); - return dateTime == null ? null : Date.valueOf(dateTime.toLocalDate()); - } - - private static Date dateValue(Value value, ZoneId zone) throws SQLException { - LocalDateTime dateTime = dateTimeValue(value, "Date", zone); - if (dateTime == null) { - return null; - } - return new Date(dateTime.toLocalDate().atStartOfDay(zone).toInstant().toEpochMilli()); + return JdbcValueReader.dateValue(getCurrentValue(columnIndex)); } @Override public Time getTime(int columnIndex) throws SQLException { checkReadable(columnIndex); - return timeValue(getCurrentValue(columnIndex)); - } - - private static Time timeValue(Value value) throws SQLException { - LocalDateTime dateTime = dateTimeValue(value, "Time"); - return dateTime == null ? null : Time.valueOf(dateTime.toLocalTime()); - } - - private static Time timeValue(Value value, ZoneId zone) throws SQLException { - LocalDateTime dateTime = dateTimeValue(value, "Time", zone); - if (dateTime == null) { - return null; - } - return new Time(LocalDate.of(1970, 1, 1) - .atTime(dateTime.toLocalTime()) - .atZone(zone) - .toInstant() - .toEpochMilli()); + return JdbcValueReader.timeValue(getCurrentValue(columnIndex)); } @Override public Timestamp getTimestamp(int columnIndex) throws SQLException { checkReadable(columnIndex); - return timestampValue(getCurrentValue(columnIndex)); - } - - private static Timestamp timestampValue(Value value) throws SQLException { - LocalDateTime dateTime = dateTimeValue(value, "Timestamp"); - return dateTime == null ? null : Timestamp.valueOf(dateTime); - } - - private static Timestamp timestampValue(Value value, ZoneId zone) throws SQLException { - LocalDateTime dateTime = dateTimeValue(value, "Timestamp", zone); - return dateTime == null ? null : Timestamp.from(dateTime.atZone(zone).toInstant()); - } - - private static OffsetTime offsetTimeValue(Value value) throws SQLException { - String text = stringValue(value); - if (text == null) { - return null; - } - try { - return OffsetTime.parse(text); - } catch (DateTimeParseException e) { - throw new SQLException("Bad value for type OffsetTime: " + text, e); - } - } - - private static OffsetDateTime offsetDateTimeValue(Value value) throws SQLException { - String text = stringValue(value); - if (text == null) { - return null; - } - try { - return OffsetDateTime.parse(sqlTimestampSeparator(text)); - } catch (DateTimeParseException e) { - throw new SQLException("Bad value for type OffsetDateTime: " + text, e); - } - } - - /** - * Accepts the SQLite text form with a space between the date and the time as well as the - * ISO-8601 form with {@code T}. {@link OffsetDateTime#parse(CharSequence)} accepts only the - * latter, and SQLite columns are commonly written with the former. - */ - private static String sqlTimestampSeparator(String text) { - return text.length() > 10 && text.charAt(10) == ' ' - ? text.substring(0, 10) + 'T' + text.substring(11) - : text; + return JdbcValueReader.timestampValue(getCurrentValue(columnIndex)); } @Override public BigDecimal getBigDecimal(int columnIndex) throws SQLException { checkReadable(columnIndex); - return bigDecimalValue(getCurrentValue(columnIndex)); - } - - private static BigDecimal bigDecimalValue(Value value) throws SQLException { - String s = stringValue(value); - if (s == null) { - return null; - } - try { - return new BigDecimal(s); - } catch (NumberFormatException e) { - throw new SQLException("Bad value for type BigDecimal: " + s, e); - } + return JdbcValueReader.bigDecimalValue(getCurrentValue(columnIndex)); } @Override @@ -504,7 +326,7 @@ public Object getObject(int columnIndex) throws SQLException { // Shortcuts for a value already stored as the declared Java box. They mirror the storage // switch trunk used, so a matching column costs one class test and one type test. if (value instanceof Value.Integer integer && type.objectClass() == Integer.class) { - return checkedInt(integer.value(), type.sqlType()); + return JdbcValueReader.checkedInt(integer.value(), type.sqlType()); } if (value instanceof Value.Float floating) { if (type.objectClass() == Float.class) { @@ -517,112 +339,7 @@ public Object getObject(int columnIndex) throws SQLException { if (value instanceof Value.Text text && type.objectClass() == String.class) { return text.value(); } - return objectValue(value, type); - } - - private static Object objectValue(Value value, JdbcTypeMapping.JdbcType type) throws SQLException { - Class objectClass = type.objectClass(); - if (objectClass == Integer.class) { - return checkedInt(value, type.sqlType()); - } - if (objectClass == Long.class) { - return toLong(value); - } - if (objectClass == Double.class) { - return toDouble(value); - } - if (objectClass == Float.class) { - return (float) toDouble(value); - } - if (objectClass == String.class) { - return stringValue(value); - } - return convertedObject(value, type); - } - - /** - * Converts every declared type outside the numeric and text fast path. It stays out of {@link - * #getObject} so the common reads stay small enough for the JIT to inline and scalar-replace - * their boxes. - */ - private static Object convertedObject(Value value, JdbcTypeMapping.JdbcType type) throws SQLException { - Class objectClass = type.objectClass(); - if (objectClass == BigDecimal.class) { - return bigDecimalValue(value); - } - if (objectClass == Boolean.class) { - return booleanValue(value); - } - if (objectClass == byte[].class) { - return bytesValue(value); - } - if (objectClass == Date.class) { - return dateValue(value); - } - if (objectClass == Time.class) { - return timeValue(value); - } - if (objectClass == Timestamp.class) { - return timestampValue(value); - } - if (objectClass == OffsetTime.class) { - return offsetTimeValue(value); - } - if (objectClass == OffsetDateTime.class) { - return offsetDateTimeValue(value); - } - if (objectClass == Clob.class) { - return clobValue(value); - } - if (objectClass == NClob.class) { - return nclobValue(value); - } - if (objectClass == Blob.class) { - return blobValue(value); - } - if (objectClass == RowId.class) { - return rowIdValue(value); - } - if (objectClass == SQLXML.class) { - return sqlxmlValue(value); - } - if (objectClass == URL.class) { - return urlValue(value); - } - return storedObject(value); - } - - private static Object storedObject(Value value) { - return switch (value) { - case Value.Null n -> null; - case Value.Integer i -> i.value() >= Integer.MIN_VALUE && i.value() <= Integer.MAX_VALUE - ? (Object) (int) i.value() - : (Object) i.value(); - case Value.Float f -> f.value(); - case Value.Text t -> t.value(); - case Value.Blob b -> b.bytes(); - }; - } - - private static Integer checkedInt(Value value, int sqlType) throws SQLException { - return checkedInt(toLong(value), sqlType); - } - - private static Integer checkedInt(long number, int sqlType) throws SQLException { - if (number < Integer.MIN_VALUE || number > Integer.MAX_VALUE) { - throw new SQLException( - "Value " + number + " does not fit the declared " + sqlTypeName(sqlType) + " type"); - } - return (int) number; - } - - private static String sqlTypeName(int sqlType) { - return switch (sqlType) { - case java.sql.Types.TINYINT -> "TINYINT"; - case java.sql.Types.SMALLINT -> "SMALLINT"; - case java.sql.Types.INTEGER -> "INTEGER"; - default -> "numeric"; - }; + return JdbcValueReader.objectValue(value, type); } private static void rejectTypeMap(Map> map) throws SQLException { @@ -787,7 +504,7 @@ public boolean isLast() throws SQLException { @Override public InputStream getAsciiStream(int columnIndex) throws SQLException { checkReadable(columnIndex); - String text = stringValue(getCurrentValue(columnIndex)); + String text = JdbcValueReader.stringValue(getCurrentValue(columnIndex)); return text == null ? null : new ByteArrayInputStream(text.getBytes(java.nio.charset.StandardCharsets.US_ASCII)); @@ -795,7 +512,7 @@ public InputStream getAsciiStream(int columnIndex) throws SQLException { @Override @Deprecated @SuppressWarnings("deprecation") public InputStream getUnicodeStream(int columnIndex) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public InputStream getBinaryStream(int columnIndex) throws SQLException { checkReadable(columnIndex); - byte[] bytes = bytesValue(getCurrentValue(columnIndex)); + byte[] bytes = JdbcValueReader.bytesValue(getCurrentValue(columnIndex)); return bytes == null ? null : new ByteArrayInputStream(bytes); } @Override public InputStream getAsciiStream(String columnLabel) throws SQLException { return getAsciiStream(findColumn(columnLabel)); } @@ -806,7 +523,7 @@ public InputStream getAsciiStream(int columnIndex) throws SQLException { @Override public String getCursorName() throws SQLException { return null; } @Override public Reader getCharacterStream(int columnIndex) throws SQLException { checkReadable(columnIndex); - String string = stringValue(getCurrentValue(columnIndex)); + String string = JdbcValueReader.stringValue(getCurrentValue(columnIndex)); return string == null ? null : new StringReader(string); } @Override public Reader getCharacterStream(String columnLabel) throws SQLException { return getCharacterStream(findColumn(columnLabel)); } @@ -893,19 +610,11 @@ public void setFetchSize(int rows) throws SQLException { } @Override public Blob getBlob(int columnIndex) throws SQLException { checkReadable(columnIndex); - return blobValue(getCurrentValue(columnIndex)); - } - private static Blob blobValue(Value value) { - byte[] bytes = bytesValue(value); - return bytes == null ? null : new LibsqlBlob(bytes); + return JdbcValueReader.blobValue(getCurrentValue(columnIndex)); } @Override public Clob getClob(int columnIndex) throws SQLException { checkReadable(columnIndex); - return clobValue(getCurrentValue(columnIndex)); - } - private static Clob clobValue(Value value) { - String string = stringValue(value); - return string == null ? null : new LibsqlClob(string); + return JdbcValueReader.clobValue(getCurrentValue(columnIndex)); } @Override public Array getArray(int columnIndex) throws SQLException { checkReadable(columnIndex); @@ -925,7 +634,7 @@ public Date getDate(int columnIndex, Calendar cal) throws SQLException { return getDate(columnIndex); } checkReadable(columnIndex); - return dateValue(getCurrentValue(columnIndex), zone(cal)); + return JdbcValueReader.dateValue(getCurrentValue(columnIndex), zone(cal)); } @Override public Date getDate(String columnLabel, Calendar cal) throws SQLException { return getDate(findColumn(columnLabel), cal); } @@ -936,7 +645,7 @@ public Time getTime(int columnIndex, Calendar cal) throws SQLException { return getTime(columnIndex); } checkReadable(columnIndex); - return timeValue(getCurrentValue(columnIndex), zone(cal)); + return JdbcValueReader.timeValue(getCurrentValue(columnIndex), zone(cal)); } @Override public Time getTime(String columnLabel, Calendar cal) throws SQLException { return getTime(findColumn(columnLabel), cal); } @@ -947,7 +656,7 @@ public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException return getTimestamp(columnIndex); } checkReadable(columnIndex); - return timestampValue(getCurrentValue(columnIndex), zone(cal)); + return JdbcValueReader.timestampValue(getCurrentValue(columnIndex), zone(cal)); } @Override public Timestamp getTimestamp(String columnLabel, Calendar cal) throws SQLException { return getTimestamp(findColumn(columnLabel), cal); } @@ -958,20 +667,7 @@ private static ZoneId zone(Calendar cal) { @Override public URL getURL(int columnIndex) throws SQLException { checkReadable(columnIndex); - return urlValue(getCurrentValue(columnIndex)); - } - - private static URL urlValue(Value value) throws SQLException { - String text = stringValue(value); - if (text == null) { - return null; - } - try { - // URI.toURL replaces the deprecated URL(String) constructor and rejects the same text. - return URI.create(text).toURL(); - } catch (IllegalArgumentException | MalformedURLException e) { - throw new SQLException("Bad value for type URL: " + text, e); - } + return JdbcValueReader.urlValue(getCurrentValue(columnIndex)); } @Override public URL getURL(String columnLabel) throws SQLException { return getURL(findColumn(columnLabel)); } @@ -985,11 +681,7 @@ private static URL urlValue(Value value) throws SQLException { @Override public void updateArray(String columnLabel, Array x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public RowId getRowId(int columnIndex) throws SQLException { checkReadable(columnIndex); - return rowIdValue(getCurrentValue(columnIndex)); - } - private static RowId rowIdValue(Value value) { - byte[] bytes = bytesValue(value); - return bytes == null ? null : new LibsqlRowId(bytes); + return JdbcValueReader.rowIdValue(getCurrentValue(columnIndex)); } @Override public RowId getRowId(String columnLabel) throws SQLException { return getRowId(findColumn(columnLabel)); } @Override public void updateRowId(int columnIndex, RowId x) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @@ -1001,32 +693,24 @@ private static RowId rowIdValue(Value value) { @Override public void updateNClob(String columnLabel, NClob nClob) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public NClob getNClob(int columnIndex) throws SQLException { checkReadable(columnIndex); - return nclobValue(getCurrentValue(columnIndex)); - } - private static NClob nclobValue(Value value) { - String text = stringValue(value); - return text == null ? null : new LibsqlNClob(text); + return JdbcValueReader.nclobValue(getCurrentValue(columnIndex)); } @Override public NClob getNClob(String columnLabel) throws SQLException { return getNClob(findColumn(columnLabel)); } @Override public SQLXML getSQLXML(int columnIndex) throws SQLException { checkReadable(columnIndex); - return sqlxmlValue(getCurrentValue(columnIndex)); - } - private static SQLXML sqlxmlValue(Value value) { - String text = stringValue(value); - return text == null ? null : new LibsqlSQLXML(text); + return JdbcValueReader.sqlxmlValue(getCurrentValue(columnIndex)); } @Override public SQLXML getSQLXML(String columnLabel) throws SQLException { return getSQLXML(findColumn(columnLabel)); } @Override public void updateSQLXML(int columnIndex, SQLXML xmlObject) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public void updateSQLXML(String columnLabel, SQLXML xmlObject) throws SQLException { throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); } @Override public String getNString(int columnIndex) throws SQLException { checkReadable(columnIndex); - return stringValue(getCurrentValue(columnIndex)); + return JdbcValueReader.stringValue(getCurrentValue(columnIndex)); } @Override public String getNString(String columnLabel) throws SQLException { return getNString(findColumn(columnLabel)); } @Override public Reader getNCharacterStream(int columnIndex) throws SQLException { checkReadable(columnIndex); - String text = stringValue(getCurrentValue(columnIndex)); + String text = JdbcValueReader.stringValue(getCurrentValue(columnIndex)); return text == null ? null : new StringReader(text); } @Override public Reader getNCharacterStream(String columnLabel) throws SQLException { return getNCharacterStream(findColumn(columnLabel)); } @@ -1132,98 +816,7 @@ public T getObject(int columnIndex, Class type) throws SQLException { throw new SQLException("requested type cannot be null"); } checkReadable(columnIndex); - if (type == String.class) { - return type.cast(getString(columnIndex)); - } - if (type == Boolean.class) { - return type.cast(getBoolean(columnIndex)); - } - if (type == BigDecimal.class) { - return type.cast(getBigDecimal(columnIndex)); - } - if (type == byte[].class) { - return type.cast(getBytes(columnIndex)); - } - if (type == Date.class) { - return type.cast(getDate(columnIndex)); - } - if (type == Time.class) { - return type.cast(getTime(columnIndex)); - } - if (type == Timestamp.class) { - return type.cast(getTimestamp(columnIndex)); - } - if (type == LocalDate.class) { - return type.cast(getLocalDate(columnIndex)); - } - if (type == LocalTime.class) { - return type.cast(getLocalTime(columnIndex)); - } - if (type == LocalDateTime.class) { - return type.cast(getLocalDateTime(columnIndex)); - } - if (type == OffsetTime.class) { - return type.cast(offsetTimeValue(getCurrentValue(columnIndex))); - } - if (type == OffsetDateTime.class) { - return type.cast(offsetDateTimeValue(getCurrentValue(columnIndex))); - } - - Value stored = getCurrentValue(columnIndex); - boolean numeric = stored instanceof Value.Integer || stored instanceof Value.Float; - if (type == Double.class) { - if (numeric) { - return type.cast(getDouble(columnIndex)); - } - throw new SQLException("Bad value for type Double"); - } - if (type == Long.class) { - if (numeric) { - return type.cast(getLong(columnIndex)); - } - throw new SQLException("Bad value for type Long"); - } - if (type == Float.class) { - if (numeric) { - return type.cast(getFloat(columnIndex)); - } - throw new SQLException("Bad value for type Float"); - } - if (type == Integer.class) { - if (numeric) { - return type.cast(getInt(columnIndex)); - } - throw new SQLException("Bad value for type Integer"); - } - - throw new SQLFeatureNotSupportedException("not implemented by SQLite JDBC driver"); - } - - private LocalDate getLocalDate(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(getCurrentValue(columnIndex), "LocalDate"); - return value == null ? null : value.toLocalDate(); - } - - private LocalTime getLocalTime(int columnIndex) throws SQLException { - LocalDateTime value = dateTimeValue(getCurrentValue(columnIndex), "LocalTime"); - return value == null ? null : value.toLocalTime(); - } - - private LocalDateTime getLocalDateTime(int columnIndex) throws SQLException { - return dateTimeValue(getCurrentValue(columnIndex), "LocalDateTime"); - } - - /** - * Reads a value as a local date and time using SQLite storage-class conventions: INTEGER is - * epoch milliseconds, FLOAT is a Julian day, and TEXT is a SQLite date/time string. - */ - private static LocalDateTime dateTimeValue(Value value, String targetType) throws SQLException { - return SqliteDateTimeValues.from(value, targetType); - } - - private static LocalDateTime dateTimeValue(Value value, String targetType, ZoneId zone) - throws SQLException { - return SqliteDateTimeValues.from(value, targetType, zone); + return JdbcValueReader.typedObject(getCurrentValue(columnIndex), type); } @Override diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlResultSetConversionTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlResultSetConversionTest.java new file mode 100644 index 0000000..dce8ee1 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlResultSetConversionTest.java @@ -0,0 +1,106 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.hrana.codec.Col; +import io.github.magnusp.libsql.hrana.codec.Row; +import io.github.magnusp.libsql.hrana.codec.StmtResult; +import io.github.magnusp.libsql.hrana.codec.Value; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.time.OffsetTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LibsqlResultSetConversionTest { + + @Test + void declaredNumericTextRejectsANonNumberWithTheXerialMessage() throws SQLException { + LibsqlResultSet rs = resultSet("NUMERIC", new Value.Text("abc")); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getInt(1)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type numeric: abc"); + } + + @Test + void declaredNumericBlobRejectsANonNumberWithTheXerialMessage() throws SQLException { + LibsqlResultSet rs = + resultSet("NUMERIC", new Value.Blob("abc".getBytes(StandardCharsets.UTF_8))); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getDouble(1)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type numeric: abc"); + } + + @Test + void typedIntegerTargetOnTextStorageUsesTheTargetTypeMessage() throws SQLException { + LibsqlResultSet rs = resultSet("NUMERIC", new Value.Text("abc")); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, Integer.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type Integer"); + } + + @Test + void declaredTinyIntRejectsAValueOutsideTheIntRange() throws SQLException { + LibsqlResultSet rs = resultSet("TINYINT", Value.of(4_000_000_000L)); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1)) + .isInstanceOf(SQLException.class) + .hasMessage("Value 4000000000 does not fit the declared TINYINT type"); + } + + @Test + void bigDecimalRejectsNonNumericText() throws SQLException { + LibsqlResultSet rs = resultSet("DECIMAL", new Value.Text("abc")); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getBigDecimal(1)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type BigDecimal: abc"); + } + + @Test + void urlRejectsMalformedText() throws SQLException { + LibsqlResultSet rs = resultSet("DATALINK", new Value.Text("not a url")); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getURL(1)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type URL: not a url"); + } + + @Test + void offsetTimeRejectsMalformedText() throws SQLException { + LibsqlResultSet rs = resultSet("TIME_WITH_TIMEZONE", new Value.Text("abc")); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, OffsetTime.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type OffsetTime: abc"); + } + + @Test + void offsetDateTimeAcceptsTheSqliteSpaceSeparator() throws SQLException { + LibsqlResultSet rs = + resultSet("TIMESTAMP_WITH_TIMEZONE", new Value.Text("2024-01-02 03:04:05+02:00")); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getObject(1, OffsetDateTime.class)) + .isEqualTo(OffsetDateTime.parse("2024-01-02T03:04:05+02:00")); + } + + private static LibsqlResultSet resultSet(String declaredType, Value value) { + List cols = List.of(new Col("c1", declaredType)); + StmtResult result = new StmtResult(cols, List.of(new Row(List.of(value))), 1, null); + return new LibsqlResultSet(null, result); + } +}