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 db3b68e..84dfa9c 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 @@ -8,6 +8,7 @@ import java.io.InputStream; import java.io.Reader; import java.math.BigDecimal; +import java.math.BigInteger; import java.net.URL; import java.sql.Array; import java.sql.Blob; @@ -124,18 +125,287 @@ public boolean wasNull() throws SQLException { return wasNull; } + /** + * Returns SQLite's canonical text for each storage class, matching sqlite-jdbc 3.53.4.0. + * + *

REAL values use the same representation as SQLite's {@code %!.17g}: up to 17 significant + * digits with trailing zeros removed, fixed notation when the decimal exponent is between -4 + * and 16, and scientific notation with a signed, at least two-digit exponent otherwise. The + * result always has at least one digit after the decimal point. For example, {@code 0.0001} + * becomes {@code 0.0001}, {@code 10000000.0} becomes {@code 10000000.0}, and {@code 1e20} + * becomes {@code 1.0e+20}. Negative zero renders as {@code 0.0}. + */ @Override public String getString(int columnIndex) throws SQLException { Value val = getCurrentValue(columnIndex); return switch (val) { case Value.Null n -> null; case Value.Integer i -> Long.toString(i.value()); - case Value.Float f -> Double.toString(f.value()); + case Value.Float f -> sqliteRealText(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 diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetRealTextTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetRealTextTest.java new file mode 100644 index 0000000..f9a2121 --- /dev/null +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetRealTextTest.java @@ -0,0 +1,103 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.hrana.codec.Col; +import com.github.magnusp.libsql.hrana.codec.Row; +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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class LibsqlResultSetRealTextTest { + + @Test + void realTextMatchesSqliteCanonicalForm() throws SQLException { + LibsqlResultSet rs = resultSet( + new Value.Float(0.0001), + new Value.Float(10_000_000.0), + new Value.Float(1e20)); + + assertThat(rs.next()).isTrue(); + + assertThat(rs.getString(1)).isEqualTo("0.0001"); + assertThat(rs.getString(2)).isEqualTo("10000000.0"); + assertThat(rs.getString(3)).isEqualTo("1.0e+20"); + } + + @Test + void realBigDecimalScaleMatchesSqliteCanonicalForm() throws SQLException { + LibsqlResultSet rs = resultSet( + new Value.Float(0.0001), + new Value.Float(10_000_000.0), + new Value.Float(1e20)); + + assertThat(rs.next()).isTrue(); + + assertThat(rs.getBigDecimal(1)).isEqualTo(new BigDecimal("0.0001")); + assertThat(rs.getBigDecimal(1).scale()).isEqualTo(4); + assertThat(rs.getBigDecimal(2)).isEqualTo(new BigDecimal("10000000.0")); + assertThat(rs.getBigDecimal(2).scale()).isEqualTo(1); + assertThat(rs.getBigDecimal(3)).isEqualTo(new BigDecimal("1.0e+20")); + assertThat(rs.getBigDecimal(3).scale()).isEqualTo(-19); + } + + @Test + void realBigDecimalEqualsIsStableAcrossRepeatedAndRowReads() throws SQLException { + Value value = new Value.Float(1e20); + StmtResult result = new StmtResult( + List.of(new Col("c", null)), + List.of(new Row(List.of(value)), new Row(List.of(value))), + 1, + null); + LibsqlResultSet rs = new LibsqlResultSet(null, result); + + assertThat(rs.next()).isTrue(); + BigDecimal first = rs.getBigDecimal(1); + BigDecimal repeated = rs.getBigDecimal(1); + assertThat(rs.next()).isTrue(); + BigDecimal secondRow = rs.getBigDecimal(1); + + assertThat(repeated).isEqualTo(first); + assertThat(secondRow).isEqualTo(first); + assertThat(first.toString()).isEqualTo("1.0E+20"); + } + + static Stream canonicalText() { + return Stream.of( + Arguments.of("0.0001", "0.0001"), + Arguments.of("10000000.0", "10000000.0"), + Arguments.of("1e20", "1.0e+20"), + Arguments.of("-0.0001", "-0.0001"), + Arguments.of("-10000000.0", "-10000000.0"), + Arguments.of("-1e20", "-1.0e+20"), + Arguments.of("0.0", "0.0"), + Arguments.of("1e16", "10000000000000000.0"), + Arguments.of("1e17", "1.0e+17"), + Arguments.of("1e-4", "0.0001"), + Arguments.of("1e-5", "1.0e-05")); + } + + @ParameterizedTest(name = "{0} -> {1}") + @MethodSource("canonicalText") + void realTextUsesFixedNotationBetweenNegativeFourAndSixteen(String sqliteValue, String expected) + throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Float(Double.parseDouble(sqliteValue))); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getString(1)).isEqualTo(expected); + } + + private static LibsqlResultSet resultSet(Value... values) { + List cols = Stream.generate(() -> new Col("c", null)).limit(values.length).toList(); + StmtResult result = new StmtResult(cols, List.of(new Row(List.of(values))), 1, null); + return new LibsqlResultSet(null, result); + } +}