fix(jdbc): render bit columns as bit strings

This commit is contained in:
zipg
2026-09-22 22:33:07 +08:00
committed by t8y2
parent 47939bd556
commit a85fc7fb93
2 changed files with 309 additions and 9 deletions
@@ -82,6 +82,24 @@ public final class DbxJdbcPlugin {
"SYSTEM TABLE",
"SYSTEM VIEW"
};
/**
* MySQL/PostgreSQL 家族(含金仓、瀚高、优炫、海量这类 PG 衍生库)里 BIT 是位字段/位串:数据库自带
* 工具和 DBX 的内置驱动都按 `0`/`1`/`10101010` 展示。JDBC 驱动把这些列暴露成裸字节或强制转布尔,
* 走通用分支就成了 `0x00`/`true`。这类连接由 readBitStringColumnValue 按位串读取。
* SQL Server 这类把 BIT 当布尔类型的库不在名单内,保持驱动返回的布尔值。
*/
private static final String[] BIT_STRING_JDBC_URL_PREFIXES = new String[] {
"jdbc:mysql:",
"jdbc:mariadb:",
"jdbc:starrocks:",
"jdbc:doris:",
"jdbc:postgresql:",
"jdbc:kingbase",
"jdbc:highgo:",
"jdbc:uxdb:",
"jdbc:vastbase:"
};
private static final JdbcDriverQuirks DEFAULT_QUIRKS = new JdbcDriverQuirks(
false,
false,
@@ -853,6 +871,16 @@ public final class DbxJdbcPlugin {
return driverClass != null && driverClass.equalsIgnoreCase("org.postgresql.Driver");
}
private static boolean usesBitStringColumns(JsonNode connection) {
String url = jdbcUrl(connection);
for (String prefix : BIT_STRING_JDBC_URL_PREFIXES) {
if (urlMatchesPrefix(url, prefix)) {
return true;
}
}
return false;
}
private static boolean isPrestoOrTrinoConnection(JsonNode connection) {
String url = jdbcUrl(connection);
if (urlMatchesPrefix(url, "jdbc:presto:") || urlMatchesPrefix(url, "jdbc:trino:")) {
@@ -993,6 +1021,7 @@ public final class DbxJdbcPlugin {
JdbcDriverQuirks quirks = driverQuirks(connection);
boolean preserveOracleDateTime = isOracleUrl(jdbcUrl(connection));
ZoneId timestampZone = tdengineTimestampZone(connection, conn);
boolean bitStringColumns = usesBitStringColumns(connection);
try (Statement statement = conn.createStatement()) {
applyStatementOptions(statement, maxRows, fetchSize, timeoutSecs, quirks);
String trimmedSql = trimStatementSql(sql);
@@ -1022,7 +1051,7 @@ public final class DbxJdbcPlugin {
}
ArrayNode row = MAPPER.createArrayNode();
for (int i = 1; i <= columnCount; i++) {
row.add(MAPPER.valueToTree(readValue(rs, meta, i, preserveOracleDateTime, timestampZone)));
row.add(MAPPER.valueToTree(readValue(rs, meta, i, preserveOracleDateTime, timestampZone, bitStringColumns)));
}
rows.add(row);
}
@@ -1116,6 +1145,7 @@ public final class DbxJdbcPlugin {
private final boolean restoreAutoCommit;
private final boolean preserveOracleDateTime;
private final ZoneId timestampZone;
private final boolean bitStringColumns;
private int rowsReturned;
private ArrayNode pendingRow;
@@ -1130,7 +1160,8 @@ public final class DbxJdbcPlugin {
Connection connection,
boolean restoreAutoCommit,
boolean preserveOracleDateTime,
ZoneId timestampZone
ZoneId timestampZone,
boolean bitStringColumns
) {
this.id = id;
this.statement = statement;
@@ -1143,6 +1174,7 @@ public final class DbxJdbcPlugin {
this.restoreAutoCommit = restoreAutoCommit;
this.preserveOracleDateTime = preserveOracleDateTime;
this.timestampZone = timestampZone;
this.bitStringColumns = bitStringColumns;
}
}
@@ -1162,6 +1194,7 @@ public final class DbxJdbcPlugin {
JdbcDriverQuirks quirks = driverQuirks(connection);
boolean preserveOracleDateTime = isOracleUrl(jdbcUrl(connection));
ZoneId timestampZone = tdengineTimestampZone(connection, conn);
boolean bitStringColumns = usesBitStringColumns(connection);
boolean restoreAutoCommit = beginPagedQueryTransaction(connection, conn);
Statement statement;
try {
@@ -1209,7 +1242,8 @@ public final class DbxJdbcPlugin {
conn,
restoreAutoCommit,
preserveOracleDateTime,
timestampZone
timestampZone,
bitStringColumns
);
QUERY_SESSIONS.put(sessionId, session);
try {
@@ -1282,7 +1316,7 @@ public final class DbxJdbcPlugin {
closeQuerySession(session.id);
return queryPageResult(session, rows, false, false);
}
row = readRow(session.resultSet, session.meta, session.preserveOracleDateTime, session.timestampZone);
row = readRow(session.resultSet, session.meta, session.preserveOracleDateTime, session.timestampZone, session.bitStringColumns);
}
rows.add(row);
session.rowsReturned++;
@@ -1300,7 +1334,7 @@ public final class DbxJdbcPlugin {
return queryPageResult(session, rows, false, false);
}
session.pendingRow = readRow(session.resultSet, session.meta, session.preserveOracleDateTime, session.timestampZone);
session.pendingRow = readRow(session.resultSet, session.meta, session.preserveOracleDateTime, session.timestampZone, session.bitStringColumns);
return queryPageResult(session, rows, false, true);
}
@@ -1354,11 +1388,12 @@ public final class DbxJdbcPlugin {
ResultSet rs,
ResultSetMetaData meta,
boolean preserveOracleDateTime,
ZoneId timestampZone
ZoneId timestampZone,
boolean bitStringColumns
) throws SQLException {
ArrayNode row = MAPPER.createArrayNode();
for (int i = 1; i <= meta.getColumnCount(); i++) {
row.add(MAPPER.valueToTree(readValue(rs, meta, i, preserveOracleDateTime, timestampZone)));
row.add(MAPPER.valueToTree(readValue(rs, meta, i, preserveOracleDateTime, timestampZone, bitStringColumns)));
}
return row;
}
@@ -4258,7 +4293,7 @@ public final class DbxJdbcPlugin {
int index,
boolean preserveOracleDateTime
) throws SQLException {
return readValue(rs, meta, index, preserveOracleDateTime, null);
return readValue(rs, meta, index, preserveOracleDateTime, null, false);
}
private static Object readValue(
@@ -4266,7 +4301,8 @@ public final class DbxJdbcPlugin {
ResultSetMetaData meta,
int index,
boolean preserveOracleDateTime,
ZoneId timestampZone
ZoneId timestampZone,
boolean bitStringColumns
) throws SQLException {
int columnType = meta.getColumnType(index);
@@ -4295,6 +4331,13 @@ public final class DbxJdbcPlugin {
return bytes == null ? null : binaryToHex(bytes);
}
if (bitStringColumns && isBitStringColumn(meta, index, columnType)) {
Object bitValue = readBitStringColumnValue(rs, meta, index);
if (bitValue != BIT_COLUMN_UNSUPPORTED) {
return bitValue;
}
}
Object value = rs.getObject(index);
if (value == null) {
return null;
@@ -4454,6 +4497,116 @@ public final class DbxJdbcPlugin {
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
/**
* readBitStringColumnValue 的哨兵:驱动读不出位串(例如 mssql-jdbc 不允许把 BIT 读成 byte[]),
* 调用方据此回落到原来的取值路径。SQL NULL 用 null 表示,两者必须分开。
*/
private static final Object BIT_COLUMN_UNSUPPORTED = new Object();
private static boolean isBitStringColumn(ResultSetMetaData meta, int index, int columnType) {
String typeName = columnTypeName(meta, index);
String normalized = typeName.trim().toLowerCase(Locale.ROOT);
if (normalized.equals("bool") || normalized.equals("boolean")) {
// 金仓/PostgreSQL 的布尔列走 Types.BIT 上报,它们继续保持布尔值。
return false;
}
if (normalized.equals("bit") || normalized.equals("varbit") || normalized.startsWith("bit ")
|| normalized.startsWith("bit(") || normalized.startsWith("bit varying")) {
return true;
}
return columnType == Types.BIT;
}
private static String columnTypeName(ResultSetMetaData meta, int index) {
try {
String typeName = meta.getColumnTypeName(index);
return typeName == null ? "" : typeName;
} catch (SQLException | RuntimeException error) {
return "";
}
}
private static Object readBitStringColumnValue(ResultSet rs, ResultSetMetaData meta, int index) throws SQLException {
byte[] payload;
try {
payload = rs.getBytes(index);
} catch (SQLException | AbstractMethodError | UnsupportedOperationException error) {
return BIT_COLUMN_UNSUPPORTED;
}
if (payload == null) {
// wasNull() 区分 SQL NULL 与「驱动不支持按字节读取」。
return rs.wasNull() ? null : BIT_COLUMN_UNSUPPORTED;
}
int precision = bitColumnPrecision(meta, index);
if (payload.length == 1 && precision <= 1 && (payload[0] == 't' || payload[0] == 'f')) {
// GaussDB/金仓的布尔位串按 't'/'f' 返回,保持布尔语义;位宽大于 1 的列是位字段而不是布尔。
return payload[0] == 't';
}
if (payload.length == 1 && precision == 1 && payload[0] != 0x00 && payload[0] != 0x01
&& payload[0] != '0' && payload[0] != '1') {
// Connector/J 默认把 tinyint(1) 上报成 Types.BIT 且位宽为 1,载荷超出 0/1 说明是数值列:
// 截成单个比特会静默丢数据,按有符号字节十进制展示(`'0'`/`'1'` 文本是驱动的位串形式,除外)。
return payload[0];
}
String bitString = bitStringFromPayload(payload, precision);
// 位宽不可信时 bitStringFromPayload 返回 null(不是 SQL NULL),交给通用分支保持 `0x..` 展示。
return bitString == null ? BIT_COLUMN_UNSUPPORTED : bitString;
}
private static int bitColumnPrecision(ResultSetMetaData meta, int index) {
try {
return meta.getPrecision(index);
} catch (SQLException | RuntimeException error) {
return 0;
}
}
/**
* 把 BIT 列的字节载荷还原成位串:
*
* - `0x30`/`0x31`(`'0'`/`'1'` 文本)是 PG 家族驱动的文本形式,位串长度等于声明位宽;
* - 其余载荷是裸位字段(MySQL 的 `bit(n)`、驱动不返回文本的 PG 列),按声明位宽展开;
* - 声明位宽超出载荷容量时返回 null,交给调用方保持原有的 `0x..` 展示。
*/
private static String bitStringFromPayload(byte[] payload, int precision) {
if (payload.length == 0) {
return "";
}
if (isAsciiBitStringPayload(payload) && (precision <= 0 || payload.length == precision)) {
return new String(payload, StandardCharsets.US_ASCII);
}
int width = precision > 0 ? precision : significantBitWidth(payload);
if (width > payload.length * 8) {
return null;
}
StringBuilder bits = new StringBuilder(width);
for (int bit = width - 1; bit >= 0; bit--) {
int value = payload[payload.length - 1 - bit / 8];
bits.append(((value >> bit % 8) & 1) == 1 ? '1' : '0');
}
return bits.toString();
}
private static boolean isAsciiBitStringPayload(byte[] payload) {
for (byte value : payload) {
if (value != '0' && value != '1') {
return false;
}
}
return true;
}
/** 没有声明位宽时,用能表达该数值的最短位宽(`0x00` -> 1 位,`0xaa` -> 8 位)。 */
private static int significantBitWidth(byte[] payload) {
for (int index = 0; index < payload.length; index++) {
int value = payload[index] & 0xff;
if (value != 0) {
return (payload.length - index - 1) * 8 + (32 - Integer.numberOfLeadingZeros(value));
}
}
return 1;
}
private static String binaryToHex(byte[] bytes) {
StringBuilder out = new StringBuilder(2 + bytes.length * 2);
out.append("0x");
@@ -11,6 +11,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Clob;
import java.sql.DatabaseMetaData;
@@ -760,6 +761,108 @@ final class DbxJdbcPluginTest {
assertEquals("0x0102", method.invoke(null, objectResultSet(new byte[] { 1, 2 }), columnMeta(Types.BIT), 1, false));
}
@Test
void readValueRendersBitFieldColumnsAsBitStrings() throws Exception {
Method method = bitStringReadValue();
// MySQL Connector/J 对 bit(n) 直接返回裸位字段,DBX 之前会渲染成 `0x..`。
assertEquals("0", method.invoke(null, bytesResultSet(new byte[] { 0x00 }), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
assertEquals("1", method.invoke(null, bytesResultSet(new byte[] { 0x01 }), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
assertEquals("10101010", method.invoke(null, bytesResultSet(new byte[] { (byte) 0xaa }), columnMeta(Types.BIT, "BIT", 8), 1, false, null, true));
assertEquals("0000000100000010", method.invoke(null, bytesResultSet(new byte[] { 0x01, 0x02 }), columnMeta(Types.BIT, "BIT", 16), 1, false, null, true));
assertEquals(null, method.invoke(null, bytesResultSet(null), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
}
@Test
void readValueRendersTextBitPayloadsAsBitStrings() throws Exception {
Method method = bitStringReadValue();
// 金仓/PostgreSQL 驱动把 bit(n) 当 `0`/`1` 文本返回,getBytes 给出 ASCII 位串。
assertEquals("0", method.invoke(null, bytesResultSet(new byte[] { '0' }), columnMeta(Types.BIT, "bit", 1), 1, false, null, true));
assertEquals("1", method.invoke(null, bytesResultSet(new byte[] { '1' }), columnMeta(Types.BIT, "bit", 1), 1, false, null, true));
assertEquals("10101010", method.invoke(null, bytesResultSet("10101010".getBytes(StandardCharsets.US_ASCII)), columnMeta(Types.BIT, "bit", 8), 1, false, null, true));
assertEquals("101", method.invoke(null, bytesResultSet("101".getBytes(StandardCharsets.US_ASCII)), columnMeta(Types.OTHER, "varbit", 3), 1, false, null, true));
// 布尔位串按 `t`/`f` 返回时保持布尔语义。
assertEquals(true, method.invoke(null, bytesResultSet(new byte[] { 't' }), columnMeta(Types.BIT, "bit", 1), 1, false, null, true));
assertEquals(false, method.invoke(null, bytesResultSet(new byte[] { 'f' }), columnMeta(Types.BIT, "bit", 1), 1, false, null, true));
// 位宽超出载荷容量(不可信的元数据)时保持原来的 `0x..` 展示。
assertEquals("0x0102", method.invoke(null, bytesResultSet(new byte[] { 0x01, 0x02 }), columnMeta(Types.BIT, "BIT", 24), 1, false, null, true));
// 没有声明位宽时按数值的最短位宽展开。
assertEquals("100000010", method.invoke(null, bytesResultSet(new byte[] { 0x01, 0x02 }), columnMeta(Types.BIT, "BIT", 0), 1, false, null, true));
}
@Test
void readValueRendersTinyInt1BitColumnsAsNumbers() throws Exception {
Method method = bitStringReadValue();
// Connector/J 默认把 tinyint(1) 上报成 Types.BIT(位宽 1),超出 0/1 的载荷是数值列,
// 截成单个比特会静默丢数据(2 显示成 "0"、3 显示成 "1"),必须按有符号字节十进制展示。
assertEquals(Byte.valueOf((byte) 2), method.invoke(null, bytesResultSet(new byte[] { 2 }), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
assertEquals(Byte.valueOf((byte) 127), method.invoke(null, bytesResultSet(new byte[] { 127 }), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
assertEquals(Byte.valueOf((byte) -1), method.invoke(null, bytesResultSet(new byte[] { (byte) 0xff }), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
// 真正的 bit(1) 载荷仍是 0/1,按位串展示。
assertEquals("0", method.invoke(null, bytesResultSet(new byte[] { 0x00 }), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
assertEquals("1", method.invoke(null, bytesResultSet(new byte[] { 0x01 }), columnMeta(Types.BIT, "BIT", 1), 1, false, null, true));
}
@Test
void readValueDoesNotTreatMultiBitPayloadsAsBooleanText() throws Exception {
Method method = bitStringReadValue();
// bit(8) 的裸载荷恰好等于 't'/'f' 的字节值时是位字段而不是布尔文本,按位串展开。
assertEquals("01110100", method.invoke(null, bytesResultSet(new byte[] { 't' }), columnMeta(Types.BIT, "BIT", 8), 1, false, null, true));
assertEquals("01100110", method.invoke(null, bytesResultSet(new byte[] { 'f' }), columnMeta(Types.BIT, "BIT", 8), 1, false, null, true));
}
@Test
void readValueKeepsDriverBooleanWhenBitColumnCannotBeReadAsBytes() throws Exception {
Method method = bitStringReadValue();
// mssql-jdbc 拒绝把 BIT 读成 byte[],此时必须保持驱动返回的布尔值。
ResultSet rs = (ResultSet) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { ResultSet.class },
(proxy, invokedMethod, args) -> switch (invokedMethod.getName()) {
case "getBytes" -> throw new SQLException("The conversion from bit to byte[] is not supported.");
case "getObject" -> Boolean.TRUE;
default -> defaultValue(invokedMethod.getReturnType());
}
);
assertEquals(true, method.invoke(null, rs, columnMeta(Types.BIT, "bit", 1), 1, false, null, true));
}
@Test
void readValueKeepsBooleanSourceColumnBoolean() throws Exception {
Method method = bitStringReadValue();
// 金仓的布尔列同样上报 Types.BIT,不能被当成位字段。
ResultSet rs = (ResultSet) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { ResultSet.class },
(proxy, invokedMethod, args) -> switch (invokedMethod.getName()) {
case "getBytes" -> new byte[] { 't' };
case "getObject" -> Boolean.TRUE;
default -> defaultValue(invokedMethod.getReturnType());
}
);
assertEquals(true, method.invoke(null, rs, columnMeta(Types.BIT, "bool", 1), 1, false, null, true));
}
@Test
void bitStringColumnsMatchOnlyBitFieldDialects() throws Exception {
Method method = DbxJdbcPlugin.class.getDeclaredMethod("usesBitStringColumns", JsonNode.class);
method.setAccessible(true);
assertTrue((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:mysql://db:3306/demo\" }")));
assertTrue((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:mariadb://db:3306/demo\" }")));
assertTrue((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:kingbase8://db:54321/demo\" }")));
assertTrue((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:postgresql://db:5432/demo\" }")));
assertTrue((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:vastbase://db:5432/demo\" }")));
assertFalse((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:sqlserver://db:1433;databaseName=demo\" }")));
assertFalse((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:oracle:thin:@db:1521/XE\" }")));
assertFalse((Boolean) method.invoke(null, MAPPER.readTree("{ \"connection_string\": \"jdbc:h2:mem:dbx\" }")));
}
@Test
void readValueUsesJdbcBooleanAccessForBooleanColumns() throws Exception {
Method method = DbxJdbcPlugin.class.getDeclaredMethod(
@@ -3209,6 +3312,23 @@ final class DbxJdbcPluginTest {
return columnMeta(columnType, "");
}
private static ResultSetMetaData columnMeta(int columnType, String typeName, int precision) {
return (ResultSetMetaData) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { ResultSetMetaData.class },
(proxy, method, args) -> {
return switch (method.getName()) {
case "getColumnCount" -> 1;
case "getColumnLabel", "getColumnName" -> "FLAG";
case "getColumnType" -> columnType;
case "getColumnTypeName" -> typeName;
case "getPrecision" -> precision;
default -> defaultValue(method.getReturnType());
};
}
);
}
private static ResultSetMetaData columnMeta(int columnType, String typeName) {
return (ResultSetMetaData) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
@@ -3244,6 +3364,33 @@ final class DbxJdbcPluginTest {
);
}
private static Method bitStringReadValue() throws Exception {
Method method = DbxJdbcPlugin.class.getDeclaredMethod(
"readValue",
ResultSet.class,
ResultSetMetaData.class,
int.class,
boolean.class,
ZoneId.class,
boolean.class
);
method.setAccessible(true);
return method;
}
private static ResultSet bytesResultSet(byte[] value) {
return (ResultSet) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),
new Class<?>[] { ResultSet.class },
(proxy, method, args) -> switch (method.getName()) {
// 真实驱动会同时通过 getBytes/getObject 暴露二进制载荷。
case "getBytes", "getObject" -> value;
case "wasNull" -> value == null;
default -> defaultValue(method.getReturnType());
}
);
}
private static ResultSet objectResultSet(Object value) {
return (ResultSet) Proxy.newProxyInstance(
DbxJdbcPluginTest.class.getClassLoader(),