diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlBlob.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlBlob.java new file mode 100644 index 0000000..d910722 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlBlob.java @@ -0,0 +1,112 @@ +package com.github.magnusp.libsql.jdbc; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.sql.Blob; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.Arrays; + + /** Read-only {@link Blob} over the materialized bytes of one result column. */ +final class LibsqlBlob implements Blob { + + private byte[] bytes; + private boolean freed; + + LibsqlBlob(byte[] bytes) { + this.bytes = bytes; + } + + private byte[] data() throws SQLException { + if (freed) { + throw new SQLException("Blob has been freed"); + } + return bytes; + } + + @Override + public long length() throws SQLException { + return data().length; + } + + @Override + public byte[] getBytes(long pos, int length) throws SQLException { + byte[] data = data(); + if (pos < 1) { + throw new SQLException("Invalid position: " + pos); + } + if (length < 0) { + throw new SQLException("Invalid length: " + length); + } + int start = (int) Math.min(pos - 1, data.length); + int end = Math.min(start + length, data.length); + return Arrays.copyOfRange(data, start, end); + } + + @Override + public InputStream getBinaryStream() throws SQLException { + return new ByteArrayInputStream(data()); + } + + @Override + public long position(byte[] pattern, long start) throws SQLException { + if (start < 1) { + throw new SQLException("Invalid position: " + start); + } + byte[] data = data(); + int from = (int) Math.min(start - 1, data.length); + for (int i = from; i + pattern.length <= data.length; i++) { + boolean match = true; + for (int j = 0; j < pattern.length; j++) { + if (data[i + j] != pattern[j]) { + match = false; + break; + } + } + if (match) { + return i + 1L; + } + } + return -1; + } + + @Override + public long position(Blob pattern, long start) throws SQLException { + return position(pattern.getBytes(1, (int) pattern.length()), start); + } + + @Override + public int setBytes(long pos, byte[] bytes) throws SQLException { + throw new SQLFeatureNotSupportedException("Blob is read-only"); + } + + @Override + public int setBytes(long pos, byte[] bytes, int offset, int len) throws SQLException { + throw new SQLFeatureNotSupportedException("Blob is read-only"); + } + + @Override + public OutputStream setBinaryStream(long pos) throws SQLException { + throw new SQLFeatureNotSupportedException("Blob is read-only"); + } + + @Override + public void truncate(long len) throws SQLException { + throw new SQLFeatureNotSupportedException("Blob is read-only"); + } + + @Override + public void free() throws SQLException { + freed = true; + bytes = null; + } + + @Override + public InputStream getBinaryStream(long pos, long length) throws SQLException { + if (length < 0) { + throw new SQLException("Invalid length: " + length); + } + return new ByteArrayInputStream(getBytes(pos, (int) Math.min(length, Integer.MAX_VALUE))); + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlClob.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlClob.java new file mode 100644 index 0000000..b1b917b --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlClob.java @@ -0,0 +1,114 @@ +package com.github.magnusp.libsql.jdbc; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.sql.Clob; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; + + /** Read-only {@link Clob} over the materialized text of one result column. */ +final class LibsqlClob implements Clob { + + private String text; + private boolean freed; + + LibsqlClob(String text) { + this.text = text; + } + + private String data() throws SQLException { + if (freed) { + throw new SQLException("Clob has been freed"); + } + return text; + } + + @Override + public long length() throws SQLException { + return data().length(); + } + + @Override + public String getSubString(long pos, int length) throws SQLException { + String data = data(); + if (pos < 1) { + throw new SQLException("Invalid position: " + pos); + } + if (length < 0) { + throw new SQLException("Invalid length: " + length); + } + int start = (int) Math.min(pos - 1, data.length()); + int end = Math.min(start + length, data.length()); + return data.substring(start, end); + } + + @Override + public Reader getCharacterStream() throws SQLException { + return new StringReader(data()); + } + + @Override + public InputStream getAsciiStream() throws SQLException { + return new ByteArrayInputStream(data().getBytes(StandardCharsets.US_ASCII)); + } + + @Override + public long position(String searchstr, long start) throws SQLException { + if (start < 1) { + throw new SQLException("Invalid position: " + start); + } + String data = data(); + int from = (int) Math.min(start - 1, data.length()); + int index = data.indexOf(searchstr, from); + return index < 0 ? -1 : index + 1L; + } + + @Override + public long position(Clob searchstr, long start) throws SQLException { + return position(searchstr.getSubString(1, (int) searchstr.length()), start); + } + + @Override + public int setString(long pos, String str) throws SQLException { + throw new SQLFeatureNotSupportedException("Clob is read-only"); + } + + @Override + public int setString(long pos, String str, int offset, int len) throws SQLException { + throw new SQLFeatureNotSupportedException("Clob is read-only"); + } + + @Override + public OutputStream setAsciiStream(long pos) throws SQLException { + throw new SQLFeatureNotSupportedException("Clob is read-only"); + } + + @Override + public Writer setCharacterStream(long pos) throws SQLException { + throw new SQLFeatureNotSupportedException("Clob is read-only"); + } + + @Override + public void truncate(long len) throws SQLException { + throw new SQLFeatureNotSupportedException("Clob is read-only"); + } + + @Override + public void free() throws SQLException { + freed = true; + text = null; + } + + @Override + public Reader getCharacterStream(long pos, long length) throws SQLException { + if (length < 0) { + throw new SQLException("Invalid length: " + length); + } + return new StringReader(getSubString(pos, (int) Math.min(length, Integer.MAX_VALUE))); + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index b8a5acf..6b883f2 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -7,14 +7,10 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; -import java.io.OutputStream; import java.io.Reader; import java.io.StringReader; -import java.io.Writer; import java.math.BigDecimal; -import java.math.BigInteger; import java.net.URL; -import java.nio.charset.StandardCharsets; import java.sql.Array; import java.sql.Blob; import java.sql.Clob; @@ -38,7 +34,6 @@ import java.time.LocalTime; import java.time.ZoneId; import java.time.ZoneOffset; -import java.util.Arrays; import java.util.Calendar; import java.util.HashMap; import java.util.List; @@ -178,271 +173,12 @@ public String getString(int columnIndex) throws SQLException { return switch (val) { case Value.Null n -> null; case Value.Integer i -> Long.toString(i.value()); - case Value.Float f -> sqliteRealText(f.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); }; } - // Port of the fixed-point conversion SQLite 3.53.4 uses for printf("%!.17g"), which is the - // text sqlite-jdbc 3.53.4.0 returns for REAL. The power-of-ten tables come from the same - // source (util.c and tool/mkfptab.c). - private static final long[] FP_POWERS_OF_TEN = { - 0x8000000000000000L, 0xa000000000000000L, 0xc800000000000000L, 0xfa00000000000000L, - 0x9c40000000000000L, 0xc350000000000000L, 0xf424000000000000L, 0x9896800000000000L, - 0xbebc200000000000L, 0xee6b280000000000L, 0x9502f90000000000L, 0xba43b74000000000L, - 0xe8d4a51000000000L, 0x9184e72a00000000L, 0xb5e620f480000000L, 0xe35fa931a0000000L, - 0x8e1bc9bf04000000L, 0xb1a2bc2ec5000000L, 0xde0b6b3a76400000L, 0x8ac7230489e80000L, - 0xad78ebc5ac620000L, 0xd8d726b7177a8000L, 0x878678326eac9000L, 0xa968163f0a57b400L, - 0xd3c21bcecceda100L, 0x84595161401484a0L, 0xa56fa5b99019a5c8L, - }; - - private static final long[] FP_POWER_SCALES = { - 0x8049a4ac0c5811aeL, 0xcf42894a5dce35eaL, 0xa76c582338ed2621L, 0x873e4f75e2224e68L, - 0xda7f5bf590966848L, 0xb080392cc4349decL, 0x8e938662882af53eL, 0xe65829b3046b0afaL, - 0xba121a4650e4ddebL, 0x964e858c91ba2655L, 0xf2d56790ab41c2a2L, 0xc428d05aa4751e4cL, - 0x9e74d1b791e07e48L, 0xccccccccccccccccL, 0xcecb8f27f4200f3aL, 0xa70c3c40a64e6c51L, - 0x86f0ac99b4e8dafdL, 0xda01ee641a708de9L, 0xb01ae745b101e9e4L, 0x8e41ade9fbebc27dL, - 0xe5d3ef282a242e81L, 0xb9a74a0637ce2ee1L, 0x95f83d0a1fb69cd9L, 0xf24a01a73cf2dccfL, - 0xc3b8358109e84f07L, 0x9e19db92b4e31ba9L, - }; - - private static final int[] FP_POWER_SCALE_LOW = { - 0x205b896d, 0x52064cad, 0xaf2af2b8, 0x5a7744a7, 0xaf39a475, 0xbd8d794e, - 0x547eb47b, 0x0cb4a5a3, 0x92f34d62, 0x3a6a07f9, 0xfae27299, 0xaa97e14c, - 0x775ea265, 0xcccccccc, 0x00000000, 0x999090b6, 0x69a028bb, 0xe80e6f48, - 0x5ec05dd0, 0x14588f14, 0x8f1668c9, 0x6d953e2c, 0x4abdaf10, 0xbc633b39, - 0x0a862f81, 0x6c07a2c2, - }; - - private static String sqliteRealText(double value) { - if (Double.isNaN(value)) { - return "NaN"; - } - if (Double.isInfinite(value)) { - return value < 0 ? "-Inf" : "Inf"; - } - boolean negative = value < 0.0; - double magnitude = negative ? -value : value; - if (magnitude == 0.0) { - return "0.0"; - } - - long bits = Double.doubleToRawLongBits(magnitude); - int exponent = (int) ((bits >>> 52) & 0x7ff); - long mantissa = bits & 0x000fffffffffffffL; - if (exponent == 0) { - int shift = Long.numberOfLeadingZeros(mantissa); - mantissa <<= shift; - exponent = -1074 - shift; - } else { - mantissa = (mantissa << 11) | (1L << 63); - exponent -= 1086; - } - - int power = 17 - fpPowerOfTwoToTen(exponent + 63); - long powerOfTen = fpPowerOfTen(power); - long high = fpMultiplyHigh(mantissa, powerOfTen); - int shift = -(exponent + fpPowerOfTenToTwo(power) + 2); - high >>>= shift; - long digitsValue = (high + ((high << 1) & 2)) >>> 1; - int powerOfTenExponent = -power; - - String digits = Long.toUnsignedString(digitsValue); - int digitCount = digits.length(); - int decimalPoint = digitCount + powerOfTenExponent; - - if (digitCount > 17) { - int roundAt = 17; - if (digits.charAt(15) == '9' && digits.charAt(14) == '9') { - int lastKept = 14; - while (lastKept > 0 && digits.charAt(lastKept - 1) == '9') { - lastKept--; - } - BigInteger candidate = lastKept == 0 - ? BigInteger.ONE - : new BigInteger(digits.substring(0, lastKept)).add(BigInteger.ONE); - if (roundTripsTo(magnitude, candidate, powerOfTenExponent + digitCount - lastKept)) { - roundAt = lastKept + 1; - } - } else if (decimalPoint >= digitCount - || (digits.charAt(15) == '0' - && digits.charAt(14) == '0' - && digits.charAt(13) == '0')) { - int lastKept = 13; - while (lastKept > 0 && digits.charAt(lastKept - 1) == '0') { - lastKept--; - } - BigInteger candidate = new BigInteger(digits.substring(0, lastKept)); - if (roundTripsTo(magnitude, candidate, powerOfTenExponent + digitCount - lastKept)) { - roundAt = lastKept + 1; - } - } - - char[] rounded = new char[digitCount + 1]; - digits.getChars(0, digitCount, rounded, 1); - int offset = 1; - int count = roundAt; - if (rounded[offset + roundAt] >= '5') { - int index = offset + roundAt - 1; - while (true) { - rounded[index]++; - if (rounded[index] <= '9') { - break; - } - rounded[index] = '0'; - if (index == offset) { - offset--; - rounded[offset] = '1'; - count++; - decimalPoint++; - break; - } - index--; - } - } - digits = new String(rounded, offset, count); - } - - int end = digits.length(); - while (end > 1 && digits.charAt(end - 1) == '0') { - end--; - } - digits = digits.substring(0, end); - - int decimalExponent = decimalPoint - 1; - StringBuilder out = new StringBuilder(); - if (negative) { - out.append('-'); - } - if (decimalExponent < -4 || decimalExponent > 16) { - out.append(digits.charAt(0)).append('.'); - int fractionEnd = digits.length(); - while (fractionEnd > 1 && digits.charAt(fractionEnd - 1) == '0') { - fractionEnd--; - } - if (fractionEnd > 1) { - out.append(digits, 1, fractionEnd); - } else { - out.append('0'); - } - out.append('e'); - out.append(decimalExponent < 0 ? '-' : '+'); - int absoluteExponent = Math.abs(decimalExponent); - if (absoluteExponent >= 100) { - out.append((char) ('0' + absoluteExponent / 100)); - } - out.append((char) ('0' + (absoluteExponent / 10) % 10)); - out.append((char) ('0' + absoluteExponent % 10)); - } else { - int integerPlaces = decimalPoint - 1; - int fractionPrecision = 16 - decimalExponent; - StringBuilder fraction = new StringBuilder(); - if (integerPlaces < 0) { - out.append('0'); - int leadingZeros = Math.min(-1 - integerPlaces, fractionPrecision); - fraction.append("0".repeat(leadingZeros)); - fractionPrecision -= leadingZeros; - fraction.append(digits, 0, Math.min(digits.length(), fractionPrecision)); - } else { - int integerDigits = Math.min(integerPlaces + 1, digits.length()); - out.append(digits, 0, integerDigits); - out.append("0".repeat(Math.max(0, integerPlaces + 1 - integerDigits))); - int significantDigits = Math.min(digits.length() - integerDigits, fractionPrecision); - if (significantDigits > 0) { - fraction.append(digits, integerDigits, integerDigits + significantDigits); - } - } - int fractionEnd = fraction.length(); - while (fractionEnd > 0 && fraction.charAt(fractionEnd - 1) == '0') { - fractionEnd--; - } - out.append('.'); - if (fractionEnd == 0) { - out.append('0'); - } else { - out.append(fraction, 0, fractionEnd); - } - } - return out.toString(); - } - - private static boolean roundTripsTo(double magnitude, BigInteger digits, int powerOfTen) { - double candidate = new BigDecimal(digits).scaleByPowerOfTen(powerOfTen).doubleValue(); - return magnitude == candidate; - } - - private static int fpPowerOfTenToTwo(int power) { - return (power * 108853) >> 15; - } - - private static int fpPowerOfTwoToTen(int power) { - return (power * 78913) >> 18; - } - - private static long fpPowerOfTen(int power) { - int[] nextBits = new int[1]; - if (power < 0) { - if (power == -1) { - nextBits[0] = FP_POWER_SCALE_LOW[13]; - return FP_POWER_SCALES[13]; - } - int group = power / 27; - int index = power % 27; - if (index != 0) { - group--; - index += 27; - } - long scale = FP_POWER_SCALES[group + 13]; - if (index == 0) { - nextBits[0] = FP_POWER_SCALE_LOW[group + 13]; - return scale; - } - return fpNormalizeHigh( - fpMultiplyWide( - scale, FP_POWER_SCALE_LOW[group + 13], FP_POWERS_OF_TEN[index], nextBits), - nextBits); - } - if (power < 27) { - nextBits[0] = 0; - return FP_POWERS_OF_TEN[power]; - } - int group = power / 27; - int index = power % 27; - long scale = FP_POWER_SCALES[group + 13]; - if (index == 0) { - nextBits[0] = FP_POWER_SCALE_LOW[group + 13]; - return scale; - } - return fpNormalizeHigh( - fpMultiplyWide(scale, FP_POWER_SCALE_LOW[group + 13], FP_POWERS_OF_TEN[index], nextBits), - nextBits); - } - - private static long fpNormalizeHigh(long high, int[] nextBits) { - if (high >= 0) { - high = (high << 1) | ((nextBits[0] >>> 31) & 1); - nextBits[0] = (nextBits[0] << 1) | 1; - } - return high; - } - - private static long fpMultiplyWide(long high, int low, long multiplier, int[] resultLow) { - BigInteger left = fpUnsigned(high).shiftLeft(32).or(BigInteger.valueOf(low & 0xffffffffL)); - BigInteger product = left.multiply(fpUnsigned(multiplier)); - resultLow[0] = product.shiftRight(64).intValue(); - return product.shiftRight(96).longValue(); - } - - private static long fpMultiplyHigh(long left, long right) { - return fpUnsigned(left).multiply(fpUnsigned(right)).shiftRight(64).longValue(); - } - - private static BigInteger fpUnsigned(long value) { - BigInteger unsigned = BigInteger.valueOf(value); - return value < 0 ? unsigned.add(BigInteger.ONE.shiftLeft(64)) : unsigned; - } - /** * Converts every storage class through {@link #getInt(int)}, matching sqlite-jdbc. NULL and values * whose integer coercion is zero are false: non-numeric text such as {@code "true"}, REAL values @@ -466,14 +202,7 @@ public short getShort(int columnIndex) throws SQLException { @Override public int getInt(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); - return switch (val) { - case Value.Null n -> 0; - case Value.Integer i -> (int) i.value(); - case Value.Float f -> (int) (long) f.value(); - case Value.Text t -> (int) parseSqliteLong(t.value()); - case Value.Blob b -> (int) parseSqliteLong(blobAsText(b)); - }; + return (int) toLong(getCurrentValue(columnIndex)); } @Override @@ -483,14 +212,7 @@ public long getLong(int columnIndex) throws SQLException { @Override public float getFloat(int columnIndex) throws SQLException { - Value val = getCurrentValue(columnIndex); - return switch (val) { - case Value.Null n -> 0.0F; - case Value.Integer i -> (float) (double) i.value(); - case Value.Float f -> (float) f.value(); - case Value.Text t -> (float) parseSqliteDouble(t.value()); - case Value.Blob b -> (float) parseSqliteDouble(blobAsText(b)); - }; + return (float) toDouble(getCurrentValue(columnIndex)); } @Override @@ -503,8 +225,9 @@ private static long toLong(Value val) { case Value.Null n -> 0L; case Value.Integer i -> i.value(); case Value.Float f -> (long) f.value(); - case Value.Text t -> parseSqliteLong(t.value()); - case Value.Blob b -> parseSqliteLong(blobAsText(b)); + case Value.Text t -> SqliteNumericText.parseLong(t.value()); + case Value.Blob b -> + SqliteNumericText.parseLong(SqliteNumericText.blobAsText(b.bytes())); }; } @@ -513,116 +236,12 @@ private static double toDouble(Value val) { case Value.Null n -> 0.0; case Value.Integer i -> (double) i.value(); case Value.Float f -> f.value(); - case Value.Text t -> parseSqliteDouble(t.value()); - case Value.Blob b -> parseSqliteDouble(blobAsText(b)); + case Value.Text t -> SqliteNumericText.parseDouble(t.value()); + case Value.Blob b -> + SqliteNumericText.parseDouble(SqliteNumericText.blobAsText(b.bytes())); }; } - private static String blobAsText(Value.Blob b) { - return new String(b.bytes(), java.nio.charset.StandardCharsets.UTF_8); - } - - private static long parseSqliteLong(String text) { - int length = text.length(); - int index = skipSqliteWhitespace(text, 0); - boolean negative = false; - if (index < length && (text.charAt(index) == '+' || text.charAt(index) == '-')) { - negative = text.charAt(index) == '-'; - index++; - } - - int digitsStart = index; - long value = 0L; - boolean overflow = false; - while (index < length) { - char c = text.charAt(index); - if (c < '0' || c > '9') { - break; - } - if (!overflow) { - int digit = c - '0'; - if (value > (Long.MAX_VALUE - digit) / 10) { - overflow = true; - } else { - value = value * 10 + digit; - } - } - index++; - } - - if (index == digitsStart) { - return 0L; - } - if (overflow) { - return negative ? Long.MIN_VALUE : Long.MAX_VALUE; - } - return negative ? -value : value; - } - - private static double parseSqliteDouble(String text) { - int length = text.length(); - int index = skipSqliteWhitespace(text, 0); - int start = index; - if (index < length && (text.charAt(index) == '+' || text.charAt(index) == '-')) { - index++; - } - - int digits = 0; - while (index < length && isAsciiDigit(text.charAt(index))) { - index++; - digits++; - } - if (index < length && text.charAt(index) == '.') { - index++; - while (index < length && isAsciiDigit(text.charAt(index))) { - index++; - digits++; - } - } - - if (digits == 0) { - return 0.0; - } - - int end = index; - if (index < length && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) { - int exponentIndex = index + 1; - if (exponentIndex < length - && (text.charAt(exponentIndex) == '+' || text.charAt(exponentIndex) == '-')) { - exponentIndex++; - } - int exponentDigits = 0; - while (exponentIndex < length && isAsciiDigit(text.charAt(exponentIndex))) { - exponentIndex++; - exponentDigits++; - } - if (exponentDigits > 0) { - end = exponentIndex; - } - } - - try { - return Double.parseDouble(text.substring(start, end)); - } catch (NumberFormatException e) { - return 0.0; - } - } - - private static int skipSqliteWhitespace(String text, int index) { - while (index < text.length() && isSqliteWhitespace(text.charAt(index))) { - index++; - } - return index; - } - - private static boolean isSqliteWhitespace(char c) { - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\u000b' || c == '\f'; - } - - private static boolean isAsciiDigit(char c) { - return c >= '0' && c <= '9'; - } - @Override @Deprecated @SuppressWarnings("deprecation") @@ -638,7 +257,7 @@ public byte[] getBytes(int columnIndex) throws SQLException { 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(f.value()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + case Value.Float f -> SqliteRealText.format(f.value()).getBytes(java.nio.charset.StandardCharsets.UTF_8); }; } @@ -1290,209 +909,4 @@ public T unwrap(Class iface) throws SQLException { public boolean isWrapperFor(Class iface) throws SQLException { return iface.isInstance(this); } - - /** Read-only {@link Blob} over the materialized bytes of one result column. */ - private static final class LibsqlBlob implements Blob { - - private byte[] bytes; - private boolean freed; - - private LibsqlBlob(byte[] bytes) { - this.bytes = bytes; - } - - private byte[] data() throws SQLException { - if (freed) { - throw new SQLException("Blob has been freed"); - } - return bytes; - } - - @Override - public long length() throws SQLException { - return data().length; - } - - @Override - public byte[] getBytes(long pos, int length) throws SQLException { - byte[] data = data(); - if (pos < 1) { - throw new SQLException("Invalid position: " + pos); - } - if (length < 0) { - throw new SQLException("Invalid length: " + length); - } - int start = (int) Math.min(pos - 1, data.length); - int end = Math.min(start + length, data.length); - return Arrays.copyOfRange(data, start, end); - } - - @Override - public InputStream getBinaryStream() throws SQLException { - return new ByteArrayInputStream(data()); - } - - @Override - public long position(byte[] pattern, long start) throws SQLException { - if (start < 1) { - throw new SQLException("Invalid position: " + start); - } - byte[] data = data(); - int from = (int) Math.min(start - 1, data.length); - for (int i = from; i + pattern.length <= data.length; i++) { - boolean match = true; - for (int j = 0; j < pattern.length; j++) { - if (data[i + j] != pattern[j]) { - match = false; - break; - } - } - if (match) { - return i + 1L; - } - } - return -1; - } - - @Override - public long position(Blob pattern, long start) throws SQLException { - return position(pattern.getBytes(1, (int) pattern.length()), start); - } - - @Override - public int setBytes(long pos, byte[] bytes) throws SQLException { - throw new SQLFeatureNotSupportedException("Blob is read-only"); - } - - @Override - public int setBytes(long pos, byte[] bytes, int offset, int len) throws SQLException { - throw new SQLFeatureNotSupportedException("Blob is read-only"); - } - - @Override - public OutputStream setBinaryStream(long pos) throws SQLException { - throw new SQLFeatureNotSupportedException("Blob is read-only"); - } - - @Override - public void truncate(long len) throws SQLException { - throw new SQLFeatureNotSupportedException("Blob is read-only"); - } - - @Override - public void free() throws SQLException { - freed = true; - bytes = null; - } - - @Override - public InputStream getBinaryStream(long pos, long length) throws SQLException { - if (length < 0) { - throw new SQLException("Invalid length: " + length); - } - return new ByteArrayInputStream(getBytes(pos, (int) Math.min(length, Integer.MAX_VALUE))); - } - } - - /** Read-only {@link Clob} over the materialized text of one result column. */ - private static final class LibsqlClob implements Clob { - - private String text; - private boolean freed; - - private LibsqlClob(String text) { - this.text = text; - } - - private String data() throws SQLException { - if (freed) { - throw new SQLException("Clob has been freed"); - } - return text; - } - - @Override - public long length() throws SQLException { - return data().length(); - } - - @Override - public String getSubString(long pos, int length) throws SQLException { - String data = data(); - if (pos < 1) { - throw new SQLException("Invalid position: " + pos); - } - if (length < 0) { - throw new SQLException("Invalid length: " + length); - } - int start = (int) Math.min(pos - 1, data.length()); - int end = Math.min(start + length, data.length()); - return data.substring(start, end); - } - - @Override - public Reader getCharacterStream() throws SQLException { - return new StringReader(data()); - } - - @Override - public InputStream getAsciiStream() throws SQLException { - return new ByteArrayInputStream(data().getBytes(StandardCharsets.US_ASCII)); - } - - @Override - public long position(String searchstr, long start) throws SQLException { - if (start < 1) { - throw new SQLException("Invalid position: " + start); - } - String data = data(); - int from = (int) Math.min(start - 1, data.length()); - int index = data.indexOf(searchstr, from); - return index < 0 ? -1 : index + 1L; - } - - @Override - public long position(Clob searchstr, long start) throws SQLException { - return position(searchstr.getSubString(1, (int) searchstr.length()), start); - } - - @Override - public int setString(long pos, String str) throws SQLException { - throw new SQLFeatureNotSupportedException("Clob is read-only"); - } - - @Override - public int setString(long pos, String str, int offset, int len) throws SQLException { - throw new SQLFeatureNotSupportedException("Clob is read-only"); - } - - @Override - public OutputStream setAsciiStream(long pos) throws SQLException { - throw new SQLFeatureNotSupportedException("Clob is read-only"); - } - - @Override - public Writer setCharacterStream(long pos) throws SQLException { - throw new SQLFeatureNotSupportedException("Clob is read-only"); - } - - @Override - public void truncate(long len) throws SQLException { - throw new SQLFeatureNotSupportedException("Clob is read-only"); - } - - @Override - public void free() throws SQLException { - freed = true; - text = null; - } - - @Override - public Reader getCharacterStream(long pos, long length) throws SQLException { - if (length < 0) { - throw new SQLException("Invalid length: " + length); - } - return new StringReader(getSubString(pos, (int) Math.min(length, Integer.MAX_VALUE))); - } - } } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqliteNumericText.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqliteNumericText.java new file mode 100644 index 0000000..6fcbbb6 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqliteNumericText.java @@ -0,0 +1,121 @@ +package com.github.magnusp.libsql.jdbc; + +import java.nio.charset.StandardCharsets; + +/** + * Parses SQLite's text-to-number coercion, matching the conversion sqlite-jdbc 3.53.4.0 applies + * for text and BLOB columns. Extracted verbatim from {@link LibsqlResultSet}. + * + *

Parsing consumes a numeric prefix only, so trailing garbage is ignored. An exponent counts + * only when at least one digit follows it. Unparseable text yields {@code 0} or {@code 0.0}, and + * integer overflow saturates to {@link Long#MIN_VALUE} or {@link Long#MAX_VALUE}. + */ +final class SqliteNumericText { + + private SqliteNumericText() {} + + static long parseLong(String text) { + int length = text.length(); + int index = skipSqliteWhitespace(text, 0); + boolean negative = false; + if (index < length && (text.charAt(index) == '+' || text.charAt(index) == '-')) { + negative = text.charAt(index) == '-'; + index++; + } + + int digitsStart = index; + long value = 0L; + boolean overflow = false; + while (index < length) { + char c = text.charAt(index); + if (c < '0' || c > '9') { + break; + } + if (!overflow) { + int digit = c - '0'; + if (value > (Long.MAX_VALUE - digit) / 10) { + overflow = true; + } else { + value = value * 10 + digit; + } + } + index++; + } + + if (index == digitsStart) { + return 0L; + } + if (overflow) { + return negative ? Long.MIN_VALUE : Long.MAX_VALUE; + } + return negative ? -value : value; + } + + static double parseDouble(String text) { + int length = text.length(); + int index = skipSqliteWhitespace(text, 0); + int start = index; + if (index < length && (text.charAt(index) == '+' || text.charAt(index) == '-')) { + index++; + } + + int digits = 0; + while (index < length && isAsciiDigit(text.charAt(index))) { + index++; + digits++; + } + if (index < length && text.charAt(index) == '.') { + index++; + while (index < length && isAsciiDigit(text.charAt(index))) { + index++; + digits++; + } + } + + if (digits == 0) { + return 0.0; + } + + int end = index; + if (index < length && (text.charAt(index) == 'e' || text.charAt(index) == 'E')) { + int exponentIndex = index + 1; + if (exponentIndex < length + && (text.charAt(exponentIndex) == '+' || text.charAt(exponentIndex) == '-')) { + exponentIndex++; + } + int exponentDigits = 0; + while (exponentIndex < length && isAsciiDigit(text.charAt(exponentIndex))) { + exponentIndex++; + exponentDigits++; + } + if (exponentDigits > 0) { + end = exponentIndex; + } + } + + try { + return Double.parseDouble(text.substring(start, end)); + } catch (NumberFormatException e) { + return 0.0; + } + } + + static String blobAsText(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } + + private static int skipSqliteWhitespace(String text, int index) { + while (index < text.length() && isSqliteWhitespace(text.charAt(index))) { + index++; + } + return index; + } + + private static boolean isSqliteWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\u000b' || c == '\f'; + } + + private static boolean isAsciiDigit(char c) { + return c >= '0' && c <= '9'; + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqliteRealText.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqliteRealText.java new file mode 100644 index 0000000..c877e1d --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqliteRealText.java @@ -0,0 +1,272 @@ +package com.github.magnusp.libsql.jdbc; + +import java.math.BigDecimal; +import java.math.BigInteger; + +/** + * Renders a SQLite REAL as text using the same fixed-point conversion SQLite 3.53.4 applies for + * {@code printf("%!.17g")}. Extracted verbatim from {@link LibsqlResultSet}. + */ +final class SqliteRealText { + + private SqliteRealText() {} + + // Port of the fixed-point conversion SQLite 3.53.4 uses for printf("%!.17g"), which is the + // text sqlite-jdbc 3.53.4.0 returns for REAL. The power-of-ten tables come from the same + // source (util.c and tool/mkfptab.c). + private static final long[] FP_POWERS_OF_TEN = { + 0x8000000000000000L, 0xa000000000000000L, 0xc800000000000000L, 0xfa00000000000000L, + 0x9c40000000000000L, 0xc350000000000000L, 0xf424000000000000L, 0x9896800000000000L, + 0xbebc200000000000L, 0xee6b280000000000L, 0x9502f90000000000L, 0xba43b74000000000L, + 0xe8d4a51000000000L, 0x9184e72a00000000L, 0xb5e620f480000000L, 0xe35fa931a0000000L, + 0x8e1bc9bf04000000L, 0xb1a2bc2ec5000000L, 0xde0b6b3a76400000L, 0x8ac7230489e80000L, + 0xad78ebc5ac620000L, 0xd8d726b7177a8000L, 0x878678326eac9000L, 0xa968163f0a57b400L, + 0xd3c21bcecceda100L, 0x84595161401484a0L, 0xa56fa5b99019a5c8L, + }; + + private static final long[] FP_POWER_SCALES = { + 0x8049a4ac0c5811aeL, 0xcf42894a5dce35eaL, 0xa76c582338ed2621L, 0x873e4f75e2224e68L, + 0xda7f5bf590966848L, 0xb080392cc4349decL, 0x8e938662882af53eL, 0xe65829b3046b0afaL, + 0xba121a4650e4ddebL, 0x964e858c91ba2655L, 0xf2d56790ab41c2a2L, 0xc428d05aa4751e4cL, + 0x9e74d1b791e07e48L, 0xccccccccccccccccL, 0xcecb8f27f4200f3aL, 0xa70c3c40a64e6c51L, + 0x86f0ac99b4e8dafdL, 0xda01ee641a708de9L, 0xb01ae745b101e9e4L, 0x8e41ade9fbebc27dL, + 0xe5d3ef282a242e81L, 0xb9a74a0637ce2ee1L, 0x95f83d0a1fb69cd9L, 0xf24a01a73cf2dccfL, + 0xc3b8358109e84f07L, 0x9e19db92b4e31ba9L, + }; + + private static final int[] FP_POWER_SCALE_LOW = { + 0x205b896d, 0x52064cad, 0xaf2af2b8, 0x5a7744a7, 0xaf39a475, 0xbd8d794e, + 0x547eb47b, 0x0cb4a5a3, 0x92f34d62, 0x3a6a07f9, 0xfae27299, 0xaa97e14c, + 0x775ea265, 0xcccccccc, 0x00000000, 0x999090b6, 0x69a028bb, 0xe80e6f48, + 0x5ec05dd0, 0x14588f14, 0x8f1668c9, 0x6d953e2c, 0x4abdaf10, 0xbc633b39, + 0x0a862f81, 0x6c07a2c2, + }; + + static String format(double value) { + if (Double.isNaN(value)) { + return "NaN"; + } + if (Double.isInfinite(value)) { + return value < 0 ? "-Inf" : "Inf"; + } + boolean negative = value < 0.0; + double magnitude = negative ? -value : value; + if (magnitude == 0.0) { + return "0.0"; + } + + long bits = Double.doubleToRawLongBits(magnitude); + int exponent = (int) ((bits >>> 52) & 0x7ff); + long mantissa = bits & 0x000fffffffffffffL; + if (exponent == 0) { + int shift = Long.numberOfLeadingZeros(mantissa); + mantissa <<= shift; + exponent = -1074 - shift; + } else { + mantissa = (mantissa << 11) | (1L << 63); + exponent -= 1086; + } + + int power = 17 - fpPowerOfTwoToTen(exponent + 63); + long powerOfTen = fpPowerOfTen(power); + long high = fpMultiplyHigh(mantissa, powerOfTen); + int shift = -(exponent + fpPowerOfTenToTwo(power) + 2); + high >>>= shift; + long digitsValue = (high + ((high << 1) & 2)) >>> 1; + int powerOfTenExponent = -power; + + String digits = Long.toUnsignedString(digitsValue); + int digitCount = digits.length(); + int decimalPoint = digitCount + powerOfTenExponent; + + if (digitCount > 17) { + int roundAt = 17; + if (digits.charAt(15) == '9' && digits.charAt(14) == '9') { + int lastKept = 14; + while (lastKept > 0 && digits.charAt(lastKept - 1) == '9') { + lastKept--; + } + BigInteger candidate = lastKept == 0 + ? BigInteger.ONE + : new BigInteger(digits.substring(0, lastKept)).add(BigInteger.ONE); + if (roundTripsTo(magnitude, candidate, powerOfTenExponent + digitCount - lastKept)) { + roundAt = lastKept + 1; + } + } else if (decimalPoint >= digitCount + || (digits.charAt(15) == '0' + && digits.charAt(14) == '0' + && digits.charAt(13) == '0')) { + int lastKept = 13; + while (lastKept > 0 && digits.charAt(lastKept - 1) == '0') { + lastKept--; + } + BigInteger candidate = new BigInteger(digits.substring(0, lastKept)); + if (roundTripsTo(magnitude, candidate, powerOfTenExponent + digitCount - lastKept)) { + roundAt = lastKept + 1; + } + } + + char[] rounded = new char[digitCount + 1]; + digits.getChars(0, digitCount, rounded, 1); + int offset = 1; + int count = roundAt; + if (rounded[offset + roundAt] >= '5') { + int index = offset + roundAt - 1; + while (true) { + rounded[index]++; + if (rounded[index] <= '9') { + break; + } + rounded[index] = '0'; + if (index == offset) { + offset--; + rounded[offset] = '1'; + count++; + decimalPoint++; + break; + } + index--; + } + } + digits = new String(rounded, offset, count); + } + + int end = digits.length(); + while (end > 1 && digits.charAt(end - 1) == '0') { + end--; + } + digits = digits.substring(0, end); + + int decimalExponent = decimalPoint - 1; + StringBuilder out = new StringBuilder(); + if (negative) { + out.append('-'); + } + if (decimalExponent < -4 || decimalExponent > 16) { + out.append(digits.charAt(0)).append('.'); + int fractionEnd = digits.length(); + while (fractionEnd > 1 && digits.charAt(fractionEnd - 1) == '0') { + fractionEnd--; + } + if (fractionEnd > 1) { + out.append(digits, 1, fractionEnd); + } else { + out.append('0'); + } + out.append('e'); + out.append(decimalExponent < 0 ? '-' : '+'); + int absoluteExponent = Math.abs(decimalExponent); + if (absoluteExponent >= 100) { + out.append((char) ('0' + absoluteExponent / 100)); + } + out.append((char) ('0' + (absoluteExponent / 10) % 10)); + out.append((char) ('0' + absoluteExponent % 10)); + } else { + int integerPlaces = decimalPoint - 1; + int fractionPrecision = 16 - decimalExponent; + StringBuilder fraction = new StringBuilder(); + if (integerPlaces < 0) { + out.append('0'); + int leadingZeros = Math.min(-1 - integerPlaces, fractionPrecision); + fraction.append("0".repeat(leadingZeros)); + fractionPrecision -= leadingZeros; + fraction.append(digits, 0, Math.min(digits.length(), fractionPrecision)); + } else { + int integerDigits = Math.min(integerPlaces + 1, digits.length()); + out.append(digits, 0, integerDigits); + out.append("0".repeat(Math.max(0, integerPlaces + 1 - integerDigits))); + int significantDigits = Math.min(digits.length() - integerDigits, fractionPrecision); + if (significantDigits > 0) { + fraction.append(digits, integerDigits, integerDigits + significantDigits); + } + } + int fractionEnd = fraction.length(); + while (fractionEnd > 0 && fraction.charAt(fractionEnd - 1) == '0') { + fractionEnd--; + } + out.append('.'); + if (fractionEnd == 0) { + out.append('0'); + } else { + out.append(fraction, 0, fractionEnd); + } + } + return out.toString(); + } + + private static boolean roundTripsTo(double magnitude, BigInteger digits, int powerOfTen) { + double candidate = new BigDecimal(digits).scaleByPowerOfTen(powerOfTen).doubleValue(); + return magnitude == candidate; + } + + private static int fpPowerOfTenToTwo(int power) { + return (power * 108853) >> 15; + } + + private static int fpPowerOfTwoToTen(int power) { + return (power * 78913) >> 18; + } + + private static long fpPowerOfTen(int power) { + int[] nextBits = new int[1]; + if (power < 0) { + if (power == -1) { + nextBits[0] = FP_POWER_SCALE_LOW[13]; + return FP_POWER_SCALES[13]; + } + int group = power / 27; + int index = power % 27; + if (index != 0) { + group--; + index += 27; + } + long scale = FP_POWER_SCALES[group + 13]; + if (index == 0) { + nextBits[0] = FP_POWER_SCALE_LOW[group + 13]; + return scale; + } + return fpNormalizeHigh( + fpMultiplyWide( + scale, FP_POWER_SCALE_LOW[group + 13], FP_POWERS_OF_TEN[index], nextBits), + nextBits); + } + if (power < 27) { + nextBits[0] = 0; + return FP_POWERS_OF_TEN[power]; + } + int group = power / 27; + int index = power % 27; + long scale = FP_POWER_SCALES[group + 13]; + if (index == 0) { + nextBits[0] = FP_POWER_SCALE_LOW[group + 13]; + return scale; + } + return fpNormalizeHigh( + fpMultiplyWide(scale, FP_POWER_SCALE_LOW[group + 13], FP_POWERS_OF_TEN[index], nextBits), + nextBits); + } + + private static long fpNormalizeHigh(long high, int[] nextBits) { + if (high >= 0) { + high = (high << 1) | ((nextBits[0] >>> 31) & 1); + nextBits[0] = (nextBits[0] << 1) | 1; + } + return high; + } + + private static long fpMultiplyWide(long high, int low, long multiplier, int[] resultLow) { + BigInteger left = fpUnsigned(high).shiftLeft(32).or(BigInteger.valueOf(low & 0xffffffffL)); + BigInteger product = left.multiply(fpUnsigned(multiplier)); + resultLow[0] = product.shiftRight(64).intValue(); + return product.shiftRight(96).longValue(); + } + + private static long fpMultiplyHigh(long left, long right) { + return fpUnsigned(left).multiply(fpUnsigned(right)).shiftRight(64).longValue(); + } + + private static BigInteger fpUnsigned(long value) { + BigInteger unsigned = BigInteger.valueOf(value); + return value < 0 ? unsigned.add(BigInteger.ONE.shiftLeft(64)) : unsigned; + } +} diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java index bf0c0d4..f296424 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java @@ -54,6 +54,34 @@ void resolvesTableNameNullabilityAndAutoIncrement() throws SQLException { assertThat(metadata.isAutoIncrement(2)).isFalse(); } + @Test + void resolvesAttachedTableMetadataThroughPragmaDatabaseList() throws SQLException { + LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( + List.of(new Col("id", "INTEGER"), new Col("name", "TEXT")), + List.of(), + "SELECT id, name FROM aux.People", + connectionForAttachedSchema("main", "temp", "Aux")); + + assertThat(metadata.getTableName(1)).isEqualTo("People"); + assertThat(metadata.getTableName(2)).isEqualTo("People"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls); + assertThat(metadata.isNullable(2)).isEqualTo(ResultSetMetaData.columnNoNulls); + assertThat(metadata.isAutoIncrement(1)).isTrue(); + assertThat(metadata.isAutoIncrement(2)).isFalse(); + } + + @Test + void reportsNoTableMetadataForASchemaMissingFromPragmaDatabaseList() throws SQLException { + LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( + List.of(new Col("id", "INTEGER")), + List.of(), + "SELECT id FROM ghost.People", + connectionForAttachedSchema("main", "temp", "Aux")); + + assertThat(metadata.getTableName(1)).isEmpty(); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullableUnknown); + } + @Test void resolvesNonAsciiTableName() throws SQLException { LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( @@ -752,4 +780,59 @@ public StmtResult executeStatement(Stmt stmt) throws SQLException { } }; } + + /** + * Answers {@code pragma_database_list} with the given schema names and every per-schema table + * lookup from a fixed catalogue, so the caller sees metadata only for schemas that the database + * list actually reports. + */ + private static LibsqlConnection connectionForAttachedSchema(String... databaseNames) { + Map tables = new LinkedHashMap<>(); + tables.put( + "Aux", + new FakeTable( + "People", + "CREATE TABLE People (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)", + List.of(new FakeColumn("id", true, true), new FakeColumn("name", true, false)))); + tables.put( + "main", + new FakeTable( + "People", + "CREATE TABLE People (id INTEGER, name TEXT)", + List.of(new FakeColumn("id", false, false), new FakeColumn("name", false, false)))); + tables.put( + "ghost", + new FakeTable( + "People", + "CREATE TABLE People (id INTEGER)", + List.of(new FakeColumn("id", false, false)))); + List databases = new ArrayList<>(); + for (String name : databaseNames) { + databases.add(new Row(List.of(Value.of(name)))); + } + StmtResult databaseList = new StmtResult(List.of(), databases, 0, null); + + return new LibsqlConnection(null, null) { + @Override + public StmtResult executeStatement(Stmt stmt) throws SQLException { + if (stmt.sql().contains("pragma_database_list")) { + return databaseList; + } + FakeTable table = tables.get(argument(stmt, 1)); + if (table == null) { + return new StmtResult(List.of(), List.of(), 0, null); + } + List rows = new ArrayList<>(); + for (FakeColumn column : table.columns()) { + rows.add(new Row(List.of( + Value.of(table.name()), + Value.of(table.sql()), + Value.of(column.name()), + Value.of(column.notNull() ? 1L : 0L), + Value.of(column.primaryKey() ? 1L : 0L)))); + } + return new StmtResult(List.of(), rows, 0, null); + } + }; + } } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java index 43fcc5e..35b01b6 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java @@ -5,10 +5,13 @@ import com.github.magnusp.libsql.hrana.codec.StmtResult; import com.github.magnusp.libsql.hrana.codec.Value; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.io.IOException; import java.io.InputStream; import java.io.Reader; +import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.sql.Array; import java.sql.Blob; @@ -24,6 +27,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; import java.util.stream.IntStream; @@ -137,6 +141,39 @@ void textWithLeadingWhitespaceAndTrailingGarbageUsesNumericPrefix() throws SQLEx assertNumeric(rs, 1, 42, 42L, 42.0f, 42.0); } + @ParameterizedTest + @ValueSource(strings = {" ", "\t", "\n", "\r", "\u000b", "\f"}) + void everySqliteWhitespaceCharacterIsSkippedBeforeTheNumericPrefix(String whitespace) throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text(whitespace + "42xyz")); + assertThat(rs.next()).isTrue(); + assertNumeric(rs, 1, 42, 42L, 42.0f, 42.0); + } + + @Test + void largeIntegerTextBelowLongMaxIsReturnedUnchanged() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("6000000000000000000")); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getLong(1)).isEqualTo(6_000_000_000_000_000_000L); + assertThat(rs.getBigDecimal(1)).isEqualByComparingTo(new BigDecimal("6000000000000000000")); + } + + @Test + void textJustPastLongMaxSaturatesToLongMax() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("9223372036854775808")); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getLong(1)).isEqualTo(Long.MAX_VALUE); + } + + @Test + void textJustPastLongMinSaturatesToLongMin() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("-9223372036854775809")); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getLong(1)).isEqualTo(Long.MIN_VALUE); + } + @Test void numericBlobIsInterpretedAsText() throws SQLException { LibsqlResultSet rs = resultSet(new Value.Blob("123".getBytes(StandardCharsets.UTF_8))); @@ -276,6 +313,63 @@ void realJulianDayFeedsEveryDateAndTimeGetter() throws SQLException { assertDateTimeGetters(rs, 1_704_153_600_000L); } + @Test + void textDateTimeWithAnOffsetSuffixIsConvertedToTheSystemZone() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-03-15 10:30:00+02:00")); + assertThat(rs.next()).isTrue(); + + LocalDateTime expected = LocalDateTime.of(2024, 3, 15, 10, 30) + .atOffset(ZoneOffset.ofHours(2)) + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); + + assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected); + assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate()); + assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime()); + } + + @Test + void textDateTimeWithAZSuffixIsReadAsUtc() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-03-15T10:30:00Z")); + assertThat(rs.next()).isTrue(); + + LocalDateTime expected = LocalDateTime.of(2024, 3, 15, 10, 30) + .atOffset(ZoneOffset.UTC) + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); + + assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected); + assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate()); + assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime()); + } + + @Test + void bareTimeTextGetsTheYear2000DefaultDate() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("10:30:45")); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getObject(1, LocalDateTime.class)) + .isEqualTo(LocalDateTime.of(2000, 1, 1, 10, 30, 45)); + assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(LocalDate.of(2000, 1, 1)); + assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(LocalTime.of(10, 30, 45)); + } + + @Test + void blobRejectsDateAndTimeGettersWithTheTargetTypeName() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Blob(new byte[] {1, 2, 3})); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, LocalDate.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type LocalDate"); + assertThatThrownBy(() -> rs.getObject(1, LocalTime.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type LocalTime"); + assertThatThrownBy(() -> rs.getObject(1, LocalDateTime.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type LocalDateTime"); + } + @Test void binaryStreamContentMatchesGetBytesByteForByte() throws Exception { byte[] payload = {0, 1, 2, 65, 127, -128, -1}; diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java index 883152b..c9aab60 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java @@ -208,6 +208,52 @@ void releasingTheOutermostSavepointInsideAnExplicitBeginKeepsTheTransaction() "INSERT INTO sample VALUES (2)"); } + @Test + void releasingTheLaterOfTwoSavepointsWithTheSameNameKeepsTheEarlierOneActive() + throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + Savepoint first = connection.setSavepoint("dup"); + Savepoint second = connection.setSavepoint("dup"); + + connection.releaseSavepoint(second); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + connection.rollback(first); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)")); + + assertThat(client.executedStatements()) + .containsExactly( + "SAVEPOINT \"dup\"", + "SAVEPOINT \"dup\"", + "RELEASE SAVEPOINT \"dup\"", + "INSERT INTO sample VALUES (1)", + "ROLLBACK TO SAVEPOINT \"dup\"", + "INSERT INTO sample VALUES (2)"); + assertThat(client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Close.class::isInstance)) + .isEmpty(); + } + + @Test + void releasingTheOnlySavepointOfAnAutoCommitTransactionLeavesCommitWithNothingToSend() + throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + Savepoint savepoint = connection.setSavepoint("only"); + + connection.releaseSavepoint(savepoint); + connection.commit(); + + assertThat(connection.getAutoCommit()).isFalse(); + assertThat(client.executedStatements()) + .containsExactly("SAVEPOINT \"only\"", "RELEASE SAVEPOINT \"only\""); + assertThat(client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Close.class::isInstance)) + .hasSize(1); + } + @Test void rollbackAndReleaseThrowInAutoCommitModeWithoutSendingAnything() throws SQLException { ScriptedClient client = successClient(); diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java index e375fb5..116bd7d 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java @@ -34,6 +34,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class LibsqlStatementConcurrencyTest { @@ -181,6 +182,32 @@ void closeDoesNotInterleaveWithExecuteAndLeavesNoStreamOpen() throws Exception { } } + @Test + void executeBatchThatLosesTheRaceToCloseFailsWithStatementClosed() throws Exception { + Pause pause = new Pause(); + LibsqlStatement statement = new LibsqlStatement(new PausingBatchConnection(pause)); + statement.addBatch("insert into t(value) values (1)"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future batch = executor.submit(statement::executeBatch); + + assertThat(pause.awaitReached()).isTrue(); + AtomicReference closeFailure = new AtomicReference<>(); + joinAndCheck(closeInThread(statement, closeFailure), closeFailure); + assertThat(statement.isClosed()).isTrue(); + pause.resume(); + + assertThatThrownBy(() -> batch.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(SQLException.class) + .hasMessageContaining("Statement is closed"); + } finally { + pause.resume(); + executor.shutdownNow(); + } + } + private static Void executeRepeatedly(LibsqlStatement statement, int count) throws SQLException { for (int i = 0; i < count; i++) { statement.execute("SELECT " + i); @@ -300,6 +327,27 @@ protected void beforeResultSetAssignment() throws SQLException { } } + /** + * Holds the batch inside the connection round trip so a close can land before the statement + * reaches its own cleanup. + */ + private static final class PausingBatchConnection extends LibsqlConnection { + private final Pause pause; + + private PausingBatchConnection(Pause pause) { + super(null, null); + this.pause = pause; + } + + @Override + public List executeBatchResults(List statements) throws SQLException { + pause.await(); + return statements.stream() + .map(statement -> new StmtResult(List.of(), List.of(), 1, null)) + .toList(); + } + } + /** * Models the baton rules the connection must uphold: one open stream per connection, one * request on a stream at a time, and a baton that is consumed by its request. Every rule break